feat: integration screen
This commit is contained in:
1 parent
a9a208b80a
commit
ad55137883
13 files changed
+786
-6
No files matched your search
@@ -1,9 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const origin = computed(() => window.location.origin)
|
||||
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const copyButtonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
|
||||
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
|
||||
function copyBaseUrl() {
|
||||
copy(origin.value)
|
||||
}
|
||||
|
||||
const sampleArticle = {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>Every bathroom is different, but the way we price them isn’t.</p>',
|
||||
images: [
|
||||
{
|
||||
id: 1,
|
||||
path: '/static/uploadedImages/2f6a1c0e-2b8b-4c3d-9c1e-1a2b3c4d5e6f.png',
|
||||
article_id: 12,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-03-12T09:14:00Z',
|
||||
},
|
||||
],
|
||||
is_online: true,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-10T07:00:00Z',
|
||||
}
|
||||
|
||||
const sampleArticleList = [sampleArticle]
|
||||
|
||||
const endpoints = computed(() => [
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/',
|
||||
description: 'Lists every online article.',
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/`,
|
||||
response: JSON.stringify(sampleArticleList, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET' as const,
|
||||
path: '/v1/article/{id}',
|
||||
description: 'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
examples: {
|
||||
fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
|
||||
curl: `curl ${origin.value}/v1/article/12`,
|
||||
response: JSON.stringify(sampleArticle, null, 2),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const notes = [
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<p>Integration placeholder.</p>
|
||||
<DashboardShell mobile-title="Integration">
|
||||
<div class="flex max-w-199 flex-col gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Connect your website</h1>
|
||||
<p class="text-muted">A guide for the developer who builds your public site.</p>
|
||||
</div>
|
||||
|
||||
<p class="text-base text-pretty text-toned">
|
||||
RenewCMS is headless: it stores and publishes your articles but doesn't show them to
|
||||
visitors. Your website fetches published articles from the two addresses below and
|
||||
displays them in its own design.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Base URL</span>
|
||||
<div class="flex border border-accented bg-default">
|
||||
<span
|
||||
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
|
||||
>{{ origin }}</span
|
||||
>
|
||||
<UButton
|
||||
:icon="copyButtonIcon"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
aria-live="polite"
|
||||
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
|
||||
:class="copied ? 'text-success' : 'text-highlighted'"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="copyBaseUrl"
|
||||
>
|
||||
{{ copyButtonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndpointCard
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.path"
|
||||
:method="endpoint.method"
|
||||
:path="endpoint.path"
|
||||
:description="endpoint.description"
|
||||
:examples="endpoint.examples"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="note in notes" :key="note" class="flex gap-2.5">
|
||||
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
|
||||
<span class="text-base text-pretty text-toned">{{ note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
|
||||
const STUB_ORIGIN = 'https://cms.renew.example'
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
function stubOrigin(origin: string) {
|
||||
vi.stubGlobal('location', { ...window.location, origin })
|
||||
}
|
||||
|
||||
function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return writeText
|
||||
}
|
||||
|
||||
async function mountIntegration() {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push('/integration')
|
||||
const wrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, name: string) {
|
||||
return wrapper.findAll('button').find((button) => button.text().trim() === name)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe("Default — the page loads with the app's own origin", () => {
|
||||
it('renders the header, base URL, endpoint cards and notes with the stubbed origin', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(wrapper.text()).toContain('Connect your website')
|
||||
expect(wrapper.text()).toContain('A guide for the developer who builds your public site.')
|
||||
expect(wrapper.text()).toContain(
|
||||
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors.",
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain(STUB_ORIGIN)
|
||||
expect(findButton(wrapper, 'Copy')).toBeTruthy()
|
||||
|
||||
const cardTexts = wrapper.findAllComponents(EndpointCard).map((c) => c.text())
|
||||
expect(cardTexts).toHaveLength(2)
|
||||
expect(cardTexts[0]).toContain('/v1/article/')
|
||||
expect(cardTexts[0]).toContain('Lists every online article.')
|
||||
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/`)
|
||||
expect(cardTexts[1]).toContain('/v1/article/{id}')
|
||||
expect(cardTexts[1]).toContain(
|
||||
'Returns one online article. Drafts and unknown ids both return 404.',
|
||||
)
|
||||
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12`)
|
||||
|
||||
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
|
||||
expect(fetchTabs.length).toBeGreaterThan(0)
|
||||
fetchTabs.forEach((tab) => {
|
||||
expect(tab.attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the breadcrumb Integration', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const wrapper = await mountIntegration()
|
||||
expect(wrapper.text()).toContain('Integration')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copied — the Base URL copy button', () => {
|
||||
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const writeText = stubClipboard()
|
||||
vi.useFakeTimers()
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const copyButton = findButton(wrapper, 'Copy')!
|
||||
await copyButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
|
||||
expect(findButton(wrapper, 'Copied')).toBeTruthy()
|
||||
expect(wrapper.find('[aria-live="polite"]').text()).toContain('Copied')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
expect(findButton(wrapper, 'Copy')).toBeTruthy()
|
||||
expect(findButton(wrapper, 'Copied')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Each EndpointCard's own Copy button is independent of the Base URL's", () => {
|
||||
it("copies only that card's code and leaves the other buttons unaffected", async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const writeText = stubClipboard()
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const cards = wrapper.findAllComponents(EndpointCard)
|
||||
const firstCardCopyButton = cards[0]
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === 'Copy')!
|
||||
await firstCardCopyButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const expectedCode =
|
||||
`const res = await fetch('${STUB_ORIGIN}/v1/article/')\n` +
|
||||
'const articles = await res.json()'
|
||||
expect(writeText).toHaveBeenCalledWith(expectedCode)
|
||||
|
||||
const copyButtons = wrapper.findAll('button').filter((button) => {
|
||||
const text = button.text().trim()
|
||||
return text === 'Copy' || text === 'Copied'
|
||||
})
|
||||
const copiedButtons = copyButtons.filter((button) => button.text().trim() === 'Copied')
|
||||
expect(copiedButtons).toHaveLength(1)
|
||||
|
||||
const baseUrlCopyButton = findButton(wrapper, 'Copy')
|
||||
expect(baseUrlCopyButton).toBeTruthy()
|
||||
|
||||
const secondCardCopyButton = cards[1]
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === 'Copy')
|
||||
expect(secondCardCopyButton).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
async function activateTab(container: HTMLElement, label: string) {
|
||||
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined
|
||||
button?.focus()
|
||||
button?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('curl and Response tabs render, unchecked visually', () => {
|
||||
it('shows the curl command and the Response JSON sample built from the real Article fields', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
|
||||
await activateTab(firstCard.element as HTMLElement, 'curl')
|
||||
|
||||
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/`)
|
||||
|
||||
await activateTab(firstCard.element as HTMLElement, 'Response')
|
||||
|
||||
expect(firstCard.text()).toContain('"is_online"')
|
||||
expect(firstCard.text()).toContain('"created_at"')
|
||||
expect(firstCard.text()).toContain('"id"')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user