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,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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user