@prenta/admin 1.16.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +5 -6
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
- package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
- package/dist/components/seo/FaqProposalPreview.js +62 -0
- package/dist/components/seo/FaqProposalPreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +7 -2
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +61 -7
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +9 -13
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +160 -7
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +6 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
- package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +57 -15
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/dist/components/ContentOverviewChart.d.ts +0 -8
- package/dist/components/ContentOverviewChart.d.ts.map +0 -1
- package/dist/components/ContentOverviewChart.js +0 -20
- package/dist/components/ContentOverviewChart.js.map +0 -1
- package/dist/components/SEOPerformance.d.ts +0 -5
- package/dist/components/SEOPerformance.d.ts.map +0 -1
- package/dist/components/SEOPerformance.js +0 -29
- package/dist/components/SEOPerformance.js.map +0 -1
- package/dist/components/ui/Avatar.d.ts +0 -9
- package/dist/components/ui/Avatar.d.ts.map +0 -1
- package/dist/components/ui/Avatar.js +0 -21
- package/dist/components/ui/Avatar.js.map +0 -1
- package/dist/components/ui/CommandPalette.d.ts +0 -14
- package/dist/components/ui/CommandPalette.d.ts.map +0 -1
- package/dist/components/ui/CommandPalette.js +0 -58
- package/dist/components/ui/CommandPalette.js.map +0 -1
- package/dist/components/ui/DataTable.d.ts +0 -30
- package/dist/components/ui/DataTable.d.ts.map +0 -1
- package/dist/components/ui/DataTable.js +0 -44
- package/dist/components/ui/DataTable.js.map +0 -1
- package/dist/components/ui/Pagination.d.ts +0 -9
- package/dist/components/ui/Pagination.d.ts.map +0 -1
- package/dist/components/ui/Pagination.js +0 -26
- package/dist/components/ui/Pagination.js.map +0 -1
- package/dist/components/ui/Select.d.ts +0 -16
- package/dist/components/ui/Select.d.ts.map +0 -1
- package/dist/components/ui/Select.js +0 -24
- package/dist/components/ui/Select.js.map +0 -1
- package/dist/components/ui/Toast.d.ts +0 -18
- package/dist/components/ui/Toast.d.ts.map +0 -1
- package/dist/components/ui/Toast.js +0 -29
- package/dist/components/ui/Toast.js.map +0 -1
- package/dist/components/ui/index.d.ts +0 -46
- package/dist/components/ui/index.d.ts.map +0 -1
- package/dist/components/ui/index.js +0 -24
- package/dist/components/ui/index.js.map +0 -1
- package/dist/hooks/useContentLock.d.ts +0 -12
- package/dist/hooks/useContentLock.d.ts.map +0 -1
- package/dist/hooks/useContentLock.js +0 -38
- package/dist/hooks/useContentLock.js.map +0 -1
- package/dist/hooks/useDebounce.d.ts +0 -2
- package/dist/hooks/useDebounce.d.ts.map +0 -1
- package/dist/hooks/useDebounce.js +0 -11
- package/dist/hooks/useDebounce.js.map +0 -1
- package/src/components/ContentOverviewChart.tsx +0 -75
- package/src/components/SEOPerformance.tsx +0 -163
- package/src/components/ui/Avatar.tsx +0 -42
- package/src/components/ui/CommandPalette.tsx +0 -133
- package/src/components/ui/DataTable.tsx +0 -204
- package/src/components/ui/Pagination.tsx +0 -94
- package/src/components/ui/Select.tsx +0 -53
- package/src/components/ui/Toast.tsx +0 -72
- package/src/components/ui/index.ts +0 -61
- package/src/hooks/useContentLock.ts +0 -55
- package/src/hooks/useDebounce.ts +0 -14
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
FAQ_MIN_SELECTED,
|
|
6
|
+
FaqProposalPreview,
|
|
7
|
+
faqDraftBlocker,
|
|
8
|
+
faqSignalLine,
|
|
9
|
+
type FaqProposalPreviewProps,
|
|
10
|
+
} from '../../components/seo/FaqProposalPreview.js'
|
|
11
|
+
|
|
12
|
+
const items = [
|
|
13
|
+
{
|
|
14
|
+
question: 'How long does it take?',
|
|
15
|
+
answer: 'Three to five days.',
|
|
16
|
+
sourceQuote: 'take three to five days',
|
|
17
|
+
},
|
|
18
|
+
{ question: 'Can I finance?', answer: 'Yes, zero down.', sourceQuote: 'zero down' },
|
|
19
|
+
{ question: 'Warranty?', answer: '25 years.', sourceQuote: '25-year' },
|
|
20
|
+
]
|
|
21
|
+
const signals = {
|
|
22
|
+
geo: { referralVisits: 14, retrievalHits: 0, engines: ['chatgpt'] },
|
|
23
|
+
queries: [{ query: 'how long does it take', impressions: 120, clicks: 3 }],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function setup(selected = [true, true, true], over: Partial<FaqProposalPreviewProps> = {}) {
|
|
27
|
+
const onToggle = vi.fn()
|
|
28
|
+
const onHeadingChange = vi.fn()
|
|
29
|
+
render(
|
|
30
|
+
<FaqProposalPreview
|
|
31
|
+
heading="Questions"
|
|
32
|
+
items={items}
|
|
33
|
+
signals={signals}
|
|
34
|
+
selected={selected}
|
|
35
|
+
onToggle={onToggle}
|
|
36
|
+
headingValue="Questions"
|
|
37
|
+
onHeadingChange={onHeadingChange}
|
|
38
|
+
{...over}
|
|
39
|
+
/>,
|
|
40
|
+
)
|
|
41
|
+
return { onToggle, onHeadingChange }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('FaqProposalPreview', () => {
|
|
45
|
+
it('renders every Q&A with its source quote and a labelled checkbox', () => {
|
|
46
|
+
setup()
|
|
47
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(3)
|
|
48
|
+
expect(screen.getByRole('checkbox', { name: /How long does it take\?/ })).toHaveProperty(
|
|
49
|
+
'checked',
|
|
50
|
+
true,
|
|
51
|
+
)
|
|
52
|
+
expect(screen.getByText('Three to five days.')).toBeTruthy()
|
|
53
|
+
expect(screen.getByText(/take three to five days/)).toBeTruthy()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows the signal line', () => {
|
|
57
|
+
setup()
|
|
58
|
+
expect(screen.getByText(/Cited by chatgpt 14×/)).toBeTruthy()
|
|
59
|
+
expect(screen.getByText(/"how long does it take"/)).toBeTruthy()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('calls onToggle with the index and onHeadingChange with the value', () => {
|
|
63
|
+
const { onToggle, onHeadingChange } = setup()
|
|
64
|
+
fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
|
|
65
|
+
expect(onToggle).toHaveBeenCalledWith(2)
|
|
66
|
+
fireEvent.change(screen.getByLabelText('Section heading'), {
|
|
67
|
+
target: { value: 'Top questions' },
|
|
68
|
+
})
|
|
69
|
+
expect(onHeadingChange).toHaveBeenCalledWith('Top questions')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('warns when fewer than two items are selected', () => {
|
|
73
|
+
setup([true, false, false])
|
|
74
|
+
expect(screen.getByRole('status').textContent).toBe(
|
|
75
|
+
`Select at least ${FAQ_MIN_SELECTED} questions to approve (1 selected).`,
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reports how many questions will be added when the draft is approvable', () => {
|
|
80
|
+
setup([true, true, false])
|
|
81
|
+
expect(screen.getByRole('status').textContent).toBe('2 of 3 questions will be added.')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('treats a blank heading as "use the drafted heading", not a blocker', () => {
|
|
85
|
+
setup([true, true, true], { headingValue: ' ' })
|
|
86
|
+
expect(screen.getByRole('status').textContent).toBe('3 of 3 questions will be added.')
|
|
87
|
+
expect(screen.getByLabelText('Section heading')).toHaveProperty('placeholder', 'Questions')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders no source line for an item without a quote', () => {
|
|
91
|
+
setup([true, true], {
|
|
92
|
+
items: [
|
|
93
|
+
{ question: 'Q1?', answer: 'A1.', sourceQuote: '' },
|
|
94
|
+
{ question: 'Q2?', answer: 'A2.' },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
expect(screen.queryByText(/^Source:/)).toBeNull()
|
|
98
|
+
expect(screen.queryByText(/“”/)).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('omits the signal line when there are no signals', () => {
|
|
102
|
+
setup([true, true, true], { signals: null })
|
|
103
|
+
expect(screen.queryByText(/Cited by|Fetched by|asked/)).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('disables the heading field and every checkbox when disabled', () => {
|
|
107
|
+
setup([true, true, true], { disabled: true })
|
|
108
|
+
expect(screen.getByLabelText('Section heading')).toHaveProperty('disabled', true)
|
|
109
|
+
for (const box of screen.getAllByRole('checkbox')) expect(box).toHaveProperty('disabled', true)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('faqSignalLine', () => {
|
|
114
|
+
it('prefers referral citations, then retrieval fetches, then the top query', () => {
|
|
115
|
+
expect(faqSignalLine(signals)).toBe(
|
|
116
|
+
'Cited by chatgpt 14× in 28 days · asked "how long does it take" 120× on Google',
|
|
117
|
+
)
|
|
118
|
+
expect(
|
|
119
|
+
faqSignalLine({ geo: { referralVisits: 0, retrievalHits: 40, engines: [] }, queries: [] }),
|
|
120
|
+
).toBe('Fetched by AI engines 40× in 28 days')
|
|
121
|
+
expect(
|
|
122
|
+
faqSignalLine({ geo: null, queries: [{ query: 'why', impressions: 9, clicks: 0 }] }),
|
|
123
|
+
).toBe('asked "why" 9× on Google')
|
|
124
|
+
expect(faqSignalLine({ geo: null, queries: [] })).toBeNull()
|
|
125
|
+
expect(faqSignalLine(null)).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('faqDraftBlocker', () => {
|
|
130
|
+
it('blocks only on too few selected items', () => {
|
|
131
|
+
expect(faqDraftBlocker([true, false, false])).toBe(
|
|
132
|
+
`Select at least ${FAQ_MIN_SELECTED} questions to approve (1 selected).`,
|
|
133
|
+
)
|
|
134
|
+
expect(faqDraftBlocker([false, false, false])).toMatch(/\(0 selected\)/)
|
|
135
|
+
expect(faqDraftBlocker([true, true, false])).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -27,21 +27,45 @@ const orphanIssue: SeoIssue = {
|
|
|
27
27
|
resolvedAt: null,
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const faqSchemaIssue: SeoIssue = {
|
|
31
|
+
...orphanIssue,
|
|
32
|
+
id: 'iss-faq-schema',
|
|
33
|
+
entityTitle: 'Pricing',
|
|
34
|
+
url: '/pricing',
|
|
35
|
+
category: 'schema',
|
|
36
|
+
type: 'faq-schema-missing',
|
|
37
|
+
title: 'FAQ section without FAQPage schema',
|
|
38
|
+
fixActionType: 'edit-schema',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fetchSuggestionMock = vi.fn(async (issueId: string) => ({
|
|
42
|
+
suggestion:
|
|
43
|
+
issueId === faqSchemaIssue.id
|
|
44
|
+
? {
|
|
45
|
+
autoFixable: true,
|
|
46
|
+
source: 'deterministic',
|
|
47
|
+
// Metadata/schema fixes carry no strategy — only redirects do.
|
|
48
|
+
fingerprint: 'fp-faq',
|
|
49
|
+
patch: { structuredData: {} },
|
|
50
|
+
changes: [],
|
|
51
|
+
justification: 'Adds a FAQPage node.',
|
|
52
|
+
}
|
|
53
|
+
: {
|
|
54
|
+
autoFixable: true,
|
|
55
|
+
source: 'deterministic',
|
|
56
|
+
fixStrategy: 'insert-link',
|
|
57
|
+
fingerprint: 'fp-insert',
|
|
58
|
+
patch: {},
|
|
59
|
+
changes: [],
|
|
60
|
+
justification: 'Add an inbound link from a related page.',
|
|
61
|
+
},
|
|
62
|
+
}))
|
|
63
|
+
|
|
30
64
|
vi.mock('../../lib/seo-service.js', async (importOriginal) => {
|
|
31
65
|
const actual = await importOriginal<typeof import('../../lib/seo-service.js')>()
|
|
32
66
|
return {
|
|
33
67
|
...actual,
|
|
34
|
-
fetchSeoIssueFixSuggestion:
|
|
35
|
-
suggestion: {
|
|
36
|
-
autoFixable: true,
|
|
37
|
-
source: 'deterministic',
|
|
38
|
-
fixStrategy: 'insert-link',
|
|
39
|
-
fingerprint: 'fp-insert',
|
|
40
|
-
patch: {},
|
|
41
|
-
changes: [],
|
|
42
|
-
justification: 'Add an inbound link from a related page.',
|
|
43
|
-
},
|
|
44
|
-
})),
|
|
68
|
+
fetchSeoIssueFixSuggestion: (issueId: string) => fetchSuggestionMock(issueId),
|
|
45
69
|
applySeoIssueFix: vi.fn(async () => ({})),
|
|
46
70
|
fetchSeoContentBrief: vi.fn(async () => ({})),
|
|
47
71
|
fetchSeoContentResearch: vi.fn(async () => ({})),
|
|
@@ -62,4 +86,11 @@ describe('SeoIssueFixPanel insert-link copy', () => {
|
|
|
62
86
|
expect(await screen.findByRole('button', { name: 'Approve link' })).toBeTruthy()
|
|
63
87
|
expect(screen.queryByRole('button', { name: 'Approve redirect' })).toBeNull()
|
|
64
88
|
})
|
|
89
|
+
|
|
90
|
+
it('labels a strategy-less schema fix (faq-schema-missing) Approve fix, not Approve redirect', async () => {
|
|
91
|
+
render(<SeoIssueFixPanel issue={faqSchemaIssue} expanded />)
|
|
92
|
+
|
|
93
|
+
expect(await screen.findByRole('button', { name: 'Approve fix' })).toBeTruthy()
|
|
94
|
+
expect(screen.queryByRole('button', { name: 'Approve redirect' })).toBeNull()
|
|
95
|
+
})
|
|
65
96
|
})
|
|
@@ -17,7 +17,6 @@ const {
|
|
|
17
17
|
fetchRedirectRules,
|
|
18
18
|
generate404RecoverySuggestions,
|
|
19
19
|
generateSeoField,
|
|
20
|
-
generateStructuredData,
|
|
21
20
|
isInlineSeoFixIssue,
|
|
22
21
|
runSeoAudit,
|
|
23
22
|
summarizeSeoAudit,
|
|
@@ -113,7 +112,7 @@ describe('SEO service read fetchers surface errors', () => {
|
|
|
113
112
|
|
|
114
113
|
/**
|
|
115
114
|
* Regression tests for the "swallowed governance errors" gap: the governed AI
|
|
116
|
-
* runtime returns crafted 429/
|
|
115
|
+
* runtime returns crafted 429/503 messages (daily request cap, monthly budget,
|
|
117
116
|
* disabled feature) and the copilot helpers must surface them, not collapse to
|
|
118
117
|
* `{}` — which the UI renders as the misleading "No suggestion was returned."
|
|
119
118
|
* Unavailability (501/503) stays a distinct graceful state.
|
|
@@ -141,13 +140,6 @@ describe('AI copilot helpers surface governance errors', () => {
|
|
|
141
140
|
await expect(explainSeoIssue('issue-1')).resolves.toEqual({ error: capMsg })
|
|
142
141
|
})
|
|
143
142
|
|
|
144
|
-
it('generateStructuredData returns the server message on 403', async () => {
|
|
145
|
-
cmsApi.mockResolvedValue({ error: 'This AI feature is disabled.', status: 403 })
|
|
146
|
-
await expect(
|
|
147
|
-
generateStructuredData({ entityType: 'page', entityId: 'p1', schemaType: 'Article' }),
|
|
148
|
-
).resolves.toEqual({ error: 'This AI feature is disabled.' })
|
|
149
|
-
})
|
|
150
|
-
|
|
151
143
|
it('summarizeSeoAudit returns the server message on 429', async () => {
|
|
152
144
|
cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
|
|
153
145
|
await expect(summarizeSeoAudit()).resolves.toEqual({ error: capMsg })
|
|
@@ -220,6 +212,10 @@ describe('inline SEO fix issue types', () => {
|
|
|
220
212
|
it('treats orphan-page as an inline Approve issue', () => {
|
|
221
213
|
expect(isInlineSeoFixIssue('orphan-page')).toBe(true)
|
|
222
214
|
})
|
|
215
|
+
|
|
216
|
+
it('treats faq-schema-missing as an inline Approve issue (mirrors core DETERMINISTIC_FIX_TYPES)', () => {
|
|
217
|
+
expect(isInlineSeoFixIssue('faq-schema-missing')).toBe(true)
|
|
218
|
+
})
|
|
223
219
|
})
|
|
224
220
|
|
|
225
221
|
describe('SEO audit mutation contract', () => {
|
|
@@ -100,6 +100,36 @@ describe('proposal service helpers', () => {
|
|
|
100
100
|
})
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
+
it('applySeoIssueFix sends includeItems and headingOverride only when given', async () => {
|
|
104
|
+
cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
|
|
105
|
+
await expect(
|
|
106
|
+
svc.applySeoIssueFix('i1', 'fp', { includeItems: [0, 2], headingOverride: 'H' }),
|
|
107
|
+
).resolves.toEqual({ issue: { id: 'i1' } })
|
|
108
|
+
expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body: JSON.stringify({ fingerprint: 'fp', includeItems: [0, 2], headingOverride: 'H' }),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
|
|
114
|
+
await svc.applySeoIssueFix('i1', 'fp')
|
|
115
|
+
expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
body: JSON.stringify({ fingerprint: 'fp' }),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
|
|
121
|
+
await svc.applySeoIssueFix('i1', 'fp', {})
|
|
122
|
+
expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
body: JSON.stringify({ fingerprint: 'fp' }),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
cmsApi.mockResolvedValueOnce({ status: 409, error: 'Fix suggestion is stale.' })
|
|
128
|
+
await expect(svc.applySeoIssueFix('i1', 'fp', { includeItems: [0, 1] })).resolves.toEqual({
|
|
129
|
+
error: 'Fix suggestion is stale.',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
103
133
|
it('dismissSeoIssueFix POSTs to the encoded issue path', async () => {
|
|
104
134
|
cmsApi.mockResolvedValueOnce({ status: 200, data: { dismissed: true } })
|
|
105
135
|
await expect(svc.dismissSeoIssueFix('a/b')).resolves.toEqual({})
|