Skip to content

Commit

Permalink
ntp: support auto-open + settings link (#1351)
Browse files Browse the repository at this point in the history
* ntp: support auto-open + settings link

* fixing tests
  • Loading branch information
shakyShane authored Dec 23, 2024
1 parent 58f3b65 commit 4c6371f
Show file tree
Hide file tree
Showing 18 changed files with 342 additions and 38 deletions.
2 changes: 1 addition & 1 deletion .stylelintrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": ["stylelint-config-standard"],
"plugins": ["stylelint-csstree-validator"],
"ignoreFiles": ["build/**/*.css", "Sources/**/*.css", "docs/**/*.css"],
"ignoreFiles": ["build/**/*.css", "Sources/**/*.css", "docs/**/*.css", "special-pages/pages/**/*/dist/*.css"],
"rules": {
"csstree/validator": {
"ignoreProperties": ["text-wrap"]
Expand Down
2 changes: 1 addition & 1 deletion special-pages/pages/new-tab/app/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function App() {
hidden,
buttonId,
drawerId
} = useDrawer();
} = useDrawer(customizerDrawer.autoOpen ? 'visible' : 'hidden');

const tabIndex = useComputed(() => (hidden.value ? -1 : 0));
const { toggle } = useDrawerControls();
Expand Down
49 changes: 35 additions & 14 deletions special-pages/pages/new-tab/app/components/Drawer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef, useId, useLayoutEffect } from 'preact/hooks';
import { useRef, useId, useLayoutEffect, useEffect } from 'preact/hooks';
import { batch, useComputed, useSignal } from '@preact/signals';
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
import { useMessaging } from '../types.js';

const CLOSE_DRAWER_EVENT = 'close-drawer';
const TOGGLE_DRAWER_EVENT = 'toggle-drawer';
Expand All @@ -18,7 +19,7 @@ const REQUEST_VISIBILITY_EVENT = 'request-visibility';
* - 1: we make the API available with events (via `useDrawerControls`)
* - 2: we use signals to trigger animations for performance (to prevent VDOM diffing)
* - 3: we provide a way for child components to render AFTER animations have ended, again for performance.
*
* @param {DrawerVisibility} initial
* @return {{
* wrapperRef: import("preact").RefObject<HTMLDivElement>,
* buttonRef: import("preact").RefObject<HTMLButtonElement>,
Expand All @@ -30,7 +31,7 @@ const REQUEST_VISIBILITY_EVENT = 'request-visibility';
* displayChildren: import("@preact/signals").Signal<boolean>,
* }}
*/
export function useDrawer() {
export function useDrawer(initial) {
const { isReducedMotion } = useEnv();
const wrapperRef = useRef(/** @type {HTMLDivElement|null} */ (null));
const buttonRef = useRef(/** @type {HTMLButtonElement|null} */ (null));
Expand Down Expand Up @@ -123,7 +124,21 @@ export function useDrawer() {
return () => {
controller.abort();
};
}, [isReducedMotion]);
}, [isReducedMotion, initial]);

const ntp = useMessaging();

/**
* Open initially if required too
*/
useEffect(() => {
if (initial === 'visible') {
_open();
}
return ntp.messaging.subscribe('customizer_autoOpen', () => {
_open();
});
}, [initial, ntp]);

return {
wrapperRef,
Expand All @@ -137,19 +152,25 @@ export function useDrawer() {
};
}

function _toggle() {
window.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}

function _open() {
window.dispatchEvent(new CustomEvent(OPEN_DRAWER_EVENT));
}

function _close() {
window.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
}

/**
*
* familiar React-style API
*/
export function useDrawerControls() {
return {
toggle: () => {
window.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
},
close: () => {
window.dispatchEvent(new CustomEvent(CLOSE_DRAWER_EVENT));
},
open: () => {
window.dispatchEvent(new CustomEvent(OPEN_DRAWER_EVENT));
},
toggle: _toggle,
close: _close,
open: _open,
};
}
16 changes: 16 additions & 0 deletions special-pages/pages/new-tab/app/components/icons/Open.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { h } from 'preact';

