Skip to content

Commit 246f0a1

Browse files
committed
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.
1 parent bf74acc commit 246f0a1

File tree

5 files changed

+756
-3
lines changed

5 files changed

+756
-3
lines changed
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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 {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+
overviewPageQueryInput?: WebstatusTypeahead,
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+
await component.open(operation, savedSearch, overviewPageQueryInput);
66+
await component.updateComplete;
67+
return component;
68+
}
69+
70+
afterEach(() => {
71+
sinon.restore();
72+
});
73+
74+
describe('Rendering', () => {
75+
it('renders correctly for a new search (save operation)', async () => {
76+
const mockTypeahead = {value: newSearchQuery} as WebstatusTypeahead;
77+
el = await setupComponent('save', undefined, mockTypeahead);
78+
await expect(el).shadowDom.to.be.accessible();
79+
80+
const dialog = el.shadowRoot?.querySelector<SlDialog>('sl-dialog');
81+
expect(dialog?.label).to.equal('Save New Search');
82+
83+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
84+
const descriptionInput =
85+
el.shadowRoot?.querySelector<SlTextarea>('#description');
86+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
87+
'webstatus-typeahead',
88+
);
89+
expect(nameInput?.value).to.equal('');
90+
expect(descriptionInput?.value).to.equal('');
91+
expect(queryInput?.value).to.equal(newSearchQuery);
92+
});
93+
94+
it('renders correctly for an existing search (edit operation)', async () => {
95+
el = await setupComponent('edit', existingSearch);
96+
await expect(el).shadowDom.to.be.accessible();
97+
98+
const dialog = el.shadowRoot?.querySelector<SlDialog>('sl-dialog');
99+
expect(dialog?.label).to.equal('Edit Saved Search');
100+
101+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
102+
const descriptionInput =
103+
el.shadowRoot?.querySelector<SlTextarea>('#description');
104+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
105+
'webstatus-typeahead',
106+
);
107+
expect(nameInput?.value).to.equal(existingSearch.name);
108+
expect(descriptionInput?.value).to.equal(existingSearch.description);
109+
expect(queryInput?.value).to.equal(existingSearch.query);
110+
});
111+
112+
it('renders correctly for delete operation', async () => {
113+
el = await setupComponent('delete', existingSearch);
114+
await expect(el).shadowDom.to.be.accessible();
115+
116+
const dialog = el.shadowRoot?.querySelector<SlDialog>('sl-dialog');
117+
expect(dialog?.label).to.equal('Delete Saved Search');
118+
expect(dialog?.textContent).to.contain('Are you sure');
119+
});
120+
});
121+
122+
describe('Form Submission (Save)', () => {
123+
it('calls createSavedSearch for a new search and dispatches "save" event', async () => {
124+
const mockTypeahead = {value: newSearchQuery} as WebstatusTypeahead;
125+
el = await setupComponent('save', undefined, mockTypeahead);
126+
const savedSearchData = {
127+
...existingSearch,
128+
id: 'new123',
129+
name: 'New Search',
130+
description: 'New Desc',
131+
query: newSearchQuery,
132+
created_at: '2024-01-01T00:00:00Z',
133+
updated_at: '2024-01-01T00:00:00Z',
134+
};
135+
apiClientStub.createSavedSearch.resolves(savedSearchData);
136+
137+
// Simulate user input
138+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
139+
const descriptionInput =
140+
el.shadowRoot?.querySelector<SlTextarea>('#description');
141+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
142+
'webstatus-typeahead',
143+
);
144+
nameInput!.value = 'New Search';
145+
descriptionInput!.value = 'New Desc';
146+
// Should already be set by open()
147+
queryInput!.value = newSearchQuery;
148+
await el.updateComplete;
149+
150+
const form =
151+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
152+
const saveEventPromise = oneEvent(el, 'saved-search-saved');
153+
154+
form?.requestSubmit();
155+
156+
const saveEvent = await saveEventPromise;
157+
158+
expect(apiClientStub.createSavedSearch).to.have.been.calledOnceWith(
159+
'mock-token',
160+
{
161+
name: 'New Search',
162+
description: 'New Desc',
163+
query: newSearchQuery,
164+
},
165+
);
166+
expect(saveEvent.detail).to.deep.equal(savedSearchData);
167+
// Toast is handled internally by the component on success/error
168+
expect(toastStub).to.not.have.been.called;
169+
expect(el.isOpen()).to.be.false;
170+
});
171+
172+
it('calls updateSavedSearch for an existing search and dispatches "save" event', async () => {
173+
el = await setupComponent('edit', existingSearch);
174+
const updatedSearchData = {
175+
...existingSearch,
176+
name: 'Updated Name',
177+
query: 'updated-query',
178+
created_at: '2024-01-01T00:00:00Z',
179+
updated_at: '2025-01-01T00:00:00Z',
180+
};
181+
apiClientStub.updateSavedSearch.resolves(updatedSearchData);
182+
183+
// Simulate user input
184+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
185+
const queryInput = el.shadowRoot?.querySelector<WebstatusTypeahead>(
186+
'webstatus-typeahead',
187+
);
188+
nameInput!.value = 'Updated Name';
189+
queryInput!.value = 'updated-query';
190+
await el.updateComplete;
191+
192+
const form =
193+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
194+
const editEventPromise = oneEvent(el, 'saved-search-edited');
195+
196+
form?.requestSubmit();
197+
198+
const editEvent = await editEventPromise;
199+
200+
expect(apiClientStub.updateSavedSearch).to.have.been.calledOnceWith(
201+
{
202+
id: existingSearch.id,
203+
name: 'Updated Name',
204+
description: undefined, // Description didn't change
205+
query: 'updated-query',
206+
},
207+
'mock-token',
208+
);
209+
expect(editEvent.detail).to.deep.equal(updatedSearchData);
210+
expect(toastStub).to.not.have.been.called;
211+
expect(el.isOpen()).to.be.false;
212+
});
213+
214+
it('shows an error toast if saving fails', async () => {
215+
el = await setupComponent('save', undefined, {
216+
value: newSearchQuery,
217+
} as WebstatusTypeahead);
218+
const error = new InternalServerError('Save failed');
219+
apiClientStub.createSavedSearch.rejects(error);
220+
221+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
222+
nameInput!.value = 'Fail Search';
223+
await el.updateComplete;
224+
225+
const form =
226+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
227+
form?.requestSubmit();
228+
229+
// Wait for the task to complete (or fail)
230+
await waitUntil(() => el['_currentTask']?.status === TaskStatus.ERROR);
231+
await taskUpdateComplete();
232+
233+
expect(apiClientStub.createSavedSearch).to.have.been.calledOnce;
234+
expect(toastStub).to.have.been.calledWith(
235+
'Save failed',
236+
'danger',
237+
'exclamation-triangle',
238+
);
239+
// Dialog should remain open on error
240+
expect(el.isOpen()).to.be.true;
241+
});
242+
243+
it('shows alert and prevents submission if name is empty', async () => {
244+
el = await setupComponent('save', undefined, {
245+
value: newSearchQuery,
246+
} as WebstatusTypeahead);
247+
const form =
248+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
249+
const alert = el.shadowRoot?.querySelector<SlAlert>(
250+
'sl-alert#editor-alert',
251+
);
252+
const nameInput = el.shadowRoot?.querySelector<SlInput>('#name');
253+
254+
// Ensure name is empty
255+
nameInput!.value = '';
256+
await el.updateComplete;
257+
258+
form?.requestSubmit();
259+
await el.updateComplete;
260+
261+
expect(apiClientStub.createSavedSearch).to.not.have.been.called;
262+
expect(alert?.open).to.be.true;
263+
// Dialog should remain open
264+
expect(el.isOpen()).to.be.true;
265+
});
266+
});
267+
268+
describe('Delete Functionality', () => {
269+
beforeEach(async () => {
270+
el = await setupComponent('delete', existingSearch);
271+
});
272+
273+
it('calls removeSavedSearchByID and dispatches "saved-search-deleted" event on confirmation', async () => {
274+
apiClientStub.removeSavedSearchByID.resolves();
275+
const form =
276+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
277+
const deleteEventPromise = oneEvent(el, 'saved-search-deleted');
278+
279+
// Submit the delete confirmation form
280+
form?.requestSubmit();
281+
282+
const deleteEvent = await deleteEventPromise;
283+
284+
expect(apiClientStub.removeSavedSearchByID).to.have.been.calledOnceWith(
285+
existingSearch.id,
286+
'mock-token',
287+
);
288+
expect(deleteEvent.detail).to.equal(existingSearch.id);
289+
expect(toastStub).to.not.have.been.called;
290+
expect(el.isOpen()).to.be.false;
291+
});
292+
293+
it('shows an error toast if deletion fails', async () => {
294+
const error = new InternalServerError('Delete failed');
295+
apiClientStub.removeSavedSearchByID.rejects(error);
296+
const form =
297+
el.shadowRoot?.querySelector<HTMLFormElement>('#editor-form');
298+
299+
// Submit the delete confirmation form
300+
form?.requestSubmit();
301+
302+
// Wait for the task to complete (or fail)
303+
await waitUntil(() => el['_currentTask']?.status === TaskStatus.ERROR);
304+
await taskUpdateComplete();
305+
306+
expect(apiClientStub.removeSavedSearchByID).to.have.been.calledOnce;
307+
expect(toastStub).to.have.been.calledWith(
308+
'Delete failed',
309+
'danger',
310+
'exclamation-triangle',
311+
);
312+
// Dialog should remain open on error
313+
expect(el.isOpen()).to.be.true;
314+
});
315+
});
316+
317+
describe('Cancel Button', () => {
318+
it('dispatches "saved-search-cancelled" event when cancel button is clicked', async () => {
319+
el = await setupComponent('edit', existingSearch);
320+
const cancelButton = el.shadowRoot?.querySelector<HTMLButtonElement>(
321+
'sl-button[variant="default"]',
322+
); // Assuming cancel is the default button
323+
const cancelEventPromise = oneEvent(el, 'saved-search-cancelled');
324+
325+
cancelButton?.click();
326+
327+
// Just ensure the event is fired
328+
await cancelEventPromise;
329+
// No specific detail expected for cancel event
330+
expect(el.isOpen()).to.be.false;
331+
});
332+
});
333+
});

0 commit comments

Comments
 (0)