Skip to content

Commit 4c6371f

Browse files
authored
ntp: support auto-open + settings link (#1351)
* ntp: support auto-open + settings link * fixing tests
1 parent 58f3b65 commit 4c6371f

File tree

18 files changed

+342
-38
lines changed

18 files changed

+342
-38
lines changed

.stylelintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": ["stylelint-config-standard"],
33
"plugins": ["stylelint-csstree-validator"],
4-
"ignoreFiles": ["build/**/*.css", "Sources/**/*.css", "docs/**/*.css"],
4+
"ignoreFiles": ["build/**/*.css", "Sources/**/*.css", "docs/**/*.css", "special-pages/pages/**/*/dist/*.css"],
55
"rules": {
66
"csstree/validator": {
77
"ignoreProperties": ["text-wrap"]

special-pages/pages/new-tab/app/components/App.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function App() {
3737
hidden,
3838
buttonId,
3939
drawerId
40-
} = useDrawer();
40+
} = useDrawer(customizerDrawer.autoOpen ? 'visible' : 'hidden');
4141

4242
const tabIndex = useComputed(() => (hidden.value ? -1 : 0));
4343
const { toggle } = useDrawerControls();

special-pages/pages/new-tab/app/components/Drawer.js

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useRef, useId, useLayoutEffect } from 'preact/hooks';
1+
import { useRef, useId, useLayoutEffect, useEffect } from 'preact/hooks';
22
import { batch, useComputed, useSignal } from '@preact/signals';
33
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
4+
import { useMessaging } from '../types.js';
45

56
const CLOSE_DRAWER_EVENT = 'close-drawer';
67
const TOGGLE_DRAWER_EVENT = 'toggle-drawer';
@@ -18,7 +19,7 @@ const REQUEST_VISIBILITY_EVENT = 'request-visibility';
1819
* - 1: we make the API available with events (via `useDrawerControls`)
1920
* - 2: we use signals to trigger animations for performance (to prevent VDOM diffing)
2021
* - 3: we provide a way for child components to render AFTER animations have ended, again for performance.
21-
*
22+
* @param {DrawerVisibility} initial
2223
* @return {{
2324
* wrapperRef: import("preact").RefObject<HTMLDivElement>,
2425
* buttonRef: import("preact").RefObject<HTMLButtonElement>,
@@ -30,7 +31,7 @@ const REQUEST_VISIBILITY_EVENT = 'request-visibility';
3031
* displayChildren: import("@preact/signals").Signal<boolean>,
3132
* }}
3233
*/
33-
export function useDrawer() {
34+
export function useDrawer(initial) {
3435
const { isReducedMotion } = useEnv();
3536
const wrapperRef = useRef(/** @type {HTMLDivElement|null} */ (null));
3637
const buttonRef = useRef(/** @type {HTMLButtonElement|null} */ (null));
@@ -123,7 +124,21 @@ export function useDrawer() {
123124
return () => {
124125
controller.abort();
125126
};
126-
}, [isReducedMotion]);
127+
}, [isReducedMotion, initial]);
128+
129+
const ntp = useMessaging();
130+
131+
/**
132+
* Open initially if required too
133+
*/
134+
useEffect(() => {
135+
if (initial === 'visible') {
136+
_open();
137+
}
138+
return ntp.messaging.subscribe('customizer_autoOpen', () => {
139+
_open();
140+
});
141+
}, [initial, ntp]);
127142

128143
return {
129144
wrapperRef,
@@ -137,19 +152,25 @@ export function useDrawer() {
137152
};
138153
}
139154

