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,211 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
|
||||
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||
import LocaleInput from '@/components/localization/LocaleInput.vue'
|
||||
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
|
||||
import type { SaveFailure } from '@/composables/useSiteLanguagesPage'
|
||||
import { baseLanguage, derivedSocialLocale, type SiteLanguages } from '@/lib/siteLanguages'
|
||||
|
||||
const props = defineProps<{
|
||||
languages: SiteLanguages
|
||||
siteName: string
|
||||
pending: boolean
|
||||
failure: SaveFailure | null
|
||||
nameOf: (tag: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; confirm: [tag: string]; reload: [] }>()
|
||||
|
||||
const selected = ref<string | undefined>(undefined)
|
||||
const accepted = ref(false)
|
||||
|
||||
const items = computed(() =>
|
||||
props.languages.available.map((option) => {
|
||||
const listed = props.languages.languages.find((row) => row.lang === option.lang)
|
||||
const disabled = listed !== undefined && !listed.enabled
|
||||
return {
|
||||
value: option.lang,
|
||||
label: disabled
|
||||
? t('{0} · Disabled — re-enable instead', [props.nameOf(option.lang)])
|
||||
: props.nameOf(option.lang),
|
||||
disabled,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const option = computed(() =>
|
||||
props.languages.available.find((candidate) => candidate.lang === selected.value),
|
||||
)
|
||||
const substitute = computed(() => option.value?.substitute ?? null)
|
||||
const target = computed(() =>
|
||||
substitute.value && accepted.value ? substitute.value : selected.value,
|
||||
)
|
||||
const duplicate = computed(() =>
|
||||
props.languages.languages.some((row) => row.lang === target.value && row.enabled),
|
||||
)
|
||||
const duplicateError = computed(() =>
|
||||
duplicate.value && target.value
|
||||
? t('{0} is already enabled for {1}.', [props.nameOf(target.value), props.siteName])
|
||||
: undefined,
|
||||
)
|
||||
const socialLocale = computed(() => {
|
||||
const derived = selected.value ? derivedSocialLocale(selected.value) : null
|
||||
return derived ? t('Derived: {0}', [derived]) : t('Not set')
|
||||
})
|
||||
const broaderWarning = computed(() => {
|
||||
if (!substitute.value || !selected.value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
title: t("{0} isn't available", [props.nameOf(selected.value)]),
|
||||
description: t(
|
||||
"Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}. Articles written in this language can't be told apart from other {0}.",
|
||||
[props.nameOf(baseLanguage(substitute.value)), substitute.value, selected.value],
|
||||
),
|
||||
}
|
||||
})
|
||||
const needsAcceptance = computed(() => substitute.value !== null && !accepted.value)
|
||||
const confirmDisabled = computed(() => !selected.value || duplicate.value || needsAcceptance.value)
|
||||
const longDescription = computed(
|
||||
() => props.failure === null && !duplicate.value && substitute.value === null,
|
||||
)
|
||||
const confirmLabel = computed(() => {
|
||||
if (props.failure === 'stale') {
|
||||
return t('Reload')
|
||||
}
|
||||
if (props.failure) {
|
||||
return t('Try again')
|
||||
}
|
||||
return substitute.value
|
||||
? t('Add {0}', [props.nameOf(baseLanguage(substitute.value))])
|
||||
: t('Add language')
|
||||
})
|
||||
const error = computed(() => {
|
||||
if (props.failure === 'stale') {
|
||||
return {
|
||||
title: t('Languages changed. Reload.'),
|
||||
description: t("Your change wasn't applied."),
|
||||
}
|
||||
}
|
||||
return props.failure
|
||||
? {
|
||||
title: t("Couldn't save languages"),
|
||||
description: t('Nothing changed. Your selection is kept. Try again.'),
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
function select(value: string | undefined) {
|
||||
selected.value = value
|
||||
accepted.value = false
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.failure === 'stale') {
|
||||
emit('reload')
|
||||
} else if (target.value) {
|
||||
emit('confirm', target.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LocaleDialog
|
||||
:title="t('Add a language')"
|
||||
:description="
|
||||
longDescription
|
||||
? t(
|
||||
'Choose a language for {0}. Regional variants such as English (United Kingdom) are separate entries.',
|
||||
[siteName],
|
||||
)
|
||||
: t('Choose a language for {0}.', [siteName])
|
||||
"
|
||||
:confirm-label="confirmLabel"
|
||||
:confirm-disabled="failure === 'stale' ? false : confirmDisabled"
|
||||
:pending="pending"
|
||||
:error="error"
|
||||
@close="emit('close')"
|
||||
@confirm="confirm"
|
||||
>
|
||||
<UFormField
|
||||
:label="t('Language')"
|
||||
:error="duplicateError"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
container: 'mt-1.5',
|
||||
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
|
||||
}"
|
||||
>
|
||||
<LocaleSelect
|
||||
:model-value="selected"
|
||||
:items="items"
|
||||
:placeholder="t('Choose a language')"
|
||||
:aria-label="t('Language')"
|
||||
:invalid="duplicate"
|
||||
@update:model-value="select"
|
||||
/>
|
||||
<template v-if="duplicateError" #error>
|
||||
<UIcon name="i-lucide-circle-x" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ duplicateError }}</span>
|
||||
</template>
|
||||
</UFormField>
|
||||
<LocaleAlert
|
||||
v-if="broaderWarning"
|
||||
tone="warning"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="broaderWarning.title"
|
||||
:description="broaderWarning.description"
|
||||
/>
|
||||
<UCheckbox
|
||||
v-if="substitute"
|
||||
v-model="accepted"
|
||||
:label="t('Add {0} ({1}) instead', [nameOf(baseLanguage(substitute)), substitute])"
|
||||
:ui="{
|
||||
root: 'min-h-6 items-center max-md:min-h-11',
|
||||
label: 'font-medium text-highlighted',
|
||||
}"
|
||||
/>
|
||||
<template v-if="selected && !substitute && !duplicate">
|
||||
<UFormField
|
||||
:label="t('Language code')"
|
||||
:help="t('Used in the API and in page language metadata.')"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-muted',
|
||||
}"
|
||||
>
|
||||
<LocaleInput
|
||||
:model-value="selected"
|
||||
mono
|
||||
readonly
|
||||
locked
|
||||
:aria-label="t('Language code')"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
:label="t('Social locale')"
|
||||
:help="
|
||||
t(
|
||||
'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-muted',
|
||||
}"
|
||||
>
|
||||
<LocaleInput
|
||||
:model-value="socialLocale"
|
||||
mono
|
||||
readonly
|
||||
locked
|
||||
:aria-label="t('Social locale')"
|
||||
/>
|
||||
</UFormField>
|
||||
</template>
|
||||
</LocaleDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
|
||||
import type { SaveFailure } from '@/composables/useSiteLanguagesPage'
|
||||
import {
|
||||
disableDescription,
|
||||
disableWarning,
|
||||
reenableDescription,
|
||||
removeBlockedDescription,
|
||||
usedByLine,
|
||||
} from '@/lib/languageCopy'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'default' | 'disable' | 'reenable' | 'remove' | 'removeBlocked'
|
||||
language: SiteLanguage
|
||||
name: string
|
||||
pending: boolean
|
||||
failure: SaveFailure | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; confirm: []; reload: [] }>()
|
||||
|
||||
const noContentChanges = computed(() =>
|
||||
t(
|
||||
"Existing versions keep their language, status and links. Public requests that name no language will resolve {0} where a published {0} version exists, and return not found where one doesn't. Requests that name a language aren't affected.",
|
||||
[props.name],
|
||||
),
|
||||
)
|
||||
const stale = computed(() => props.failure === 'stale')
|
||||
const failed = computed(() => props.failure !== null)
|
||||
|
||||
const title = computed(() => {
|
||||
switch (props.kind) {
|
||||
case 'default':
|
||||
return t('Make {0} the default language?', [props.name])
|
||||
case 'disable':
|
||||
return t('Disable {0}?', [props.name])
|
||||
case 'reenable':
|
||||
return t('Re-enable {0}?', [props.name])
|
||||
case 'remove':
|
||||
return t('Remove {0}?', [props.name])
|
||||
default:
|
||||
return t("{0} can't be removed", [props.name])
|
||||
}
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
const name = props.name
|
||||
switch (props.kind) {
|
||||
case 'default':
|
||||
return failed.value
|
||||
? t('New articles start in the default language.')
|
||||
: t(
|
||||
"New articles start in the default language, and it is the first choice for each article's title in the list.",
|
||||
)
|
||||
case 'disable':
|
||||
return stale.value
|
||||
? usedByLine(name, props.language)
|
||||
: disableDescription(name, props.language)
|
||||
case 'reenable':
|
||||
return failed.value
|
||||
? t('Writers can create {0} versions again.', [name])
|
||||
: reenableDescription(name, props.language)
|
||||
case 'remove':
|
||||
return failed.value
|
||||
? t('No article uses {0}.', [name])
|
||||
: t('No article uses {0}. You can add it again later.', [name])
|
||||
default:
|
||||
return removeBlockedDescription(name, props.language)
|
||||
}
|
||||
})
|
||||
|
||||
const error = computed(() => {
|
||||
if (stale.value) {
|
||||
return {
|
||||
title: t('Languages changed. Reload.'),
|
||||
description: t("Your change wasn't applied."),
|
||||
}
|
||||
}
|
||||
if (!failed.value) {
|
||||
return null
|
||||
}
|
||||
const name = props.name
|
||||
switch (props.kind) {
|
||||
case 'default':
|
||||
return {
|
||||
title: t("Couldn't change the default language"),
|
||||
description: t('Nothing changed. Try again.'),
|
||||
}
|
||||
case 'reenable':
|
||||
return {
|
||||
title: t("Couldn't re-enable {0}", [name]),
|
||||
description: t('Nothing changed. {0} is still disabled. Try again.', [name]),
|
||||
}
|
||||
case 'remove':
|
||||
return {
|
||||
title: t("Couldn't remove {0}", [name]),
|
||||
description: t('Nothing changed. {0} is still listed. Try again.', [name]),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: t("Couldn't save languages"),
|
||||
description: t('Nothing changed. Try again.'),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (stale.value) {
|
||||
return t('Reload')
|
||||
}
|
||||
if (failed.value) {
|
||||
return t('Try again')
|
||||
}
|
||||
switch (props.kind) {
|
||||
case 'default':
|
||||
return t('Make {0} default', [props.name])
|
||||
case 'disable':
|
||||
case 'removeBlocked':
|
||||
return t('Disable {0}', [props.name])
|
||||
case 'reenable':
|
||||
return t('Re-enable {0}', [props.name])
|
||||
default:
|
||||
return t('Remove {0}', [props.name])
|
||||
}
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
if (stale.value) {
|
||||
emit('reload')
|
||||
} else {
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LocaleDialog
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="confirmLabel"
|
||||
:cancel-label="kind === 'removeBlocked' ? t('Close') : undefined"
|
||||
:tone="kind === 'remove' ? 'danger' : 'primary'"
|
||||
:pending="pending"
|
||||
:error="error"
|
||||
@close="emit('close')"
|
||||
@confirm="confirm"
|
||||
>
|
||||
<LocaleAlert
|
||||
v-if="kind === 'default' && !failed"
|
||||
tone="info"
|
||||
icon="i-lucide-info"
|
||||
:title="t('No content changes')"
|
||||
:description="noContentChanges"
|
||||
/>
|
||||
<LocaleAlert
|
||||
v-else-if="kind === 'disable' && !failed && language.version_count > 0"
|
||||
tone="warning"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="t('Nothing is deleted or unpublished')"
|
||||
:description="disableWarning(name, language)"
|
||||
/>
|
||||
</LocaleDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
import { socialLocaleLabel, versionsLine } from '@/lib/languageCopy'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
defineProps<{
|
||||
languages: SiteLanguage[]
|
||||
owner: boolean
|
||||
nameOf: (tag: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
editSocial: [lang: string]
|
||||
reenable: [lang: string]
|
||||
makeDefault: [lang: string]
|
||||
disable: [lang: string]
|
||||
remove: [lang: string]
|
||||
}>()
|
||||
|
||||
function menuItems(language: SiteLanguage) {
|
||||
const lang = language.lang
|
||||
if (!language.enabled) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: t('Remove'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('remove', lang),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
return [
|
||||
[
|
||||
...(language.is_default
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t('Make default'),
|
||||
icon: 'i-lucide-star',
|
||||
onSelect: () => emit('makeDefault', lang),
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: t('Disable'),
|
||||
icon: 'i-lucide-eye-off',
|
||||
onSelect: () => emit('disable', lang),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: t('Remove'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
disabled: language.is_default,
|
||||
onSelect: () => emit('remove', lang),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-default bg-default" role="group" :aria-label="t('Languages')">
|
||||
<div class="bg-elevated px-4 py-2 font-medium text-muted">{{ t('Language') }}</div>
|
||||
<div
|
||||
v-for="language in languages"
|
||||
:key="language.lang"
|
||||
class="flex min-h-12 flex-wrap items-center gap-3 border-t border-default px-4 py-2.5 max-md:min-h-14"
|
||||
:class="language.enabled ? '' : 'opacity-85'"
|
||||
:data-testid="`language-row-${language.lang}`"
|
||||
>
|
||||
<div class="flex min-w-0 grow shrink basis-40 flex-col">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
nameOf(language.lang)
|
||||
}}</span>
|
||||
<span class="font-mono text-xs/5 text-muted">{{ language.lang }}</span>
|
||||
</div>
|
||||
<LocaleBadge v-if="language.is_default" tone="info" icon="i-lucide-star">
|
||||
{{ t('Default') }}
|
||||
</LocaleBadge>
|
||||
<LocaleBadge v-else-if="!language.enabled" tone="neutral" icon="i-lucide-eye-off">
|
||||
{{ t('Disabled') }}
|
||||
</LocaleBadge>
|
||||
<span class="w-42.5 text-muted max-md:hidden">{{ versionsLine(language) }}</span>
|
||||
<div class="flex items-center gap-1.5 md:w-67.5 md:whitespace-nowrap max-md:basis-full">
|
||||
<span class="text-xs/5 whitespace-nowrap text-muted">{{ t('Social locale') }}</span>
|
||||
<span
|
||||
class="font-mono text-xs"
|
||||
:class="
|
||||
language.social_locale_state === 'not_set'
|
||||
? 'text-muted'
|
||||
: 'text-highlighted'
|
||||
"
|
||||
>{{ socialLocaleLabel(language) }}</span
|
||||
>
|
||||
<LocaleButton
|
||||
v-if="owner"
|
||||
kind="ghost"
|
||||
small
|
||||
:aria-label="t('Edit social locale for {0}', [nameOf(language.lang)])"
|
||||
@click="emit('editSocial', language.lang)"
|
||||
>
|
||||
{{ t('Edit') }}
|
||||
</LocaleButton>
|
||||
</div>
|
||||
<LocaleButton
|
||||
v-if="owner && !language.enabled"
|
||||
small
|
||||
@click="emit('reenable', language.lang)"
|
||||
>
|
||||
{{ t('Re-enable') }}
|
||||
</LocaleButton>
|
||||
<UDropdownMenu v-if="owner" :items="menuItems(language)" :content="{ align: 'end' }">
|
||||
<LocaleButton
|
||||
kind="ghost"
|
||||
small
|
||||
icon="i-lucide-ellipsis"
|
||||
icon-only
|
||||
:aria-label="t('Actions for {0}', [nameOf(language.lang)])"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
tone: 'info' | 'error' | 'warning' | 'neutral' | 'success'
|
||||
icon: string
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
:role="tone === 'error' ? 'alert' : 'status'"
|
||||
:color="tone"
|
||||
variant="soft"
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:close="false"
|
||||
orientation="vertical"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
icon: 'size-4',
|
||||
title: 'font-semibold text-highlighted',
|
||||
description: 'text-pretty text-toned',
|
||||
actions: 'mt-2.5 gap-2',
|
||||
}"
|
||||
>
|
||||
<template v-if="$slots.actions" #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
tone: 'neutral' | 'info'
|
||||
icon: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:icon="icon"
|
||||
class="border"
|
||||
:class="
|
||||
tone === 'info'
|
||||
? 'border-(--rc-info-soft) bg-(--rc-info-soft) text-info'
|
||||
: 'border-(--ui-bg-elevated) bg-elevated text-toned'
|
||||
"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
kind?: 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
small?: boolean
|
||||
icon?: string
|
||||
iconOnly?: boolean
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
block?: boolean
|
||||
}>(),
|
||||
{
|
||||
kind: 'secondary',
|
||||
small: false,
|
||||
icon: undefined,
|
||||
iconOnly: false,
|
||||
ariaLabel: undefined,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
block: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
:color="kind === 'primary' ? 'primary' : kind === 'danger' ? 'error' : 'neutral'"
|
||||
:variant="kind === 'secondary' ? 'outline' : kind === 'ghost' ? 'ghost' : 'solid'"
|
||||
:icon="icon"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
:block="block"
|
||||
class="justify-center font-medium whitespace-nowrap max-md:h-11"
|
||||
:class="[
|
||||
small ? 'h-7' : 'h-8',
|
||||
iconOnly ? (small ? 'w-7 px-0 max-md:w-11' : 'w-8 px-0 max-md:w-11') : 'px-3',
|
||||
kind === 'secondary'
|
||||
? 'border border-accented bg-default text-highlighted ring-0'
|
||||
: kind === 'ghost'
|
||||
? 'text-toned'
|
||||
: '',
|
||||
]"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
<slot />
|
||||
</UButton>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
tone?: 'primary' | 'danger'
|
||||
pending?: boolean
|
||||
confirmDisabled?: boolean
|
||||
error?: { title: string; description?: string } | null
|
||||
}>(),
|
||||
{
|
||||
description: undefined,
|
||||
cancelLabel: undefined,
|
||||
tone: 'primary',
|
||||
pending: false,
|
||||
confirmDisabled: false,
|
||||
error: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: []; confirm: [] }>()
|
||||
|
||||
const CLOSE_BUTTON = {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
class: 'size-11 justify-center p-0 text-muted md:size-7',
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="true"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
:close="pending ? false : CLOSE_BUTTON"
|
||||
:ui="{
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-25 sm:max-w-115`,
|
||||
close: 'end-6 top-8 md:top-5',
|
||||
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
|
||||
description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10',
|
||||
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
@update:open="(value: boolean) => !value && emit('close')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<LocaleAlert
|
||||
v-if="error"
|
||||
tone="error"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="error.title"
|
||||
:description="error.description"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:justify-end">
|
||||
<LocaleButton :disabled="pending" block class="md:w-auto" @click="emit('close')">
|
||||
{{ cancelLabel ?? t('Cancel') }}
|
||||
</LocaleButton>
|
||||
<LocaleButton
|
||||
:kind="tone === 'danger' ? 'danger' : 'primary'"
|
||||
:disabled="confirmDisabled || pending"
|
||||
:loading="pending"
|
||||
block
|
||||
class="md:w-auto"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</LocaleButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
mono?: boolean
|
||||
readonly?: boolean
|
||||
flat?: boolean
|
||||
locked?: boolean
|
||||
invalid?: boolean
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
maxlength?: number
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UInput
|
||||
v-model="model"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:maxlength="maxlength"
|
||||
:icon="locked ? 'i-lucide-lock' : undefined"
|
||||
trailing
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: [
|
||||
'h-8 rounded-none border bg-default px-2.5 text-highlighted ring-0 max-md:h-11',
|
||||
mono ? 'font-mono text-xs' : 'text-sm',
|
||||
invalid ? 'border-error' : 'border-accented',
|
||||
readonly && !flat ? 'bg-elevated' : '',
|
||||
],
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: { label: string; value: string; disabled?: boolean }[]
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
invalid?: boolean
|
||||
readonly?: boolean
|
||||
hasValue?: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<string | undefined>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelect
|
||||
v-model="model"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:disabled="readonly"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: [
|
||||
'h-8 rounded-none border bg-default px-2.5 text-sm ring-0 max-md:h-11',
|
||||
invalid ? 'border-error' : 'border-accented',
|
||||
readonly ? 'bg-elevated' : '',
|
||||
],
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
placeholder: 'text-muted',
|
||||
value: 'text-highlighted',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
|
||||
import LocaleInput from '@/components/localization/LocaleInput.vue'
|
||||
import type { SaveFailure } from '@/composables/useSiteLanguagesPage'
|
||||
import {
|
||||
derivedSocialLocale,
|
||||
isSocialLocaleInput,
|
||||
socialLocaleExample,
|
||||
type SiteLanguage,
|
||||
} from '@/lib/siteLanguages'
|
||||
|
||||
const props = defineProps<{
|
||||
language: SiteLanguage
|
||||
name: string
|
||||
pending: boolean
|
||||
failure: SaveFailure | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; confirm: [value: string | null]; reload: [] }>()
|
||||
|
||||
const value = ref(props.language.social_locale ?? '')
|
||||
|
||||
const example = computed(() => socialLocaleExample(props.language.lang))
|
||||
const derived = computed(() => derivedSocialLocale(props.language.lang))
|
||||
const typedInvalid = computed(
|
||||
() => value.value !== '' && !isSocialLocaleInput(value.value, props.language.lang),
|
||||
)
|
||||
const invalid = computed(() => typedInvalid.value || props.failure === 'invalidSocialLocale')
|
||||
const fieldError = computed(() =>
|
||||
invalid.value
|
||||
? t('Use letters and one underscore, for example {0}.', [example.value])
|
||||
: undefined,
|
||||
)
|
||||
const error = computed(() => {
|
||||
if (props.failure === 'stale') {
|
||||
return {
|
||||
title: t('Languages changed. Reload.'),
|
||||
description: t("Your change wasn't applied."),
|
||||
}
|
||||
}
|
||||
return props.failure === 'failed'
|
||||
? {
|
||||
title: t("Couldn't save languages"),
|
||||
description: t('Nothing changed. Your selection is kept. Try again.'),
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
if (props.failure === 'stale') {
|
||||
emit('reload')
|
||||
return
|
||||
}
|
||||
emit('confirm', value.value === '' ? null : value.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LocaleDialog
|
||||
:title="t('Social locale for {0}', [name])"
|
||||
:description="t('Used for og:locale on {0} pages. Leave it empty to omit the tag.', [name])"
|
||||
:confirm-label="
|
||||
failure === 'stale'
|
||||
? t('Reload')
|
||||
: failure === 'failed'
|
||||
? t('Try again')
|
||||
: t('Save social locale')
|
||||
"
|
||||
:confirm-disabled="failure === 'stale' ? false : typedInvalid"
|
||||
:pending="pending"
|
||||
:error="error"
|
||||
@close="emit('close')"
|
||||
@confirm="confirm"
|
||||
>
|
||||
<UFormField
|
||||
:label="t('Social locale')"
|
||||
:error="fieldError"
|
||||
:help="
|
||||
t('Format: language_REGION, for example {0}. Letters only, one underscore.', [
|
||||
example,
|
||||
])
|
||||
"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-muted',
|
||||
error: 'mt-1.5 flex gap-1.5 text-xs/4.5 text-error',
|
||||
}"
|
||||
>
|
||||
<LocaleInput v-model="value" mono :invalid="invalid" :aria-label="t('Social locale')" />
|
||||
<template v-if="fieldError" #error>
|
||||
<UIcon name="i-lucide-circle-x" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ fieldError }}</span>
|
||||
</template>
|
||||
</UFormField>
|
||||
<LocaleAlert
|
||||
v-if="language.social_locale_state === 'derived' && derived"
|
||||
tone="info"
|
||||
icon="i-lucide-info"
|
||||
:title="t('Derived from the language code')"
|
||||
:description="
|
||||
t('{0} gives {1}. Setting a value replaces the derived one.', [
|
||||
language.lang,
|
||||
derived,
|
||||
])
|
||||
"
|
||||
/>
|
||||
</LocaleDialog>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ export interface Article extends Partial<ArticleSeo> {
|
||||
title: string
|
||||
body: string
|
||||
is_online: boolean
|
||||
lang?: string
|
||||
site_id?: number
|
||||
created_by?: number
|
||||
authors?: Byline[]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
useSiteLanguages,
|
||||
type LanguageSetup,
|
||||
type SetupSite,
|
||||
} from '@/composables/useSiteLanguages'
|
||||
import { useSites } from '@/composables/useSites'
|
||||
|
||||
export type SetupLoadState = 'loading' | 'ready' | 'error'
|
||||
export type SetupStage = 'editing' | 'applying' | 'inProgress' | 'done' | 'failed'
|
||||
|
||||
export function useLanguageSetupPage(key: Ref<string>) {
|
||||
const { getSetup, applySetup } = useSiteLanguages()
|
||||
const { listAllSites } = useSites()
|
||||
|
||||
const loadState = ref<SetupLoadState>('loading')
|
||||
const setup = ref<LanguageSetup | null>(null)
|
||||
const choices = ref<Record<string, string | undefined>>({})
|
||||
const stage = ref<SetupStage>('editing')
|
||||
const assigned = ref<number | null>(null)
|
||||
const knownSiteName = ref<string | null>(null)
|
||||
const checking = ref(false)
|
||||
|
||||
const sites = computed<SetupSite[]>(() => setup.value?.sites ?? [])
|
||||
const nothingPending = computed(() => loadState.value === 'ready' && sites.value.length === 0)
|
||||
const everyRowChosen = computed(() =>
|
||||
sites.value.every((site) => (choices.value[site.key] ?? '') !== ''),
|
||||
)
|
||||
const articleTotal = computed(() =>
|
||||
sites.value.reduce((total, site) => total + site.article_count, 0),
|
||||
)
|
||||
const onlyUnseeded = computed(
|
||||
() => sites.value.length > 0 && sites.value.every((site) => site.suggestion === null),
|
||||
)
|
||||
const singleEmptySite = computed(
|
||||
() => sites.value.length === 1 && sites.value[0]!.article_count === 0,
|
||||
)
|
||||
const locked = computed(
|
||||
() => stage.value === 'done' || stage.value === 'inProgress' || stage.value === 'applying',
|
||||
)
|
||||
|
||||
function seed(next: LanguageSetup) {
|
||||
const seeded: Record<string, string | undefined> = {}
|
||||
for (const site of next.sites) {
|
||||
seeded[site.key] = choices.value[site.key] ?? site.suggestion?.lang
|
||||
}
|
||||
choices.value = seeded
|
||||
}
|
||||
|
||||
async function loadSiteName() {
|
||||
try {
|
||||
const all = await listAllSites()
|
||||
knownSiteName.value = all.find((site) => site.key === key.value)?.name ?? null
|
||||
} catch {
|
||||
knownSiteName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadState.value = 'loading'
|
||||
stage.value = 'editing'
|
||||
void loadSiteName()
|
||||
try {
|
||||
const next = await getSetup()
|
||||
setup.value = next
|
||||
seed(next)
|
||||
loadState.value = 'ready'
|
||||
if (next.sites.length === 0) {
|
||||
stage.value = 'done'
|
||||
}
|
||||
} catch {
|
||||
loadState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAgain() {
|
||||
checking.value = true
|
||||
try {
|
||||
const next = await getSetup()
|
||||
setup.value = next
|
||||
seed(next)
|
||||
stage.value = next.sites.length === 0 ? 'done' : 'inProgress'
|
||||
} catch {
|
||||
stage.value = 'inProgress'
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function choose(siteKey: string, lang: string | undefined) {
|
||||
choices.value = { ...choices.value, [siteKey]: lang }
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!everyRowChosen.value || locked.value) {
|
||||
return
|
||||
}
|
||||
stage.value = 'applying'
|
||||
try {
|
||||
const result = await applySetup(
|
||||
sites.value.map((site) => ({ key: site.key, lang: choices.value[site.key]! })),
|
||||
)
|
||||
assigned.value = result.assigned
|
||||
stage.value = 'done'
|
||||
} catch (error) {
|
||||
stage.value =
|
||||
error instanceof ApiError && error.status === 409 && error.body === 'setup_in_progress'
|
||||
? 'inProgress'
|
||||
: 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadState,
|
||||
setup,
|
||||
sites,
|
||||
choices,
|
||||
stage,
|
||||
assigned,
|
||||
knownSiteName,
|
||||
checking,
|
||||
nothingPending,
|
||||
everyRowChosen,
|
||||
articleTotal,
|
||||
onlyUnseeded,
|
||||
singleEmptySite,
|
||||
locked,
|
||||
load,
|
||||
checkAgain,
|
||||
choose,
|
||||
apply,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { t, locale } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useArticles } from '@/composables/useArticles'
|
||||
import { useSiteLanguages } from '@/composables/useSiteLanguages'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import {
|
||||
PUBLIC_URL_PATTERN_MAX_LENGTH,
|
||||
checkPublicUrlPattern,
|
||||
previewPublicUrl,
|
||||
unknownPlaceholderOf,
|
||||
type PublicUrlPatternError,
|
||||
} from '@/lib/publicUrlPattern'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
export type PublicUrlLoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
interface PreviewSource {
|
||||
title: string
|
||||
slug: string
|
||||
lang: string
|
||||
}
|
||||
|
||||
const SERVER_CODES: readonly PublicUrlPatternError[] = [
|
||||
'invalid_scheme',
|
||||
'invalid_host',
|
||||
'unknown_placeholder',
|
||||
'not_encoded',
|
||||
'missing_slug',
|
||||
]
|
||||
|
||||
export function usePublicUrlPage(key: Ref<string>) {
|
||||
const { listAllSites, updateSite } = useSites()
|
||||
const { listArticles } = useArticles()
|
||||
const { getLanguages } = useSiteLanguages()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const loadState = ref<PublicUrlLoadState>('loading')
|
||||
const site = ref<Site | null>(null)
|
||||
const value = ref('')
|
||||
const preview = ref<PreviewSource | null>(null)
|
||||
const serverError = ref<PublicUrlPatternError | null>(null)
|
||||
const saving = ref(false)
|
||||
const saveFailed = ref(false)
|
||||
|
||||
const saved = computed(() => site.value?.public_url_pattern ?? null)
|
||||
const clientError = computed(() => checkPublicUrlPattern(value.value))
|
||||
const errorCode = computed(() => clientError.value ?? serverError.value)
|
||||
const unset = computed(() => saved.value === null)
|
||||
const empty = computed(() => value.value.trim() === '')
|
||||
const canSave = computed(
|
||||
() => errorCode.value === null && !(empty.value && unset.value) && !saving.value,
|
||||
)
|
||||
const errorMessage = computed(() => {
|
||||
switch (errorCode.value) {
|
||||
case 'invalid_scheme':
|
||||
case 'invalid_host':
|
||||
return t('Start with https:// or http://.')
|
||||
case 'unknown_placeholder': {
|
||||
const token = unknownPlaceholderOf(value.value)
|
||||
return token
|
||||
? t('Unknown placeholder {0}. Use {lang} and {slug}.', [token])
|
||||
: t('Unknown placeholder. Use {lang} and {slug}.')
|
||||
}
|
||||
case 'not_encoded':
|
||||
return t(
|
||||
'Remove spaces and characters that need encoding. Use letters, digits, - and _.',
|
||||
)
|
||||
case 'missing_slug':
|
||||
return t('Add {slug} so each article has its own address.')
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
const previewUrl = computed(() =>
|
||||
preview.value && errorCode.value === null && !empty.value
|
||||
? previewPublicUrl(value.value, preview.value.lang, preview.value.slug)
|
||||
: null,
|
||||
)
|
||||
const previewLabel = computed(() =>
|
||||
preview.value
|
||||
? t('Preview with "{0}" in {1}', [
|
||||
preview.value.title,
|
||||
languageName(preview.value.lang, locale.value),
|
||||
])
|
||||
: '',
|
||||
)
|
||||
|
||||
async function loadPreview(siteKey: string) {
|
||||
try {
|
||||
const page = await listArticles({ page: 1, size: 1, site: siteKey })
|
||||
const article = page.items[0]
|
||||
if (!article) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
const lang = article.lang || (await getLanguages(siteKey)).default
|
||||
preview.value = lang ? { title: article.title, slug: article.slug ?? '', lang } : null
|
||||
} catch {
|
||||
preview.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadState.value = 'loading'
|
||||
try {
|
||||
const sites = await listAllSites()
|
||||
const found = sites.find((candidate) => candidate.key === key.value)
|
||||
if (!found) {
|
||||
loadState.value = 'error'
|
||||
return
|
||||
}
|
||||
site.value = found
|
||||
value.value = found.public_url_pattern ?? ''
|
||||
serverError.value = null
|
||||
loadState.value = 'ready'
|
||||
await loadPreview(found.key)
|
||||
} catch {
|
||||
loadState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function edit(next: string) {
|
||||
value.value = next.slice(0, PUBLIC_URL_PATTERN_MAX_LENGTH)
|
||||
serverError.value = null
|
||||
saveFailed.value = false
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
value.value = saved.value ?? ''
|
||||
serverError.value = null
|
||||
saveFailed.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!site.value || !canSave.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
saveFailed.value = false
|
||||
try {
|
||||
const updated = await updateSite(site.value.id, {
|
||||
public_url_pattern: empty.value ? null : value.value.trim(),
|
||||
})
|
||||
site.value = { ...site.value, ...updated }
|
||||
value.value = updated.public_url_pattern ?? ''
|
||||
toast.add(() => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('Public URL saved'),
|
||||
description: t('{0} pages now use this pattern.', [site.value?.name ?? key.value]),
|
||||
}))
|
||||
} catch (error) {
|
||||
const code = SERVER_CODES.find(
|
||||
(candidate) =>
|
||||
error instanceof ApiError && error.status === 422 && error.body === candidate,
|
||||
)
|
||||
if (code) {
|
||||
serverError.value = code
|
||||
} else {
|
||||
saveFailed.value = true
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadState,
|
||||
site,
|
||||
value,
|
||||
saving,
|
||||
saveFailed,
|
||||
unset,
|
||||
canSave,
|
||||
errorMessage,
|
||||
previewUrl,
|
||||
previewLabel,
|
||||
load,
|
||||
edit,
|
||||
cancel,
|
||||
save,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type { AvailableLanguage, LanguagesUpdate, SiteLanguages } from '@/lib/siteLanguages'
|
||||
|
||||
export interface SetupSuggestion {
|
||||
lang: string
|
||||
substituted: boolean
|
||||
}
|
||||
|
||||
export interface SetupSite {
|
||||
key: string
|
||||
name: string
|
||||
locale: string | null
|
||||
article_count: number
|
||||
suggestion: SetupSuggestion | null
|
||||
}
|
||||
|
||||
export interface LanguageSetup {
|
||||
sites: SetupSite[]
|
||||
available: AvailableLanguage[]
|
||||
}
|
||||
|
||||
export interface SetupChoice {
|
||||
key: string
|
||||
lang: string
|
||||
}
|
||||
|
||||
export interface SetupResult {
|
||||
assigned: number
|
||||
skipped: string[]
|
||||
sites: { key: string; lang: string; assigned: number }[]
|
||||
}
|
||||
|
||||
export function useSiteLanguages() {
|
||||
const { request } = useApi()
|
||||
|
||||
function getLanguages(key: string): Promise<SiteLanguages> {
|
||||
return request<SiteLanguages>(`/v1/site/${encodeURIComponent(key)}/languages`)
|
||||
}
|
||||
|
||||
function putLanguages(key: string, body: LanguagesUpdate): Promise<SiteLanguages> {
|
||||
return request<SiteLanguages>(
|
||||
`/v1/site/${encodeURIComponent(key)}/languages`,
|
||||
jsonInit('PUT', body),
|
||||
)
|
||||
}
|
||||
|
||||
function getSetup(): Promise<LanguageSetup> {
|
||||
return request<LanguageSetup>('/v1/language/setup')
|
||||
}
|
||||
|
||||
function applySetup(sites: SetupChoice[]): Promise<SetupResult> {
|
||||
return request<SetupResult>('/v1/language/setup', jsonInit('POST', { sites }))
|
||||
}
|
||||
|
||||
return { getLanguages, putLanguages, getSetup, applySetup }
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { t, locale } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useSiteLanguages } from '@/composables/useSiteLanguages'
|
||||
import { useSites } from '@/composables/useSites'
|
||||
import {
|
||||
languageName,
|
||||
languagesUpdate,
|
||||
withDefault,
|
||||
withEnabled,
|
||||
withLanguage,
|
||||
withoutLanguage,
|
||||
withSocialLocale,
|
||||
type EditedLanguages,
|
||||
type SiteLanguage,
|
||||
type SiteLanguages,
|
||||
} from '@/lib/siteLanguages'
|
||||
|
||||
export type LoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
export type LanguageDialog =
|
||||
| { kind: 'add' }
|
||||
| { kind: 'default'; lang: string }
|
||||
| { kind: 'disable'; lang: string }
|
||||
| { kind: 'reenable'; lang: string }
|
||||
| { kind: 'remove'; lang: string }
|
||||
| { kind: 'removeBlocked'; lang: string }
|
||||
| { kind: 'social'; lang: string }
|
||||
|
||||
export type LanguageBanner =
|
||||
| { kind: 'disableDefault'; lang: string }
|
||||
| { kind: 'disableLast' }
|
||||
| { kind: 'stale' }
|
||||
| { kind: 'failed'; action: 'default' | 'remove'; lang: string }
|
||||
|
||||
export type SaveFailure = 'failed' | 'stale' | 'invalidSocialLocale'
|
||||
|
||||
type SaveOutcome = null | SaveFailure | 'inUse' | 'setupRequired'
|
||||
|
||||
export function useSiteLanguagesPage(key: Ref<string>) {
|
||||
const { getLanguages, putLanguages } = useSiteLanguages()
|
||||
const { listAllSites } = useSites()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const data = ref<SiteLanguages | null>(null)
|
||||
const dialog = ref<LanguageDialog | null>(null)
|
||||
const banner = ref<LanguageBanner | null>(null)
|
||||
const failure = ref<SaveFailure | null>(null)
|
||||
const saving = ref(false)
|
||||
const siteName = ref<string | null>(null)
|
||||
|
||||
const enabledCount = computed(
|
||||
() => data.value?.languages.filter((language) => language.enabled).length ?? 0,
|
||||
)
|
||||
|
||||
function name(tag: string) {
|
||||
return languageName(tag, locale.value)
|
||||
}
|
||||
|
||||
function languageOf(tag: string): SiteLanguage | undefined {
|
||||
return data.value?.languages.find((language) => language.lang === tag)
|
||||
}
|
||||
|
||||
async function loadSiteName() {
|
||||
try {
|
||||
const sites = await listAllSites()
|
||||
siteName.value = sites.find((site) => site.key === key.value)?.name ?? null
|
||||
} catch {
|
||||
siteName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadState.value = 'loading'
|
||||
void loadSiteName()
|
||||
try {
|
||||
data.value = await getLanguages(key.value)
|
||||
loadState.value = 'ready'
|
||||
} catch {
|
||||
loadState.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
data.value = await getLanguages(key.value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
dialog.value = null
|
||||
banner.value = null
|
||||
failure.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
dialog.value = null
|
||||
failure.value = null
|
||||
}
|
||||
|
||||
function openDialog(next: LanguageDialog) {
|
||||
failure.value = null
|
||||
banner.value = null
|
||||
dialog.value = next
|
||||
}
|
||||
|
||||
function openDisable(lang: string) {
|
||||
if (enabledCount.value <= 1) {
|
||||
banner.value = { kind: 'disableLast' }
|
||||
return
|
||||
}
|
||||
if (languageOf(lang)?.is_default) {
|
||||
banner.value = { kind: 'disableDefault', lang }
|
||||
return
|
||||
}
|
||||
openDialog({ kind: 'disable', lang })
|
||||
}
|
||||
|
||||
function disableInstead(lang: string) {
|
||||
if (languageOf(lang)?.enabled) {
|
||||
openDisable(lang)
|
||||
} else {
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function openRemove(lang: string) {
|
||||
const language = languageOf(lang)
|
||||
openDialog({ kind: (language?.version_count ?? 0) > 0 ? 'removeBlocked' : 'remove', lang })
|
||||
}
|
||||
|
||||
function classify(error: unknown): SaveOutcome {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return 'failed'
|
||||
}
|
||||
if (error.status === 409 && error.body === 'languages_changed') {
|
||||
return 'stale'
|
||||
}
|
||||
if (error.status === 409 && error.body === 'language_in_use') {
|
||||
return 'inUse'
|
||||
}
|
||||
if (error.status === 409 && error.body === 'setup_required') {
|
||||
return 'setupRequired'
|
||||
}
|
||||
if (error.status === 422 && error.body === 'invalid_social_locale') {
|
||||
return 'invalidSocialLocale'
|
||||
}
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
async function save(change: EditedLanguages): Promise<SaveOutcome> {
|
||||
saving.value = true
|
||||
failure.value = null
|
||||
try {
|
||||
data.value = await putLanguages(key.value, languagesUpdate(change))
|
||||
banner.value = null
|
||||
return null
|
||||
} catch (error) {
|
||||
const outcome = classify(error)
|
||||
if (outcome === 'stale') {
|
||||
banner.value = { kind: 'stale' }
|
||||
}
|
||||
if (outcome === 'inUse' || outcome === 'setupRequired') {
|
||||
await refresh()
|
||||
} else {
|
||||
failure.value = outcome
|
||||
}
|
||||
return outcome
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addLanguage(tag: string) {
|
||||
if (!data.value) {
|
||||
return
|
||||
}
|
||||
const outcome = await save(withLanguage(data.value, tag))
|
||||
if (outcome === null) {
|
||||
dialog.value = null
|
||||
toast.add(() => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('{0} added', [name(tag)]),
|
||||
description: t('Writers can now create {0} versions.', [name(tag)]),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function makeDefault(lang: string) {
|
||||
if (!data.value) {
|
||||
return
|
||||
}
|
||||
const outcome = await save(withDefault(data.value, lang))
|
||||
if (outcome === null) {
|
||||
dialog.value = null
|
||||
} else if (outcome === 'failed') {
|
||||
banner.value = { kind: 'failed', action: 'default', lang }
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(lang: string) {
|
||||
if (!data.value) {
|
||||
return
|
||||
}
|
||||
const outcome = await save(withEnabled(data.value, lang, false))
|
||||
if (outcome === null) {
|
||||
dialog.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reenable(lang: string) {
|
||||
if (!data.value) {
|
||||
return
|
||||
}
|
||||
const outcome = await save(withEnabled(data.value, lang, true))
|
||||
if (outcome === null) {
|
||||
dialog.value = null
|
||||
toast.add(() => ({
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('{0} re-enabled', [name(lang)]),
|
||||
description: t('Writers can create {0} versions again. Drafts can be published.', [
|
||||
name(lang),
|
||||
]),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(lang: string) {
|
||||
if (!data.value) {
|
||||
return
|
||||
}
|
||||
const outcome = await save(withoutLanguage(data.value, lang))
|
||||
if (outcome === null) {
|
||||
dialog.value = null
|
||||
} else if (outcome === 'inUse') {
|
||||
dialog.value = { kind: 'removeBlocked', lang }
|
||||
} else if (outcome === 'failed') {
|
||||
banner.value = { kind: 'failed', action: 'remove', lang }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSocialLocale(lang: string, value: string | null) {
|
||||
if (!data.value) {
|
||||
return
|
||||
}
|
||||
const outcome = await save(withSocialLocale(data.value, lang, value))
|
||||
if (outcome === null) {
|
||||
dialog.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function retryBanner() {
|
||||
const current = banner.value
|
||||
if (current?.kind !== 'failed') {
|
||||
return
|
||||
}
|
||||
return current.action === 'default' ? makeDefault(current.lang) : remove(current.lang)
|
||||
}
|
||||
|
||||
return {
|
||||
loadState,
|
||||
data,
|
||||
dialog,
|
||||
banner,
|
||||
failure,
|
||||
saving,
|
||||
siteName,
|
||||
name,
|
||||
languageOf,
|
||||
load,
|
||||
reload,
|
||||
closeDialog,
|
||||
openDialog,
|
||||
openDisable,
|
||||
disableInstead,
|
||||
openRemove,
|
||||
addLanguage,
|
||||
makeDefault,
|
||||
disable,
|
||||
reenable,
|
||||
remove,
|
||||
saveSocialLocale,
|
||||
retryBanner,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface Site {
|
||||
default_description?: string
|
||||
is_default: boolean
|
||||
article_count: number
|
||||
public_url_pattern?: string | null
|
||||
}
|
||||
|
||||
export interface NewSite {
|
||||
@@ -42,6 +43,7 @@ export interface SitePatch {
|
||||
site_name?: string
|
||||
title_template?: string
|
||||
default_description?: string
|
||||
public_url_pattern?: string | null
|
||||
}
|
||||
|
||||
export function useSites() {
|
||||
|
||||
@@ -57,7 +57,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
],
|
||||
'Sites and authors': ['GET /v1/site/', 'GET /v1/author/'],
|
||||
'Sites and authors': ['GET /v1/site/', 'GET /v1/site/{key}/languages', 'GET /v1/author/'],
|
||||
'Users and invites': [
|
||||
'GET /v1/user/',
|
||||
'PATCH /v1/user/me',
|
||||
@@ -91,11 +91,13 @@ const EXPECTED_SESSION: Record<string, string[]> = {
|
||||
'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',
|
||||
],
|
||||
'Language setup · owner only': ['GET /v1/language/setup', 'POST /v1/language/setup'],
|
||||
'Image variants': [
|
||||
'GET /v1/image-variants/',
|
||||
'POST /v1/image-variants/',
|
||||
@@ -241,7 +243,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 42 key-callable routes in 9 groups, in order', () => {
|
||||
it('holds exactly the 43 key-callable routes in 9 groups, in order', () => {
|
||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
@@ -258,20 +260,20 @@ describe('Catalogue routes', () => {
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(42)
|
||||
expect(allEndpoints()).toHaveLength(43)
|
||||
})
|
||||
|
||||
it('counts the routes of each group', () => {
|
||||
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
|
||||
8, 2, 5, 5, 2, 11, 2, 6, 1,
|
||||
8, 2, 5, 5, 2, 11, 3, 6, 1,
|
||||
])
|
||||
})
|
||||
|
||||
it('holds the 37 session-only rows in 6 sub-groups', () => {
|
||||
it('holds the 40 session-only rows in 7 sub-groups', () => {
|
||||
expect(
|
||||
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
|
||||
).toEqual(EXPECTED_SESSION)
|
||||
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(37)
|
||||
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('holds the 6 public auth flow rows', () => {
|
||||
@@ -293,7 +295,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(9 + 42 + 37 + 6)
|
||||
expect(described).toHaveLength(9 + 43 + 40 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -860,7 +862,7 @@ describe('Session only rows', () => {
|
||||
expect(rows.filter((row) => row.answers403).map(label)).toEqual(
|
||||
rows.map(label).filter((route) => !excluded.includes(route)),
|
||||
)
|
||||
expect(rows.filter((row) => row.answers403)).toHaveLength(33)
|
||||
expect(rows.filter((row) => row.answers403)).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => {
|
||||
|
||||
@@ -1560,6 +1560,43 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
'site-languages',
|
||||
'GET',
|
||||
'/v1/site/{key}/languages',
|
||||
'List the content languages of a site: the default first, then the other enabled ones, then the disabled ones. Each carries its version and published counts and its social locale. Send the revision back when you change them. A site with no confirmed default answers needs_setup true and no languages.',
|
||||
'Verified user',
|
||||
verified([e(401, UNAUTHORIZED), e(404, 'site_not_found')]),
|
||||
json({
|
||||
revision: '9f2c4d1e7a',
|
||||
default: 'en',
|
||||
needs_setup: false,
|
||||
languages: [
|
||||
{
|
||||
lang: 'en',
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
version_count: 7,
|
||||
published_count: 6,
|
||||
social_locale: null,
|
||||
social_locale_state: 'not_set',
|
||||
},
|
||||
{
|
||||
lang: 'fr',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
version_count: 4,
|
||||
published_count: 2,
|
||||
social_locale: 'fr_FR',
|
||||
social_locale_state: 'set',
|
||||
},
|
||||
],
|
||||
available: [
|
||||
{ lang: 'pt', substitute: null },
|
||||
{ lang: 'pt-PT', substitute: 'pt' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
endpoint(
|
||||
'author-list',
|
||||
'GET',
|
||||
@@ -1797,6 +1834,7 @@ export const SESSION_GROUPS: SessionGroup[] = [
|
||||
row('PATCH', '/v1/site/{id}', 'Edit a site'),
|
||||
row('PATCH', '/v1/site/{id}/default', 'Make a site the default'),
|
||||
row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'),
|
||||
row('PUT', '/v1/site/{key}/languages', 'Replace the content languages of a site'),
|
||||
row('POST', '/v1/author/', 'Create an author'),
|
||||
row(
|
||||
'PATCH',
|
||||
@@ -1811,6 +1849,13 @@ export const SESSION_GROUPS: SessionGroup[] = [
|
||||
row('POST', '/v1/author/me', 'Create your own author profile, as any verified user'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Language setup · owner only',
|
||||
rows: [
|
||||
row('GET', '/v1/language/setup', 'Sites that still need a default language'),
|
||||
row('POST', '/v1/language/setup', 'Confirm the default language of every pending site'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Image variants',
|
||||
rows: [
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { localizationMessages } from './localizationMessages'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/localization/AddLanguageDialog.vue',
|
||||
'components/localization/LanguageActionDialog.vue',
|
||||
'components/localization/LanguageRows.vue',
|
||||
'components/localization/LocaleDialog.vue',
|
||||
'components/localization/SocialLocaleDialog.vue',
|
||||
'composables/usePublicUrlPage.ts',
|
||||
'composables/useSiteLanguagesPage.ts',
|
||||
'lib/languageCopy.ts',
|
||||
'views/LanguageSetupView.vue',
|
||||
'views/PublicUrlView.vue',
|
||||
'views/SiteLanguagesView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set(['Setup', 'Disable', 'Sites', '1 article', '{0} articles'])
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
const NUMBERED = /\{\d+\}/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Localization messages', () => {
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
it('finds the strings of the three pages', () => {
|
||||
expect(keys.length).toBeGreaterThan(120)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has its own French entry',
|
||||
(_file, key) => {
|
||||
const french = (messages as Record<string, string>)[key as string]
|
||||
|
||||
expect(french).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.each(Object.entries(localizationMessages))(
|
||||
'keeps the placeholders of "%s" in French',
|
||||
(english, french) => {
|
||||
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
|
||||
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
|
||||
)
|
||||
expect(french.includes('{lang}')).toBe(english.includes('{lang}'))
|
||||
expect(french.includes('{slug}')).toBe(english.includes('{slug}'))
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
export const localizationMessages = {
|
||||
Languages: 'Langues',
|
||||
'{0} · Languages': '{0} · Langues',
|
||||
'Back to Sites': 'Retour aux sites',
|
||||
'Content languages for {0}. Each article version is written in one language.':
|
||||
'Langues de contenu de {0}. Chaque version d’article est écrite dans une seule langue.',
|
||||
'Add language': 'Ajouter une langue',
|
||||
"Couldn't load languages": 'Impossible de charger les langues',
|
||||
'Only owners can change languages': 'Seuls les propriétaires peuvent modifier les langues',
|
||||
'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.':
|
||||
'Vous pouvez voir quelles langues sont activées, leur locale sociale et le nombre de versions de chacune. Demandez à un propriétaire de les modifier.',
|
||||
'Set up languages': 'Configurer les langues',
|
||||
"Existing articles don't have a language yet. Confirm or change the language for each site.":
|
||||
'Les articles existants n’ont pas encore de langue. Confirmez ou changez la langue de chaque site.',
|
||||
'{0} is the default': '{0} est la langue par défaut',
|
||||
'Choose another default first, then disable {0}.':
|
||||
'Choisissez d’abord une autre langue par défaut, puis désactivez {0}.',
|
||||
'At least one language must stay enabled': 'Au moins une langue doit rester activée',
|
||||
'{0} needs a language for new articles.':
|
||||
'{0} a besoin d’une langue pour les nouveaux articles.',
|
||||
'Languages changed. Reload.': 'Langues modifiées. Rechargez.',
|
||||
"Another owner changed the languages for {0} while this page was open. Reload to see the current list. Your change wasn't applied.":
|
||||
'Un autre propriétaire a modifié les langues de {0} pendant que cette page était ouverte. Rechargez pour voir la liste actuelle. Votre modification n’a pas été appliquée.',
|
||||
"Couldn't change the default language": 'Impossible de changer la langue par défaut',
|
||||
"Couldn't remove {0}": 'Impossible de retirer {0}',
|
||||
'Nothing changed. {0} is still the default. Try again.':
|
||||
'Rien n’a changé. {0} reste la langue par défaut. Réessayez.',
|
||||
'Nothing changed. {0} is still listed. Try again.':
|
||||
'Rien n’a changé. {0} figure toujours dans la liste. Réessayez.',
|
||||
'{0} uses one language': '{0} utilise une seule langue',
|
||||
'New articles are written in {0}. Add a language to write translations and publish them separately.':
|
||||
'Les nouveaux articles sont rédigés en {0}. Ajoutez une langue pour écrire des traductions et les publier séparément.',
|
||||
'Social locale': 'Locale sociale',
|
||||
'Regional language kept: {0}': 'Langue régionale conservée : {0}',
|
||||
'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.':
|
||||
'La configuration a conservé la locale complète du site : la langue de contenu par défaut est donc {0}, et non {1} seul. La locale du site continue de formater les dates et les nombres. Locale sociale : {2}, tirée du code de langue.',
|
||||
'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.':
|
||||
'Désactiver une langue garde ses versions lisibles et modifiables, et les versions publiées restent en ligne. Les brouillons peuvent être publiés de nouveau après la réactivation. Locale sociale : Dérivée vient du code de langue, Définie est choisie par un propriétaire, Non définie omet og:locale.',
|
||||
'Public URL': 'URL publique',
|
||||
'{0} · Public URL': '{0} · URL publique',
|
||||
'Where {0} articles appear on your website. Used for canonical and alternate links.':
|
||||
'L’adresse où les articles de {0} apparaissent sur votre site web. Utilisée pour les liens canoniques et alternatifs.',
|
||||
'Only owners can change this': 'Seuls les propriétaires peuvent modifier ceci',
|
||||
'You can read the pattern. Ask an owner to change it.':
|
||||
'Vous pouvez lire le modèle. Demandez à un propriétaire de le modifier.',
|
||||
'Public URL not configured': 'URL publique non configurée',
|
||||
'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.':
|
||||
'Les adresses de page, canoniques et alternatives restent masquées tant que vous n’ajoutez pas de modèle. Les liens par identifiant d’article fonctionnent toujours.',
|
||||
"Couldn't save languages": 'Impossible d’enregistrer les langues',
|
||||
'Nothing changed. Your selection is kept. Try again.':
|
||||
'Rien n’a changé. Votre sélection est conservée. Réessayez.',
|
||||
'Public URL pattern': 'Modèle d’URL publique',
|
||||
'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.':
|
||||
'Variables : {lang} est le code de langue, comme fr ou pt-BR. {slug} est l’adresse de l’article dans cette langue.',
|
||||
Setup: 'Configuration',
|
||||
'Site locale {0}': 'Locale du site {0}',
|
||||
'No site locale': 'Aucune locale de site',
|
||||
'Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}.':
|
||||
'Seule la langue plus large {0} ({1}) est prise en charge. Les pages et l’API indiqueront {1}, et non {2}.',
|
||||
'Seeded from site locale {0}. Confirm or change.':
|
||||
'Issue de la locale du site {0}. Confirmez ou changez.',
|
||||
'Required before you can apply.': 'Obligatoire avant de pouvoir appliquer.',
|
||||
'Seeded from site locale {0}. The full locale is kept. Confirm or change it.':
|
||||
'Issue de la locale du site {0}. La locale complète est conservée. Confirmez ou changez-la.',
|
||||
'Set article languages': 'Définir les langues des articles',
|
||||
'Back to Languages': 'Retour aux langues',
|
||||
'Content languages for {0}.': 'Langues de contenu de {0}.',
|
||||
'Languages assigned': 'Langues attribuées',
|
||||
'Nothing was translated, and no articles were grouped.':
|
||||
'Rien n’a été traduit et aucun article n’a été regroupé.',
|
||||
'1 article now has a language. Nothing was translated, and no articles were grouped.':
|
||||
'1 article a maintenant une langue. Rien n’a été traduit et aucun article n’a été regroupé.',
|
||||
'{0} articles now have a language. Nothing was translated, and no articles were grouped.':
|
||||
'{0} articles ont maintenant une langue. Rien n’a été traduit et aucun article n’a été regroupé.',
|
||||
'Open Articles': 'Ouvrir les articles',
|
||||
'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.':
|
||||
'La mise à jour a échoué : aucun article n’a encore de langue. Vos choix sont conservés. Réessayez. L’exécuter deux fois est sans risque.',
|
||||
'Setup is already running': 'La configuration est déjà en cours',
|
||||
"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.":
|
||||
'Un autre propriétaire l’a lancée. Les langues apparaissent ici quand elle se termine. Les articles créés entre-temps reçoivent la langue par défaut de leur site. La relancer est sans risque : les sites qui ont déjà une langue sont ignorés.',
|
||||
'Check again': 'Vérifier à nouveau',
|
||||
'Assigning languages': 'Attribution des langues',
|
||||
"Articles created while this runs get the site's default language.":
|
||||
'Les articles créés pendant l’opération reçoivent la langue par défaut du site.',
|
||||
'Choose a language for {0}': 'Choisissez une langue pour {0}',
|
||||
'{0} has no site locale, so there is nothing to start from. Choose the language its articles are written in.':
|
||||
'{0} n’a pas de locale de site, il n’y a donc rien dont partir. Choisissez la langue dans laquelle ses articles sont écrits.',
|
||||
'{0} have no site locale, so there is nothing to start from. Choose the language their articles are written in.':
|
||||
'{0} n’ont pas de locale de site, il n’y a donc rien dont partir. Choisissez la langue dans laquelle leurs articles sont écrits.',
|
||||
"This labels articles. It doesn't translate them.":
|
||||
'Cela étiquette les articles. Cela ne les traduit pas.',
|
||||
"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.":
|
||||
'Chaque article existant devient la seule version de son propre groupe d’articles. La langue de chaque site part de sa locale en gardant la région : pt-BR reste portugais (Brésil). Rien n’est appliqué avant votre confirmation.',
|
||||
'Confirm the default language': 'Confirmer la langue par défaut',
|
||||
'{0} has no articles yet. New articles will be written in this language.':
|
||||
'{0} n’a pas encore d’articles. Les nouveaux articles seront rédigés dans cette langue.',
|
||||
'Default content language': 'Langue de contenu par défaut',
|
||||
'Choose a language': 'Choisir une langue',
|
||||
'Assigning…': 'Attribution…',
|
||||
'Language for {0}': 'Langue de {0}',
|
||||
'Confirm and assign to {0} articles': 'Confirmer et attribuer à {0} articles',
|
||||
'Counts are articles in 1 site.': 'Les totaux comptent les articles de 1 site.',
|
||||
'Counts are articles in {0} sites.': 'Les totaux comptent les articles de {0} sites.',
|
||||
'{0} · Disabled — re-enable instead': '{0} · Désactivée — réactivez-la plutôt',
|
||||
'{0} is already enabled for {1}.': '{0} est déjà activée pour {1}.',
|
||||
'Derived: {0}': 'Dérivée : {0}',
|
||||
'Not set': 'Non définie',
|
||||
"{0} isn't available": '{0} n’est pas disponible',
|
||||
"Only the broader language {0} ({1}) is supported. Pages and the API will say {1}, not {2}. Articles written in this language can't be told apart from other {0}.":
|
||||
'Seule la langue plus large {0} ({1}) est prise en charge. Les pages et l’API indiqueront {1}, et non {2}. Les articles écrits dans cette langue ne se distinguent pas des autres articles en {0}.',
|
||||
"Your change wasn't applied.": 'Votre modification n’a pas été appliquée.',
|
||||
'Add a language': 'Ajouter une langue',
|
||||
'Choose a language for {0}. Regional variants such as English (United Kingdom) are separate entries.':
|
||||
'Choisissez une langue pour {0}. Les variantes régionales comme l’anglais (Royaume-Uni) sont des entrées distinctes.',
|
||||
'Choose a language for {0}.': 'Choisissez une langue pour {0}.',
|
||||
'Add {0} ({1}) instead': 'Ajouter {0} ({1}) à la place',
|
||||
'Language code': 'Code de langue',
|
||||
'Used in the API and in page language metadata.':
|
||||
'Utilisé dans l’API et dans les métadonnées de langue des pages.',
|
||||
'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.':
|
||||
'Tirée du code de langue. Pour un code sans région, comme fr, un propriétaire en définit une ou la laisse vide et og:locale est omis.',
|
||||
"Existing versions keep their language, status and links. Public requests that name no language will resolve {0} where a published {0} version exists, and return not found where one doesn't. Requests that name a language aren't affected.":
|
||||
'Les versions existantes gardent leur langue, leur statut et leurs liens. Les requêtes publiques qui ne nomment aucune langue renverront {0} lorsqu’une version {0} publiée existe, et « introuvable » sinon. Les requêtes qui nomment une langue ne sont pas affectées.',
|
||||
'Make {0} the default language?': 'Faire de {0} la langue par défaut ?',
|
||||
'Disable {0}?': 'Désactiver {0} ?',
|
||||
'Re-enable {0}?': 'Réactiver {0} ?',
|
||||
"{0} can't be removed": '{0} ne peut pas être retirée',
|
||||
'New articles start in the default language.':
|
||||
'Les nouveaux articles commencent dans la langue par défaut.',
|
||||
"New articles start in the default language, and it is the first choice for each article's title in the list.":
|
||||
'Les nouveaux articles commencent dans la langue par défaut, qui est le premier choix pour le titre de chaque article dans la liste.',
|
||||
'Writers can create {0} versions again.':
|
||||
'Les rédacteurs peuvent de nouveau créer des versions en {0}.',
|
||||
'No article uses {0}.': 'Aucun article n’utilise {0}.',
|
||||
'No article uses {0}. You can add it again later.':
|
||||
'Aucun article n’utilise {0}. Vous pourrez la rajouter plus tard.',
|
||||
'Nothing changed. Try again.': 'Rien n’a changé. Réessayez.',
|
||||
"Couldn't re-enable {0}": 'Impossible de réactiver {0}',
|
||||
'Nothing changed. {0} is still disabled. Try again.':
|
||||
'Rien n’a changé. {0} est toujours désactivée. Réessayez.',
|
||||
'Make {0} default': 'Faire de {0} la langue par défaut',
|
||||
'Disable {0}': 'Désactiver {0}',
|
||||
'Re-enable {0}': 'Réactiver {0}',
|
||||
'No content changes': 'Aucun changement de contenu',
|
||||
'Nothing is deleted or unpublished': 'Rien n’est supprimé ni dépublié',
|
||||
Disable: 'Désactiver',
|
||||
'Edit social locale for {0}': 'Modifier la locale sociale de {0}',
|
||||
'Re-enable': 'Réactiver',
|
||||
'Use letters and one underscore, for example {0}.':
|
||||
'Utilisez des lettres et un seul tiret bas, par exemple {0}.',
|
||||
'Social locale for {0}': 'Locale sociale de {0}',
|
||||
'Used for og:locale on {0} pages. Leave it empty to omit the tag.':
|
||||
'Utilisée pour og:locale sur les pages en {0}. Laissez vide pour omettre la balise.',
|
||||
'Save social locale': 'Enregistrer la locale sociale',
|
||||
'Format: language_REGION, for example {0}. Letters only, one underscore.':
|
||||
'Format : langue_RÉGION, par exemple {0}. Lettres uniquement, un seul tiret bas.',
|
||||
'Derived from the language code': 'Dérivée du code de langue',
|
||||
'{0} gives {1}. Setting a value replaces the derived one.':
|
||||
'{0} donne {1}. Définir une valeur remplace la valeur dérivée.',
|
||||
'1 version · {0} published': '1 version · {0} publiée',
|
||||
'{0} versions · {1} published': '{0} versions · {1} publiées',
|
||||
'Set: {0}': 'Définie : {0}',
|
||||
'1 draft': '1 brouillon',
|
||||
'{0} drafts': '{0} brouillons',
|
||||
'{0} published': '{0} publiées',
|
||||
' and ': ' et ',
|
||||
'{0} is used by 1 version: {1}.': '{0} est utilisée par 1 version : {1}.',
|
||||
'{0} is used by {1} versions: {2}.': '{0} est utilisée par {1} versions : {2}.',
|
||||
'The published {0} version stays online.': 'La version {0} publiée reste en ligne.',
|
||||
'The {0} published {1} versions stay online.':
|
||||
'Les {0} versions {1} publiées restent en ligne.',
|
||||
'The version stays readable and editable.': 'La version reste lisible et modifiable.',
|
||||
'All {0} versions stay readable and editable.':
|
||||
'Les {0} versions restent lisibles et modifiables.',
|
||||
"The draft can't be published until you re-enable {0}.":
|
||||
'Le brouillon ne peut pas être publié avant la réactivation de {0}.',
|
||||
"The {0} drafts can't be published until you re-enable {1}.":
|
||||
'Les {0} brouillons ne peuvent pas être publiés avant la réactivation de {1}.',
|
||||
'Nobody can create new {0} versions.': 'Personne ne peut créer de nouvelles versions en {0}.',
|
||||
'Writers can create {0} versions again. Nothing else changes.':
|
||||
'Les rédacteurs peuvent de nouveau créer des versions en {0}. Rien d’autre ne change.',
|
||||
'Writers can create {0} versions again. The 1 existing {0} version stays as it is. Nothing else changes.':
|
||||
'Les rédacteurs peuvent de nouveau créer des versions en {0}. La version {0} existante reste telle quelle. Rien d’autre ne change.',
|
||||
'Writers can create {0} versions again. The {1} existing {0} versions stay as they are. Nothing else changes.':
|
||||
'Les rédacteurs peuvent de nouveau créer des versions en {0}. Les {1} versions {0} existantes restent telles quelles. Rien d’autre ne change.',
|
||||
'{0} is used by 1 version. Disable the language to keep it, or delete it first.':
|
||||
'{0} est utilisée par 1 version. Désactivez la langue pour la garder, ou supprimez-la d’abord.',
|
||||
'{0} is used by {1} versions. Disable the language to keep it, or delete it first.':
|
||||
'{0} est utilisée par {1} versions. Désactivez la langue pour les garder, ou supprimez-les d’abord.',
|
||||
'{0} is used by 1 version.': '{0} est utilisée par 1 version.',
|
||||
'{0} is used by {1} versions.': '{0} est utilisée par {1} versions.',
|
||||
'{0} added': '{0} ajoutée',
|
||||
'Writers can now create {0} versions.':
|
||||
'Les rédacteurs peuvent maintenant créer des versions en {0}.',
|
||||
'{0} re-enabled': '{0} réactivée',
|
||||
'Writers can create {0} versions again. Drafts can be published.':
|
||||
'Les rédacteurs peuvent de nouveau créer des versions en {0}. Les brouillons peuvent être publiés.',
|
||||
'Start with https:// or http://.': 'Commencez par https:// ou http://.',
|
||||
'Unknown placeholder {0}. Use {lang} and {slug}.':
|
||||
'Variable inconnue {0}. Utilisez {lang} et {slug}.',
|
||||
'Unknown placeholder. Use {lang} and {slug}.': 'Variable inconnue. Utilisez {lang} et {slug}.',
|
||||
'Remove spaces and characters that need encoding. Use letters, digits, - and _.':
|
||||
'Retirez les espaces et les caractères à encoder. Utilisez des lettres, des chiffres, - et _.',
|
||||
'Add {slug} so each article has its own address.':
|
||||
'Ajoutez {slug} pour que chaque article ait sa propre adresse.',
|
||||
'Preview with "{0}" in {1}': 'Aperçu avec « {0} » en {1}',
|
||||
'Public URL saved': 'URL publique enregistrée',
|
||||
'{0} pages now use this pattern.': 'Les pages de {0} utilisent maintenant ce modèle.',
|
||||
'List the content languages of a site: the default first, then the other enabled ones, then the disabled ones. Each carries its version and published counts and its social locale. Send the revision back when you change them. A site with no confirmed default answers needs_setup true and no languages.':
|
||||
'Liste les langues de contenu d’un site : la langue par défaut d’abord, puis les autres langues activées, puis les désactivées. Chacune porte son nombre de versions et de versions publiées, et sa locale sociale. Renvoyez la révision quand vous les modifiez. Un site sans langue par défaut confirmée répond needs_setup true et aucune langue.',
|
||||
'Replace the content languages of a site': 'Remplacer les langues de contenu d’un site',
|
||||
'Language setup · owner only': 'Configuration des langues · propriétaire uniquement',
|
||||
'Sites that still need a default language':
|
||||
'Sites qui ont encore besoin d’une langue par défaut',
|
||||
'Confirm the default language of every pending site':
|
||||
'Confirmer la langue par défaut de chaque site en attente',
|
||||
} as const
|
||||
@@ -1,4 +1,7 @@
|
||||
import { localizationMessages } from './localizationMessages'
|
||||
|
||||
export const messages = {
|
||||
...localizationMessages,
|
||||
'Not found': 'Introuvable',
|
||||
'Edit article': 'Modifier l’article',
|
||||
unselected: 'désélectionnée',
|
||||
|
||||
@@ -65,10 +65,10 @@ describe('index', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(85)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42)
|
||||
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37)
|
||||
it('holds 89 endpoints: 43 documented, 40 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(89)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(43)
|
||||
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(40)
|
||||
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
|
||||
})
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('matching', () => {
|
||||
expect(routes(login.sections)).toEqual(['section Session only'])
|
||||
expect(login.sections[0]!.entry).toMatchObject({
|
||||
locked: true,
|
||||
description: '37 routes, not available to API keys',
|
||||
description: '40 routes, not available to API keys',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('matching', () => {
|
||||
describe('custom content types', () => {
|
||||
it.each([
|
||||
['media', 16, 1],
|
||||
['404', 23, 1],
|
||||
['404', 24, 1],
|
||||
['login', 1, 1],
|
||||
['entry', 7, 1],
|
||||
['capacity', 2, 0],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
checkPublicUrlPattern,
|
||||
previewPublicUrl,
|
||||
unknownPlaceholderOf,
|
||||
} from '@/lib/publicUrlPattern'
|
||||
|
||||
describe('Public URL pattern checks', () => {
|
||||
it.each([
|
||||
['https://journal.example.com/{lang}/{slug}', null],
|
||||
['https://journal.example.com/{lang}/articles/{slug}', null],
|
||||
['http://localhost:3000/{slug}', null],
|
||||
['https://journal.example.com/{slug}/{slug}', null],
|
||||
['https://journal.example.com/a%20b/{slug}', null],
|
||||
[' https://journal.example.com/{slug} ', null],
|
||||
['', null],
|
||||
['ftp://journal.example.com/{lang}/{slug}', 'invalid_scheme'],
|
||||
['journal.example.com/{slug}', 'invalid_scheme'],
|
||||
['https://', 'invalid_host'],
|
||||
['https:///{slug}', 'invalid_host'],
|
||||
['https://journal.example.com/{language}/{slug}', 'unknown_placeholder'],
|
||||
['https://journal.example.com/{lang', 'unknown_placeholder'],
|
||||
['https://journal.example.com/lang}/{slug}', 'unknown_placeholder'],
|
||||
['https://journal.example.com/my pages/{lang}/{slug}', 'not_encoded'],
|
||||
['https://journal.example.com/é/{slug}', 'not_encoded'],
|
||||
['https://journal.example.com/100%/{slug}', 'not_encoded'],
|
||||
['https://journal.example.com/{lang}', 'missing_slug'],
|
||||
['ftp://x.example.com/{language} here', 'invalid_scheme'],
|
||||
['https://x.example.com/{language} here', 'unknown_placeholder'],
|
||||
['https://x.example.com/a b', 'not_encoded'],
|
||||
])('checks %j as %s', (value, expected) => {
|
||||
expect(checkPublicUrlPattern(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('names the first placeholder that is not known', () => {
|
||||
expect(unknownPlaceholderOf('https://x.example.com/{language}/{slug}')).toBe('{language}')
|
||||
expect(unknownPlaceholderOf('https://x.example.com/{lang')).toBe('{lang')
|
||||
expect(unknownPlaceholderOf('https://x.example.com/{lang}/{slug}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Public URL preview', () => {
|
||||
it('replaces every placeholder', () => {
|
||||
expect(
|
||||
previewPublicUrl('https://journal.example.com/{lang}/{slug}/{slug}', 'fr', 'guide'),
|
||||
).toBe('https://journal.example.com/fr/guide/guide')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
effectiveSocialLocale,
|
||||
isSocialLocaleInput,
|
||||
languageName,
|
||||
derivedSocialLocale,
|
||||
languagesUpdate,
|
||||
socialLocaleExample,
|
||||
withDefault,
|
||||
withEnabled,
|
||||
withLanguage,
|
||||
withoutLanguage,
|
||||
withSocialLocale,
|
||||
type SiteLanguages,
|
||||
} from '@/lib/siteLanguages'
|
||||
|
||||
function journal(): SiteLanguages {
|
||||
const row = (lang: string, enabled: boolean, extra = {}) => ({
|
||||
lang,
|
||||
enabled,
|
||||
is_default: lang === 'en',
|
||||
version_count: 1,
|
||||
published_count: 1,
|
||||
social_locale: null,
|
||||
social_locale_state: 'not_set' as const,
|
||||
...extra,
|
||||
})
|
||||
return {
|
||||
revision: 'r1',
|
||||
default: 'en',
|
||||
needs_setup: false,
|
||||
languages: [
|
||||
row('en', true),
|
||||
row('fr', true, { social_locale: 'fr_FR', social_locale_state: 'set' }),
|
||||
row('es', false),
|
||||
],
|
||||
available: [{ lang: 'pt-BR', substitute: null }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Language names', () => {
|
||||
it.each([
|
||||
['en', 'en', 'English'],
|
||||
['pt-BR', 'en', 'Portuguese (Brazil)'],
|
||||
['en-GB', 'en', 'English (United Kingdom)'],
|
||||
['pt-PT', 'en', 'Portuguese (Portugal)'],
|
||||
['fr', 'fr', 'Français'],
|
||||
])('names %s in %s as %s', (tag, locale, expected) => {
|
||||
expect(languageName(tag, locale as 'en' | 'fr')).toBe(expected)
|
||||
})
|
||||
|
||||
it('falls back to the tag when it has no name', () => {
|
||||
expect(languageName('not a tag', 'en')).toBe('not a tag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Social locale', () => {
|
||||
it('is explicit when set, derived from a regional tag, and absent otherwise', () => {
|
||||
const rows = journal().languages
|
||||
expect(effectiveSocialLocale(rows[1]!)).toBe('fr_FR')
|
||||
expect(effectiveSocialLocale(rows[0]!)).toBeNull()
|
||||
expect(
|
||||
effectiveSocialLocale({
|
||||
...rows[0]!,
|
||||
lang: 'pt-BR',
|
||||
social_locale_state: 'derived',
|
||||
}),
|
||||
).toBe('pt_BR')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fr_FR', 'fr', true],
|
||||
['fr_CA', 'fr', true],
|
||||
['fil_PH', 'fil', true],
|
||||
['fr-FR', 'fr', false],
|
||||
['fr', 'fr', false],
|
||||
['', 'fr', false],
|
||||
['pt_BR', 'fr', false],
|
||||
['fr_', 'fr', false],
|
||||
['fr_FRA', 'fr', false],
|
||||
])('checks %j on %s as %s', (value, tag, expected) => {
|
||||
expect(isSocialLocaleInput(value, tag)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Building the full state to send', () => {
|
||||
it('sends every row with the revision and the default', () => {
|
||||
expect(languagesUpdate(journal())).toEqual({
|
||||
revision: 'r1',
|
||||
default: 'en',
|
||||
languages: [
|
||||
{ lang: 'en', enabled: true, social_locale: null },
|
||||
{ lang: 'fr', enabled: true, social_locale: 'fr_FR' },
|
||||
{ lang: 'es', enabled: false, social_locale: null },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a language as an enabled row', () => {
|
||||
const body = languagesUpdate(withLanguage(journal(), 'pt-BR'))
|
||||
expect(body.languages.at(-1)).toEqual({
|
||||
lang: 'pt-BR',
|
||||
enabled: true,
|
||||
social_locale: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('changes the default without touching enabled flags', () => {
|
||||
const body = languagesUpdate(withDefault(journal(), 'fr'))
|
||||
expect(body.default).toBe('fr')
|
||||
expect(body.languages.map((row) => row.enabled)).toEqual([true, true, false])
|
||||
})
|
||||
|
||||
it('disables and re-enables one language', () => {
|
||||
const off = languagesUpdate(withEnabled(journal(), 'fr', false))
|
||||
expect(off.languages[1]!.enabled).toBe(false)
|
||||
const on = languagesUpdate(withEnabled(journal(), 'es', true))
|
||||
expect(on.languages[2]!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('removes a language', () => {
|
||||
const body = languagesUpdate(withoutLanguage(journal(), 'es'))
|
||||
expect(body.languages.map((row) => row.lang)).toEqual(['en', 'fr'])
|
||||
})
|
||||
|
||||
it('sets and clears a social locale', () => {
|
||||
expect(
|
||||
languagesUpdate(withSocialLocale(journal(), 'fr', 'fr_CA')).languages[1]!.social_locale,
|
||||
).toBe('fr_CA')
|
||||
expect(
|
||||
languagesUpdate(withSocialLocale(journal(), 'fr', null)).languages[1]!.social_locale,
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Social locale examples', () => {
|
||||
it.each([
|
||||
['fr', 'fr_FR'],
|
||||
['en', 'en_US'],
|
||||
['pt-BR', 'pt_BR'],
|
||||
['en-GB', 'en_GB'],
|
||||
['zh-Hans', 'zh_CN'],
|
||||
['de', 'de_DE'],
|
||||
])('suggests %s as %s', (tag, expected) => {
|
||||
expect(socialLocaleExample(tag)).toBe(expected)
|
||||
})
|
||||
|
||||
it('derives a value only from a tag with a region', () => {
|
||||
expect(derivedSocialLocale('pt-BR')).toBe('pt_BR')
|
||||
expect(derivedSocialLocale('fr')).toBeNull()
|
||||
expect(derivedSocialLocale('zh-Hans')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { t } from '@/i18n'
|
||||
import { effectiveSocialLocale, type SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
export function versionsLine(language: SiteLanguage) {
|
||||
return language.version_count === 1
|
||||
? t('1 version · {0} published', [language.published_count])
|
||||
: t('{0} versions · {1} published', [language.version_count, language.published_count])
|
||||
}
|
||||
|
||||
export function socialLocaleLabel(language: SiteLanguage) {
|
||||
const value = effectiveSocialLocale(language)
|
||||
if (language.social_locale_state === 'set') {
|
||||
return t('Set: {0}', [value ?? ''])
|
||||
}
|
||||
if (language.social_locale_state === 'derived') {
|
||||
return t('Derived: {0}', [value ?? ''])
|
||||
}
|
||||
return t('Not set')
|
||||
}
|
||||
|
||||
function draftsOf(language: SiteLanguage) {
|
||||
return language.version_count - language.published_count
|
||||
}
|
||||
|
||||
function usageParts(language: SiteLanguage) {
|
||||
const drafts = draftsOf(language)
|
||||
const parts: string[] = []
|
||||
if (drafts > 0) {
|
||||
parts.push(drafts === 1 ? t('1 draft') : t('{0} drafts', [drafts]))
|
||||
}
|
||||
if (language.published_count > 0) {
|
||||
parts.push(t('{0} published', [language.published_count]))
|
||||
}
|
||||
return parts.join(t(' and '))
|
||||
}
|
||||
|
||||
export function disableDescription(name: string, language: SiteLanguage) {
|
||||
if (language.version_count === 0) {
|
||||
return t('No article uses {0}.', [name])
|
||||
}
|
||||
const count =
|
||||
language.version_count === 1
|
||||
? t('{0} is used by 1 version: {1}.', [name, usageParts(language)])
|
||||
: t('{0} is used by {1} versions: {2}.', [
|
||||
name,
|
||||
language.version_count,
|
||||
usageParts(language),
|
||||
])
|
||||
return count
|
||||
}
|
||||
|
||||
export function disableWarning(name: string, language: SiteLanguage) {
|
||||
const drafts = draftsOf(language)
|
||||
const sentences: string[] = []
|
||||
if (language.published_count === 1) {
|
||||
sentences.push(t('The published {0} version stays online.', [name]))
|
||||
} else if (language.published_count > 1) {
|
||||
sentences.push(
|
||||
t('The {0} published {1} versions stay online.', [language.published_count, name]),
|
||||
)
|
||||
}
|
||||
sentences.push(
|
||||
language.version_count === 1
|
||||
? t('The version stays readable and editable.')
|
||||
: t('All {0} versions stay readable and editable.', [language.version_count]),
|
||||
)
|
||||
if (drafts === 1) {
|
||||
sentences.push(t("The draft can't be published until you re-enable {0}.", [name]))
|
||||
} else if (drafts > 1) {
|
||||
sentences.push(
|
||||
t("The {0} drafts can't be published until you re-enable {1}.", [drafts, name]),
|
||||
)
|
||||
}
|
||||
sentences.push(t('Nobody can create new {0} versions.', [name]))
|
||||
return sentences.join(' ')
|
||||
}
|
||||
|
||||
export function reenableDescription(name: string, language: SiteLanguage) {
|
||||
if (language.version_count === 0) {
|
||||
return t('Writers can create {0} versions again. Nothing else changes.', [name])
|
||||
}
|
||||
return language.version_count === 1
|
||||
? t(
|
||||
'Writers can create {0} versions again. The 1 existing {0} version stays as it is. Nothing else changes.',
|
||||
[name],
|
||||
)
|
||||
: t(
|
||||
'Writers can create {0} versions again. The {1} existing {0} versions stay as they are. Nothing else changes.',
|
||||
[name, language.version_count],
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBlockedDescription(name: string, language: SiteLanguage) {
|
||||
return language.version_count === 1
|
||||
? t('{0} is used by 1 version. Disable the language to keep it, or delete it first.', [
|
||||
name,
|
||||
])
|
||||
: t('{0} is used by {1} versions. Disable the language to keep it, or delete it first.', [
|
||||
name,
|
||||
language.version_count,
|
||||
])
|
||||
}
|
||||
|
||||
export function usedByLine(name: string, language: SiteLanguage) {
|
||||
return language.version_count === 1
|
||||
? t('{0} is used by 1 version.', [name])
|
||||
: t('{0} is used by {1} versions.', [name, language.version_count])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type PublicUrlPatternError =
|
||||
| 'invalid_scheme'
|
||||
| 'invalid_host'
|
||||
| 'unknown_placeholder'
|
||||
| 'not_encoded'
|
||||
| 'missing_slug'
|
||||
|
||||
export const PUBLIC_URL_PATTERN_MAX_LENGTH = 512
|
||||
|
||||
const HTTP_SCHEME = /^https?:\/\//i
|
||||
const SCHEME_WITH_HOST = /^https?:\/\/[^/?#\s]+/i
|
||||
const BRACE_GROUP = /\{[^{}]*\}?|\}/g
|
||||
const KNOWN_PLACEHOLDERS = new Set(['{lang}', '{slug}'])
|
||||
const ENCODED_CHARACTERS = /^(?:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/
|
||||
|
||||
export function unknownPlaceholderOf(pattern: string) {
|
||||
const found = pattern.match(BRACE_GROUP) ?? []
|
||||
return found.find((token) => !KNOWN_PLACEHOLDERS.has(token)) ?? null
|
||||
}
|
||||
|
||||
export function checkPublicUrlPattern(value: string): PublicUrlPatternError | null {
|
||||
const pattern = value.trim()
|
||||
if (pattern === '') {
|
||||
return null
|
||||
}
|
||||
if (!HTTP_SCHEME.test(pattern)) {
|
||||
return 'invalid_scheme'
|
||||
}
|
||||
if (!SCHEME_WITH_HOST.test(pattern)) {
|
||||
return 'invalid_host'
|
||||
}
|
||||
if (unknownPlaceholderOf(pattern) !== null) {
|
||||
return 'unknown_placeholder'
|
||||
}
|
||||
const literal = pattern.replace(/\{lang\}|\{slug\}/g, '')
|
||||
if (!ENCODED_CHARACTERS.test(literal)) {
|
||||
return 'not_encoded'
|
||||
}
|
||||
return pattern.includes('{slug}') ? null : 'missing_slug'
|
||||
}
|
||||
|
||||
export function previewPublicUrl(pattern: string, lang: string, slug: string) {
|
||||
return pattern
|
||||
.trim()
|
||||
.replace(/\{lang\}/g, lang)
|
||||
.replace(/\{slug\}/g, slug)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export type SocialLocaleState = 'set' | 'derived' | 'not_set'
|
||||
|
||||
export interface SiteLanguage {
|
||||
lang: string
|
||||
enabled: boolean
|
||||
is_default: boolean
|
||||
version_count: number
|
||||
published_count: number
|
||||
social_locale: string | null
|
||||
social_locale_state: SocialLocaleState
|
||||
}
|
||||
|
||||
export interface AvailableLanguage {
|
||||
lang: string
|
||||
substitute: string | null
|
||||
}
|
||||
|
||||
export interface SiteLanguages {
|
||||
revision: string
|
||||
default: string | null
|
||||
needs_setup: boolean
|
||||
languages: SiteLanguage[]
|
||||
available: AvailableLanguage[]
|
||||
}
|
||||
|
||||
export interface LanguageEntry {
|
||||
lang: string
|
||||
enabled: boolean
|
||||
social_locale: string | null
|
||||
}
|
||||
|
||||
export interface LanguagesUpdate {
|
||||
revision: string
|
||||
default: string
|
||||
languages: LanguageEntry[]
|
||||
}
|
||||
|
||||
export interface EditedLanguages {
|
||||
revision: string
|
||||
default: string
|
||||
entries: LanguageEntry[]
|
||||
}
|
||||
|
||||
const SOCIAL_LOCALE_PATTERN = /^[A-Za-z]{2,3}_[A-Za-z]{2}$/
|
||||
|
||||
export function languageName(tag: string, locale: 'en' | 'fr') {
|
||||
let name = tag
|
||||
try {
|
||||
name =
|
||||
new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' }).of(
|
||||
tag,
|
||||
) ?? tag
|
||||
} catch {
|
||||
return tag
|
||||
}
|
||||
return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1)
|
||||
}
|
||||
|
||||
export function baseLanguage(tag: string) {
|
||||
return tag.split('-')[0]!.toLowerCase()
|
||||
}
|
||||
|
||||
export function effectiveSocialLocale(language: SiteLanguage) {
|
||||
if (language.social_locale_state === 'set') {
|
||||
return language.social_locale
|
||||
}
|
||||
if (language.social_locale_state === 'derived') {
|
||||
return language.lang.replace('-', '_')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isSocialLocaleInput(value: string, tag: string) {
|
||||
return (
|
||||
SOCIAL_LOCALE_PATTERN.test(value) &&
|
||||
baseLanguage(value.split('_')[0]!) === baseLanguage(tag)
|
||||
)
|
||||
}
|
||||
|
||||
export function editedLanguages(state: SiteLanguages): EditedLanguages {
|
||||
return {
|
||||
revision: state.revision,
|
||||
default: state.default ?? '',
|
||||
entries: state.languages.map((row) => ({
|
||||
lang: row.lang,
|
||||
enabled: row.enabled,
|
||||
social_locale: row.social_locale,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function mapEntry(
|
||||
state: SiteLanguages,
|
||||
tag: string,
|
||||
change: (entry: LanguageEntry) => LanguageEntry,
|
||||
): EditedLanguages {
|
||||
const edited = editedLanguages(state)
|
||||
return {
|
||||
...edited,
|
||||
entries: edited.entries.map((entry) => (entry.lang === tag ? change(entry) : entry)),
|
||||
}
|
||||
}
|
||||
|
||||
export function withLanguage(state: SiteLanguages, tag: string): EditedLanguages {
|
||||
const edited = editedLanguages(state)
|
||||
return {
|
||||
...edited,
|
||||
entries: [...edited.entries, { lang: tag, enabled: true, social_locale: null }],
|
||||
}
|
||||
}
|
||||
|
||||
export function withDefault(state: SiteLanguages, tag: string): EditedLanguages {
|
||||
return { ...editedLanguages(state), default: tag }
|
||||
}
|
||||
|
||||
export function withEnabled(state: SiteLanguages, tag: string, enabled: boolean) {
|
||||
return mapEntry(state, tag, (entry) => ({ ...entry, enabled }))
|
||||
}
|
||||
|
||||
export function withoutLanguage(state: SiteLanguages, tag: string): EditedLanguages {
|
||||
const edited = editedLanguages(state)
|
||||
return { ...edited, entries: edited.entries.filter((entry) => entry.lang !== tag) }
|
||||
}
|
||||
|
||||
export function withSocialLocale(state: SiteLanguages, tag: string, value: string | null) {
|
||||
return mapEntry(state, tag, (entry) => ({ ...entry, social_locale: value }))
|
||||
}
|
||||
|
||||
export function languagesUpdate(change: SiteLanguages | EditedLanguages): LanguagesUpdate {
|
||||
const edited = 'entries' in change ? change : editedLanguages(change)
|
||||
return { revision: edited.revision, default: edited.default, languages: edited.entries }
|
||||
}
|
||||
|
||||
const EXAMPLE_REGIONS: Record<string, string> = { en: 'US', ar: 'EG', ja: 'JP', zh: 'CN', pt: 'PT' }
|
||||
|
||||
export function regionOf(tag: string) {
|
||||
return tag
|
||||
.split('-')
|
||||
.slice(1)
|
||||
.find((part) => /^[A-Za-z]{2}$/.test(part))
|
||||
?.toUpperCase()
|
||||
}
|
||||
|
||||
export function socialLocaleExample(tag: string) {
|
||||
const base = baseLanguage(tag)
|
||||
const region = regionOf(tag) ?? EXAMPLE_REGIONS[base] ?? base.toUpperCase()
|
||||
return `${base}_${region}`
|
||||
}
|
||||
|
||||
export function derivedSocialLocale(tag: string) {
|
||||
const region = regionOf(tag)
|
||||
return region ? `${baseLanguage(tag)}_${region}` : null
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import { createAppRouter, resolveAuthRedirect } from '@/router'
|
||||
import { isRouteAllowed } from '@/lib/routeAccess'
|
||||
import { accessFor } from '@/test/access'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const router = createAppRouter(createMemoryHistory())
|
||||
|
||||
function meta(path: string) {
|
||||
return router.resolve(path).meta
|
||||
}
|
||||
|
||||
describe('Localization routes', () => {
|
||||
it.each([
|
||||
['/settings/sites/journal/languages'],
|
||||
['/settings/sites/journal/public-url'],
|
||||
['/settings/sites/journal/languages/setup'],
|
||||
])('%s needs a verified session', (path) => {
|
||||
expect(meta(path).auth).toBe('verified')
|
||||
})
|
||||
|
||||
it.each([['/settings/sites/journal/languages'], ['/settings/sites/journal/public-url']])(
|
||||
'%s is open to every verified user',
|
||||
(path) => {
|
||||
expect(meta(path).access).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the setup page for the permission to manage sites', () => {
|
||||
const access = meta('/settings/sites/journal/languages/setup').access as { key: string }
|
||||
expect(access.key).toBe('settings.manage_sites')
|
||||
const owner = new Set(accessFor('owner')!.permissions.map((grant) => grant.key))
|
||||
const reader = new Set(accessFor('viewer')!.permissions.map((grant) => grant.key))
|
||||
expect(isRouteAllowed(access, {}, (key) => owner.has(key))).toBe(true)
|
||||
expect(isRouteAllowed(access, {}, (key) => reader.has(key))).toBe(false)
|
||||
})
|
||||
|
||||
it('passes the site key as a route parameter', () => {
|
||||
expect(router.resolve('/settings/sites/archive/languages').params.key).toBe('archive')
|
||||
expect(router.resolve('/settings/sites/archive/languages/setup').params.key).toBe('archive')
|
||||
expect(router.resolve('/settings/sites/archive/public-url').params.key).toBe('archive')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/settings/sites/journal/languages'],
|
||||
['/settings/sites/journal/public-url'],
|
||||
['/settings/sites/journal/languages/setup'],
|
||||
])('redirects an anonymous visitor of %s to the login page', (path) => {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.status = 'ready'
|
||||
session.session = null
|
||||
const destination = resolveAuthRedirect(router.resolve(path), session)
|
||||
|
||||
expect(destination).toEqual({
|
||||
path: '/login',
|
||||
query: { redirect: path, reason: 'expired' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -227,6 +227,24 @@ const routes: RouteRecordRaw[] = [
|
||||
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/sites/:key/languages',
|
||||
component: () => import('@/views/SiteLanguagesView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
},
|
||||
{
|
||||
path: '/settings/sites/:key/languages/setup',
|
||||
component: gated(
|
||||
'@/views/LanguageSetupView.vue',
|
||||
() => import('@/views/LanguageSetupView.vue'),
|
||||
),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/sites/:key/public-url',
|
||||
component: () => import('@/views/PublicUrlView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
},
|
||||
{
|
||||
path: '/settings/permissions',
|
||||
redirect: '/settings/roles',
|
||||
|
||||
@@ -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