export function Open() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
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"
fill="currentColor"
/>
<path
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"
fill="currentColor"
/>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import cn from 'classnames';
import styles from './CustomizerDrawerInner.module.css';
import { h } from 'preact';
import { useMessaging } from '../../types.js';
import { Open } from '../../components/icons/Open.js';

export function SettingsLink() {
const messaging = useMessaging();
function onClick(e) {
e.preventDefault();
messaging.open({ target: 'settings' });
}
return (
<a href="duck://settings" class={cn(styles.settingsLink)} onClick={onClick}>
<span>Go to Settings</span>
<Open />
</a>
);
}
6 changes: 5 additions & 1 deletion special-pages/pages/new-tab/app/customizer/customizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ title: Customizer
],
"settings": {
"customizerDrawer": {
"state": "enabled"
"state": "enabled",
"autoOpen": false
}
},
"customizer": {
Expand Down Expand Up @@ -124,6 +125,9 @@ title: Customizer
"theme": "system"
}
```

- {@link "NewTab Messages".CustomizerAutoOpenSubscription `customizer_autoOpen`}.
- Send this into the page to trigger the customizer to be opened.

## Notifications

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@ import { values } from '../values.js';

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

const named = {
/** @type {(n: NotificationNames) => NotificationNames} */
notification: (n) => n,
/** @type {(n: SubscriptionEventNames) => SubscriptionEventNames} */
subscription: (n) => n,
};
export class CustomizerPage {
/**
* @param {import("../../../integration-tests/new-tab.page.js").NewtabPage} ntp
Expand All @@ -23,6 +30,24 @@ export class CustomizerPage {
await page.getByRole('button', { name: 'Customize' }).click();
}

async closesCustomizer() {
const { page } = this.ntp;
await page.locator('aside').getByRole('button', { name: 'Close' }).click();
await expect(page.locator('aside')).toHaveAttribute('aria-hidden', 'true');
// todo: This will be added in a follow up
// await expect(page.locator('aside')).toHaveCSS('visibility', 'hidden');
}

async opensSettings() {
const { page } = this.ntp;
await page.locator('aside').getByRole('link', { name: 'Go to Settings' }).click();
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('open') });
expect(calls[0].payload).toMatchObject({
method: 'open',
params: { target: 'settings' },
});
}

async hasDefaultBackgroundSelected() {
const { page } = this.ntp;
const selected = page.locator('aside').getByLabel('Default');
Expand Down Expand Up @@ -71,12 +96,13 @@ export class CustomizerPage {
async uploadsFirstImage() {
const { page } = this.ntp;
await page.getByLabel('Add Background').click();
await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_upload' });
await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_upload') });
}

async setsDarkTheme() {
const { page } = this.ntp;
await page.getByRole('radio', { name: 'Select dark theme' }).click();
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setTheme' });
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setTheme') });
expect(calls[0].payload).toMatchObject({
method: 'customizer_setTheme',
params: { theme: 'dark' },
Expand All @@ -95,7 +121,7 @@ export class CustomizerPage {
async selectsDefault() {
const { page } = this.ntp;
await page.locator('aside').getByLabel('Default').click();
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setBackground') });
expect(calls[0].payload).toMatchObject({
method: 'customizer_setBackground',
params: { background: { kind: 'default' } },
Expand Down Expand Up @@ -125,7 +151,7 @@ export class CustomizerPage {
const { page } = this.ntp;
await this.showsColorSelectionPanel();
await page.getByRole('radio', { name: 'Select color03' }).click();
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setBackground') });
expect(calls[0].payload).toMatchObject({
method: 'customizer_setBackground',
params: { background: { kind: 'color', value: 'color03' } },
Expand All @@ -137,7 +163,7 @@ export class CustomizerPage {
const { page } = this.ntp;
await page.locator('aside').getByLabel('Gradients').click();
await page.getByRole('radio', { name: 'Select gradient01' }).click();
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_setBackground') });
expect(calls[0].payload).toMatchObject({
method: 'customizer_setBackground',
params: { background: { kind: 'gradient', value: 'gradient01' } },
Expand All @@ -151,9 +177,7 @@ export class CustomizerPage {
async acceptsBackgroundUpdate(bg) {
/** @type {import('../../../types/new-tab.js').BackgroundData} */
const payload = { background: bg };
/** @type {SubscriptionEventNames} */
const named = 'customizer_onBackgroundUpdate';
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onBackgroundUpdate'), payload);
}

/**
Expand All @@ -163,8 +187,7 @@ export class CustomizerPage {
/** @type {import('../../../types/new-tab.js').ThemeData} */
const payload = { theme };
/** @type {SubscriptionEventNames} */
const named = 'customizer_onThemeUpdate';
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onThemeUpdate'), payload);
}

/**
Expand All @@ -174,9 +197,7 @@ export class CustomizerPage {
await test.step('subscription event: customizer_onColorUpdate', async () => {
/** @type {import('../../../types/new-tab.js').UserColorData} */
const payload = { userColor: { kind: 'hex', value: color } };
/** @type {SubscriptionEventNames} */
const named = 'customizer_onColorUpdate';
await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onColorUpdate'), payload);
});
}

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

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

const response = await resPromise;
await page.pause();
Expand Down Expand Up @@ -256,4 +275,88 @@ export class CustomizerPage {
const { page } = this.ntp;
await page.getByLabel('Add Background').waitFor();
}

async opensImages() {
const { page } = this.ntp;
await page.getByLabel('My Backgrounds').click();
}

async hasImages(number) {
const { page } = this.ntp;
await expect(page.locator('aside').getByRole('radio')).toHaveCount(number);
}

async hasPlaceholders(number) {
const { page } = this.ntp;
await expect(page.locator('aside').getByRole('button', { name: 'Add Background' })).toHaveCount(number);
}

async uploadsAdditional({ existing }) {
const { page } = this.ntp;
const expectedPlaceholderCount = 8 - existing;

await this.hasImages(existing);
await this.hasPlaceholders(expectedPlaceholderCount);
await page.locator('aside').getByRole('button', { name: 'Add Background' }).nth(existing).click();
await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('customizer_upload') });

// check the last placeholder element is also clickable
await page
.locator('aside')
.getByRole('button', { name: 'Add Background' })
.nth(expectedPlaceholderCount - 1)
.click();

await this.ntp.mocks.waitForCallCount({ count: 2, method: named.notification('customizer_upload') });
}

/**
*
*/
async acceptsBadImagesUpdate() {
const { page } = this.ntp;
await test.step('subscription event with bad data: customizer_onImagesUpdate', async () => {
/** @type {import('../../../types/new-tab.js').UserImageData} */
// @ts-expect-error - the test is for an error!
const payload = { lol: '' };
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_onImagesUpdate'), payload);
await expect(page.getByRole('complementary')).toContainText('A problem occurred with this feature. DuckDuckGo was notified');

// sends the report
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('reportPageException') });
expect(calls[0].payload).toMatchObject({
method: 'reportPageException',
params: {
message:
"Customizer section 'Customizer Drawer' threw an exception: TypeError: Cannot read properties of undefined (reading 'length')",
},
});
});
}

/**
*
*/
async handlesNestedException() {
const { page } = this.ntp;
await expect(page.getByRole('complementary')).toContainText('A problem occurred with this feature. DuckDuckGo was notified');
await page.getByRole('button', { name: 'My Backgrounds' }).click();
await page.getByTestId('dismissBtn').click();

// sends the report
const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: named.notification('reportPageException') });
expect(calls[0].payload).toMatchObject({
method: 'reportPageException',
params: {
message: "Customizer section 'Image Selection' threw an exception: Error: Simulated error",
},
});
}

async customizerOpensAutomatically() {
await this.ntp.mocks.simulateSubscriptionMessage(named.subscription('customizer_autoOpen'), {});

// can only close after being opened
await this.closesCustomizer();
}
}
Loading

0 comments on commit 4c6371f

Please sign in to comment.