diff --git a/frontend/src/static/img/shoelace/assets/icons/exclamation-octagon.svg b/frontend/src/static/img/shoelace/assets/icons/exclamation-octagon.svg new file mode 100644 index 000000000..7f2593813 --- /dev/null +++ b/frontend/src/static/img/shoelace/assets/icons/exclamation-octagon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/static/js/components/test/webstatus-saved-search-editor.test.ts b/frontend/src/static/js/components/test/webstatus-saved-search-editor.test.ts new file mode 100644 index 000000000..0e9e1ccb8 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-saved-search-editor.test.ts @@ -0,0 +1,326 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, fixture, html, oneEvent, waitUntil} from '@open-wc/testing'; +import sinon from 'sinon'; +import {WebstatusSavedSearchEditor} from '../webstatus-saved-search-editor.js'; +import '../webstatus-saved-search-editor.js'; +import {APIClient} from '../../api/client.js'; +import {UserSavedSearch} from '../../utils/constants.js'; +import {SlAlert, SlDialog, SlInput, SlTextarea} from '@shoelace-style/shoelace'; +import {Toast} from '../../utils/toast.js'; +import {type WebstatusTypeahead} from '../webstatus-typeahead.js'; +import {taskUpdateComplete} from './test-helpers.js'; +import {User} from '../../contexts/firebase-user-context.js'; +import {InternalServerError} from '../../api/errors.js'; +import {TaskStatus} from '@lit/task'; +describe('webstatus-saved-search-editor', () => { + let el: WebstatusSavedSearchEditor; + let apiClientStub: sinon.SinonStubbedInstance; + let toastStub: sinon.SinonStub; + + const newSearchQuery = 'new-query'; + const existingSearch: UserSavedSearch = { + id: 'existing123', + name: 'Existing Search', + query: 'existing-query', + description: 'Existing Description', + updated_at: '2024-01-01T00:00:00Z', + created_at: '2024-01-01T00:00:00Z', + permissions: {role: 'saved_search_owner'}, + }; + + const mockUser: User = { + getIdToken: sinon.stub().resolves('mock-token'), + } as unknown as User; + + async function setupComponent( + operation: 'save' | 'edit' | 'delete', + savedSearch?: UserSavedSearch, + existingOverviewPageQuery?: string, + ): Promise { + apiClientStub = sinon.createStubInstance(APIClient); + toastStub = sinon.stub(Toast.prototype, 'toast'); + + const component = await fixture(html` + + `); + // Manually open the dialog after fixture creation + // TODO: Using await on component.open() causes this to fail on chromium in GitHub CI. + void component.open(operation, savedSearch, existingOverviewPageQuery); + await component.updateComplete; + return component; + } + + afterEach(() => { + sinon.restore(); + }); + + describe('Rendering', () => { + it('renders correctly for a new search (save operation)', async () => { + el = await setupComponent('save', undefined, newSearchQuery); + + const dialog = el.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.label).to.equal('Save New Search'); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const descriptionInput = + el.shadowRoot?.querySelector('#description'); + const queryInput = el.shadowRoot?.querySelector( + 'webstatus-typeahead', + ); + expect(nameInput?.value).to.equal(''); + expect(descriptionInput?.value).to.equal(''); + expect(queryInput?.value).to.equal(newSearchQuery); + }); + + it('renders correctly for an existing search (edit operation)', async () => { + el = await setupComponent('edit', existingSearch); + + const dialog = el.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.label).to.equal('Edit Saved Search'); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const descriptionInput = + el.shadowRoot?.querySelector('#description'); + const queryInput = el.shadowRoot?.querySelector( + 'webstatus-typeahead', + ); + expect(nameInput?.value).to.equal(existingSearch.name); + expect(descriptionInput?.value).to.equal(existingSearch.description); + expect(queryInput?.value).to.equal(existingSearch.query); + }); + + it('renders correctly for delete operation', async () => { + el = await setupComponent('delete', existingSearch); + + const dialog = el.shadowRoot?.querySelector('sl-dialog'); + expect(dialog?.label).to.equal('Delete Saved Search'); + expect(dialog?.textContent).to.contain('Are you sure'); + }); + }); + + describe('Form Submission (Save)', () => { + it('calls createSavedSearch for a new search and dispatches "save" event', async () => { + el = await setupComponent('save', undefined, newSearchQuery); + const savedSearchData = { + ...existingSearch, + id: 'new123', + name: 'New Search', + description: 'New Desc', + query: newSearchQuery, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + apiClientStub.createSavedSearch.resolves(savedSearchData); + + // Simulate user input + const nameInput = el.shadowRoot?.querySelector('#name'); + const descriptionInput = + el.shadowRoot?.querySelector('#description'); + const queryInput = el.shadowRoot?.querySelector( + 'webstatus-typeahead', + ); + nameInput!.value = 'New Search'; + descriptionInput!.value = 'New Desc'; + // Should already be set by open() + queryInput!.value = newSearchQuery; + await el.updateComplete; + + const form = + el.shadowRoot?.querySelector('#editor-form'); + const saveEventPromise = oneEvent(el, 'saved-search-saved'); + + form?.requestSubmit(); + + const saveEvent = await saveEventPromise; + + expect(apiClientStub.createSavedSearch).to.have.been.calledOnceWith( + 'mock-token', + { + name: 'New Search', + description: 'New Desc', + query: newSearchQuery, + }, + ); + expect(saveEvent.detail).to.deep.equal(savedSearchData); + // Toast is handled internally by the component on success/error + expect(toastStub).to.not.have.been.called; + expect(el.isOpen()).to.be.false; + }); + + it('calls updateSavedSearch for an existing search and dispatches "save" event', async () => { + el = await setupComponent('edit', existingSearch); + const updatedSearchData = { + ...existingSearch, + name: 'Updated Name', + query: 'updated-query', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }; + apiClientStub.updateSavedSearch.resolves(updatedSearchData); + + // Simulate user input + const nameInput = el.shadowRoot?.querySelector('#name'); + const queryInput = el.shadowRoot?.querySelector( + 'webstatus-typeahead', + ); + nameInput!.value = 'Updated Name'; + queryInput!.value = 'updated-query'; + await el.updateComplete; + + const form = + el.shadowRoot?.querySelector('#editor-form'); + const editEventPromise = oneEvent(el, 'saved-search-edited'); + + form?.requestSubmit(); + + const editEvent = await editEventPromise; + + expect(apiClientStub.updateSavedSearch).to.have.been.calledOnceWith( + { + id: existingSearch.id, + name: 'Updated Name', + description: undefined, // Description didn't change + query: 'updated-query', + }, + 'mock-token', + ); + expect(editEvent.detail).to.deep.equal(updatedSearchData); + expect(toastStub).to.not.have.been.called; + expect(el.isOpen()).to.be.false; + }); + + it('shows an error toast if saving fails', async () => { + el = await setupComponent('save', undefined, newSearchQuery); + const error = new InternalServerError('Save failed'); + apiClientStub.createSavedSearch.rejects(error); + + const nameInput = el.shadowRoot?.querySelector('#name'); + nameInput!.value = 'Fail Search'; + await el.updateComplete; + + const form = + el.shadowRoot?.querySelector('#editor-form'); + form?.requestSubmit(); + + // Wait for the task to complete (or fail) + await waitUntil(() => el['_currentTask']?.status === TaskStatus.ERROR); + await taskUpdateComplete(); + + expect(apiClientStub.createSavedSearch).to.have.been.calledOnce; + expect(toastStub).to.have.been.calledWith( + 'Save failed', + 'danger', + 'exclamation-triangle', + ); + // Dialog should remain open on error + expect(el.isOpen()).to.be.true; + }); + + it('shows alert and prevents submission if name is empty', async () => { + el = await setupComponent('save', undefined, newSearchQuery); + const form = + el.shadowRoot?.querySelector('#editor-form'); + const alert = el.shadowRoot?.querySelector( + 'sl-alert#editor-alert', + ); + const nameInput = el.shadowRoot?.querySelector('#name'); + + // Ensure name is empty + nameInput!.value = ''; + await el.updateComplete; + + form?.requestSubmit(); + await el.updateComplete; + + expect(apiClientStub.createSavedSearch).to.not.have.been.called; + expect(alert?.open).to.be.true; + // Dialog should remain open + expect(el.isOpen()).to.be.true; + }); + }); + + describe('Delete Functionality', () => { + beforeEach(async () => { + el = await setupComponent('delete', existingSearch); + }); + + it('calls removeSavedSearchByID and dispatches "saved-search-deleted" event on confirmation', async () => { + apiClientStub.removeSavedSearchByID.resolves(); + const form = + el.shadowRoot?.querySelector('#editor-form'); + const deleteEventPromise = oneEvent(el, 'saved-search-deleted'); + + // Submit the delete confirmation form + form?.requestSubmit(); + + const deleteEvent = await deleteEventPromise; + + expect(apiClientStub.removeSavedSearchByID).to.have.been.calledOnceWith( + existingSearch.id, + 'mock-token', + ); + expect(deleteEvent.detail).to.equal(existingSearch.id); + expect(toastStub).to.not.have.been.called; + expect(el.isOpen()).to.be.false; + }); + + it('shows an error toast if deletion fails', async () => { + const error = new InternalServerError('Delete failed'); + apiClientStub.removeSavedSearchByID.rejects(error); + const form = + el.shadowRoot?.querySelector('#editor-form'); + + // Submit the delete confirmation form + form?.requestSubmit(); + + // Wait for the task to complete (or fail) + await waitUntil(() => el['_currentTask']?.status === TaskStatus.ERROR); + await taskUpdateComplete(); + + expect(apiClientStub.removeSavedSearchByID).to.have.been.calledOnce; + expect(toastStub).to.have.been.calledWith( + 'Delete failed', + 'danger', + 'exclamation-triangle', + ); + // Dialog should remain open on error + expect(el.isOpen()).to.be.true; + }); + }); + + describe('Cancel Button', () => { + it('dispatches "saved-search-cancelled" event when cancel button is clicked', async () => { + el = await setupComponent('edit', existingSearch); + + const cancelButton = el.shadowRoot?.querySelector( + 'sl-button[variant="default"]', + ); + expect(cancelButton).to.exist; + // Assuming cancel is the default button + const cancelEventPromise = oneEvent(el, 'saved-search-cancelled'); + cancelButton!.click(); + // Just ensure the event is fired + await cancelEventPromise; + // No specific detail expected for cancel event + expect(el.isOpen()).to.eq(false); + }); + }); +}); diff --git a/frontend/src/static/js/components/webstatus-saved-search-editor.ts b/frontend/src/static/js/components/webstatus-saved-search-editor.ts new file mode 100644 index 000000000..b450faa0e --- /dev/null +++ b/frontend/src/static/js/components/webstatus-saved-search-editor.ts @@ -0,0 +1,408 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {LitElement, html, css, type TemplateResult, nothing} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators.js'; +import {SlAlert, SlButton, SlDialog, SlInput} from '@shoelace-style/shoelace'; +import {UserSavedSearch, VOCABULARY} from '../utils/constants.js'; +import './webstatus-typeahead.js'; +import {WebstatusTypeahead} from './webstatus-typeahead.js'; +import {Task, TaskStatus} from '@lit/task'; +import {APIClient, UpdateSavedSearchInput} from '../api/client.js'; +import {Toast} from '../utils/toast.js'; +import {User} from '../contexts/firebase-user-context.js'; +import {ApiError} from '../api/errors.js'; + +type OperationType = 'save' | 'edit' | 'delete'; + +interface OperationConfig { + label: string; + render: (inProgress: boolean) => TemplateResult; + actionHandler: () => Promise; + primaryButtonText: string; + buttonVariant: SlButton['variant']; +} + +// SavedSearchInputConstraints come from components/schemas/SavedSearch in the openapi document. +const SavedSearchInputConstraints = { + NameMinLength: 1, + NameMaxLength: 32, + // There is no minimum length for the description. We drop the description if it is an empty string. + DescriptionMaxLength: 256, + QueryMinLength: 1, + QueryMaxLength: 256, +}; + +@customElement('webstatus-saved-search-editor') +export class WebstatusSavedSearchEditor extends LitElement { + static styles = css` + .dialog-buttons { + display: flex; + justify-content: end; + gap: 1em; + } + sl-input { + padding-bottom: 1em; + } + webstatus-typeahead { + padding-bottom: 1em; + } + `; + + @property({type: Object}) + savedSearch?: UserSavedSearch; + + @property({type: String}) + operation: OperationType = 'save'; + + @property({type: Object}) + apiClient!: APIClient; + + @property({type: Object}) + user!: User; + + // This is the value from the typeahead on the overview page so that we can carry over the user's existing query. + @state() + existingOverviewPageQuery?: string; + + @query('sl-alert#editor-alert') + editorAlert?: SlAlert; + + @query('sl-input#name') + nameInput?: SlInput; + + @query('sl-textarea#description') + descriptionInput?: SlInput; + + @query('webstatus-typeahead') + queryInput?: WebstatusTypeahead; + + @query('sl-dialog') + private _dialog?: SlDialog; + + @state() + private _currentTask?: Task; + + private operationConfigMap: {[key in OperationType]: OperationConfig} = { + save: { + label: 'Save New Search', + render: (inProgress: boolean) => this.renderForm(inProgress), + actionHandler: this.handleSave.bind(this), + primaryButtonText: 'Save', + buttonVariant: 'primary', + }, + edit: { + label: 'Edit Saved Search', + render: (inProgress: boolean) => this.renderForm(inProgress), + actionHandler: this.handleEdit.bind(this), + primaryButtonText: 'Save', + buttonVariant: 'primary', + }, + delete: { + label: 'Delete Saved Search', + render: (_: boolean) => + html`

Are you sure you want to delete this search?

`, + actionHandler: this.handleDelete.bind(this), + primaryButtonText: 'Delete', + buttonVariant: 'danger', + }, + }; + isOpen(): boolean { + return this._dialog?.open ?? false; + } + async open( + operation: OperationType, + savedSearch?: UserSavedSearch, + overviewPageQueryInput?: string, + ) { + this.savedSearch = savedSearch; + this.operation = operation; + this.existingOverviewPageQuery = overviewPageQueryInput; + await this._dialog?.show(); + } + + async close() { + this._currentTask = undefined; + this.existingOverviewPageQuery = undefined; + this.savedSearch = undefined; + await this._dialog?.hide(); + } + + async handleSave() { + const isNameValid = this.nameInput!.reportValidity(); + const isDescriptionValid = this.descriptionInput!.reportValidity(); + const isQueryValid = this.isQueryValid(); + if (isNameValid && isDescriptionValid && isQueryValid) { + await this.editorAlert?.hide(); + this._currentTask = new Task(this, { + autoRun: false, + task: async ([name, description, query, user, apiClient]) => { + const token = await user!.getIdToken(); + return apiClient!.createSavedSearch(token, { + name: name, + description: description !== '' ? description : undefined, + query: query, + }); + }, + args: () => [ + this.nameInput!.value, + this.descriptionInput!.value, + this.queryInput!.value, + this.user, + this.apiClient, + ], + onComplete: async result => { + this.dispatchEvent( + new CustomEvent('saved-search-saved', { + detail: result, + bubbles: true, + composed: true, + }), + ); + await this.close(); + }, + onError: async (error: unknown) => { + let message: string; + if (error instanceof ApiError) { + message = error.message; + } else { + message = + 'Unknown error saving saved search. Check console for details.'; + console.error(error); + } + await new Toast().toast(message, 'danger', 'exclamation-triangle'); + }, + }); + await this._currentTask.run(); + } else { + await this.editorAlert?.show(); + } + } + + isQueryValid(): boolean { + if (this.queryInput) { + // TODO: Figure out a way to configure the form constraints on typeahead constraint + // I also tried to set the constraints up in the firstUpdated callback but the child is not rendered yet. + // Also, setting the custom validity message does not work because the typeahead renders the sl-input in a shadow DOM. + // Moving the typeahead to the light dom with createRenderRoot messes up the style of the dropdown. + // Until then, check manually. + // Once that is resolved, we can get rid of the sl-alert component below. + if ( + this.queryInput.value.length < + SavedSearchInputConstraints.QueryMinLength || + this.queryInput.value.length > + SavedSearchInputConstraints.QueryMaxLength + ) { + return false; + } else { + return true; + } + } + + return false; + } + + async handleEdit() { + const isNameValid = this.nameInput!.reportValidity(); + const isDescriptionValid = this.descriptionInput!.reportValidity(); + const isQueryValid = this.isQueryValid(); + if (isNameValid && isDescriptionValid && isQueryValid && this.savedSearch) { + await this.editorAlert?.hide(); + this._currentTask = new Task(this, { + autoRun: false, + task: async ([ + savedSearch, + name, + description, + query, + user, + apiClient, + ]) => { + const token = await user.getIdToken(); + const update: UpdateSavedSearchInput = { + id: savedSearch.id, + name: name !== savedSearch.name ? name : undefined, + description: + description !== savedSearch.description && description !== '' + ? description + : undefined, + query: query !== savedSearch.query ? query : undefined, + }; + return apiClient!.updateSavedSearch(update, token); + }, + args: () => [ + this.savedSearch!, + this.nameInput!.value, + this.descriptionInput!.value, + this.queryInput!.value, + this.user, + this.apiClient, + ], + onComplete: async result => { + this.dispatchEvent( + new CustomEvent('saved-search-edited', { + detail: result, + bubbles: true, + composed: true, + }), + ); + await this.close(); + }, + onError: async (error: unknown) => { + let message: string; + if (error instanceof ApiError) { + message = error.message; + } else { + message = + 'Unknown error editing saved search. Check console for details.'; + console.error(error); + } + await new Toast().toast(message, 'danger', 'exclamation-triangle'); + }, + }); + await this._currentTask.run(); + } else { + await this.editorAlert?.show(); + } + } + + async handleDelete() { + this._currentTask = new Task(this, { + autoRun: false, + task: async ([savedSearchID, user, apiClient]) => { + const token = await user!.getIdToken(); + await apiClient!.removeSavedSearchByID(savedSearchID!, token); + return savedSearchID!; + }, + args: () => [this.savedSearch?.id, this.user, this.apiClient], + onComplete: async savedSearchID => { + this.dispatchEvent( + new CustomEvent('saved-search-deleted', { + detail: savedSearchID, + bubbles: true, + composed: true, + }), + ); + await this.close(); + }, + onError: async (error: unknown) => { + let message: string; + if (error instanceof ApiError) { + message = error.message; + } else { + message = + 'Unknown error deleting saved search. Check console for details.'; + console.error(error); + } + await new Toast().toast(message, 'danger', 'exclamation-triangle'); + }, + }); + await this._currentTask.run(); + } + + async handleCancel() { + this.dispatchEvent( + new CustomEvent('saved-search-cancelled', { + bubbles: true, + composed: true, + }), + ); + await this.close(); + } + + renderForm(inProgress: boolean): TemplateResult { + let query: string; + if (this.existingOverviewPageQuery !== undefined) { + query = this.existingOverviewPageQuery; + } else if (this.savedSearch) { + query = this.savedSearch.query; + } else { + query = ''; + } + return html` + + + + +
+ + + Please check that you provided at least a name and query before + submitting. The name must be between + ${SavedSearchInputConstraints.NameMinLength} and + ${SavedSearchInputConstraints.NameMaxLength} characters long, and the + query must be between ${SavedSearchInputConstraints.QueryMinLength} + and ${SavedSearchInputConstraints.QueryMaxLength} characters long. + +
+ `; + } + + render() { + const config = this.operationConfigMap[this.operation]; + const inProgress = this._currentTask?.status === TaskStatus.PENDING; + return html` + { + event.preventDefault(); + await this.handleCancel(); + }} + > +
{ + e.preventDefault(); + await config.actionHandler(); + }} + > + ${inProgress ? html`` : nothing} + ${config.render(inProgress)} +
+ Cancel + ${config.primaryButtonText} +
+
+
+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-typeahead.ts b/frontend/src/static/js/components/webstatus-typeahead.ts index 3bdece8e2..264cb962e 100644 --- a/frontend/src/static/js/components/webstatus-typeahead.ts +++ b/frontend/src/static/js/components/webstatus-typeahead.ts @@ -31,6 +31,7 @@ import { SlMenu, SlMenuItem, } from '@shoelace-style/shoelace'; +import {ifDefined} from 'lit/directives/if-defined.js'; /* This file consists of 3 classes that together implement a "typeahead" text field with autocomplete: @@ -58,12 +59,15 @@ export class WebstatusTypeahead extends LitElement { slDropdownRef = createRef(); slInputRef = createRef(); - @property() + @property({type: String}) value: string; - @property() + @property({type: String}) placeholder: string; + @property({type: String}) + label?: string; + @state() candidates: Array; @@ -257,6 +261,7 @@ export class WebstatusTypeahead extends LitElement {