Skip to content

Commit e1ab334

Browse files
authored
Add saved search editor component (#1411)
* Add saved search editor component This introduces a new form for the saved search component. We currently are not rendering this shoelace dialog. That will happen in a future PR along with some playwright tests. This dialog handles the creation, updating and deletion of the saved search. * adjustments for CI * attempt skip * different skips * another skip * debugging * more debugging * test * test again * reorder * basics * fixup * try small * try new way * manual fixture * fixup * try again * temp * more * try again * try again 2 * final try * final adjustments * address feedback: use string instead of typeahead object
1 parent 7297f03 commit e1ab334

File tree

4 files changed

+745
-2
lines changed

4 files changed

+745
-2
lines changed
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {expect, fixture, html, oneEvent, waitUntil} from '@open-wc/testing';
18+
import sinon from 'sinon';
19+
import {WebstatusSavedSearchEditor} from '../webstatus-saved-search-editor.js';
20+
import '../webstatus-saved-search-editor.js';
21+
import {APIClient} from '../../api/client.js';
22+
import {UserSavedSearch} from '../../utils/constants.js';
23+
import {SlAlert, SlDialog, SlInput, SlTextarea} from '@shoelace-style/shoelace';
24+
import {Toast} from '../../utils/toast.js';
25+
import {type WebstatusTypeahead} from '../webstatus-typeahead.js';
26+
import {taskUpdateComplete} from './test-helpers.js';
27+
import {User} from '../../contexts/firebase-user-context.js';
28+
import {InternalServerError} from '../../api/errors.js';
29+
import {TaskStatus} from '@lit/task';
30+
describe('webstatus-saved-search-editor', () => {
31+
let el: WebstatusSavedSearchEditor;
32+
let apiClientStub: sinon.SinonStubbedInstance<APIClient>;
33+
let toastStub: sinon.SinonStub;
34+
35+
const newSearchQuery = 'new-query';
36+
const existingSearch: UserSavedSearch = {
37+
id: 'existing123',
38+
name: 'Existing Search',
39+
query: 'existing-query',
40+
description: 'Existing Description',
41+
updated_at: '2024-01-01T00:00:00Z',
42+
created_at: '2024-01-01T00:00:00Z',
43+
permissions: {role: 'saved_search_owner'},
44+
};
45+
46+
const mockUser: User = {
47+
getIdToken: sinon.stub().resolves('mock-token'),
48+
} as unknown as User;
49+
50+
async function setupComponent(
51+
operation: 'save' | 'edit' | 'delete',
52+
savedSearch?: UserSavedSearch,
53+
existingOverviewPageQuery?: string,
54+
): Promise<WebstatusSavedSearchEditor> {
55+
apiClientStub = sinon.createStubInstance(APIClient);
56+
toastStub = sinon.stub(Toast.prototype, 'toast');
57+
58+
const component = await fixture<WebstatusSavedSearchEditor>(html`
59+
<webstatus-saved-search-editor
60+
.user=${mockUser}
61+
.apiClient=${apiClientStub}
62+
></webstatus-saved-search-editor>
63+
`);
64+
// Manually open the dialog after fixture creation
65+
// TODO: Using await on component.open() causes this to fail on chromium in GitHub CI.
66+
void component.open(operation, savedSearch, existingOverviewPageQuery);
67+
await component.updateComplete;
68+
return component;
69+
}
70+
71+
afterEach(() => {
72+
sinon.restore();
73+
});
74+
75+
describe('Rendering', () => {
76+
it('renders correctly for a new search (save operation)', async () => {
77+
el = await setupComponent('save', undefined, newSearchQuery);
78+
79+
const dialog = el.shadowRoot?.querySelector<SlDialog>('sl-dialog');
80+
expect(dialog?.label).to.equal('Save New Search');
81+
82+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
83+
const descriptionInput =
84+
el.shadowRoot?.querySelector<SlTextarea>('#description');
85+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
86+
'webstatus-typeahead',
87+
);
88+
expect(nameInput?.value).to.equal('');
89+
expect(descriptionInput?.value).to.equal('');
90+
expect(queryInput?.value).to.equal(newSearchQuery);
91+
});
92+
93+
it('renders correctly for an existing search (edit operation)', async () => {
94+
el = await setupComponent('edit', existingSearch);
95+
96+
const dialog = el.shadowRoot?.querySelector<SlDialog>('sl-dialog');
97+
expect(dialog?.label).to.equal('Edit Saved Search');
98+
99+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
100+
const descriptionInput =
101+
el.shadowRoot?.querySelector<SlTextarea>('#description');
102+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
103+
'webstatus-typeahead',
104+
);
105+
expect(nameInput?.value).to.equal(existingSearch.name);
106+
expect(descriptionInput?.value).to.equal(existingSearch.description);
107+
expect(queryInput?.value).to.equal(existingSearch.query);
108+
});
109+
110+
it('renders correctly for delete operation', async () => {
111+
el = await setupComponent('delete', existingSearch);
112+
113+
const dialog = el.shadowRoot?.querySelector<SlDialog>('sl-dialog');
114+
expect(dialog?.label).to.equal('Delete Saved Search');
115+
expect(dialog?.textContent).to.contain('Are you sure');
116+
});
117+
});
118+
119+
describe('Form Submission (Save)', () => {
120+
it('calls createSavedSearch for a new search and dispatches "save" event', async () => {
121+
el = await setupComponent('save', undefined, newSearchQuery);
122+
const savedSearchData = {
123+
...existingSearch,
124+
id: 'new123',
125+
name: 'New Search',
126+
description: 'New Desc',
127+
query: newSearchQuery,
128+
created_at: '2024-01-01T00:00:00Z',
129+
updated_at: '2024-01-01T00:00:00Z',
130+
};
131+
apiClientStub.createSavedSearch.resolves(savedSearchData);
132+
133+
// Simulate user input
134+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
135+
const descriptionInput =
136+
el.shadowRoot?.querySelector<SlTextarea>('#description');
137+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
138+
'webstatus-typeahead',
139+
);
140+
nameInput!.value = 'New Search';
141+
descriptionInput!.value = 'New Desc';
142+
// Should already be set by open()
143+
queryInput!.value = newSearchQuery;
144+
await el.updateComplete;
145+
146+
const form =
147+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
148+
const saveEventPromise = oneEvent(el, 'saved-search-saved');
149+
150+
form?.requestSubmit();
151+
152+
const saveEvent = await saveEventPromise;
153+
154+
expect(apiClientStub.createSavedSearch).to.have.been.calledOnceWith(
155+
'mock-token',
156+
{
157+
name: 'New Search',
158+
description: 'New Desc',
159+
query: newSearchQuery,
160+
},
161+
);
162+
expect(saveEvent.detail).to.deep.equal(savedSearchData);
163+
// Toast is handled internally by the component on success/error
164+
expect(toastStub).to.not.have.been.called;
165+
expect(el.isOpen()).to.be.false;
166+
});
167+
168+
it('calls updateSavedSearch for an existing search and dispatches "save" event', async () => {
169+
el = await setupComponent('edit', existingSearch);
170+
const updatedSearchData = {
171+
...existingSearch,
172+
name: 'Updated Name',
173+
query: 'updated-query',
174+
created_at: '2024-01-01T00:00:00Z',
175+
updated_at: '2025-01-01T00:00:00Z',
176+
};
177+
apiClientStub.updateSavedSearch.resolves(updatedSearchData);
178+
179+
// Simulate user input
180+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
181+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
182+
'webstatus-typeahead',
183+
);
184+
nameInput!.value = 'Updated Name';
185+
queryInput!.value = 'updated-query';
186+
await el.updateComplete;
187+
188+
const form =
189+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
190+
const editEventPromise = oneEvent(el, 'saved-search-edited');
191+
192+
form?.requestSubmit();
193+
194+
const editEvent = await editEventPromise;
195+
196+
expect(apiClientStub.updateSavedSearch).to.have.been.calledOnceWith(
197+
{
198+
id: existingSearch.id,
199+
name: 'Updated Name',
200+
description: undefined, // Description didn't change
201+
query: 'updated-query',
202+
},
203+
'mock-token',
204+
);
205+
expect(editEvent.detail).to.deep.equal(updatedSearchData);
206+
expect(toastStub).to.not.have.been.called;
207+
expect(el.isOpen()).to.be.false;
208+
});
209+
210+
it('shows an error toast if saving fails', async () => {
211+
el = await setupComponent('save', undefined, newSearchQuery);
212+
const error = new InternalServerError('Save failed');
213+
apiClientStub.createSavedSearch.rejects(error);
214+
215+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
216+
nameInput!.value = 'Fail Search';
217+
await el.updateComplete;
218+
219+
const form =
220+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
221+
form?.requestSubmit();
222+
223+
// Wait for the task to complete (or fail)
224+
await waitUntil(() => el['_currentTask']?.status === TaskStatus.ERROR);
225+
await taskUpdateComplete();
226+
227+
expect(apiClientStub.createSavedSearch).to.have.been.calledOnce;
228+
expect(toastStub).to.have.been.calledWith(
229+
'Save failed',
230+
'danger',
231+
'exclamation-triangle',
232+
);
233+
// Dialog should remain open on error
234+
expect(el.isOpen()).to.be.true;
235+
});
236+
237+
it('shows alert and prevents submission if name is empty', async () => {
238+
el = await setupComponent('save', undefined, newSearchQuery);
239+
const form =
240+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
241+
const alert = el.shadowRoot?.querySelector<SlAlert>(
242+
'sl-alert#editor-alert',
243+
);
244+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
245+
246+
// Ensure name is empty
247+
nameInput!.value = '';
248+
await el.updateComplete;
249+
250+
form?.requestSubmit();
251+
await el.updateComplete;
252+
253+
expect(apiClientStub.createSavedSearch).to.not.have.been.called;
254+
expect(alert?.open).to.be.true;
255+
// Dialog should remain open
256+
expect(el.isOpen()).to.be.true;
257+
});
258+
});
259+
260+
describe('Delete Functionality', () => {
261+
beforeEach(async () => {
262+
el = await setupComponent('delete', existingSearch);
263+
});
264+
265+
it('calls removeSavedSearchByID and dispatches "saved-search-deleted" event on confirmation', async () => {
266+
apiClientStub.removeSavedSearchByID.resolves();
267+
const form =
268+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
269+
const deleteEventPromise = oneEvent(el, 'saved-search-deleted');
270+
271+
// Submit the delete confirmation form
272+
form?.requestSubmit();
273+
274+
const deleteEvent = await deleteEventPromise;
275+
276+
expect(apiClientStub.removeSavedSearchByID).to.have.been.calledOnceWith(
277+
existingSearch.id,
278+
'mock-token',
279+
);
280+
expect(deleteEvent.detail).to.equal(existingSearch.id);
281+
expect(toastStub).to.not.have.been.called;
282+
expect(el.isOpen()).to.be.false;
283+
});
284+
285+
it('shows an error toast if deletion fails', async () => {
286+
const error = new InternalServerError('Delete failed');
287+
apiClientStub.removeSavedSearchByID.rejects(error);
288+
const form =
289+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
290+
291+
// Submit the delete confirmation form
292+
form?.requestSubmit();
293+
294+
// Wait for the task to complete (or fail)
295+
await waitUntil(() => el['_currentTask']?.status === TaskStatus.ERROR);
296+
await taskUpdateComplete();
297+
298+
expect(apiClientStub.removeSavedSearchByID).to.have.been.calledOnce;
299+
expect(toastStub).to.have.been.calledWith(
300+
'Delete failed',
301+
'danger',
302+
'exclamation-triangle',
303+
);
304+
// Dialog should remain open on error
305+
expect(el.isOpen()).to.be.true;
306+
});
307+
});
308+
309+
describe('Cancel Button', () => {
310+
it('dispatches "saved-search-cancelled" event when cancel button is clicked', async () => {
311+
el = await setupComponent('edit', existingSearch);
312+
313+
const cancelButton = el.shadowRoot?.querySelector<HTMLButtonElement>(
314+
'sl-button[variant="default"]',
315+
);
316+
expect(cancelButton).to.exist;
317+
// Assuming cancel is the default button
318+
const cancelEventPromise = oneEvent(el, 'saved-search-cancelled');
319+
cancelButton!.click();
320+
// Just ensure the event is fired
321+
await cancelEventPromise;
322+
// No specific detail expected for cancel event
323+
expect(el.isOpen()).to.eq(false);
324+
});
325+
});
326+
});

0 commit comments

Comments
 (0)