diff --git a/Directory.Packages.props b/Directory.Packages.props index be5918099a..d613cd4da9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,6 +33,7 @@ + @@ -51,8 +52,9 @@ - - + + + 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..805442bd91 --- /dev/null +++ b/docs/development/page-feedback.md @@ -0,0 +1,61 @@ +--- +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. + +The thumbs-up or thumbs-down selection writes a document with the reaction after +a short debounce, so quickly changing the selection records only the final +choice. The follow-up questionnaire writes the same document again with a +structured reason, the reason-set version, and optional details. Submitting the +questionnaire flushes any pending reaction write first. This keeps abandoned +questionnaires useful while ensuring the richer submission wins. + +Reasons are stored as keyword enum values for filtering and aggregation. +`reason_set_version` identifies the questionnaire revision that presented the +option. Display labels may change without changing their stored value. Add a new +enum value when an option's meaning changes, and retain retired values so older +clients and historical documents remain valid. + +The current positive reasons are `accurate`, `solvedProblem`, +`easyToUnderstand`, `helpfulExamples`, and `anotherReason`. The current negative +reasons are `inaccurate`, `missingInformation`, `hardToUnderstand`, +`codeSampleErrors`, and `anotherReason`. + +## 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. The generated context uses the API assembly +version as its mapping version. Bootstrap skips unchanged mapping hashes and +prevents an older API task from replacing templates installed by a newer task +during a rolling deployment. + +The bootstrap service and runtime gateway share one ingest channel. Feedback +upserts use `DirectWriteAsync` and await the bulk item result before the API +responds. They do not use the channel's retry overload because the browser owns +the bounded retry UX. The first successful feedback write creates the concrete +index from the 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. The assembly mapping version is +an additional downgrade guard for templates that retain the same schema-versioned +name; it does not replace index versioning. 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..a42e115990 100644 --- a/src/Elastic.Codex/_MarkdownLayout.cshtml +++ b/src/Elastic.Codex/_MarkdownLayout.cshtml @@ -32,6 +32,7 @@ @await RenderBodyAsync() + @await RenderPartialAsync(_PageFeedback.Create(Model)) @await RenderPartialAsync(_PrevNextNav.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.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index ff43a62399..22c57211b9 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs b/src/Elastic.Documentation.Configuration/ElasticsearchTransportFactory.cs similarity index 76% rename from src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs rename to src/Elastic.Documentation.Configuration/ElasticsearchTransportFactory.cs index 78652bd1d9..2f8a84b5b5 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchTransportFactory.cs +++ b/src/Elastic.Documentation.Configuration/ElasticsearchTransportFactory.cs @@ -2,15 +2,11 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Documentation.Configuration; using Elastic.Transport; using Elastic.Transport.Products.Elasticsearch; -namespace Elastic.Markdown.Exporters.Elasticsearch; +namespace Elastic.Documentation.Configuration; -/// -/// Factory for creating Elasticsearch transport from endpoint configuration. -/// public static class ElasticsearchTransportFactory { public static DistributedTransport Create(ElasticsearchEndpoint endpoint) @@ -30,10 +26,10 @@ public static DistributedTransport Create(ElasticsearchEndpoint endpoint) ProxyUsername = endpoint.ProxyUsername, ServerCertificateValidationCallback = endpoint.DisableSslVerification ? CertificateValidations.AllowAll - : endpoint.Certificate is { } cert + : endpoint.Certificate is { } certificate ? endpoint.CertificateIsNotRoot - ? CertificateValidations.AuthorityPartOfChain(cert) - : CertificateValidations.AuthorityIsRoot(cert) + ? CertificateValidations.AuthorityPartOfChain(certificate) + : CertificateValidations.AuthorityIsRoot(certificate) : null }; 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..cc9523de4c --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/page-feedback.css @@ -0,0 +1,299 @@ +page-feedback { + display: block; + width: 100%; + margin-top: 3rem; + border-top: 1px solid var(--color-grey-20); + padding-top: 4rem; +} + +page-feedback + #prev-next-nav { + margin-top: 4rem; +} + +.page-feedback { + box-sizing: border-box; + width: 100%; + max-width: 32rem; + color: var(--color-grey-100); +} + +.page-feedback__prompt { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.page-feedback__question { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.page-feedback__choices { + display: flex; + gap: 0.5rem; +} + +.page-feedback__choice { + display: inline-flex; + align-items: center; + gap: 0.375rem; + justify-content: center; + border: 1px solid var(--color-grey-30); + border-radius: 0.375rem; + background: var(--color-white); + padding: 0.375rem 0.625rem; + font: inherit; + font-size: 1rem; + font-weight: 500; + color: var(--color-grey-70); + cursor: pointer; + transition: + border-color 120ms ease, + background-color 120ms ease, + color 120ms ease; +} + +.page-feedback__choice[aria-pressed='true'] { + border-color: var(--color-grey-50); + background: var(--color-grey-10); + color: var(--color-grey-100); +} + +.page-feedback__choice--yes[aria-pressed='true'] { + border-color: var(--color-green-50); + background: var(--color-green-10); + color: var(--color-green-100); +} + +.page-feedback__choice--no[aria-pressed='true'] { + border-color: var(--color-red-50); + background: var(--color-red-10); + color: var(--color-red-100); +} + +.page-feedback__choice--yes:hover:not(:disabled) { + border-color: var(--color-green-50); + background: var(--color-green-10); + color: var(--color-green-100); +} + +.page-feedback__choice--no:hover:not(:disabled) { + border-color: var(--color-red-50); + background: var(--color-red-10); + color: var(--color-red-100); +} + +.page-feedback__choice:focus-visible, +.page-feedback__reason input:focus-visible, +.page-feedback__textarea:focus-visible, +.page-feedback__submit:focus-visible { + outline: 2px solid var(--color-blue-elastic); + outline-offset: 2px; +} + +.page-feedback__submit:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.page-feedback__choice:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.page-feedback__choice svg { + width: 1.125rem; + height: 1.125rem; +} + +.page-feedback__form { + display: grid; + gap: 1rem; + margin-top: 1.5rem; + animation: page-feedback-content-reveal 180ms ease-out both; +} + +.page-feedback__reasons { + display: grid; + gap: 0.875rem; + min-width: 0; + margin: 0; + border: 0; + padding: 0; +} + +.page-feedback__legend { + margin-bottom: 1rem; + padding: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.page-feedback__reason { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.625rem; + align-items: start; + cursor: pointer; +} + +.page-feedback__reason input { + width: 1rem; + height: 1rem; + margin: 0.2rem 0 0; + accent-color: var(--color-blue-elastic); +} + +.page-feedback__reason-label, +.page-feedback__reason-description { + display: block; +} + +.page-feedback__reason-label { + font-size: 1rem; + font-weight: 600; +} + +.page-feedback__reason-description { + margin-top: 0.125rem; + color: var(--color-grey-60); + font-size: 0.9375rem; +} + +.page-feedback__details { + display: grid; + gap: 0.625rem; + animation: page-feedback-content-reveal 180ms ease-out both; +} + +.page-feedback__textarea { + min-height: 4rem; + resize: vertical; + border: 1px solid var(--color-grey-30); + border-radius: 0.375rem; + padding: 0.5rem; + font: inherit; + font-size: 0.9375rem; + 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 { + flex-wrap: wrap; +} + +.page-feedback__submit { + border: 0; + border-radius: 0.375rem; + background: var(--color-blue-elastic); + padding: 0.375rem 0.875rem; + font: inherit; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-white); + cursor: pointer; + transition: background-color 120ms ease; +} + +.page-feedback__submit:hover:not(:disabled) { + background: var(--color-blue-elastic-100); +} + +.page-feedback__error { + color: var(--color-red-70, #bd271e); +} + +.page-feedback__thanks { + display: flex; + align-items: center; + gap: 0.5rem; + 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-content-reveal { + from { + opacity: 0; + transform: translateY(-0.25rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@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__choice, + .page-feedback__details, + .page-feedback__form, + .page-feedback__submit, + .page-feedback__thanks { + animation: none; + transition: 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..2ba657c9b5 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.test.tsx @@ -0,0 +1,239 @@ +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' +const successfulResponse = { ok: true, status: 204 } as Response +const failedResponse = { ok: false, status: 503 } as Response + +describe('PageFeedback', () => { + beforeEach(() => { + jest.spyOn(crypto, 'randomUUID').mockReturnValue(feedbackId) + global.fetch = jest.fn().mockResolvedValue(successfulResponse) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('records a positive reaction after the debounce and shows positive reasons', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { + name: 'Yes, this page was helpful', + }) + ) + + 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: 'thumbsUp', + }), + }) + ) + ) + expect( + screen.getByRole('group', { name: 'What did you like?' }) + ).toBeInTheDocument() + expect(screen.getByRole('radio', { name: /Accurate/ })).toHaveFocus() + expect( + screen.queryByRole('radio', { name: /Inaccurate/ }) + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled() + }) + + it('shows negative reasons after a negative reaction', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { + name: 'No, this page was not helpful', + }) + ) + + expect( + screen.getByRole('group', { name: 'What went wrong?' }) + ).toBeInTheDocument() + expect( + screen.getByRole('radio', { + name: /Couldn't find what I needed/, + }) + ).toBeInTheDocument() + expect( + screen.queryByRole('radio', { name: /Solved my problem/ }) + ).not.toBeInTheDocument() + }) + + it('debounces reaction changes and records the latest choice', async () => { + const user = userEvent.setup() + render() + + const yes = screen.getByRole('button', { + name: 'Yes, this page was helpful', + }) + const no = screen.getByRole('button', { + name: 'No, this page was not helpful', + }) + + await user.click(yes) + await user.click(screen.getByRole('radio', { name: /Accurate/ })) + await user.click(no) + + expect(yes).toHaveAttribute('aria-pressed', 'false') + expect(no).toHaveAttribute('aria-pressed', 'true') + expect( + screen.getByRole('group', { name: 'What went wrong?' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('radio', { name: /Accurate/ }) + ).not.toBeInTheDocument() + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(global.fetch).toHaveBeenLastCalledWith( + `/docs/_api/v1/page-feedback/${feedbackId}`, + expect.objectContaining({ + body: expect.stringContaining('"reaction":"thumbsDown"'), + }) + ) + }) + }) + + it('silently retries a failed immediate reaction once', async () => { + const user = userEvent.setup() + jest.mocked(global.fetch) + .mockResolvedValueOnce(failedResponse) + .mockResolvedValueOnce(successfulResponse) + render() + + await user.click( + screen.getByRole('button', { + name: 'Yes, this page was helpful', + }) + ) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + expect( + screen.queryByText("We couldn't save your feedback.") + ).not.toBeInTheDocument() + }) + + it('submits a structured reason without requiring details', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { + name: 'No, this page was not helpful', + }) + ) + expect( + screen.queryByRole('textbox', { + name: 'Tell us more (optional)', + }) + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('radio', { + name: /Couldn't find what I needed/, + }) + ) + const commentField = screen.getByRole('textbox', { + name: 'Tell us more (optional)', + }) + expect(commentField).toHaveAttribute('maxlength', '2000') + await user.click(screen.getByRole('button', { name: 'Submit' })) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + expect(global.fetch).toHaveBeenLastCalledWith( + `/docs/_api/v1/page-feedback/${feedbackId}`, + expect.objectContaining({ + body: JSON.stringify({ + pageUrl: '/docs/test-page', + pageTitle: 'Test page', + reaction: 'thumbsDown', + reason: 'missingInformation', + reasonSetVersion: 1, + }), + }) + ) + expect( + await screen.findByText('Thank you for your feedback.') + ).toBeInTheDocument() + }) + + it('waits for the immediate reaction before storing richer feedback', async () => { + const user = userEvent.setup() + let resolveInitialSave: (response: Response) => void = () => {} + const initialSave = new Promise((resolve) => { + resolveInitialSave = resolve + }) + jest.mocked(global.fetch) + .mockReturnValueOnce(initialSave) + .mockResolvedValueOnce(successfulResponse) + render() + + await user.click( + screen.getByRole('button', { + name: 'Yes, this page was helpful', + }) + ) + await user.click(screen.getByRole('radio', { name: /Accurate/ })) + await user.click(screen.getByRole('button', { name: 'Submit' })) + + expect(global.fetch).toHaveBeenCalledTimes(1) + await act(async () => resolveInitialSave(successfulResponse)) + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + expect(global.fetch).toHaveBeenLastCalledWith( + `/docs/_api/v1/page-feedback/${feedbackId}`, + expect.objectContaining({ + body: expect.stringContaining('"reason":"accurate"'), + }) + ) + }) + + it('preserves the selected reason and details when submission is retried', async () => { + const user = userEvent.setup() + jest.mocked(global.fetch) + .mockResolvedValueOnce(successfulResponse) + .mockResolvedValueOnce(failedResponse) + .mockResolvedValueOnce(successfulResponse) + render() + + await user.click( + screen.getByRole('button', { + name: 'No, this page was not helpful', + }) + ) + const reason = screen.getByRole('radio', { + name: /Code sample errors/, + }) + await user.click(reason) + const commentField = screen.getByRole('textbox', { + name: 'Tell us more (optional)', + }) + await user.type(commentField, 'The Python example fails.') + await user.click(screen.getByRole('button', { name: 'Submit' })) + + expect( + await screen.findByText("We couldn't save your feedback.") + ).toBeInTheDocument() + expect(reason).toBeChecked() + expect(commentField).toHaveValue('The Python example fails.') + + await user.click(screen.getByRole('button', { name: 'Try again' })) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(3)) + expect( + await screen.findByText('Thank you for your feedback.') + ).toBeInTheDocument() + }) +}) 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..609cd0ac45 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/PageFeedback.tsx @@ -0,0 +1,394 @@ +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 REASON_SET_VERSION = 1 +const REACTION_SAVE_DELAY = 400 + +type Reaction = 'thumbsUp' | 'thumbsDown' +type Reason = + | 'accurate' + | 'solvedProblem' + | 'easyToUnderstand' + | 'helpfulExamples' + | 'inaccurate' + | 'missingInformation' + | 'hardToUnderstand' + | 'codeSampleErrors' + | 'anotherReason' + +interface ReasonOption { + value: Reason + label: string + description?: string +} + +const POSITIVE_REASONS: ReasonOption[] = [ + { + value: 'accurate', + label: 'Accurate', + description: 'Accurately describes the product or feature.', + }, + { + value: 'solvedProblem', + label: 'Solved my problem', + description: 'Helped me resolve an issue.', + }, + { + value: 'easyToUnderstand', + label: 'Easy to understand', + description: 'Clear and easy to follow.', + }, + { + value: 'helpfulExamples', + label: 'Helpful examples', + description: 'The examples helped me complete my task.', + }, + { value: 'anotherReason', label: 'Another reason' }, +] + +const NEGATIVE_REASONS: ReasonOption[] = [ + { + value: 'inaccurate', + label: 'Inaccurate', + description: "Doesn't accurately describe the product or feature.", + }, + { + value: 'missingInformation', + label: "Couldn't find what I needed", + description: 'Missing important information.', + }, + { + value: 'hardToUnderstand', + label: 'Hard to understand', + description: 'Too complicated or unclear.', + }, + { + value: 'codeSampleErrors', + label: 'Code sample errors', + description: 'One or more code samples are incorrect.', + }, + { value: 'anotherReason', label: 'Another reason' }, +] + +interface PageFeedbackProps { + pageUrl: string + pageTitle: string +} + +interface PageFeedbackPayload { + pageUrl: string + pageTitle: string + reaction: Reaction + reason?: Reason + reasonSetVersion?: number + comment?: string +} + +interface PendingReactionSave { + finish: (shouldSave: boolean) => void +} + +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}` + ) + } +} + +export const PageFeedback = ({ pageUrl, pageTitle }: PageFeedbackProps) => { + const questionId = useId() + const guidanceId = useId() + const firstReasonRef = useRef(null) + const initialSaveRef = useRef>(Promise.resolve()) + const pendingReactionSaveRef = useRef(null) + const [feedbackId] = useState(() => crypto.randomUUID()) + const [reaction, setReaction] = useState(null) + const [reason, setReason] = useState(null) + const [comment, setComment] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [showThanks, setShowThanks] = useState(false) + const [error, setError] = useState(false) + + useEffect(() => { + if (reaction) firstReasonRef.current?.focus() + }, [reaction]) + + useEffect(() => () => pendingReactionSaveRef.current?.finish(false), []) + + const saveInitialReaction = async (nextReaction: Reaction) => { + const payload = { pageUrl, pageTitle, reaction: nextReaction } + + try { + await submitFeedback(feedbackId, payload) + } catch { + try { + await submitFeedback(feedbackId, payload) + } catch { + // The questionnaire remains available and its submission retries + // the complete feedback payload. + } + } + } + + const selectReaction = (nextReaction: Reaction) => { + if (nextReaction === reaction) return + + setReaction(nextReaction) + setReason(null) + setError(false) + pendingReactionSaveRef.current?.finish(false) + + const debounce = new Promise((resolve) => { + let settled = false + const timeout = window.setTimeout( + () => finish(true), + REACTION_SAVE_DELAY + ) + const finish = (shouldSave: boolean) => { + if (settled) return + + settled = true + window.clearTimeout(timeout) + pendingReactionSaveRef.current = null + resolve(shouldSave) + } + + pendingReactionSaveRef.current = { finish } + }) + + initialSaveRef.current = initialSaveRef.current.then(async () => { + if (await debounce) await saveInitialReaction(nextReaction) + }) + } + + const submitDetails = async (event: FormEvent) => { + event.preventDefault() + const trimmedComment = comment.trim() + if (!reaction || !reason || isSaving) return + + const payload: PageFeedbackPayload = { + pageUrl, + pageTitle, + reaction, + reason, + reasonSetVersion: REASON_SET_VERSION, + ...(trimmedComment ? { comment: trimmedComment } : {}), + } + + setIsSaving(true) + setError(false) + + try { + pendingReactionSaveRef.current?.finish(true) + await initialSaveRef.current + await submitFeedback(feedbackId, payload) + setShowThanks(true) + } catch { + setError(true) + } finally { + setIsSaving(false) + } + } + + if (showThanks) { + return ( +
+

+ + Thank you for your feedback. +

+
+ ) + } + + return ( +
+
+

+ Was this page helpful? +

+
+ + +
+
+ + {reaction && ( +
+
+ + {reaction === 'thumbsUp' + ? 'What did you like?' + : 'What went wrong?'} + + {(reaction === 'thumbsUp' + ? POSITIVE_REASONS + : NEGATIVE_REASONS + ).map((option, index) => ( + + ))} +
+ + {reason && ( +
+ +