155+
function _toggle() {
156+
window.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
157+
}
158+
159+
function _open() {
160+
window.dispatchEvent(new CustomEvent(OPEN_DRAWER_EVENT));
161+
}
162+
163+
function _close() {
164+
window.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
165+
}
166+
140167
/**
141-
*
168+
* familiar React-style API
142169
*/
143170
export function useDrawerControls() {
144171
return {
145-
toggle: () => {
146-
window.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
147-
},
148-
close: () => {
149-
window.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
150-
},
151-
open: () => {
152-
window.dispatchEvent(new CustomEvent(OPEN_DRAWER_EVENT));
153-
},
172+
toggle: _toggle,
173+
close: _close,
174+
open: _open,
154175
};
155176
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { h } from 'preact';
2+
3+
export function Open() {
4+
return (
5+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
6+
<path
7+
d="M4.125 2.25C3.08947 2.25 2.25 3.08947 2.25 4.125V11.875C2.25 12.9105 3.08947 13.75 4.125 13.75H11.875C12.9105 13.75 13.75 12.9105 13.75 11.875V8.765C13.75 8.41982 14.0298 8.14 14.375 8.14C14.7202 8.14 15 8.41982 15 8.765V11.875C15 13.6009 13.6009 15 11.875 15H4.125C2.39911 15 1 13.6009 1 11.875V4.125C1 2.39911 2.39911 1 4.125 1H7.235C7.58018 1 7.86 1.27982 7.86 1.625C7.86 1.97018 7.58018 2.25 7.235 2.25H4.125Z"
8+
fill="currentColor"
9+
/>
10+
<path
11+
d="M10.25 1.625C10.25 1.27982 10.5298 1 10.875 1H14.375C14.7202 1 15 1.27982 15 1.625V5.125C15 5.47018 14.7202 5.75 14.375 5.75C14.0298 5.75 13.75 5.47018 13.75 5.125V3.13388L9.06694 7.81694C8.82286 8.06102 8.42714 8.06102 8.18306 7.81694C7.93898 7.57286 7.93898 7.17714 8.18306 6.93306L12.8661 2.25H10.875C10.5298 2.25 10.25 1.97018 10.25 1.625Z"
12+
fill="currentColor"
13+
/>
14+
</svg>
15+
);
16+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import cn from 'classnames';
2+
import styles from './CustomizerDrawerInner.module.css';
3+
import { h } from 'preact';
4+
import { useMessaging } from '../../types.js';
5+
import { Open } from '../../components/icons/Open.js';
6+
7+
export function SettingsLink() {
8+
const messaging = useMessaging();
9+
function onClick(e) {
10+
e.preventDefault();
11+
messaging.open({ target: 'settings' });
12+
}
13+
return (
14+
<a href="duck://settings" class={cn(styles.settingsLink)} onClick={onClick}>
15+
<span>Go to Settings</span>
16+
<Open />
17+
</a>
18+
);
19+
}

special-pages/pages/new-tab/app/customizer/customizer.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ title: Customizer
2626
],
2727
"settings": {
2828
"customizerDrawer": {
29-
"state": "enabled"
29+
"state": "enabled",
30+
"autoOpen": false
3031
}
3132
},
3233
"customizer": {
@@ -124,6 +125,9 @@ title: Customizer
124125
"theme": "system"
125126
}
126127
```
128+
129+
- {@link "NewTab Messages".CustomizerAutoOpenSubscription `customizer_autoOpen`}.
130+
- Send this into the page to trigger the customizer to be opened.
127131

128132
## Notifications
129133

special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ import { values } from '../values.js';
33

44
/**
55
* @typedef {import('../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionEventNames
6+
* @typedef {import('../../../types/new-tab.js').NewTabMessages['notifications']['method']} NotificationNames
67
*/
78

9+
const named = {
10+
/** @type {(n: NotificationNames) => NotificationNames} */
11+
notification: (n) => n,
12+
/** @type {(n: SubscriptionEventNames) => SubscriptionEventNames} */
13+
subscription: (n) => n,
14+
};
815
export class CustomizerPage {
916
/**
1017
* @param {import("../../../integration-tests/new-tab.page.js").NewtabPage} ntp
@@ -23,6 +30,24 @@ export class CustomizerPage {
2330
await page.getByRole('button', { name: 'Customize' }).click();
2431
}
2532

33+
async closesCustomizer() {
34+
const { page } = this.ntp;
35+
await page.locator('aside').getByRole('button', { name: 'Close' }).click();
36+
await expect(page.locator('aside')).toHaveAttribute('aria-hidden', 'true');
37+
// todo: This will be added in a follow up
38+
// await expect(page.locator('aside')).toHaveCSS('visibility', 'hidden');
39+
}
40+
41+
async opensSettings() {
42+
const { page } = this.ntp;
43+
await page.locator('aside').getByRole('link', { name: 'Go to Settings' }).click();
44+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('open') });
45+
expect(calls[0].payload).toMatchObject({
46+
method: 'open',
47+
params: { target: 'settings' },
48+
});
49+
}
50+
2651
async hasDefaultBackgroundSelected() {
2752
const { page } = this.ntp;
2853
const selected = page.locator('aside').getByLabel('Default');
@@ -71,12 +96,13 @@ export class CustomizerPage {
7196
async uploadsFirstImage() {
7297
const { page } = this.ntp;
7398
await page.getByLabel('Add Background').click();
74-
await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_upload' });
99+
await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_upload') });
75100
}
101+
76102
async setsDarkTheme() {
77103
const { page } = this.ntp;
78104
await page.getByRole('radio', { name: 'Select dark theme' }).click();
79-
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setTheme' });
105+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setTheme') });
80106
expect(calls[0].payload).toMatchObject({
81107
method: 'customizer_setTheme',
82108
params: { theme: 'dark' },
@@ -95,7 +121,7 @@ export class CustomizerPage {
95121
async selectsDefault() {
96122
const { page } = this.ntp;
97123
await page.locator('aside').getByLabel('Default').click();
98-
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
124+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setBackground') });
99125
expect(calls[0].payload).toMatchObject({
100126
method: 'customizer_setBackground',
101127
params: { background: { kind: 'default' } },
@@ -125,7 +151,7 @@ export class CustomizerPage {
125151
const { page } = this.ntp;
126152
await this.showsColorSelectionPanel();
127153
await page.getByRole('radio', { name: 'Select color03' }).click();
128-
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
154+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setBackground') });
129155
expect(calls[0].payload).toMatchObject({
130156
method: 'customizer_setBackground',
131157
params: { background: { kind: 'color', value: 'color03' } },
@@ -137,7 +163,7 @@ export class CustomizerPage {
137163
const { page } = this.ntp;
138164
await page.locator('aside').getByLabel('Gradients').click();
139165
await page.getByRole('radio', { name: 'Select gradient01' }).click();
140-
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
166+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setBackground') });
141167
expect(calls[0].payload).toMatchObject({
142168
method: 'customizer_setBackground',
143169
params: { background: { kind: 'gradient', value: 'gradient01' } },
@@ -151,9 +177,7 @@ export class CustomizerPage {
151177
async acceptsBackgroundUpdate(bg) {
152178
/** @type {import('../../../types/new-tab.js').BackgroundData} */
153179
const payload = { background: bg };
154-
/** @type {SubscriptionEventNames} */
155-
const named = 'customizer_onBackgroundUpdate';
156-
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
180+
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onBackgroundUpdate'), payload);
157181
}
158182

159183
/**
@@ -163,8 +187,7 @@ export class CustomizerPage {
163187
/** @type {import('../../../types/new-tab.js').ThemeData} */
164188
const payload = { theme };
165189
/** @type {SubscriptionEventNames} */
166-
const named = 'customizer_onThemeUpdate';
167-
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
190+
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onThemeUpdate'), payload);
168191
}
169192

170193
/**
@@ -174,9 +197,7 @@ export class CustomizerPage {
174197
await test.step('subscription event: customizer_onColorUpdate', async () => {
175198
/** @type {import('../../../types/new-tab.js').UserColorData} */
176199
const payload = { userColor: { kind: 'hex', value: color } };
177-
/** @type {SubscriptionEventNames} */
178-
const named = 'customizer_onColorUpdate';
179-
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
200+
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onColorUpdate'), payload);
180201
});
181202
}
182203

@@ -193,9 +214,7 @@ export class CustomizerPage {
193214

194215
/** @type {import('../../../types/new-tab.js').UserImageData} */
195216
const payload = { userImages: [values.userImages['01']] };
196-
/** @type {SubscriptionEventNames} */
197-
const named = 'customizer_onImagesUpdate';
198-
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
217+
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onImagesUpdate'), payload);
199218

200219
const response = await resPromise;
201220
await page.pause();
@@ -256,4 +275,88 @@ export class CustomizerPage {
256275
const { page } = this.ntp;
257276
await page.getByLabel('Add Background').waitFor();
258277
}
278+
279+
async opensImages() {
280+
const { page } = this.ntp;
281+
await page.getByLabel('My Backgrounds').click();
282+
}
283+
284+
async hasImages(number) {
285+
const { page } = this.ntp;
286+
await expect(page.locator('aside').getByRole('radio')).toHaveCount(number);
287+
}
288+
289+
async hasPlaceholders(number) {
290+
const { page } = this.ntp;
291+
await expect(page.locator('aside').getByRole('button', { name: 'Add Background' })).toHaveCount(number);
292+
}
293+
294+
async uploadsAdditional({ existing }) {
295+
const { page } = this.ntp;
296+
const expectedPlaceholderCount = 8 - existing;
297+
298+
await this.hasImages(existing);
299+
await this.hasPlaceholders(expectedPlaceholderCount);
300+
await page.locator('aside').getByRole('button', { name: 'Add Background' }).nth(existing).click();
301+
await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_upload') });
302+
303+
// check the last placeholder element is also clickable
304+
await page
305+
.locator('aside')
306+
.getByRole('button', { name: 'Add Background' })
307+
.nth(expectedPlaceholderCount - 1)
308+
.click();
309+
310+
await this.ntp.mocks.waitForCallCount({ count: 2, method: named.notification('customizer_upload') });
311+
}
312+
313+
/**
314+
*
315+
*/
316+
async acceptsBadImagesUpdate() {
317+
const { page } = this.ntp;
318+
await test.step('subscription event with bad data: customizer_onImagesUpdate', async () => {
319+
/** @type {import('../../../types/new-tab.js').UserImageData} */
320+
// @ts-expect-error - the test is for an error!
321+
const payload = { lol: '' };
322+
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onImagesUpdate'), payload);
323+
await expect(page.getByRole('complementary')).toContainText('A problem occurred with this feature. DuckDuckGo was notified');
324+
325+
// sends the report
326+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('reportPageException') });
327+
expect(calls[0].payload).toMatchObject({
328+
method: 'reportPageException',
329+
params: {
330+
message:
331+
"Customizer section 'Customizer Drawer' threw an exception: TypeError: Cannot read properties of undefined (reading 'length')",
332+
},
333+
});
334+
});
335+
}
336+
337+
/**
338+
*
339+
*/
340+
async handlesNestedException() {
341+
const { page } = this.ntp;
342+
await expect(page.getByRole('complementary')).toContainText('A problem occurred with this feature. DuckDuckGo was notified');
343+
await page.getByRole('button', { name: 'My Backgrounds' }).click();
344+
await page.getByTestId('dismissBtn').click();
345+
346+
// sends the report
347+
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('reportPageException') });
348+
expect(calls[0].payload).toMatchObject({
349+
method: 'reportPageException',
350+
params: {
351+
message: "Customizer section 'Image Selection' threw an exception: Error: Simulated error",
352+
},
353+
});
354+
}
355+
356+
async customizerOpensAutomatically() {
357+
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_autoOpen'), {});
358+
359+
// can only close after being opened
360+
await this.closesCustomizer();
361+
}
259362
}

0 commit comments

Comments
 (0)