Skip to content

Commit

Permalink
Disable copy button when it is denied/disabled (#5285)
Browse files Browse the repository at this point in the history
* Disable copy button when it is denied/disabled

* Update entry

* Reset clipboard-write
  • Loading branch information
compulim authored Sep 6, 2024
1 parent 7d07659 commit 3faa7a6
Show file tree
Hide file tree
Showing 15 changed files with 210 additions and 9 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/
- Added `CopilotMessageHeader` component for displaying bot information in the "copilot" variant, in PR [#5258](https://github.com/microsoft/BotFramework-WebChat/pull/5258), by [@OEvgeny](https://github.com/OEvgeny)
- Updated Fluent theme styling to improve accessibility and visual consistency, in PR [#5258](https://github.com/microsoft/BotFramework-WebChat/pull/5258), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed header font in copilot variant, in PR [#5261](https://github.com/microsoft/BotFramework-WebChat/pull/5261), by [@OEvgeny](https://github.com/OEvgeny)
- Added "Copy" button to bot messages in Fluent UI if it contains keyword `AllowCopy`, in PR [#5259](https://github.com/microsoft/BotFramework-WebChat/pull/5259) and [#5262](https://github.com/microsoft/BotFramework-WebChat/pull/5262), by [@compulim](https://github.com/compulim)
- Added "Copy" button to bot messages in Fluent UI if it contains keyword `AllowCopy`, in PR [#5259](https://github.com/microsoft/BotFramework-WebChat/pull/5259), [#5262](https://github.com/microsoft/BotFramework-WebChat/pull/5262), and [#5285](https://github.com/microsoft/BotFramework-WebCHat/pull/5285), by [@compulim](https://github.com/compulim)
- Resolves [#4876](https://github.com/microsoft/BotFramework-WebChat/issues/4876) and [#4939](https://github.com/microsoft/BotFramework-WebChat/issues/4939). Added support of informative message in livestreaming, by [@compulim](https://github.com/compulim), in PR [#5265](https://github.com/microsoft/BotFramework-WebChat/pull/5265)
- Introduced centralized announcements approach via the new `usePushToLiveRegion` hook, in PR [#5251](https://github.com/microsoft/BotFramework-WebChat/pull/5251), by [@OEvgeny](https://github.com/OEvgeny)
- Added keyboard shortcut for the "New Messages" button, in PR [#5251](https://github.com/microsoft/BotFramework-WebChat/pull/5251), by [@OEvgeny](https://github.com/OEvgeny)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 72 additions & 0 deletions __tests__/html/copyButton.denied.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat" style="position: relative"></main>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { ReactWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();

render(<ReactWebChat directLine={directLine} store={store} />, document.getElementById('webchat'));

await pageConditions.uiConnected();

expect(window.isSecureContext).toBe(true);

await host.sendDevToolsCommand('Browser.setPermission', {
permission: { name: 'clipboard-write' },
setting: 'denied'
});

await expect(navigator.permissions.query({ name: 'clipboard-write' })).resolves.toHaveProperty(
'state',
'denied'
);

await directLine.emulateIncomingActivity({
entities: [
{
'@context': 'https://schema.org',
'@id': '',
'@type': 'Message',
keywords: ['AllowCopy'],
type: 'https://schema.org/Message'
}
],
text: 'Mollit *aute* **aute** dolor ea ex magna incididunt nostrud sit nisi.',
type: 'message'
});

await pageConditions.numActivitiesShown(1);

// THEN: The copy button should be disabled.
const copyButton = document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`);

expect(copyButton.getAttribute('aria-disabled')).toBe('true');

// THEN: Should match screenshot.
await host.snapshot();

// WHEN: Clicks the "Copy" button.
await host.click(copyButton);

// THEN: The copy button should not change to "Copied".
await host.snapshot();
});
</script>
</body>
</html>
3 changes: 3 additions & 0 deletions __tests__/html/copyButton.denied.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */

test('copy button with denied permission should be disabled', () => runHTML('copyButton.denied'));
72 changes: 72 additions & 0 deletions __tests__/html/copyButton.disabled.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/[email protected]/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat" style="position: relative"></main>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { ReactWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();

render(<ReactWebChat directLine={directLine} store={store} uiState="disabled" />, document.getElementById('webchat'));

await pageConditions.uiConnected();

expect(window.isSecureContext).toBe(true);

await host.sendDevToolsCommand('Browser.setPermission', {
permission: { name: 'clipboard-write' },
setting: 'granted'
});

await expect(navigator.permissions.query({ name: 'clipboard-write' })).resolves.toHaveProperty(
'state',
'granted'
);

await directLine.emulateIncomingActivity({
entities: [
{
'@context': 'https://schema.org',
'@id': '',
'@type': 'Message',
keywords: ['AllowCopy'],
type: 'https://schema.org/Message'
}
],
text: 'Mollit *aute* **aute** dolor ea ex magna incididunt nostrud sit nisi.',
type: 'message'
});

await pageConditions.numActivitiesShown(1);

// THEN: The copy button should be disabled.
const copyButton = document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`);

expect(copyButton.getAttribute('aria-disabled')).toBe('true');

// THEN: Should match screenshot.
await host.snapshot();

// WHEN: Clicks the "Copy" button.
await host.click(copyButton);

// THEN: The copy button should not change to "Copied".
await host.snapshot();
});
</script>
</body>
</html>
3 changes: 3 additions & 0 deletions __tests__/html/copyButton.disabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */

test('copy button should be able to disable', () => runHTML('copyButton.disabled'));
21 changes: 21 additions & 0 deletions __tests__/html/copyButton.layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@

await pageConditions.uiConnected();

expect(window.isSecureContext).toBe(true);

await host.sendDevToolsCommand('Browser.setPermission', {
permission: { name: 'clipboard-write' },
setting: 'granted'
});

await expect(navigator.permissions.query({ name: 'clipboard-write' })).resolves.toHaveProperty(
'state',
'granted'
);

await directLine.emulateIncomingActivity({
// TODO: Attachment is buggy now: clipped into the text content now and not aligned horizontally.
// attachments: [
Expand Down Expand Up @@ -117,6 +129,15 @@

await pageConditions.numActivitiesShown(1);

// WHEN: Wait until the copy button is enabled after permission check.
const copyButton = document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`);

await pageConditions.became(
'copy button is enabled',
() => copyButton.getAttribute('aria-disabled') !== 'true',
1_000
);

// THEN: "Copy" button should appear after the message.
await host.snapshot();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,28 @@ type Props = Readonly<{
children?: ReactNode | undefined;
className?: string | undefined;
'data-testid'?: string | undefined;
disabled?: boolean | undefined;
iconURL?: string | undefined;
onClick?: (() => void) | undefined;
text?: string | undefined;
}>;

const ActivityButton = forwardRef<HTMLButtonElement, Props>(
({ children, className, 'data-testid': dataTestId, iconURL, onClick, text }, ref) => {
({ children, className, 'data-testid': dataTestId, disabled, iconURL, onClick, text }, ref) => {
const [{ activityButton }] = useStyleSet();
const onClickRef = useRefFrom(onClick);

const handleClick = useCallback(() => onClickRef.current?.(), [onClickRef]);

return (
<button
aria-disabled={disabled ? 'true' : undefined}
className={classNames(activityButton, 'webchat__activity-button', className)}
data-testid={dataTestId}
onClick={handleClick}
onClick={disabled ? undefined : handleClick}
ref={ref}
// eslint-disable-next-line no-magic-numbers
tabIndex={disabled ? -1 : undefined}
type="button"
>
{iconURL && <MonochromeImageMasker className="webchat__activity-button__icon" src={iconURL} />}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { hooks } from 'botframework-webchat-api';
import classNames from 'classnames';
import React, { memo, useCallback, useRef } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useRefFrom } from 'use-ref-from';
import useStyleSet from '../../../hooks/useStyleSet';
import ActivityButton from './ActivityButton';

const { useLocalizer } = hooks;
const { useLocalizer, useUIState } = hooks;

type Props = Readonly<{
className?: string | undefined;
Expand All @@ -17,13 +17,16 @@ const COPY_ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent('<svg xmlns=

const ActivityCopyButton = ({ className, htmlText, plainText }: Props) => {
const [{ activityButton, activityCopyButton }] = useStyleSet();
const [permissionGranted, setPermissionGranted] = useState(false);
const [uiState] = useUIState();
const buttonRef = useRef<HTMLButtonElement>(null);
const htmlTextRef = useRefFrom(htmlText);
const localize = useLocalizer();
const plainTextRef = useRefFrom(plainText);
const htmlTextRef = useRefFrom(htmlText);

const copiedText = localize('COPY_BUTTON_COPIED_TEXT');
const copyText = localize('COPY_BUTTON_TEXT');
const disabled = !permissionGranted || uiState === 'disabled';

const handleClick = useCallback(() => {
const { current: htmlText } = htmlTextRef;
Expand All @@ -47,6 +50,20 @@ const ActivityCopyButton = ({ className, htmlText, plainText }: Props) => {
buttonRef.current?.classList.add('webchat__activity-copy-button--copied');
}, [buttonRef, htmlTextRef, plainTextRef]);

useEffect(() => {
let unmounted = false;

(async function () {
if ((await navigator.permissions.query({ name: 'clipboard-write' as any })).state === 'granted') {
unmounted || setPermissionGranted(true);
}
})();

return () => {
unmounted = true;
};
}, [setPermissionGranted]);

return (
<ActivityButton
className={classNames(
Expand All @@ -57,6 +74,7 @@ const ActivityCopyButton = ({ className, htmlText, plainText }: Props) => {
className
)}
data-testid="copy button"
disabled={disabled}
iconURL={COPY_ICON_URL}
onClick={handleClick}
ref={buttonRef}
Expand Down
5 changes: 3 additions & 2 deletions packages/component/src/Styles/StyleSet/ActivityButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ export default function createActivityButtonStyle() {
outlineOffset: '-2px'
},

'&:disabled': {
'&[aria-disabled="true"]': {
background: '#f0f0f0',
border: '1px solid #e0e0e0',
color: '#bdbdbd'
color: '#bdbdbd',
cursor: 'not-allowed'
},

'& .webchat__activity-button__icon': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@
outline-offset: calc(var(--webchat-strokeWidthThick) * -1);
}

&:disabled {
&[aria-disabled="true"] {
background: var(--webchat-colorNeutralBackgroundDisabled);
border: var(--webchat-strokeWidthThin) solid var(--webchat-colorNeutralStrokeDisabled);
color: var(--webchat-colorNeutralForegroundDisabled);
Expand Down
7 changes: 7 additions & 0 deletions packages/test/harness/src/browser/globals/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export default function () {
features: [{ name: 'prefers-reduced-motion', value: 'reduce' }]
})
)
// Some tests may have denied the clipboard-write permission, we should reset it to grant (default).
.then(() =>
host.sendDevToolsCommand('Browser.setPermission', {
permission: { name: 'clipboard-write' },
setting: 'granted'
})
)
// Some tests may have changed the time zone, we should unset it.
.then(() => host.sendDevToolsCommand('Emulation.setTimezoneOverride', { timezoneId: 'Etc/UTC' }))
.catch(error =>
Expand Down

0 comments on commit 3faa7a6

Please sign in to comment.