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? + + + selectReaction('thumbsUp')} + > + + Yes + + selectReaction('thumbsDown')} + > + + No + + + + + {reaction && ( + + + + {reaction === 'thumbsUp' + ? 'What did you like?' + : 'What went wrong?'} + + {(reaction === 'thumbsUp' + ? POSITIVE_REASONS + : NEGATIVE_REASONS + ).map((option, index) => ( + + { + setReason(option.value) + setError(false) + }} + /> + + + {option.label} + + {option.description && ( + + {option.description} + + )} + + + ))} + + + {reason && ( + + + Tell us more (optional) + + + setComment(event.target.value) + } + /> + + + Don't include passwords, API keys, or + other sensitive information. + + + {comment.length}/{COMMENT_MAX_LENGTH} + + + + )} + + + + {isSaving + ? 'Sending…' + : error + ? 'Try again' + : 'Submit'} + + {error && ( + + We couldn't save your feedback. + + )} + + + )} + + ) +} + +customElements.define( + 'page-feedback', + r2wc(PageFeedback, { + props: { + pageUrl: 'string', + pageTitle: 'string', + }, + }) +) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts b/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts index b1591a70cc..1fbbbe5918 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/loadWebComponents.ts @@ -34,6 +34,7 @@ export function createWebComponentLoader(componentLoaders: ComponentLoaders) { export const loadWebComponents = createWebComponentLoader({ 'version-dropdown': () => import('./VersionDropdown'), 'applies-to-popover': () => import('./AppliesToPopover'), + 'page-feedback': () => import('./PageFeedback'), 'diagnostics-panel': () => import('./Diagnostics/DiagnosticsComponent'), 'storybook-story': () => import('./StorybookStory/StorybookStoryComponent'), }) diff --git a/src/Elastic.Documentation.Site/Layout/_PageFeedback.cshtml b/src/Elastic.Documentation.Site/Layout/_PageFeedback.cshtml new file mode 100644 index 0000000000..e3629e8e47 --- /dev/null +++ b/src/Elastic.Documentation.Site/Layout/_PageFeedback.cshtml @@ -0,0 +1,19 @@ +@inherits RazorSlice +@using Elastic.Documentation + +@{ + var supportedBuildType = Model.BuildType is BuildType.Assembler or BuildType.Codex; +#if DEBUG + supportedBuildType |= Model.BuildType is BuildType.Isolated; +#endif +} + +@if (Model.Features.PageFeedbackEnabled + && !Model.Features.AirGappedEnabled + && supportedBuildType) +{ + + +} diff --git a/src/Elastic.Markdown/_Layout.cshtml b/src/Elastic.Markdown/_Layout.cshtml index 39dc759b14..59816d3f93 100644 --- a/src/Elastic.Markdown/_Layout.cshtml +++ b/src/Elastic.Markdown/_Layout.cshtml @@ -46,6 +46,7 @@ @await RenderBodyAsync() + @await RenderPartialAsync(_PageFeedback.Create(Model)) @await RenderPartialAsync(_PrevNextNav.Create(Model)) @if (Model.Features.WebsiteSearchEnabled && Model.Features.WebsiteSearchScriptUrl is not null) { diff --git a/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs b/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs index d01d751b4e..0f79c96d9c 100644 --- a/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs +++ b/src/api/Elastic.Documentation.Api/Adapters/AskAi/ElasticsearchAskAiMessageFeedbackGateway.cs @@ -2,12 +2,10 @@ // 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 System.Text.Json; using System.Text.Json.Serialization; -using Elastic.Clients.Elasticsearch; -using Elastic.Clients.Elasticsearch.Serialization; using Elastic.Documentation.Api; using Elastic.Documentation.Api.AskAi; -using Elastic.Documentation.Configuration; using Elastic.Transport; using Microsoft.Extensions.Logging; @@ -16,48 +14,13 @@ namespace Elastic.Documentation.Api.Adapters.AskAi; /// /// Records Ask AI message feedback to Elasticsearch. /// -public sealed class ElasticsearchAskAiMessageFeedbackGateway : IAskAiMessageFeedbackService, IDisposable +public sealed class ElasticsearchAskAiMessageFeedbackGateway( + ITransport transport, + AppEnvironment appEnvironment, + ILogger logger +) : IAskAiMessageFeedbackService { - private readonly ElasticsearchClient _client; - private readonly string _indexName; - private readonly ILogger _logger; - private readonly SingleNodePool _nodePool; - private bool _disposed; - - public ElasticsearchAskAiMessageFeedbackGateway( - DocumentationEndpoints endpoints, - AppEnvironment appEnvironment, - ILogger logger) - { - _logger = logger; - _indexName = $"ask-ai-message-feedback-{appEnvironment.Current.ToStringFast(true)}"; - - var endpoint = endpoints.Elasticsearch; - _nodePool = new SingleNodePool(endpoint.Uri); - var auth = endpoint.ApiKey is { } apiKey - ? (AuthorizationHeader)new ApiKey(apiKey) - : endpoint is { Username: { } username, Password: { } password } - ? new BasicAuthentication(username, password) - : null!; - - using var clientSettings = new ElasticsearchClientSettings( - _nodePool, - sourceSerializer: (_, settings) => new DefaultSourceSerializer(settings, MessageFeedbackJsonContext.Default) - ) - .DefaultIndex(_indexName) - .Authentication(auth); - _client = new ElasticsearchClient(clientSettings); - } - - public void Dispose() - { - if (_disposed) - return; - - _nodePool.Dispose(); - (_client.Transport as IDisposable)?.Dispose(); - _disposed = true; - } + private readonly string _indexName = $"ask-ai-message-feedback-{appEnvironment.Current.ToStringFast(true)}"; public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, CancellationToken ctx) { @@ -72,29 +35,30 @@ public async Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, Cancell Timestamp = DateTimeOffset.UtcNow }; - _logger.LogDebug("Indexing feedback with ID {FeedbackId} to index {IndexName}", feedbackId, _indexName); - - var response = await _client.IndexAsync(document, idx => idx - .Index(_indexName) - .Id(feedbackId.ToString()), ctx); + logger.LogDebug("Indexing feedback with ID {FeedbackId} to index {IndexName}", feedbackId, _indexName); + var json = JsonSerializer.Serialize(document, MessageFeedbackJsonContext.Default.MessageFeedbackDocument); + var response = await transport.PutAsync( + $"{_indexName}/_doc/{feedbackId}", + PostData.String(json), + ctx); // MessageId and ConversationId are Guid types, so no sanitization needed - if (!response.IsValidResponse) + if (!response.ApiCallDetails.HasSuccessfulStatusCode) { - _logger.LogWarning( - "Failed to index message feedback for message {MessageId}: {Error}", + logger.LogWarning( + "Failed to index message feedback for message {MessageId}: HTTP {StatusCode}", record.MessageId, - response.ElasticsearchServerError?.Error?.Reason ?? "Unknown error"); + response.ApiCallDetails.HttpStatusCode); } else { - _logger.LogInformation( + logger.LogInformation( "Message feedback recorded: {Reaction} for message {MessageId} in conversation {ConversationId}. ES _id: {EsId}, Index: {Index}", record.Reaction, record.MessageId, record.ConversationId, - response.Id, - response.Index); + feedbackId, + _indexName); } } } diff --git a/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/ElasticsearchPageFeedbackGateway.cs b/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/ElasticsearchPageFeedbackGateway.cs new file mode 100644 index 0000000000..d892313832 --- /dev/null +++ b/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/ElasticsearchPageFeedbackGateway.cs @@ -0,0 +1,79 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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.Api.PageFeedback; +using Elastic.Ingest.Elasticsearch; +using Elastic.Ingest.Elasticsearch.Serialization; +using Elastic.Transport; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Adapters.PageFeedback; + +internal sealed class ElasticsearchPageFeedbackGateway( + ITransport transport, + PageFeedbackIndex index, + IngestChannel channel, + ILogger logger +) : IPageFeedbackService +{ + public async Task UpsertFeedbackAsync(PageFeedbackRecord record, CancellationToken ctx) + { + var document = new PageFeedbackDocument + { + FeedbackId = record.FeedbackId.ToString(), + PageUrl = record.PageUrl, + PageTitle = record.PageTitle, + Reaction = record.Reaction, + Reason = record.Reason, + ReasonSetVersion = record.ReasonSetVersion, + Comment = record.Comment, + Euid = record.Euid, + Timestamp = DateTimeOffset.UtcNow + }; + + try + { + var response = await channel.DirectWriteAsync([document], ctx); + + if (response.AllItemsPersisted()) + return true; + + logger.LogWarning( + "Failed to index page feedback {FeedbackId}: HTTP {StatusCode}, item statuses {ItemStatuses}", + record.FeedbackId, + response.ApiCallDetails.HttpStatusCode, + response.Items?.Select(item => item.Status).ToArray() ?? []); + return false; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to index page feedback {FeedbackId}", record.FeedbackId); + return false; + } + } + + public async Task DeleteFeedbackAsync(Guid feedbackId, CancellationToken ctx) + { + try + { + var response = await transport.DeleteAsync( + $"{index.Name}/_doc/{feedbackId}", + cancellationToken: ctx); + + if (response.ApiCallDetails.HasSuccessfulStatusCode || response.ApiCallDetails.HttpStatusCode is 404) + return true; + + logger.LogWarning( + "Failed to delete page feedback {FeedbackId}: HTTP {StatusCode}", + feedbackId, + response.ApiCallDetails.HttpStatusCode); + return false; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to delete page feedback {FeedbackId}", feedbackId); + return false; + } + } +} diff --git a/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackBootstrapService.cs b/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackBootstrapService.cs new file mode 100644 index 0000000000..d75759cc28 --- /dev/null +++ b/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackBootstrapService.cs @@ -0,0 +1,34 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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.Ingest.Elasticsearch; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Adapters.PageFeedback; + +internal sealed class PageFeedbackBootstrapService( + IngestChannel channel, + PageFeedbackIndex index, + AppEnvironment appEnvironment, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var method = appEnvironment.Current is AppEnv.Dev + ? BootstrapMethod.Silent + : BootstrapMethod.Failure; + + if (await channel.BootstrapElasticsearchAsync(method, cancellationToken)) + { + logger.LogInformation("Bootstrapped page feedback index template for {IndexName}", index.Name); + return; + } + + logger.LogWarning("Unable to bootstrap page feedback index template for {IndexName}", index.Name); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackMapping.cs b/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackMapping.cs new file mode 100644 index 0000000000..eb1600e6ff --- /dev/null +++ b/src/api/Elastic.Documentation.Api/Adapters/PageFeedback/PageFeedbackMapping.cs @@ -0,0 +1,74 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 System.Text.Json.Serialization; +using Elastic.Documentation.Api.PageFeedback; +using Elastic.Mapping; + +namespace Elastic.Documentation.Api.Adapters.PageFeedback; + +public sealed record PageFeedbackDocument +{ + [Id] + [Keyword] + [JsonPropertyName("feedback_id")] + public required string FeedbackId { get; init; } + + [Keyword(IgnoreAbove = 2048)] + [JsonPropertyName("page_url")] + public required string PageUrl { get; init; } + + [Keyword(IgnoreAbove = 500)] + [JsonPropertyName("page_title")] + public required string PageTitle { get; init; } + + [Keyword] + [JsonPropertyName("reaction")] + public required PageFeedbackReaction Reaction { get; init; } + + [Keyword] + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PageFeedbackReason? Reason { get; init; } + + [JsonPropertyName("reason_set_version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ReasonSetVersion { get; init; } + + [Text] + [JsonPropertyName("comment")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Comment { get; init; } + + [Keyword(IgnoreAbove = 256)] + [JsonPropertyName("euid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Euid { get; init; } + + [Timestamp] + [JsonPropertyName("@timestamp")] + public required DateTimeOffset Timestamp { get; init; } +} + +[JsonSerializable(typeof(PageFeedbackDocument))] +[JsonSerializable(typeof(PageFeedbackReaction))] +[JsonSerializable(typeof(PageFeedbackReason))] +internal sealed partial class PageFeedbackJsonContext : JsonSerializerContext; + +[ElasticsearchMappingContext(JsonContext = typeof(PageFeedbackJsonContext))] +[Index( + NameTemplate = "page-feedback-v1-{env}", + Dynamic = false, + MappingVersionFromAssembly = true +)] +internal static partial class PageFeedbackMappingContext; + +internal sealed class PageFeedbackIndex(AppEnvironment appEnvironment) +{ + public ElasticsearchTypeContext MappingContext { get; } = + PageFeedbackMappingContext.PageFeedbackDocument.CreateContext(env: appEnvironment.Current.ToStringFast(true)); + + public string Name => MappingContext.IndexStrategy?.WriteTarget + ?? throw new InvalidOperationException("Page feedback index mapping has no write target."); +} diff --git a/src/api/Elastic.Documentation.Api/Elastic.Documentation.Api.csproj b/src/api/Elastic.Documentation.Api/Elastic.Documentation.Api.csproj index 26d0be4ac5..b2e1162d37 100644 --- a/src/api/Elastic.Documentation.Api/Elastic.Documentation.Api.csproj +++ b/src/api/Elastic.Documentation.Api/Elastic.Documentation.Api.csproj @@ -25,6 +25,7 @@ + diff --git a/src/api/Elastic.Documentation.Api/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api/MappingsExtensions.cs index 8c7a906551..fa32c34e3d 100644 --- a/src/api/Elastic.Documentation.Api/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api/MappingsExtensions.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Text.Json; using Elastic.Documentation.Api.AskAi; +using Elastic.Documentation.Api.PageFeedback; using Elastic.Documentation.Search; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -24,6 +25,7 @@ public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) MapNavigationSearch(group); MapFullSearch(group); MapChanges(group); + MapPageFeedback(group); } private static void MapAskAiEndpoint(IEndpointRouteBuilder group) @@ -176,4 +178,97 @@ Cancel ctx return Results.Ok(response); }); + private static void MapPageFeedback(IEndpointRouteBuilder group) + { + _ = group.MapPut("/page-feedback/{feedbackId:guid}", async ( + Guid feedbackId, + PageFeedbackRequest request, + HttpContext context, + IPageFeedbackService feedbackService, + ILogger logger, + Cancel ctx) => + { + if (!IsValidPageFeedback(request)) + return Results.BadRequest(); + + _ = context.Request.Cookies.TryGetValue("euid", out var euid); + var comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(); + var record = new PageFeedbackRecord( + feedbackId, + request.PageUrl, + request.PageTitle, + request.Reaction, + request.Reason, + request.ReasonSetVersion, + comment, + euid + ); + + if (!await feedbackService.UpsertFeedbackAsync(record, ctx)) + { + logger.LogWarning("Failed to record page feedback {FeedbackId} for {PageUrl}", feedbackId, request.PageUrl); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + logger.LogInformation("Recorded page feedback {FeedbackId} for {PageUrl}", feedbackId, request.PageUrl); + return Results.NoContent(); + }).DisableAntiforgery(); + + _ = group.MapDelete("/page-feedback/{feedbackId:guid}", async ( + Guid feedbackId, + IPageFeedbackService feedbackService, + ILogger logger, + Cancel ctx) => + { + if (!await feedbackService.DeleteFeedbackAsync(feedbackId, ctx)) + { + logger.LogWarning("Failed to delete page feedback {FeedbackId}", feedbackId); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + logger.LogInformation("Deleted page feedback {FeedbackId}", feedbackId); + return Results.NoContent(); + }).DisableAntiforgery(); + } + + private static bool IsValidPageFeedback(PageFeedbackRequest request) => + !string.IsNullOrWhiteSpace(request.PageUrl) + && request.PageUrl.Length <= 2048 + && request.PageUrl.StartsWith('/') + && !request.PageUrl.StartsWith("//", StringComparison.Ordinal) + && Uri.TryCreate(request.PageUrl, UriKind.Relative, out _) + && !string.IsNullOrWhiteSpace(request.PageTitle) + && request.PageTitle.Length <= 500 + && request.Reaction is PageFeedbackReaction.ThumbsUp or PageFeedbackReaction.ThumbsDown + && (request.Comment is null || request.Comment.Length <= 2000) + && IsValidFeedbackDetails(request); + + private static bool IsValidFeedbackDetails(PageFeedbackRequest request) + { + if (request.Reason is null) + return request.ReasonSetVersion is null && string.IsNullOrWhiteSpace(request.Comment); + + return request.ReasonSetVersion is > 0 + && Enum.IsDefined(request.Reason.Value) + && IsReasonValidForReaction(request.Reaction, request.Reason.Value); + } + + private static bool IsReasonValidForReaction(PageFeedbackReaction reaction, PageFeedbackReason reason) => + reaction switch + { + PageFeedbackReaction.ThumbsUp => reason is + PageFeedbackReason.Accurate + or PageFeedbackReason.SolvedProblem + or PageFeedbackReason.EasyToUnderstand + or PageFeedbackReason.HelpfulExamples + or PageFeedbackReason.AnotherReason, + PageFeedbackReaction.ThumbsDown => reason is + PageFeedbackReason.Inaccurate + or PageFeedbackReason.MissingInformation + or PageFeedbackReason.HardToUnderstand + or PageFeedbackReason.CodeSampleErrors + or PageFeedbackReason.AnotherReason, + _ => false + }; + } diff --git a/src/api/Elastic.Documentation.Api/PageFeedback/IPageFeedbackService.cs b/src/api/Elastic.Documentation.Api/PageFeedback/IPageFeedbackService.cs new file mode 100644 index 0000000000..87689663b6 --- /dev/null +++ b/src/api/Elastic.Documentation.Api/PageFeedback/IPageFeedbackService.cs @@ -0,0 +1,22 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 + +namespace Elastic.Documentation.Api.PageFeedback; + +public interface IPageFeedbackService +{ + Task UpsertFeedbackAsync(PageFeedbackRecord record, CancellationToken ctx); + Task DeleteFeedbackAsync(Guid feedbackId, CancellationToken ctx); +} + +public record PageFeedbackRecord( + Guid FeedbackId, + string PageUrl, + string PageTitle, + PageFeedbackReaction Reaction, + PageFeedbackReason? Reason, + int? ReasonSetVersion, + string? Comment, + string? Euid +); diff --git a/src/api/Elastic.Documentation.Api/PageFeedback/PageFeedbackRequest.cs b/src/api/Elastic.Documentation.Api/PageFeedback/PageFeedbackRequest.cs new file mode 100644 index 0000000000..f12a6ba012 --- /dev/null +++ b/src/api/Elastic.Documentation.Api/PageFeedback/PageFeedbackRequest.cs @@ -0,0 +1,60 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 System.Text.Json.Serialization; + +namespace Elastic.Documentation.Api.PageFeedback; + +public record PageFeedbackRequest( + string PageUrl, + string PageTitle, + PageFeedbackReaction Reaction, + PageFeedbackReason? Reason, + int? ReasonSetVersion, + string? Comment +); + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PageFeedbackReaction +{ + [JsonStringEnumMemberName("unspecified")] + Unspecified, + + [JsonStringEnumMemberName("thumbsUp")] + ThumbsUp, + + [JsonStringEnumMemberName("thumbsDown")] + ThumbsDown +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PageFeedbackReason +{ + [JsonStringEnumMemberName("accurate")] + Accurate, + + [JsonStringEnumMemberName("solvedProblem")] + SolvedProblem, + + [JsonStringEnumMemberName("easyToUnderstand")] + EasyToUnderstand, + + [JsonStringEnumMemberName("helpfulExamples")] + HelpfulExamples, + + [JsonStringEnumMemberName("inaccurate")] + Inaccurate, + + [JsonStringEnumMemberName("missingInformation")] + MissingInformation, + + [JsonStringEnumMemberName("hardToUnderstand")] + HardToUnderstand, + + [JsonStringEnumMemberName("codeSampleErrors")] + CodeSampleErrors, + + [JsonStringEnumMemberName("anotherReason")] + AnotherReason +} diff --git a/src/api/Elastic.Documentation.Api/SerializationContext.cs b/src/api/Elastic.Documentation.Api/SerializationContext.cs index d5474683b9..0e6701bf1c 100644 --- a/src/api/Elastic.Documentation.Api/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api/SerializationContext.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Elastic.Documentation.Api.AskAi; +using Elastic.Documentation.Api.PageFeedback; using Elastic.Documentation.Search; namespace Elastic.Documentation.Api; @@ -20,6 +21,9 @@ public record OutputMessage(string Role, MessagePart[] Parts, string FinishReaso [JsonSerializable(typeof(AskAiRequest))] [JsonSerializable(typeof(AskAiMessageFeedbackRequest))] [JsonSerializable(typeof(Reaction))] +[JsonSerializable(typeof(PageFeedbackRequest))] +[JsonSerializable(typeof(PageFeedbackReaction))] +[JsonSerializable(typeof(PageFeedbackReason))] [JsonSerializable(typeof(NavigationSearchRequest))] [JsonSerializable(typeof(NavigationSearchResponse))] [JsonSerializable(typeof(NavigationSearchAggregations))] diff --git a/src/api/Elastic.Documentation.Api/ServicesExtension.cs b/src/api/Elastic.Documentation.Api/ServicesExtension.cs index ead042603c..2b521238d3 100644 --- a/src/api/Elastic.Documentation.Api/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api/ServicesExtension.cs @@ -6,10 +6,15 @@ using Amazon.DynamoDBv2; using Elastic.Documentation.Api; using Elastic.Documentation.Api.Adapters.AskAi; +using Elastic.Documentation.Api.Adapters.PageFeedback; using Elastic.Documentation.Api.AskAi; using Elastic.Documentation.Api.Caching; using Elastic.Documentation.Api.Gcp; +using Elastic.Documentation.Api.PageFeedback; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Search; +using Elastic.Ingest.Elasticsearch; +using Elastic.Transport; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; @@ -68,8 +73,12 @@ private static void AddElasticDocsApiServices(this IServiceCollection services, }); // Register AppEnvironment as a singleton for dependency injection _ = services.AddSingleton(new AppEnvironment { Current = appEnv }); + _ = services.AddSingleton(serviceProvider => + ElasticsearchTransportFactory.Create( + serviceProvider.GetRequiredService().Elasticsearch)); AddDistributedCache(services, appEnv); AddAskAiServices(services, appEnv); + AddPageFeedbackServices(services); AddSearchServices(services, appEnv); } @@ -181,6 +190,20 @@ private static void AddAskAiServices(IServiceCollection services, AppEnv appEnv) } } + private static void AddPageFeedbackServices(IServiceCollection services) + { + _ = services.AddSingleton(); + _ = services.AddSingleton(serviceProvider => + { + var transport = serviceProvider.GetRequiredService(); + var index = serviceProvider.GetRequiredService(); + var options = new IngestChannelOptions(transport, index.MappingContext); + return new IngestChannel(options); + }); + _ = services.AddSingleton(); + _ = services.AddHostedService(); + } + private static void AddSearchServices(IServiceCollection services, AppEnv appEnv) { var logger = GetLogger(services); diff --git a/src/tooling/docs-builder/Http/DebugPageFeedbackService.cs b/src/tooling/docs-builder/Http/DebugPageFeedbackService.cs new file mode 100644 index 0000000000..c15f6273f2 --- /dev/null +++ b/src/tooling/docs-builder/Http/DebugPageFeedbackService.cs @@ -0,0 +1,18 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 + +#if DEBUG +using Elastic.Documentation.Api.PageFeedback; + +namespace Documentation.Builder.Http; + +internal sealed class DebugPageFeedbackService : IPageFeedbackService +{ + public Task UpsertFeedbackAsync(PageFeedbackRecord record, CancellationToken ctx) => + Task.FromResult(true); + + public Task DeleteFeedbackAsync(Guid feedbackId, CancellationToken ctx) => + Task.FromResult(true); +} +#endif diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 8ef88049ca..885de3ac03 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Diagnostics; #if DEBUG using Elastic.Documentation.Api; +using Elastic.Documentation.Api.PageFeedback; #endif using Elastic.Documentation.Configuration; using Elastic.Documentation.ServiceDefaults; @@ -22,6 +23,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -56,6 +58,7 @@ bool isWatchBuild #if DEBUG builder.Services.AddElasticDocsApiServices("dev"); + builder.Services.Replace(ServiceDescriptor.Singleton()); #endif _ = builder.Logging @@ -77,6 +80,9 @@ bool isWatchBuild // Enable diagnostics panel in serve mode Context.Configuration.Features.DiagnosticsPanelEnabled = true; +#if DEBUG + Context.Configuration.Features.PageFeedbackEnabled = true; +#endif // Create InMemoryBuildState for background validation builds InMemoryBuildState = new InMemoryBuildState(logFactory, configurationContext); diff --git a/src/tooling/docs-builder/Http/StaticWebHost.cs b/src/tooling/docs-builder/Http/StaticWebHost.cs index a0d99a67b8..66e9c60a71 100644 --- a/src/tooling/docs-builder/Http/StaticWebHost.cs +++ b/src/tooling/docs-builder/Http/StaticWebHost.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; #if DEBUG using Elastic.Documentation.Api; +using Elastic.Documentation.Api.PageFeedback; #endif using Elastic.Documentation.Configuration; using Elastic.Documentation.Extensions; @@ -12,6 +13,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -40,6 +43,7 @@ public StaticWebHost(int port, string? path) _ = builder.AddDocumentationServiceDefaults(); #if DEBUG builder.Services.AddElasticDocsApiServices("dev"); + builder.Services.Replace(ServiceDescriptor.Singleton()); #endif _ = builder.Logging diff --git a/src/tooling/essc/Commands/ContentStackCommands.cs b/src/tooling/essc/Commands/ContentStackCommands.cs index f1da458db7..a492c577ec 100644 --- a/src/tooling/essc/Commands/ContentStackCommands.cs +++ b/src/tooling/essc/Commands/ContentStackCommands.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.ComponentModel.DataAnnotations; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Indexing; using Elastic.SiteSearch.Cli.Elasticsearch; using Microsoft.Extensions.Logging; diff --git a/src/tooling/essc/Commands/IndicesCommands.cs b/src/tooling/essc/Commands/IndicesCommands.cs index 5e40809dcc..315c9fdf20 100644 --- a/src/tooling/essc/Commands/IndicesCommands.cs +++ b/src/tooling/essc/Commands/IndicesCommands.cs @@ -7,6 +7,7 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Elastic.Channels; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Search.Contract.Mapping; using Elastic.Ingest.Elasticsearch; diff --git a/src/tooling/essc/Commands/LabsCommands.cs b/src/tooling/essc/Commands/LabsCommands.cs index df011d7b7c..bb211eda6b 100644 --- a/src/tooling/essc/Commands/LabsCommands.cs +++ b/src/tooling/essc/Commands/LabsCommands.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.ComponentModel.DataAnnotations; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Indexing; using Elastic.SiteSearch.Cli.Elasticsearch; using Elastic.SiteSearch.Cli.LabsCrawl; diff --git a/src/tooling/essc/Commands/SyncCommand.cs b/src/tooling/essc/Commands/SyncCommand.cs index 47b15432bc..2c7ba3e205 100644 --- a/src/tooling/essc/Commands/SyncCommand.cs +++ b/src/tooling/essc/Commands/SyncCommand.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Threading.Channels; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Indexing; using Elastic.SiteSearch.Cli.ContentStack; using Elastic.SiteSearch.Cli.Elasticsearch; diff --git a/src/tooling/essc/Elasticsearch/ElasticsearchEndpoint.cs b/src/tooling/essc/Elasticsearch/ElasticsearchEndpoint.cs deleted file mode 100644 index 8a49efc51e..0000000000 --- a/src/tooling/essc/Elasticsearch/ElasticsearchEndpoint.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// 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 - -namespace Elastic.SiteSearch.Cli.Elasticsearch; - -internal sealed class ElasticsearchEndpoint -{ - public static ElasticsearchEndpoint Default { get; } = new() { Uri = new Uri("http://localhost:9200") }; - - public required Uri Uri { get; set; } - public string? Username { get; set; } - public string? Password { get; set; } - public string? ApiKey { get; set; } - - public int IndexNumThreads { get; set; } = 4; - public int BufferSize { get; set; } = 100; - public int MaxRetries { get; set; } = 5; - - public bool DebugMode { get; set; } - public string? CertificateFingerprint { get; set; } - public bool DisableSslVerification { get; set; } - - public bool EnableAiEnrichment { get; set; } = true; -} diff --git a/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs b/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs deleted file mode 100644 index 6c6ef4646d..0000000000 --- a/src/tooling/essc/Elasticsearch/ElasticsearchTransportFactory.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// 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.Transport; -using Elastic.Transport.Products.Elasticsearch; - -namespace Elastic.SiteSearch.Cli.Elasticsearch; - -internal static class ElasticsearchTransportFactory -{ - public static DistributedTransport Create(ElasticsearchEndpoint endpoint) - { - var configuration = new ElasticsearchConfiguration(endpoint.Uri) - { - Authentication = endpoint.ApiKey is { } apiKey - ? new ApiKey(apiKey) - : endpoint is { Username: { } username, Password: { } password } - ? new BasicAuthentication(username, password) - : null, - EnableHttpCompression = true, - DebugMode = endpoint.DebugMode, - CertificateFingerprint = endpoint.CertificateFingerprint, - ServerCertificateValidationCallback = endpoint.DisableSslVerification - ? CertificateValidations.AllowAll - : null - }; - - return new DistributedTransport(configuration); - } -} diff --git a/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs b/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs index c491c1c543..45318b7c6c 100644 --- a/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs +++ b/src/tooling/essc/Elasticsearch/LabsDocumentExporter.cs @@ -4,6 +4,7 @@ using System.Threading; using Elastic.Channels; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Indexing; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Search.Contract.Mapping; diff --git a/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs b/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs index 3213a1983f..272e809e93 100644 --- a/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs +++ b/src/tooling/essc/Elasticsearch/SiteDocumentExporter.cs @@ -4,6 +4,7 @@ using System.Threading; using Elastic.Channels; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Indexing; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Search.Contract.Mapping; diff --git a/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs b/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs index 95f2764bbf..b35d6e32ff 100644 --- a/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs +++ b/src/tooling/essc/Elasticsearch/SourcingConfiguration.cs @@ -2,6 +2,7 @@ // 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.SiteSearch.Cli.ContentStack; using Microsoft.Extensions.Configuration; diff --git a/src/tooling/essc/essc.csproj b/src/tooling/essc/essc.csproj index 220daec44c..e0e387685d 100644 --- a/src/tooling/essc/essc.csproj +++ b/src/tooling/essc/essc.csproj @@ -33,6 +33,7 @@ + diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs index dc6c3d819d..1efe73a639 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/Search/SearchBootstrapFixture.cs @@ -8,6 +8,7 @@ using AwesomeAssertions; using Documentation.Builder.Diagnostics.Console; using Elastic.Documentation.Aspire; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Search; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Search.Contract.Mapping; @@ -167,7 +168,8 @@ private async ValueTask IsIndexingNeeded() using var channel = new IngestChannel(options); // Get the current hash from Elasticsearch index template - var currentSemanticHash = await channel.GetIndexTemplateHashAsync(TestContext.Current.CancellationToken) ?? string.Empty; + var currentSemanticHash = + (await channel.GetIndexTemplateMetaAsync(TestContext.Current.CancellationToken)).Hash ?? string.Empty; // Get the expected channel hash _ = await channel.BootstrapElasticsearchAsync(BootstrapMethod.Silent, TestContext.Current.CancellationToken); diff --git a/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj b/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj index 3e30d7dc32..ebfcff86ca 100644 --- a/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj +++ b/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj @@ -5,10 +5,11 @@ - + + diff --git a/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs b/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs new file mode 100644 index 0000000000..9b0350988e --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs @@ -0,0 +1,198 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 System.Net; +using System.Text; +using AwesomeAssertions; +using Elastic.Documentation.Api.PageFeedback; +using Elastic.Documentation.Api.Tests.Fixtures; +using FakeItEasy; + +namespace Elastic.Documentation.Api.Tests; + +public class PageFeedbackEndpointTests +{ + [Fact] + public async Task Put_ValidFeedback_RecordsFeedbackWithEuid() + { + var feedbackService = A.Fake(); + PageFeedbackRecord? recorded = null; + A.CallTo(() => feedbackService.UpsertFeedbackAsync(A._, A._)) + .Invokes((PageFeedbackRecord record, CancellationToken _) => recorded = record) + .Returns(Task.FromResult(true)); + using var factory = ApiWebApplicationFactory.WithMockedServices(replacements => replacements.Replace(feedbackService)); + using var client = factory.CreateClient(); + var feedbackId = Guid.NewGuid(); + using var request = CreateRequest(feedbackId, ValidPayload); + request.Headers.Add("Cookie", "euid=test-euid"); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + recorded.Should().NotBeNull(); + recorded.FeedbackId.Should().Be(feedbackId); + recorded.PageUrl.Should().Be("/docs/test-page"); + recorded.Reaction.Should().Be(PageFeedbackReaction.ThumbsUp); + recorded.Reason.Should().Be(PageFeedbackReason.Accurate); + recorded.ReasonSetVersion.Should().Be(1); + recorded.Comment.Should().Be("Useful page"); + recorded.Euid.Should().Be("test-euid"); + } + + [Fact] + public async Task Put_ReactionOnly_RecordsFeedbackWithoutDetails() + { + var feedbackService = A.Fake(); + PageFeedbackRecord? recorded = null; + A.CallTo(() => feedbackService.UpsertFeedbackAsync(A._, A._)) + .Invokes((PageFeedbackRecord record, CancellationToken _) => recorded = record) + .Returns(Task.FromResult(true)); + using var factory = ApiWebApplicationFactory.WithMockedServices(replacements => replacements.Replace(feedbackService)); + using var client = factory.CreateClient(); + const string payload = /*lang=json,strict*/ + """ + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page", + "reaction": "thumbsDown" + } + """; + using var request = CreateRequest(Guid.NewGuid(), payload); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + recorded.Should().NotBeNull(); + recorded.Reason.Should().BeNull(); + recorded.ReasonSetVersion.Should().BeNull(); + recorded.Comment.Should().BeNull(); + } + + [Fact] + public async Task Put_CommentExceedsLimit_ReturnsBadRequest() + { + var feedbackService = A.Fake(); + using var factory = ApiWebApplicationFactory.WithMockedServices(replacements => replacements.Replace(feedbackService)); + using var client = factory.CreateClient(); + var payload = $$""" + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page", + "reaction": "thumbsDown", + "reason": "inaccurate", + "reasonSetVersion": 1, + "comment": "{{new string('x', 2001)}}" + } + """; + using var request = CreateRequest(Guid.NewGuid(), payload); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + A.CallTo(() => feedbackService.UpsertFeedbackAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Theory] + [InlineData( + /*lang=json,strict*/ """ + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page", + "reaction": "thumbsUp", + "reason": "inaccurate", + "reasonSetVersion": 1 + } + """)] + [InlineData( + /*lang=json,strict*/ """ + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page" + } + """)] + [InlineData( + /*lang=json,strict*/ """ + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page", + "reaction": "thumbsDown", + "comment": "Missing a required reason" + } + """)] + [InlineData( + /*lang=json,strict*/ """ + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page", + "reaction": "thumbsDown", + "reason": "inaccurate" + } + """)] + public async Task Put_InvalidFeedback_ReturnsBadRequest(string payload) + { + var feedbackService = A.Fake(); + using var factory = ApiWebApplicationFactory.WithMockedServices(replacements => replacements.Replace(feedbackService)); + using var client = factory.CreateClient(); + using var request = CreateRequest(Guid.NewGuid(), payload); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + A.CallTo(() => feedbackService.UpsertFeedbackAsync(A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Put_PersistenceFails_ReturnsServiceUnavailable() + { + var feedbackService = A.Fake(); + A.CallTo(() => feedbackService.UpsertFeedbackAsync(A._, A._)) + .Returns(Task.FromResult(false)); + using var factory = ApiWebApplicationFactory.WithMockedServices(replacements => replacements.Replace(feedbackService)); + using var client = factory.CreateClient(); + using var request = CreateRequest(Guid.NewGuid(), ValidPayload); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + } + + [Fact] + public async Task Delete_ExistingFeedback_DeletesFeedback() + { + var feedbackService = A.Fake(); + var feedbackId = Guid.NewGuid(); + A.CallTo(() => feedbackService.DeleteFeedbackAsync(feedbackId, A._)) + .Returns(Task.FromResult(true)); + using var factory = ApiWebApplicationFactory.WithMockedServices(replacements => replacements.Replace(feedbackService)); + using var client = factory.CreateClient(); + + using var response = await client.DeleteAsync( + $"/docs/_api/v1/page-feedback/{feedbackId}", + TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + A.CallTo(() => feedbackService.DeleteFeedbackAsync(feedbackId, A._)) + .MustHaveHappenedOnceExactly(); + } + + private const string ValidPayload = /*lang=json,strict*/ + """ + { + "pageUrl": "/docs/test-page", + "pageTitle": "Test page", + "reaction": "thumbsUp", + "reason": "accurate", + "reasonSetVersion": 1, + "comment": " Useful page " + } + """; + + private static HttpRequestMessage CreateRequest(Guid feedbackId, string payload) => + new(HttpMethod.Put, $"/docs/_api/v1/page-feedback/{feedbackId}") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; +} diff --git a/tests/Elastic.Documentation.Api.Tests/PageFeedbackGatewayTests.cs b/tests/Elastic.Documentation.Api.Tests/PageFeedbackGatewayTests.cs new file mode 100644 index 0000000000..84b8072f55 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/PageFeedbackGatewayTests.cs @@ -0,0 +1,95 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 AwesomeAssertions; +using Elastic.Documentation.Api.Adapters.PageFeedback; +using Elastic.Documentation.Api.PageFeedback; +using Elastic.Ingest.Elasticsearch; +using Elastic.Transport; +using Elastic.Transport.VirtualizedCluster; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.Documentation.Api.Tests; + +public class PageFeedbackGatewayTests +{ + [Fact] + public async Task UpsertFeedbackAsync_AllItemsPersisted_ReturnsTrue() + { + var transport = CreateTransport(201); + var index = CreateIndex(); + using var channel = CreateChannel(transport, index); + var gateway = new ElasticsearchPageFeedbackGateway( + transport, + index, + channel, + NullLogger.Instance + ); + + var result = await gateway.UpsertFeedbackAsync(CreateRecord(), TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + } + + [Fact] + public async Task UpsertFeedbackAsync_ItemRejected_ReturnsFalse() + { + var transport = CreateTransport(400); + var index = CreateIndex(); + using var channel = CreateChannel(transport, index); + var gateway = new ElasticsearchPageFeedbackGateway( + transport, + index, + channel, + NullLogger.Instance + ); + + var result = await gateway.UpsertFeedbackAsync(CreateRecord(), TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + } + + private static ITransport CreateTransport(int itemStatus) => + Virtual.Elasticsearch + .Bootstrap(1) + .Ping(call => call.SucceedAlways()) + .ClientCalls(call => call + .OnPath("_bulk") + .SucceedAlways() + .ReturnResponse(new + { + errors = itemStatus is < 200 or > 299, + items = new[] + { + new + { + index = new + { + _index = "page-feedback-v1-dev", + _id = "00000000-0000-4000-8000-000000000001", + status = itemStatus + } + } + } + })) + .StaticNodePool() + .Settings(settings => settings.DisablePing().EnableDebugMode()) + .RequestHandler; + + private static PageFeedbackIndex CreateIndex() => new(new AppEnvironment { Current = AppEnv.Dev }); + + private static IngestChannel CreateChannel(ITransport transport, PageFeedbackIndex index) => + new(new IngestChannelOptions(transport, index.MappingContext)); + + private static PageFeedbackRecord CreateRecord() => new( + Guid.Parse("00000000-0000-4000-8000-000000000001"), + "/docs/test-page", + "Test page", + PageFeedbackReaction.ThumbsUp, + PageFeedbackReason.Accurate, + 1, + "Clear and useful.", + "test-euid" + ); +} diff --git a/tests/Elastic.Documentation.Api.Tests/PageFeedbackMappingTests.cs b/tests/Elastic.Documentation.Api.Tests/PageFeedbackMappingTests.cs new file mode 100644 index 0000000000..1b3761254b --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/PageFeedbackMappingTests.cs @@ -0,0 +1,58 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 System.Text.Json; +using AwesomeAssertions; +using Elastic.Documentation.Api.Adapters.PageFeedback; +using Elastic.Documentation.Api.PageFeedback; + +namespace Elastic.Documentation.Api.Tests; + +public class PageFeedbackMappingTests +{ + [Fact] + public void Mapping_EnvironmentProvided_GeneratesExpectedIndexAndFields() + { + var context = PageFeedbackMappingContext.PageFeedbackDocument.CreateContext(env: "staging"); + using var mapping = JsonDocument.Parse(context.GetMappingsJson()); + var root = mapping.RootElement; + var properties = root.GetProperty("properties"); + + context.IndexStrategy.Should().NotBeNull(); + context.IndexStrategy.WriteTarget.Should().Be("page-feedback-v1-staging"); + context.MappingVersion.Should().NotBeNullOrWhiteSpace(); + context.MappingVersion.Should().Be(typeof(PageFeedbackMappingContext).Assembly.GetName().Version?.ToString()); + root.GetProperty("dynamic").GetBoolean().Should().BeFalse(); + properties.GetProperty("feedback_id").GetProperty("type").GetString().Should().Be("keyword"); + properties.GetProperty("page_url").GetProperty("ignore_above").GetInt32().Should().Be(2048); + properties.GetProperty("page_title").GetProperty("ignore_above").GetInt32().Should().Be(500); + properties.GetProperty("reaction").GetProperty("type").GetString().Should().Be("keyword"); + properties.GetProperty("reason").GetProperty("type").GetString().Should().Be("keyword"); + properties.GetProperty("reason_set_version").GetProperty("type").GetString().Should().Be("integer"); + properties.GetProperty("comment").GetProperty("type").GetString().Should().Be("text"); + properties.GetProperty("euid").GetProperty("ignore_above").GetInt32().Should().Be(256); + properties.GetProperty("@timestamp").GetProperty("type").GetString().Should().Be("date"); + } + + [Fact] + public void Serialization_DetailsProvided_WritesQueryableReasonFields() + { + var document = new PageFeedbackDocument + { + FeedbackId = Guid.NewGuid().ToString(), + PageUrl = "/docs/test-page", + PageTitle = "Test page", + Reaction = PageFeedbackReaction.ThumbsUp, + Reason = PageFeedbackReason.HelpfulExamples, + ReasonSetVersion = 2, + Timestamp = DateTimeOffset.UtcNow + }; + + var json = JsonSerializer.Serialize(document, PageFeedbackJsonContext.Default.PageFeedbackDocument); + using var serialized = JsonDocument.Parse(json); + + serialized.RootElement.GetProperty("reason").GetString().Should().Be("helpfulExamples"); + serialized.RootElement.GetProperty("reason_set_version").GetInt32().Should().Be(2); + } +} diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index 219061d660..93fa65e4d8 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -124,9 +124,9 @@ public async Task PhysicalDocsetNavigationIncludesNestedTocs() var developmentToc = tocNavs.FirstOrDefault(t => t.Url == "/development"); developmentToc.Should().NotBeNull(); - developmentToc.NavigationItems.Should().HaveCount(5); + developmentToc.NavigationItems.Should().HaveCount(6); developmentToc.Index.Should().NotBeNull(); - developmentToc.NavigationItems.OfType>().Should().HaveCount(2); + developmentToc.NavigationItems.OfType>().Should().HaveCount(3); developmentToc.NavigationItems.OfType>().Should().HaveCount(2); developmentToc.NavigationItems.OfType>().Should().HaveCount(1);
+ + ✓ + + Thank you for your feedback. +
+ Was this page helpful? +
+ Don't include passwords, API keys, or + other sensitive information. +
+ We couldn't save your feedback. +