feat: SEO SPA editor integration
This commit is contained in:
1 parent
eb9876fde3
commit
470584400f
62 files changed
+7471
-58
No files matched your search
@@ -0,0 +1,223 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import type { Call } from './directoryTestKit'
|
||||
import {
|
||||
createMetaBackend,
|
||||
mountMeta,
|
||||
renewSites,
|
||||
textOf,
|
||||
type MetaBackend,
|
||||
type MetaMountOptions,
|
||||
} from './metaTestKit'
|
||||
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
export const EDITOR_URL = '/article/12/edit'
|
||||
export const ARTICLE_TITLE = 'How we price a bathroom renovation'
|
||||
export const ARTICLE_SLUG = 'how-we-price-a-bathroom-renovation'
|
||||
export const FIRST_SENTENCE =
|
||||
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||||
export const ARTICLE_BODY = `<p>${FIRST_SENTENCE} Every project starts with a visit.</p><h2>What goes into the price</h2><p>Stripping out and making good.</p>`
|
||||
export const LONG_TITLE =
|
||||
'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
|
||||
export const NORTH_WALL = {
|
||||
id: 1,
|
||||
filename: 'north-wall.jpg',
|
||||
url: '/static/uploadedImages/north-wall.jpg',
|
||||
size: 4089446,
|
||||
content_type: 'image/jpeg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
sha256: 'a'.repeat(64),
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: '2026-09-12T12:00:00Z',
|
||||
used_in: 1,
|
||||
duplicate: false,
|
||||
variant_count: 0,
|
||||
variant_size: 0,
|
||||
}
|
||||
export const KITCHEN_AFTER = { ...NORTH_WALL, id: 2, filename: 'kitchen-after.webp' }
|
||||
|
||||
export const READY = {
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: null },
|
||||
}
|
||||
|
||||
export function suggestion(
|
||||
code: SuggestionCode,
|
||||
field: SeoSuggestion['field'],
|
||||
params: Record<string, number> = {},
|
||||
): SeoSuggestion {
|
||||
return { code, field, params }
|
||||
}
|
||||
|
||||
export function checkAnswer(
|
||||
suggestions: SeoSuggestion[],
|
||||
status: 'ready' | 'suggestions' | 'hidden' = suggestions.length ? 'suggestions' : 'ready',
|
||||
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
|
||||
) {
|
||||
return { status, suggestions, slug }
|
||||
}
|
||||
|
||||
export const FOUR_SUGGESTIONS = [
|
||||
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
suggestion('cover_alt', 'cover_image_alt'),
|
||||
suggestion('no_h', 'body'),
|
||||
suggestion('short', 'body', { length: 40, min: 300 }),
|
||||
]
|
||||
|
||||
export function renewSite(overrides: Partial<Site> = {}): Site {
|
||||
return {
|
||||
...renewSites()[0]!,
|
||||
site_name: 'Renew',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function seoBackend(
|
||||
options: {
|
||||
article?: Partial<MetaBackend['article']>
|
||||
sites?: Site[]
|
||||
check?: unknown
|
||||
role?: 'owner' | 'admin' | 'verified'
|
||||
} = {},
|
||||
) {
|
||||
const backend = createMetaBackend({
|
||||
sites: options.sites ?? [renewSite()],
|
||||
role: options.role,
|
||||
})
|
||||
Object.assign(backend.article, {
|
||||
title: ARTICLE_TITLE,
|
||||
body: ARTICLE_BODY,
|
||||
slug: ARTICLE_SLUG,
|
||||
excerpt: '',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
cover_image_id: null,
|
||||
cover_image_alt: '',
|
||||
social_title: '',
|
||||
social_description: '',
|
||||
social_image_id: null,
|
||||
canonical_url: '',
|
||||
noindex: false,
|
||||
...options.article,
|
||||
})
|
||||
backend.responders['POST /v1/article/12/seo/check'] = () => options.check ?? READY
|
||||
backend.responders['GET /v1/media/'] = () => ({
|
||||
items: [NORTH_WALL, KITCHEN_AFTER],
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 100,
|
||||
counts: { all: 2, unused: 0, duplicates: 0 },
|
||||
})
|
||||
return backend
|
||||
}
|
||||
|
||||
export async function mountSeo(backend: MetaBackend, options: MetaMountOptions = {}) {
|
||||
await mountMeta('editor', EDITOR_URL, { ...options, backend })
|
||||
return backend
|
||||
}
|
||||
|
||||
export function checkCalls(backend: MetaBackend) {
|
||||
return backend.calls.filter(
|
||||
(call) => call.method === 'POST' && call.path.endsWith('/seo/check'),
|
||||
)
|
||||
}
|
||||
|
||||
export function checkBodies(backend: MetaBackend) {
|
||||
return checkCalls(backend).map((call) => call.body)
|
||||
}
|
||||
|
||||
export function patchBodies(backend: MetaBackend) {
|
||||
return backend.calls
|
||||
.filter((call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path))
|
||||
.map((call) => call.body)
|
||||
}
|
||||
|
||||
export function seoCalls(backend: MetaBackend) {
|
||||
return backend.calls.filter((call) => call.method === 'GET' && call.path.endsWith('/seo'))
|
||||
}
|
||||
|
||||
export function answerChecks(backend: MetaBackend, answer: unknown | ((call: Call) => unknown)) {
|
||||
backend.responders['POST /v1/article/12/seo/check'] = (call) =>
|
||||
typeof answer === 'function' ? (answer as (call: Call) => unknown)(call) : answer
|
||||
}
|
||||
|
||||
export function section(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-section"]')
|
||||
}
|
||||
|
||||
export function sectionToggle(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!
|
||||
}
|
||||
|
||||
export function sectionSummary(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-summary"]')
|
||||
}
|
||||
|
||||
export function sectionBody(root: ParentNode = document.body) {
|
||||
return root.querySelector<HTMLElement>('[data-testid="seo-body"]')
|
||||
}
|
||||
|
||||
export async function openSection() {
|
||||
if (!sectionBody()) {
|
||||
sectionToggle().click()
|
||||
await flushPromises()
|
||||
}
|
||||
return sectionBody()!
|
||||
}
|
||||
|
||||
export function panelRoot() {
|
||||
return (
|
||||
document.body
|
||||
.querySelector<HTMLElement>('[data-testid="seo-panel-suggestions"]')
|
||||
?.closest('[role="dialog"]') ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export async function openPanel() {
|
||||
await openSection()
|
||||
const edit = document.body.querySelector<HTMLElement>('[data-testid="seo-edit"]')!
|
||||
edit.focus()
|
||||
edit.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
return panelRoot()!
|
||||
}
|
||||
|
||||
export async function openDisclosure(title: string) {
|
||||
const toggle = Array.from(
|
||||
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||
).find((button) => textOf(button).startsWith(title))!
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
|
||||
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
`[placeholder="${placeholder}"]`,
|
||||
)!
|
||||
}
|
||||
|
||||
export function fieldByLabel(root: ParentNode, label: string) {
|
||||
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
|
||||
textOf(candidate).startsWith(label),
|
||||
)
|
||||
const id = element?.getAttribute('for')
|
||||
return id ? (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement) : null
|
||||
}
|
||||
|
||||
export function counterTexts(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll('[data-testid="soft-counter"]')).map((node) =>
|
||||
textOf(node),
|
||||
)
|
||||
}
|
||||
|
||||
export function saveChangesButton(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(candidate) => textOf(candidate) === 'Save changes' || textOf(candidate) === 'Saving…',
|
||||
)!
|
||||
}
|
||||
Reference in new issue
Block a user