From 6fa98c4c9cee3b7b7573bbf9d2f91a0f7b840bb8 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 23 Jul 2026 11:14:35 +0200 Subject: [PATCH 1/8] Add page feedback collection Capture per-page reactions and optional comments so documentation teams can identify content gaps and prioritize improvements. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- config/assembler.yml | 2 + docs/development/page-feedback.md | 34 ++ docs/development/toc.yml | 1 + .../Infrastructure/ApiViewModel.cs | 5 +- src/Elastic.ApiExplorer/_Layout.cshtml | 1 + src/Elastic.Codex/_MarkdownLayout.cshtml | 1 + .../BuildContext.cs | 1 + .../Builder/FeatureFlags.cs | 6 + .../Assets/page-feedback.css | 364 ++++++++++++++++++ .../Assets/styles.css | 1 + .../web-components/PageFeedback.test.tsx | 208 ++++++++++ .../Assets/web-components/PageFeedback.tsx | 364 ++++++++++++++++++ .../web-components/loadWebComponents.ts | 1 + .../Layout/_PageFeedback.cshtml | 12 + src/Elastic.Markdown/_Layout.cshtml | 1 + .../Adapters/ElasticsearchTransportFactory.cs | 39 ++ .../ElasticsearchPageFeedbackGateway.cs | 78 ++++ .../PageFeedbackBootstrapService.cs | 36 ++ .../PageFeedback/PageFeedbackMapping.cs | 61 +++ .../PageFeedback/PageFeedbackTransport.cs | 15 + .../Elastic.Documentation.Api.csproj | 1 + .../MappingsExtensions.cs | 64 +++ .../PageFeedback/IPageFeedbackService.cs | 20 + .../PageFeedback/PageFeedbackRequest.cs | 24 ++ .../SerializationContext.cs | 3 + .../ServicesExtension.cs | 11 + .../docs-builder/Http/DocumentationWebHost.cs | 3 + .../PageFeedbackEndpointTests.cs | 113 ++++++ .../PageFeedbackMappingTests.cs | 32 ++ 29 files changed, 1501 insertions(+), 1 deletion(-) create mode 100644 docs/development/page-feedback.md create mode 100644 src/Elastic.Documentation.Site/Assets/page-feedback.css create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.test.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.tsx create mode 100644 src/Elastic.Documentation.Site/Layout/_PageFeedback.cshtml create mode 100644 src/api/Elastic.Documentation.Api/Adapters/ElasticsearchTransportFactory.cs create mode 100644 src/api/Elastic.Documentation.Api/Adapters/PageFeedback/ElasticsearchPageFeedbackGateway.cs create mode 100644 src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackBootstrapService.cs create mode 100644 src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackMapping.cs create mode 100644 src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackTransport.cs create mode 100644 src/api/Elastic.Documentation.Api/PageFeedback/IPageFeedbackService.cs create mode 100644 src/api/Elastic.Documentation.Api/PageFeedback/PageFeedbackRequest.cs create mode 100644 tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs create mode 100644 tests/Elastic.Documentation.Api.Tests/PageFeedbackMappingTests.cs diff --git a/config/assembler.yml b/config/assembler.yml index 5c8b222230..62e0b4f048 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -22,6 +22,7 @@ environments: cookies_win: x feature_flags: WEBSITE_SEARCH: true + PAGE_FEEDBACK: true website_search_url: https://staging-website.elastic.co/search/elastic-website-search.js edge: uri: https://d34ipnu52o64md.cloudfront.net @@ -35,6 +36,7 @@ environments: path_prefix: docs feature_flags: WEBSITE_SEARCH: true + PAGE_FEEDBACK: true website_search_url: http://localhost:4078/elastic-website-search.js preview: uri: https://docs-v3-preview.elastic.dev diff --git a/docs/development/page-feedback.md b/docs/development/page-feedback.md new file mode 100644 index 0000000000..070fdfa1e4 --- /dev/null +++ b/docs/development/page-feedback.md @@ -0,0 +1,34 @@ +--- +navigation_title: Page feedback +--- + +# Page feedback + +The documentation site records page-level reactions and optional comments in the +`page-feedback-v1-{environment}` Elasticsearch index. The API uses the feedback +identifier as the document `_id`, so later comment submissions and reaction +changes replace the same document. + +## Provision the index + +`PageFeedbackMapping` defines the mapping with `Elastic.Mapping` attributes. +During API startup, `PageFeedbackBootstrapService` uses +`Elastic.Ingest.Elasticsearch` to create or update environment-specific +component and index templates. Template updates are skipped when the generated +mapping hash has not changed. The first feedback write creates the concrete +index from those templates. + +The generated mapping disables dynamic field mapping. Fields that are not part +of `PageFeedbackDocument` remain unindexed instead of changing the schema. + +Bootstrap failures are logged and ignored in `dev` so the local documentation +server can run without Elasticsearch. They prevent startup in `staging`, `edge`, +and `prod`. + +Runtime credentials require permission to manage component and index templates, +create the environment's index, and write and delete its documents. + +Template updates do not modify existing indices. For a mapping change, increment +the schema version in the index name, deploy the new template, and migrate any +documents that must be retained. Mixed task versions then write to separate +versioned indices during a rolling deployment. diff --git a/docs/development/toc.yml b/docs/development/toc.yml index 0c86eab5ff..7e1f9a4348 100644 --- a/docs/development/toc.yml +++ b/docs/development/toc.yml @@ -1,6 +1,7 @@ toc: - file: index.md - file: changelog-bundle-registry.md + - file: page-feedback.md - file: essc.md - folder: ingest children: diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index 5aad0d56ec..b8266ab1cc 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -76,7 +76,10 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() CanonicalBaseUrl = BuildContext.CanonicalBaseUrl, GoogleTagManager = new GoogleTagManagerConfiguration(), Optimizely = new OptimizelyConfiguration(), - Features = new FeatureFlags([]), + Features = new FeatureFlags([]) + { + PageFeedbackEnabled = BuildContext.Configuration.Features.PageFeedbackEnabled + }, StaticFileContentHashProvider = StaticFileContentHashProvider, BuildType = BuildContext.BuildType, TocItems = GetTocItems(), diff --git a/src/Elastic.ApiExplorer/_Layout.cshtml b/src/Elastic.ApiExplorer/_Layout.cshtml index 1a25113422..fbb9876179 100644 --- a/src/Elastic.ApiExplorer/_Layout.cshtml +++ b/src/Elastic.ApiExplorer/_Layout.cshtml @@ -23,6 +23,7 @@ else @await RenderBodyAsync() + @await RenderPartialAsync(_PageFeedback.Create(Model)) @await RenderPartialAsync(_ApiToc.Create(Model.TocItems.ToArray())) diff --git a/src/Elastic.Codex/_MarkdownLayout.cshtml b/src/Elastic.Codex/_MarkdownLayout.cshtml index 9592460d36..aaa68a91bf 100644 --- a/src/Elastic.Codex/_MarkdownLayout.cshtml +++ b/src/Elastic.Codex/_MarkdownLayout.cshtml @@ -33,6 +33,7 @@ @await RenderBodyAsync() @await RenderPartialAsync(_PrevNextNav.Create(Model)) + @await RenderPartialAsync(_PageFeedback.Create(Model)) @await RenderPartialAsync(_TableOfContents.Create(Model)) diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 2200c21943..8f095f8f83 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -149,5 +149,6 @@ public void ReloadConfiguration() : new DocumentationSetFile(); Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration); Configuration.Features.DiagnosticsPanelEnabled = previousFeatures.DiagnosticsPanelEnabled; + Configuration.Features.PageFeedbackEnabled = previousFeatures.PageFeedbackEnabled; } } diff --git a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs index 00ecf4fc53..c7968863d7 100644 --- a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs +++ b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs @@ -52,6 +52,12 @@ public bool DiagnosticsPanelEnabled set => _featureFlags["diagnostics-panel"] = value; } + public bool PageFeedbackEnabled + { + get => IsEnabled("page-feedback"); + set => _featureFlags["page-feedback"] = value; + } + private bool IsEnabled(string key) { var envKey = $"FEATURE_{key.ToUpperInvariant().Replace('-', '_')}"; diff --git a/src/Elastic.Documentation.Site/Assets/page-feedback.css b/src/Elastic.Documentation.Site/Assets/page-feedback.css new file mode 100644 index 0000000000..76e6150fbd --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/page-feedback.css @@ -0,0 +1,364 @@ +page-feedback { + display: block; + width: 100%; + max-width: 32rem; + margin: 2rem auto 0; +} + +.page-feedback { + --page-feedback-collapsed-width: 21rem; + + box-sizing: border-box; + width: 100%; + max-width: var(--page-feedback-collapsed-width); + margin-inline: auto; + border: 1px solid var(--color-grey-20); + border-radius: 1.875rem; + background: linear-gradient( + 135deg, + var(--color-white) 55%, + var(--color-grey-10) + ); + padding: 0.75rem 1rem; + transition: + border-color 200ms ease, + border-radius 120ms ease, + background 200ms ease, + max-width 180ms ease; +} + +.page-feedback--expanded { + max-width: 32rem; + border-radius: 0.75rem; +} + +.page-feedback--closing { + max-width: var(--page-feedback-collapsed-width); + border-radius: 1.875rem; +} + +.page-feedback--positive { + border-color: var(--color-green-30); + background: linear-gradient( + 135deg, + var(--color-white) 55%, + var(--color-green-10) + ); +} + +.page-feedback--negative { + border-color: var(--color-red-30); + background: linear-gradient( + 135deg, + var(--color-white) 55%, + var(--color-red-10) + ); +} + +.page-feedback--collapsed { + cursor: pointer; +} + +.page-feedback--positive.page-feedback--collapsed:hover { + border-color: var(--color-green-70); +} + +.page-feedback--negative.page-feedback--collapsed:hover { + border-color: var(--color-red-70); +} + +.page-feedback__prompt { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.page-feedback__question { + margin: 0; + font-weight: 600; +} + +.page-feedback__reactions { + display: flex; + gap: 0.25rem; +} + +.page-feedback__reaction { + display: inline-flex; + width: 2.25rem; + height: 2.25rem; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--color-grey-70); + cursor: pointer; + transition: + border-color 120ms ease, + background-color 120ms ease, + color 120ms ease; +} + +.page-feedback__reaction--positive:hover, +.page-feedback__reaction--positive[aria-pressed='true'] { + background: var(--color-green-10); + color: var(--color-green-100); +} + +.page-feedback__reaction--positive[aria-pressed='true'] { + border-color: var(--color-green-100); +} + +.page-feedback__reaction--negative:hover, +.page-feedback__reaction--negative[aria-pressed='true'] { + background: var(--color-red-10); + color: var(--color-red-100); +} + +.page-feedback__reaction--negative[aria-pressed='true'] { + border-color: var(--color-red-100); +} + +.page-feedback__reaction[aria-pressed='true'] { + box-shadow: none; +} + +.page-feedback__reaction:focus-visible { + outline: 2px solid var(--color-blue-elastic); + outline-offset: 2px; +} + +.page-feedback__reaction:disabled, +.page-feedback__submit:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.page-feedback__reaction svg { + width: 1.25rem; + height: 1.25rem; +} + +.page-feedback__details { + display: grid; + grid-template-rows: 1fr; + animation: page-feedback-expand 280ms cubic-bezier(0.16, 1, 0.3, 1) 100ms + both; +} + +.page-feedback__details--closing { + animation: page-feedback-collapse 200ms cubic-bezier(0.4, 0, 0.2, 1) + forwards; +} + +.page-feedback__details-inner { + min-height: 0; + overflow: hidden; +} + +.page-feedback__form { + display: grid; + gap: 0.625rem; + margin-top: 1rem; +} + +.page-feedback__form > * { + animation: page-feedback-content-reveal 180ms ease-out 110ms both; +} + +.page-feedback__form > :nth-child(2) { + animation-delay: 140ms; +} + +.page-feedback__form > :nth-child(3) { + animation-delay: 170ms; +} + +.page-feedback__form > :nth-child(4) { + animation-delay: 200ms; +} + +.page-feedback__form--closing > * { + animation: page-feedback-content-hide 120ms ease-out forwards; +} + +.page-feedback__textarea { + min-height: 5rem; + resize: vertical; + border: 1px solid var(--color-grey-30); + border-radius: 0.375rem; + padding: 0.625rem; + font: inherit; + color: inherit; +} + +.page-feedback__form-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.page-feedback__guidance, +.page-feedback__count, +.page-feedback__error, +.page-feedback__thanks { + margin: 0; + font-size: 0.875rem; +} + +.page-feedback__guidance, +.page-feedback__count { + color: var(--color-grey-60); +} + +.page-feedback__count { + white-space: nowrap; +} + +.page-feedback__actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.page-feedback__form > .page-feedback__actions { + justify-content: flex-end; +} + +.page-feedback__submit, +.page-feedback__retry { + border: 0; + border-radius: 0.375rem; + padding: 0.5rem 1.5rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.page-feedback__submit { + background: var(--color-blue-elastic); + color: var(--color-white); + transition: background-color 120ms ease; +} + +.page-feedback__submit:hover:not(:disabled) { + background: var(--color-blue-elastic-100); +} + +.page-feedback__retry { + background: transparent; + color: var(--color-blue-elastic); +} + +.page-feedback__error { + color: var(--color-red-70, #bd271e); +} + +.page-feedback__thanks { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + color: var(--color-green-70, #087a5a); + font-weight: 600; + animation: page-feedback-success-reveal 240ms cubic-bezier(0.16, 1, 0.3, 1) + both; +} + +.page-feedback__thanks-icon { + display: inline-flex; + width: 1.375rem; + height: 1.375rem; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--color-green-70); + color: var(--color-white); + font-size: 0.75rem; +} + +@keyframes page-feedback-expand { + from { + grid-template-rows: 0fr; + opacity: 0; + transform: translateY(-0.375rem); + } + + to { + grid-template-rows: 1fr; + opacity: 1; + transform: translateY(0); + } +} + +@keyframes page-feedback-collapse { + from { + grid-template-rows: 1fr; + opacity: 1; + transform: translateY(0); + } + + to { + grid-template-rows: 0fr; + opacity: 0; + transform: translateY(-0.25rem); + } +} + +@keyframes page-feedback-content-reveal { + from { + opacity: 0; + transform: translateY(-0.25rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes page-feedback-content-hide { + to { + opacity: 0; + transform: translateY(-0.25rem); + } +} + +@keyframes page-feedback-success-reveal { + from { + opacity: 0; + transform: translateY(0.375rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .page-feedback, + .page-feedback__reaction, + .page-feedback__details, + .page-feedback__form > *, + .page-feedback__submit, + .page-feedback__thanks { + animation: none; + transition: none; + } + + .page-feedback__details--closing { + display: none; + } +} + +@media (max-width: 480px) { + .page-feedback__prompt, + .page-feedback__form-footer { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index c3599fa395..9a99c6bdba 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -12,6 +12,7 @@ @import './markdown/icons.css'; @import './markdown/kbd.css'; @import './copybutton.css'; +@import './page-feedback.css'; @import './markdown/admonition.css'; @import './markdown/dropdown.css'; @import './markdown/table.css'; diff --git a/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.test.tsx new file mode 100644 index 0000000000..ec00132246 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.test.tsx @@ -0,0 +1,208 @@ +import { PageFeedback } from './PageFeedback' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' + +const feedbackId = '00000000-0000-4000-8000-000000000001' + +describe('PageFeedback', () => { + beforeEach(() => { + jest.spyOn(crypto, 'randomUUID').mockReturnValue(feedbackId) + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 204, + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('opens immediately and only records the last debounced reaction', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { name: 'This page was helpful' }) + ) + + const comment = screen.getByRole('textbox', { + name: 'Tell us more (optional)', + }) + expect(comment).toHaveAttribute('maxlength', '2000') + expect(comment).toHaveFocus() + + await user.click( + screen.getByRole('button', { name: 'This page was not helpful' }) + ) + + expect(global.fetch).not.toHaveBeenCalled() + await waitFor(() => + expect(global.fetch).toHaveBeenCalledWith( + `/docs/_api/v1/page-feedback/${feedbackId}`, + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + pageUrl: '/docs/test-page', + pageTitle: 'Test page', + reaction: 'thumbsDown', + }), + }) + ) + ) + }) + + it('collapses the comment form when focus leaves the feedback', async () => { + const user = userEvent.setup() + render( + <> + + + + ) + + await user.click( + screen.getByRole('button', { name: 'This page was helpful' }) + ) + await user.click(screen.getByRole('button', { name: 'Outside' })) + + expect( + screen + .getByRole('textbox', { name: 'Tell us more (optional)' }) + .closest('form') + ).toHaveClass('page-feedback__form--closing') + await waitFor(() => + expect( + screen.queryByRole('textbox', { + name: 'Tell us more (optional)', + }) + ).not.toBeInTheDocument() + ) + + await user.click(screen.getByText('Was this page helpful?')) + + expect( + screen.getByRole('textbox', { name: 'Tell us more (optional)' }) + ).toBeInTheDocument() + }) + + it('submits optional text with the same feedback id', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { name: 'This page was not helpful' }) + ) + const comment = screen.getByRole('textbox', { + name: 'Tell us more (optional)', + }) + await user.type(comment, 'The example needs more detail.') + await user.click(screen.getByRole('button', { name: 'Send feedback' })) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)) + expect(global.fetch).toHaveBeenLastCalledWith( + `/docs/_api/v1/page-feedback/${feedbackId}`, + expect.objectContaining({ + body: JSON.stringify({ + pageUrl: '/docs/test-page', + pageTitle: 'Test page', + reaction: 'thumbsDown', + comment: 'The example needs more detail.', + }), + }) + ) + expect( + await screen.findByText('Thanks for your feedback.') + ).toBeInTheDocument() + }) + + it('keeps an optimistic reaction without showing an error', async () => { + const user = userEvent.setup() + jest.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 503, + } as Response) + render() + + const helpfulButton = screen.getByRole('button', { + name: 'This page was helpful', + }) + await user.click(helpfulButton) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)) + expect(helpfulButton).toHaveAttribute('aria-pressed', 'true') + expect( + screen.queryByText("We couldn't save your feedback.") + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Retry' }) + ).not.toBeInTheDocument() + }) + + it('preserves the reaction and retries a failed comment', async () => { + const user = userEvent.setup() + jest.mocked(global.fetch) + .mockResolvedValueOnce({ ok: false, status: 503 } as Response) + .mockResolvedValueOnce({ ok: true, status: 204 } as Response) + render() + + const helpfulButton = screen.getByRole('button', { + name: 'This page was helpful', + }) + await user.click(helpfulButton) + await user.type( + screen.getByRole('textbox', { + name: 'Tell us more (optional)', + }), + 'The example needs more detail.' + ) + await user.click(screen.getByRole('button', { name: 'Send feedback' })) + + expect( + await screen.findByText("We couldn't save your feedback.") + ).toBeInTheDocument() + expect(helpfulButton).toHaveAttribute('aria-pressed', 'true') + + await user.click(screen.getByRole('button', { name: 'Retry' })) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + }) + + it('does not record a reaction deselected during the debounce', async () => { + const user = userEvent.setup() + render() + const helpfulButton = screen.getByRole('button', { + name: 'This page was helpful', + }) + + await user.click(helpfulButton) + await user.click(helpfulButton) + await act( + () => + new Promise((resolve) => { + window.setTimeout(resolve, 550) + }) + ) + + expect(helpfulButton).toHaveAttribute('aria-pressed', 'false') + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('deletes a previously recorded reaction when deselected', async () => { + const user = userEvent.setup() + render() + const helpfulButton = screen.getByRole('button', { + name: 'This page was helpful', + }) + + await user.click(helpfulButton) + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)) + await user.click(helpfulButton) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + expect(global.fetch).toHaveBeenLastCalledWith( + `/docs/_api/v1/page-feedback/${feedbackId}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.tsx b/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.tsx new file mode 100644 index 0000000000..092c2f472e --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.tsx @@ -0,0 +1,364 @@ +import { config } from '../config' +import r2wc from '@r2wc/react-to-web-component' +import * as React from 'react' +import { FormEvent, useEffect, useId, useRef, useState } from 'react' + +const COMMENT_MAX_LENGTH = 2000 +const COLLAPSE_ANIMATION_DURATION = 200 +const REACTION_DEBOUNCE_DURATION = 500 + +type Reaction = 'thumbsUp' | 'thumbsDown' + +interface PageFeedbackProps { + pageUrl: string + pageTitle: string +} + +interface PageFeedbackPayload { + pageUrl: string + pageTitle: string + reaction: Reaction + comment?: string +} + +const ThumbIcon = ({ down = false }: { down?: boolean }) => ( + +) + +const submitFeedback = async ( + feedbackId: string, + payload: PageFeedbackPayload +) => { + const response = await fetch( + `${config.apiBasePath}/v1/page-feedback/${feedbackId}`, + { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ) + + if (!response.ok) { + throw new Error( + `Feedback request failed with status ${response.status}` + ) + } +} + +const revokeFeedback = async (feedbackId: string) => { + const response = await fetch( + `${config.apiBasePath}/v1/page-feedback/${feedbackId}`, + { + method: 'DELETE', + credentials: 'same-origin', + } + ) + + if (!response.ok) { + throw new Error( + `Feedback deletion failed with status ${response.status}` + ) + } +} + +export const PageFeedback = ({ pageUrl, pageTitle }: PageFeedbackProps) => { + const feedbackRef = useRef(null) + const commentRef = useRef(null) + const reactionTimeoutRef = useRef(undefined) + const feedbackMayExistRef = useRef(false) + const questionId = useId() + const guidanceId = useId() + const [feedbackId] = useState(() => crypto.randomUUID()) + const [reaction, setReaction] = useState(null) + const [comment, setComment] = useState('') + const [savedComment, setSavedComment] = useState() + const [showComment, setShowComment] = useState(false) + const [isClosing, setIsClosing] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [showThanks, setShowThanks] = useState(false) + const [error, setError] = useState(false) + const [lastAttempt, setLastAttempt] = useState( + null + ) + + useEffect(() => { + if (!showComment || isClosing) return + + const closeOnOutsideClick = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !feedbackRef.current?.contains(event.target) + ) { + setIsClosing(true) + } + } + + document.addEventListener('pointerdown', closeOnOutsideClick) + return () => + document.removeEventListener('pointerdown', closeOnOutsideClick) + }, [isClosing, showComment]) + + useEffect(() => { + if (!isClosing) return + + const timeout = window.setTimeout(() => { + setShowComment(false) + setIsClosing(false) + }, COLLAPSE_ANIMATION_DURATION) + + return () => window.clearTimeout(timeout) + }, [isClosing]) + + useEffect(() => { + if (showComment && !isClosing) commentRef.current?.focus() + }, [isClosing, showComment]) + + useEffect(() => () => window.clearTimeout(reactionTimeoutRef.current), []) + + const queueReaction = (nextReaction: Reaction | null) => { + window.clearTimeout(reactionTimeoutRef.current) + + if (!nextReaction && !feedbackMayExistRef.current) return + + reactionTimeoutRef.current = window.setTimeout(() => { + if (!nextReaction) { + void revokeFeedback(feedbackId) + .then(() => { + feedbackMayExistRef.current = false + }) + .catch(() => undefined) + return + } + + feedbackMayExistRef.current = true + void submitFeedback(feedbackId, { + pageUrl, + pageTitle, + reaction: nextReaction, + comment: savedComment, + }).catch(() => undefined) + }, REACTION_DEBOUNCE_DURATION) + } + + const submitCommentFeedback = async (payload: PageFeedbackPayload) => { + window.clearTimeout(reactionTimeoutRef.current) + feedbackMayExistRef.current = true + setIsSaving(true) + setError(false) + setLastAttempt(payload) + + try { + await submitFeedback(feedbackId, payload) + setLastAttempt(null) + setSavedComment(payload.comment) + setComment(payload.comment ?? '') + setIsClosing(true) + setShowThanks(true) + } catch { + setError(true) + } finally { + setIsSaving(false) + } + } + + const selectReaction = (nextReaction: Reaction) => { + if (isSaving) return + + const selectedReaction = reaction === nextReaction ? null : nextReaction + setReaction(selectedReaction) + setShowThanks(false) + queueReaction(selectedReaction) + + if (!selectedReaction) { + if (showComment) setIsClosing(true) + return + } + + setIsClosing(false) + setShowComment(true) + } + + const submitComment = (event: FormEvent) => { + event.preventDefault() + const trimmedComment = comment.trim() + if (!reaction || !trimmedComment || isSaving) return + + void submitCommentFeedback({ + pageUrl, + pageTitle, + reaction, + comment: trimmedComment, + }) + } + + const reopenComment = (event: React.MouseEvent) => { + if ( + !reaction || + (showComment && !isClosing) || + (event.target instanceof Element && + event.target.closest('button, input, textarea, form, a')) + ) { + return + } + + setIsClosing(false) + setShowComment(true) + setShowThanks(false) + } + + return ( +
{ + if ( + event.relatedTarget && + !event.currentTarget.contains(event.relatedTarget) + ) { + setIsClosing(true) + } + }} + > +
+

+ Was this page helpful? +

+
+ + +
+
+ + {showComment && ( +
+
+
+ +