diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f8113098..54bab96543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,7 +112,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Switched math block syntax from `$$` to Tex-style `\[ \]` and `\( \)` delimiters with improved rendering and error handling, in PR [#5353](https://github.com/microsoft/BotFramework-WebChat/pull/5353), by [@OEvgeny](https://github.com/OEvgeny) - Improved avatar display and grouping behavior by fixing rendering issues and activity sender identification, in PR [#5346](https://github.com/microsoft/BotFramework-WebChat/pull/5346), by [@OEvgeny](https://github.com/OEvgeny) - Activity "copy" button will use `outerHTML` and `textContent` for clipboard content, in PR [#5378](https://github.com/microsoft/BotFramework-WebChat/pull/5378), by [@compulim](https://github.com/compulim) -- Bumped dependencies to the latest versions, by [@compulim](https://github.com/compulim) in PR [#5385](https://github.com/microsoft/BotFramework-WebChat/pull/5385), [#5400](https://github.com/microsoft/BotFramework-WebChat/pull/5400), and [#5426](https://github.com/microsoft/BotFramework-WebChat/pull/5426) +- Bumped dependencies to the latest versions, by [@compulim](https://github.com/compulim) in PR [#5385](https://github.com/microsoft/BotFramework-WebChat/pull/5385), [#5400](https://github.com/microsoft/BotFramework-WebChat/pull/5400), [#5426](https://github.com/microsoft/BotFramework-WebChat/pull/5426), and [#5476](https://github.com/microsoft/BotFramework-WebChat/pull/5476) - Production dependencies - [`web-speech-cognitive-services@8.1.0`](https://npmjs.com/package/web-speech-cognitive-services) - [`react-dictate-button@4.0.0`](https://npmjs.com/package/react-dictate-button) @@ -135,7 +135,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - [`shiki@2.3.2`](https://npmjs.com/package/shiki/) - [`use-propagate@0.2.1`](https://npmjs.com/package/use-propagate/) - [`use-state-with-ref@0.1.0`](https://npmjs.com/package/use-state-with-ref/) - - [`valibot@0.42.1`](https://npmjs.com/package/valibot/) + - [`valibot@1.1.0`](https://npmjs.com/package/valibot/) - [`web-speech-cognitive-services@8.1.1`](https://npmjs.com/package/web-speech-cognitive-services/) - Development dependencies - [`@biomejs/biome@1.9.4`](https://npmjs.com/package/@biomejs/biome/) @@ -191,6 +191,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - [`webpack-cli@6.0.1`](https://npmjs.com/package/webpack-cli/) - [`webpack@5.98.0`](https://npmjs.com/package/webpack/) - Fixed [#5446](https://github.com/microsoft/BotFramework-WebChat/issues/5446). Embedded `uuid` so `microsoft-cognitiveservices-speech-sdk` do not need to use dynamic loading, as this could fail in Webpack 4 environment, in PR [#5445](https://github.com/microsoft/BotFramework-WebChat/pull/5445), by [@compulim](https://github.com/compulim) +- Fixed [#5476](https://github.com/microsoft/BotFramework-WebChat/issues/5476). Modernizing components through memoization and use [`valibot`](https://npmjs.com/package/valibot) for props validation, by [@compulim](https://github.com/compulim) ### Fixed diff --git a/__tests__/bubbleNub.js b/__tests__/bubbleNub.js deleted file mode 100644 index ced8dcbbc5..0000000000 --- a/__tests__/bubbleNub.js +++ /dev/null @@ -1,216 +0,0 @@ -import { imageSnapshotOptions, timeouts } from './constants.json'; - -import allImagesLoaded from './setup/conditions/allImagesLoaded'; -import minNumActivitiesShown from './setup/conditions/minNumActivitiesShown'; -import uiConnected from './setup/conditions/uiConnected'; - -// selenium-webdriver API doc: -// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html - -jest.setTimeout(timeouts.test); - -async function sendMessageAndMatchSnapshot(driver, pageObjects, message) { - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox(message); - - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - await driver.wait(allImagesLoaded(), timeouts.fetchImage); - - const base64PNG = await driver.takeScreenshot(); - - expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); -} - -describe('bubble nub', () => { - let props; - - test('with standard setup', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - styleOptions: { - bubbleNubOffset: 0, - bubbleNubSize: 10, - bubbleFromUserNubOffset: 0, - bubbleFromUserNubSize: 10 - } - }, - zoom: 3 - }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout carousel'); - }); - - beforeEach(() => { - props = { - styleOptions: { - bubbleBorderColor: 'red', - bubbleBorderRadius: 10, - bubbleBorderWidth: 2, - bubbleFromUserBorderColor: 'green', - bubbleFromUserBorderRadius: 10, - bubbleFromUserNubOffset: 0, - bubbleFromUserNubSize: 10, - bubbleFromUserBorderWidth: 2, - bubbleNubOffset: 0, - bubbleNubSize: 10 - } - }; - }); - - describe('with avatar initials', () => { - beforeEach(() => { - props = { - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: 'WC', - userAvatarInitials: 'WW' - } - }; - }); - - test('and carousel with a message should have nub on message only', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout carousel'); - }); - - test('and carousel without a message should not have nubs', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'carousel'); - }); - - test('and stacked without a message should not have nubs', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout single'); - }); - - test('and a single message should have nub', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - - test('and carousel with a single attachment should have nub on message only', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout single carousel'); - }); - }); - - describe('without avatar initials', () => { - test('and carousel with a message should have nub on message only and indented', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout carousel'); - }); - - test('and carousel without a message should not have nubs and indented', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'carousel'); - }); - - test('and stacked without a message should not have nubs and indented', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout single'); - }); - - test('and a single message should have nub and indented', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - - test('and carousel with a single attachment should have nub on message only', async () => { - const { driver, pageObjects } = await setupWebDriver({ props, zoom: 3 }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'layout single carousel'); - }); - }); - - describe('at corner with offset', () => { - test('of 5px should have corner radius of 5px', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - ...props, - styleOptions: { - ...props.styleOptions, - bubbleFromUserNubOffset: 5, - bubbleNubOffset: 5 - } - }, - zoom: 3 - }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - - test('of 10px should have corner radius of 10px', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - ...props, - styleOptions: { - ...props.styleOptions, - bubbleFromUserNubOffset: 10, - bubbleNubOffset: 10 - } - }, - zoom: 3 - }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - - test('of minus 5px should have corner radius of 5px and flipped nub', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - ...props, - styleOptions: { - ...props.styleOptions, - bubbleFromUserNubOffset: -5, - bubbleNubOffset: -5 - } - }, - zoom: 3 - }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - - test('of minus 10px should have corner radius of 10px and flipped nub', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - ...props, - styleOptions: { - ...props.styleOptions, - bubbleFromUserNubOffset: -10, - bubbleNubOffset: -10 - } - }, - zoom: 3 - }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - - test('at bottom should have flipped nub', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - ...props, - styleOptions: { - ...props.styleOptions, - bubbleFromUserNubOffset: 'bottom', - bubbleNubOffset: 'bottom' - } - }, - zoom: 3 - }); - - await sendMessageAndMatchSnapshot(driver, pageObjects, 'Hello, World!'); - }); - }); -}); diff --git a/__tests__/html/sendAttachmentOn/useSendFiles.deprecation.html b/__tests__/html/sendAttachmentOn/useSendFiles.deprecation.html index cc4e902e2d..bb9f7af2f2 100644 --- a/__tests__/html/sendAttachmentOn/useSendFiles.deprecation.html +++ b/__tests__/html/sendAttachmentOn/useSendFiles.deprecation.html @@ -29,8 +29,8 @@ await pageObjects.runHook(({ useSendFiles }) => useSendFiles()([fileBlob])); // THEN: It should send the file. - await pageConditions.allOutgoingActivitiesSent(); await pageConditions.numActivitiesShown(3); + await pageConditions.allOutgoingActivitiesSent(); await host.snapshot(); // THEN: Should print deprecation warning. diff --git a/__tests__/html/upload.image.html b/__tests__/html/upload.image.html index f180320d4d..083cf39c94 100644 --- a/__tests__/html/upload.image.html +++ b/__tests__/html/upload.image.html @@ -21,8 +21,8 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); - await pageConditions.allOutgoingActivitiesSent(); await pageConditions.numActivitiesShown(2); + await pageConditions.allOutgoingActivitiesSent(); await host.snapshot(); }); diff --git a/__tests__/html2/bubble/nub/carousel.html b/__tests__/html2/bubble/nub/carousel.html new file mode 100644 index 0000000000..f47193f47d --- /dev/null +++ b/__tests__/html2/bubble/nub/carousel.html @@ -0,0 +1,41 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-standard-setup-1-snap.png b/__tests__/html2/bubble/nub/carousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-standard-setup-1-snap.png rename to __tests__/html2/bubble/nub/carousel.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/offset/flipped.html b/__tests__/html2/bubble/nub/offset/flipped.html new file mode 100644 index 0000000000..df84d18ea6 --- /dev/null +++ b/__tests__/html2/bubble/nub/offset/flipped.html @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-at-bottom-should-have-flipped-nub-1-snap.png b/__tests__/html2/bubble/nub/offset/flipped.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-at-bottom-should-have-flipped-nub-1-snap.png rename to __tests__/html2/bubble/nub/offset/flipped.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/offset/negative10.html b/__tests__/html2/bubble/nub/offset/negative10.html new file mode 100644 index 0000000000..4066ddc89e --- /dev/null +++ b/__tests__/html2/bubble/nub/offset/negative10.html @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-minus-10-px-should-have-corner-radius-of-10-px-and-flipped-nub-1-snap.png b/__tests__/html2/bubble/nub/offset/negative10.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-minus-10-px-should-have-corner-radius-of-10-px-and-flipped-nub-1-snap.png rename to __tests__/html2/bubble/nub/offset/negative10.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/offset/negative5.html b/__tests__/html2/bubble/nub/offset/negative5.html new file mode 100644 index 0000000000..c40afc30bf --- /dev/null +++ b/__tests__/html2/bubble/nub/offset/negative5.html @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-minus-5-px-should-have-corner-radius-of-5-px-and-flipped-nub-1-snap.png b/__tests__/html2/bubble/nub/offset/negative5.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-minus-5-px-should-have-corner-radius-of-5-px-and-flipped-nub-1-snap.png rename to __tests__/html2/bubble/nub/offset/negative5.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/offset/positive10.html b/__tests__/html2/bubble/nub/offset/positive10.html new file mode 100644 index 0000000000..63be92e2be --- /dev/null +++ b/__tests__/html2/bubble/nub/offset/positive10.html @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-10-px-should-have-corner-radius-of-10-px-1-snap.png b/__tests__/html2/bubble/nub/offset/positive10.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-10-px-should-have-corner-radius-of-10-px-1-snap.png rename to __tests__/html2/bubble/nub/offset/positive10.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/offset/positive5.html b/__tests__/html2/bubble/nub/offset/positive5.html new file mode 100644 index 0000000000..3028e185fa --- /dev/null +++ b/__tests__/html2/bubble/nub/offset/positive5.html @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-5-px-should-have-corner-radius-of-5-px-1-snap.png b/__tests__/html2/bubble/nub/offset/positive5.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-at-corner-with-offset-of-5-px-should-have-corner-radius-of-5-px-1-snap.png rename to __tests__/html2/bubble/nub/offset/positive5.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withAvatars/carousel.html b/__tests__/html2/bubble/nub/withAvatars/carousel.html new file mode 100644 index 0000000000..7f0f66191b --- /dev/null +++ b/__tests__/html2/bubble/nub/withAvatars/carousel.html @@ -0,0 +1,49 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-carousel-without-a-message-should-not-have-nubs-1-snap.png b/__tests__/html2/bubble/nub/withAvatars/carousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-carousel-without-a-message-should-not-have-nubs-1-snap.png rename to __tests__/html2/bubble/nub/withAvatars/carousel.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withAvatars/helloWorld.html b/__tests__/html2/bubble/nub/withAvatars/helloWorld.html new file mode 100644 index 0000000000..3c35a2c649 --- /dev/null +++ b/__tests__/html2/bubble/nub/withAvatars/helloWorld.html @@ -0,0 +1,48 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-a-single-message-should-have-nub-1-snap.png b/__tests__/html2/bubble/nub/withAvatars/helloWorld.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-a-single-message-should-have-nub-1-snap.png rename to __tests__/html2/bubble/nub/withAvatars/helloWorld.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withAvatars/layoutCarousel.html b/__tests__/html2/bubble/nub/withAvatars/layoutCarousel.html new file mode 100644 index 0000000000..38b5002cf4 --- /dev/null +++ b/__tests__/html2/bubble/nub/withAvatars/layoutCarousel.html @@ -0,0 +1,49 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-carousel-with-a-message-should-have-nub-on-message-only-1-snap.png b/__tests__/html2/bubble/nub/withAvatars/layoutCarousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-carousel-with-a-message-should-have-nub-on-message-only-1-snap.png rename to __tests__/html2/bubble/nub/withAvatars/layoutCarousel.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withAvatars/layoutSingle.html b/__tests__/html2/bubble/nub/withAvatars/layoutSingle.html new file mode 100644 index 0000000000..7b4b50e6f7 --- /dev/null +++ b/__tests__/html2/bubble/nub/withAvatars/layoutSingle.html @@ -0,0 +1,49 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-stacked-without-a-message-should-not-have-nubs-1-snap.png b/__tests__/html2/bubble/nub/withAvatars/layoutSingle.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-stacked-without-a-message-should-not-have-nubs-1-snap.png rename to __tests__/html2/bubble/nub/withAvatars/layoutSingle.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withAvatars/layoutSingleCarousel.html b/__tests__/html2/bubble/nub/withAvatars/layoutSingleCarousel.html new file mode 100644 index 0000000000..4734e492a2 --- /dev/null +++ b/__tests__/html2/bubble/nub/withAvatars/layoutSingleCarousel.html @@ -0,0 +1,49 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-carousel-with-a-single-attachment-should-have-nub-on-message-only-1-snap.png b/__tests__/html2/bubble/nub/withAvatars/layoutSingleCarousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-with-avatar-initials-and-carousel-with-a-single-attachment-should-have-nub-on-message-only-1-snap.png rename to __tests__/html2/bubble/nub/withAvatars/layoutSingleCarousel.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withoutAvatars/carousel.html b/__tests__/html2/bubble/nub/withoutAvatars/carousel.html new file mode 100644 index 0000000000..bde584df72 --- /dev/null +++ b/__tests__/html2/bubble/nub/withoutAvatars/carousel.html @@ -0,0 +1,47 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-carousel-without-a-message-should-not-have-nubs-and-indented-1-snap.png b/__tests__/html2/bubble/nub/withoutAvatars/carousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-carousel-without-a-message-should-not-have-nubs-and-indented-1-snap.png rename to __tests__/html2/bubble/nub/withoutAvatars/carousel.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withoutAvatars/helloWorld.html b/__tests__/html2/bubble/nub/withoutAvatars/helloWorld.html new file mode 100644 index 0000000000..4453395cb7 --- /dev/null +++ b/__tests__/html2/bubble/nub/withoutAvatars/helloWorld.html @@ -0,0 +1,46 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-a-single-message-should-have-nub-and-indented-1-snap.png b/__tests__/html2/bubble/nub/withoutAvatars/helloWorld.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-a-single-message-should-have-nub-and-indented-1-snap.png rename to __tests__/html2/bubble/nub/withoutAvatars/helloWorld.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withoutAvatars/layoutCarousel.html b/__tests__/html2/bubble/nub/withoutAvatars/layoutCarousel.html new file mode 100644 index 0000000000..f30c186c73 --- /dev/null +++ b/__tests__/html2/bubble/nub/withoutAvatars/layoutCarousel.html @@ -0,0 +1,47 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-carousel-with-a-message-should-have-nub-on-message-only-and-indented-1-snap.png b/__tests__/html2/bubble/nub/withoutAvatars/layoutCarousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-carousel-with-a-message-should-have-nub-on-message-only-and-indented-1-snap.png rename to __tests__/html2/bubble/nub/withoutAvatars/layoutCarousel.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withoutAvatars/layoutSingle.html b/__tests__/html2/bubble/nub/withoutAvatars/layoutSingle.html new file mode 100644 index 0000000000..451d132165 --- /dev/null +++ b/__tests__/html2/bubble/nub/withoutAvatars/layoutSingle.html @@ -0,0 +1,47 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-stacked-without-a-message-should-not-have-nubs-and-indented-1-snap.png b/__tests__/html2/bubble/nub/withoutAvatars/layoutSingle.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-stacked-without-a-message-should-not-have-nubs-and-indented-1-snap.png rename to __tests__/html2/bubble/nub/withoutAvatars/layoutSingle.html.snap-1.png diff --git a/__tests__/html2/bubble/nub/withoutAvatars/layoutSingleCarousel.html b/__tests__/html2/bubble/nub/withoutAvatars/layoutSingleCarousel.html new file mode 100644 index 0000000000..b41d27aaf1 --- /dev/null +++ b/__tests__/html2/bubble/nub/withoutAvatars/layoutSingleCarousel.html @@ -0,0 +1,47 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-carousel-with-a-single-attachment-should-have-nub-on-message-only-1-snap.png b/__tests__/html2/bubble/nub/withoutAvatars/layoutSingleCarousel.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/bubble-nub-js-bubble-nub-without-avatar-initials-and-carousel-with-a-single-attachment-should-have-nub-on-message-only-1-snap.png rename to __tests__/html2/bubble/nub/withoutAvatars/layoutSingleCarousel.html.snap-1.png diff --git a/__tests__/html2/simple.html b/__tests__/html2/simple.html new file mode 100644 index 0000000000..f39d6bea33 --- /dev/null +++ b/__tests__/html2/simple.html @@ -0,0 +1,25 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html2/simple.offline.html b/__tests__/html2/simple.offline.html new file mode 100644 index 0000000000..87339aeafb --- /dev/null +++ b/__tests__/html2/simple.offline.html @@ -0,0 +1,27 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/speech/bargeIn/behavior.html b/__tests__/html2/speech/bargeIn/behavior.html index 5e31da29f1..6cc84e3243 100644 --- a/__tests__/html2/speech/bargeIn/behavior.html +++ b/__tests__/html2/speech/bargeIn/behavior.html @@ -90,7 +90,7 @@ // AFTER: Microphone button is clicked. // THEN: Should construct a SpeechRecognition() instance. - expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1); + await waitFor(() => expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1)); const { value: speechRecognition1 } = ponyfill.SpeechRecognition.mock.results[0]; diff --git a/__tests__/html2/speech/inputHint.acceptingInput.html b/__tests__/html2/speech/inputHint.acceptingInput.html index e491c498d9..cee2439000 100644 --- a/__tests__/html2/speech/inputHint.acceptingInput.html +++ b/__tests__/html2/speech/inputHint.acceptingInput.html @@ -75,7 +75,7 @@ ); // THEN: Should construct the SpeechRecognition() instance and call start(). - expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1); + await waitFor(() => expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1)); const { value: speechRecognition1 } = ponyfill.SpeechRecognition.mock.results[0]; diff --git a/__tests__/html2/speech/inputHint.ignoringInput.html b/__tests__/html2/speech/inputHint.ignoringInput.html index a210f51ecf..117226b349 100644 --- a/__tests__/html2/speech/inputHint.ignoringInput.html +++ b/__tests__/html2/speech/inputHint.ignoringInput.html @@ -75,7 +75,7 @@ ); // THEN: Should construct the SpeechRecognition() instance and call start(). - expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1); + await waitFor(() => expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1)); const { value: speechRecognition1 } = ponyfill.SpeechRecognition.mock.results[0]; diff --git a/__tests__/html2/speech/performCardAction.interactive.html b/__tests__/html2/speech/performCardAction.interactive.html index 05d55ea472..947e897a6b 100644 --- a/__tests__/html2/speech/performCardAction.interactive.html +++ b/__tests__/html2/speech/performCardAction.interactive.html @@ -75,7 +75,7 @@ ); // THEN: Should construct the SpeechRecognition() instance and call start(). - expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1); + await waitFor(() => expect(ponyfill.SpeechRecognition).toHaveBeenCalledTimes(1)); const { value: speechRecognition1 } = ponyfill.SpeechRecognition.mock.results[0]; diff --git a/package-lock.json b/package-lock.json index 739984f787..67fdf94590 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24370,7 +24370,7 @@ "redux": "5.0.1", "simple-update-in": "2.2.0", "use-ref-from": "0.1.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "devDependencies": { "@babel/cli": "^7.18.10", @@ -24428,6 +24428,20 @@ "dev": true, "license": "MIT" }, + "packages/api/node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/base": { "name": "botframework-webchat-base", "version": "0.0.0-0", @@ -24500,6 +24514,7 @@ "swiper": "8.4.7", "url-search-params-polyfill": "8.2.5", "uuid": "8.3.2", + "valibot": "1.1.0", "web-speech-cognitive-services": "8.1.1", "whatwg-fetch": "3.6.20" }, @@ -24699,6 +24714,20 @@ "dev": true, "license": "MIT" }, + "packages/bundle/node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/component": { "name": "botframework-webchat-component", "version": "0.0.0-0", @@ -24730,7 +24759,7 @@ "use-propagate": "0.2.1", "use-ref-from": "0.1.0", "use-state-with-ref": "0.1.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "devDependencies": { "@babel/cli": "^7.18.10", @@ -24787,6 +24816,20 @@ "dev": true, "license": "MIT" }, + "packages/component/node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/core": { "name": "botframework-webchat-core", "version": "0.0.0-0", @@ -24800,7 +24843,7 @@ "redux": "5.0.1", "redux-saga": "1.3.0", "simple-update-in": "2.2.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "devDependencies": { "@babel/cli": "^7.18.10", @@ -24996,6 +25039,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/core/node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/directlinespeech": { "name": "botframework-directlinespeech-sdk", "version": "0.0.0-0", @@ -26198,7 +26255,7 @@ "inject-meta-tag": "0.0.1", "math-random": "2.0.1", "use-ref-from": "0.1.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "devDependencies": { "@tsconfig/strictest": "^2.0.5", @@ -26231,6 +26288,20 @@ "dev": true, "license": "MIT" }, + "packages/fluent-theme/node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/isomorphic-react": { "version": "0.0.0-0", "license": "MIT", diff --git a/packages/api/package.json b/packages/api/package.json index 2b30b169fe..14ab024f9c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -101,10 +101,6 @@ "react-redux": [ "7", "react-redux@>7 requires newer version of React" - ], - "valibot": [ - "0", - "valibot@0 until they finalize @1" ] }, "devDependencies": { @@ -139,7 +135,7 @@ "redux": "5.0.1", "simple-update-in": "2.2.0", "use-ref-from": "0.1.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "peerDependencies": { "react": ">= 16.8.6", diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 929a1b2355..eba1563eb3 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -221,7 +221,14 @@ type ComposerCoreProps = Readonly<{ ) => Promise; grammars?: any; groupActivitiesMiddleware?: OneOrMany; - internalErrorBoxClass?: ComponentType; + internalErrorBoxClass?: + | ComponentType< + Readonly<{ + error: Error; + type?: string; + }> + > + | undefined; locale?: string; onTelemetry?: (event: TelemetryMeasurementEvent) => void; overrideLocalizedStrings?: LocalizedStrings | ((strings: LocalizedStrings, language: string) => LocalizedStrings); @@ -675,7 +682,10 @@ ComposerCore.propTypes = { downscaleImageToDataURL: PropTypes.func, grammars: PropTypes.arrayOf(PropTypes.string), groupActivitiesMiddleware: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.func), PropTypes.func]), - internalErrorBoxClass: PropTypes.func, // This is for internal use only. We don't allow customization of error box. + // This is for internal use only. We don't allow customization of error box. + // - Functional component is of type PropTypes.func + // - Memoized functional component is of type PropTypes.object + internalErrorBoxClass: PropTypes.oneOfType([PropTypes.any, PropTypes.func]), locale: PropTypes.string, onTelemetry: PropTypes.func, overrideLocalizedStrings: PropTypes.oneOfType([PropTypes.any, PropTypes.func]), diff --git a/packages/api/src/hooks/internal/WebChatAPIContext.ts b/packages/api/src/hooks/internal/WebChatAPIContext.ts index 795b196c61..0396a90010 100644 --- a/packages/api/src/hooks/internal/WebChatAPIContext.ts +++ b/packages/api/src/hooks/internal/WebChatAPIContext.ts @@ -43,7 +43,14 @@ export type WebChatAPIContextType = { emitTypingIndicator?: () => void; grammars?: any; groupActivities?: GroupActivities; - internalErrorBoxClass?: ComponentType; + internalErrorBoxClass?: + | ComponentType< + Readonly<{ + error: Error; + type?: string; + }> + > + | undefined; language?: string; localizedGlobalizeState?: PrecompiledGlobalize[]; localizedStrings?: { [language: string]: LocalizedStrings }; diff --git a/packages/api/src/internal.ts b/packages/api/src/internal.ts index f26f23fe29..0323a2a7fb 100644 --- a/packages/api/src/internal.ts +++ b/packages/api/src/internal.ts @@ -1,4 +1,5 @@ import LowPriorityDecoratorComposer from './decorator/internal/LowPriorityDecoratorComposer'; import useSetDictateState from './hooks/internal/useSetDictateState'; +import validateProps from './utils/validateProps'; -export { LowPriorityDecoratorComposer, useSetDictateState }; +export { LowPriorityDecoratorComposer, useSetDictateState, validateProps }; diff --git a/packages/api/src/types/ComponentMiddleware.ts b/packages/api/src/types/ComponentMiddleware.ts index dfa79edfbe..9c4af7a7ed 100644 --- a/packages/api/src/types/ComponentMiddleware.ts +++ b/packages/api/src/types/ComponentMiddleware.ts @@ -30,7 +30,7 @@ type ComponentEnhancer = ( * The signature of the middleware is: * * ``` - * (...args: SetupArguments) => (next: Enhancer) => (...args: ComponentFactoryArguments) => false | React.FC + * (...args: SetupArguments) => (next: Enhancer) => (...args: ComponentFactoryArguments) => false | React.ComponentType * ``` */ type ComponentMiddleware = ( diff --git a/packages/api/src/utils/validateProps.spec.ts b/packages/api/src/utils/validateProps.spec.ts new file mode 100644 index 0000000000..21d21fc076 --- /dev/null +++ b/packages/api/src/utils/validateProps.spec.ts @@ -0,0 +1,74 @@ +import { number, object } from 'valibot'; +import validateProps from './validateProps'; + +beforeEach(() => + jest.spyOn(console, 'error').mockImplementation(() => { + // Intentionally left blank. + }) +); + +afterEach(() => jest.restoreAllMocks()); + +test('when isolation is not specified then success should return as-is', () => { + const props = { one: 1, two: 2 }; + const result = validateProps(object({ one: number() }), props); + + expect(result).toBe(props); +}); + +test('when no isolation then success should return as-is', () => { + const props = { one: 1, two: 2 }; + const result = validateProps(object({ one: number() }), props, 'no isolation'); + + expect(result).toBe(props); +}); + +test('when in strict isolation then success should return isolated result', () => { + const props = { one: 1, two: 2 }; + const result = validateProps(object({ one: number() }), props, 'strict'); + + expect(result).not.toBe(props); + expect(result).toEqual({ one: 1 }); +}); + +test('when isolation is not specified then failure should throw', () => { + const props = { two: 2 }; + + expect(() => validateProps(object({ one: number() }), props)).toThrow(); +}); + +test('when no isolation then failure should throw', () => { + const props = { two: 2 }; + + expect(() => validateProps(object({ one: number() }), props, 'no isolation')).toThrow(); +}); + +test('when in strict isolation then failure should throw', () => { + const props = { two: 2 }; + + expect(() => validateProps(object({ one: number() }), props, 'strict')).toThrow(); +}); + +test('when under production mode and isolation is not specified then failure should warn', () => { + process.env.NODE_ENV = 'production'; + + const props = { two: 2 }; + + expect(validateProps(object({ one: number() }), props, 'strict')).toBe(props); +}); + +test('when under production mode and no isolation then failure should warn', () => { + process.env.NODE_ENV = 'production'; + + const props = { two: 2 }; + + expect(validateProps(object({ one: number() }), props, 'strict')).toBe(props); +}); + +test('when under production mode and in strict isolation then failure should warn', () => { + process.env.NODE_ENV = 'production'; + + const props = { two: 2 }; + + expect(validateProps(object({ one: number() }), props, 'strict')).toBe(props); +}); diff --git a/packages/api/src/utils/validateProps.ts b/packages/api/src/utils/validateProps.ts new file mode 100644 index 0000000000..087418e29d --- /dev/null +++ b/packages/api/src/utils/validateProps.ts @@ -0,0 +1,56 @@ +import { parse, safeParse, type BaseIssue, type BaseSchema, type InferInput, type InferOutput } from 'valibot'; + +/** + * Specifies the props isolation mode. + * + * - `"no isolation"` will return the props as-is without cloning or modifications + * - `"strict"` will isolate the props using `valibot.parse()` + * - Depends on schema design, it could clone object instances, remove extraneous properties from objects, or fill in optional fields + */ +type IsolationMode = 'no isolation' | 'strict'; + +export default function validateProps>>( + propsSchema: TSchema, + props: unknown, + isolationMode?: 'no isolation' | undefined +): InferInput; + +export default function validateProps>>( + propsSchema: TSchema, + props: unknown, + isolationMode?: 'strict' +): InferOutput; + +/** + * Validates props against the specified valibot schema when running under development mode. + * + * This function will not perform any validations when running under production mode. + * + * @param propsSchema validation schema + * @param props props to validate + * @param mode specifies the isolation mode, default to `"no isolation"` + * @returns + */ +export default function validateProps>>( + propsSchema: TSchema, + props: InferInput, + isolationMode?: IsolationMode | undefined +): InferInput | InferOutput { + if (process.env.NODE_ENV === 'production') { + return props as unknown as InferInput; + } + + if (isolationMode !== 'strict' && safeParse(propsSchema, props).success) { + return props as unknown as InferInput; + } + + // Code path hit here when under strict isolation, or no isolation and failed earlier. + + try { + return parse(propsSchema, props); + } catch (error) { + console.error('botframework-webchat: Validation error while parsing props.', error.issues); + + throw error; + } +} diff --git a/packages/bundle/package.json b/packages/bundle/package.json index 72af2a8475..9dd553ea90 100644 --- a/packages/bundle/package.json +++ b/packages/bundle/package.json @@ -138,6 +138,7 @@ "swiper": "8.4.7", "url-search-params-polyfill": "8.2.5", "uuid": "8.3.2", + "valibot": "1.1.0", "web-speech-cognitive-services": "8.1.1", "whatwg-fetch": "3.6.20" }, diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardAttachment.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardAttachment.tsx index 1a4abb422a..4dacc42675 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardAttachment.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardAttachment.tsx @@ -1,29 +1,32 @@ -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; -import type { DirectLineAttachment } from 'botframework-webchat-core'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { any, boolean, object, optional, pipe, readonly, type InferInput } from 'valibot'; import AdaptiveCardContent from './AdaptiveCardContent'; -type AdaptiveCardAttachmentProps = { - attachment: DirectLineAttachment; - disabled?: boolean; -}; - -const AdaptiveCardAttachment: FC = ({ attachment: { content }, disabled }) => ( - +const adaptiveCardAttachmentPropsSchema = pipe( + object({ + attachment: pipe( + object({ + content: optional(any()) + }), + readonly() + ), + disabled: optional(boolean()) + }), + readonly() ); -export default AdaptiveCardAttachment; +type AdaptiveCardAttachmentProps = InferInput; + +function AdaptiveCardAttachment(props: AdaptiveCardAttachmentProps) { + const { + attachment: { content }, + disabled + } = validateProps(adaptiveCardAttachmentPropsSchema, props); -AdaptiveCardAttachment.defaultProps = { - disabled: undefined -}; + return ; +} -AdaptiveCardAttachment.propTypes = { - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - attachment: PropTypes.shape({ - content: PropTypes.any.isRequired - }).isRequired, - disabled: PropTypes.bool -}; +export default memo(AdaptiveCardAttachment); +export { adaptiveCardAttachmentPropsSchema, type AdaptiveCardAttachmentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts index 56013d869d..34246d4a51 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts @@ -111,7 +111,7 @@ export default class AdaptiveCardBuilder { } } - addButtons(cardActions: DirectLineCardAction[], includesOAuthButtons?: boolean) { + addButtons(cardActions: readonly Readonly[], includesOAuthButtons?: boolean) { cardActions && cardActions.forEach(cardAction => { this.card.addAction(addCardAction(cardAction, includesOAuthButtons)); @@ -136,7 +136,7 @@ export default class AdaptiveCardBuilder { this.addButtons(content.buttons); } - addImage(url: string, container?: Container, selectAction?: DirectLineCardAction, altText?: string) { + addImage(url: string, container?: Container, selectAction?: Readonly, altText?: string) { container = container || this.container; const image = new Image(); @@ -151,7 +151,7 @@ export default class AdaptiveCardBuilder { } export interface ICommonContent { - buttons?: DirectLineCardAction[]; + buttons?: readonly Readonly[]; subtitle?: string; text?: string; title?: string; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardContent.tsx index 8441149ce0..6b0c4aa7d5 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardContent.tsx @@ -1,8 +1,9 @@ -import PropTypes from 'prop-types'; -import React, { FC, useMemo } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo, useMemo } from 'react'; +import { any, boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import AdaptiveCardRenderer from './AdaptiveCardRenderer'; import useParseAdaptiveCardJSON from '../hooks/internal/useParseAdaptiveCardJSON'; +import AdaptiveCardRenderer from './AdaptiveCardRenderer'; function stripSubmitAction(card) { if (!card.actions) { @@ -17,13 +18,20 @@ function stripSubmitAction(card) { return { ...card, nextActions }; } -type AdaptiveCardContentProps = { - actionPerformedClassName?: string; - content: any; - disabled?: boolean; -}; +const adaptiveCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: optional(any()), + disabled: optional(boolean()) + }), + readonly() +); + +type AdaptiveCardContentProps = InferInput; + +function AdaptiveCardContent(props: AdaptiveCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(adaptiveCardContentPropsSchema, props); -const AdaptiveCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const parseAdaptiveCardJSON = useParseAdaptiveCardJSON(); const card = useMemo( @@ -47,17 +55,7 @@ const AdaptiveCardContent: FC = ({ actionPerformedClas /> ) ); -}; - -AdaptiveCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -AdaptiveCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - content: PropTypes.any.isRequired, - disabled: PropTypes.bool -}; +} -export default AdaptiveCardContent; +export default memo(AdaptiveCardContent); +export { adaptiveCardContentPropsSchema, type AdaptiveCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts index f9284a9b6c..6a662d04d4 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts @@ -2,10 +2,10 @@ import { useMemo, useRef } from 'react'; import addEventListenerWithUndo from '../../DOMManipulationWithUndo/addEventListenerWithUndo'; import bunchUndos from '../../DOMManipulationWithUndo/bunchUndos'; -import closest from './private/closest'; import durableAddClassWithUndo from '../../DOMManipulationWithUndo/durableAddClassWithUndo'; -import findDOMNodeOwner from './private/findDOMNodeOwner'; import setOrRemoveAttributeIfFalseWithUndo from '../../DOMManipulationWithUndo/setOrRemoveAttributeIfFalseWithUndo'; +import closest from './private/closest'; +import findDOMNodeOwner from './private/findDOMNodeOwner'; import useAdaptiveCardModEffect from './private/useAdaptiveCardModEffect'; import usePrevious from './private/usePrevious'; @@ -26,14 +26,14 @@ import type { UndoFunction } from '../../DOMManipulationWithUndo/types/UndoFunct */ export default function useActionShouldBePushButtonModEffect( adaptiveCard: AdaptiveCard -): readonly [(cardElement: HTMLElement, actionPerformedClassName?: string) => void, () => void] { +): readonly [(cardElement: HTMLElement, actionPerformedClassName?: string | undefined) => void, () => void] { const prevAdaptiveCard = usePrevious(adaptiveCard); const pushedCardObjectsRef = useRef>(new Set()); prevAdaptiveCard === adaptiveCard || pushedCardObjectsRef.current.clear(); const modder = useMemo( - () => (adaptiveCard: AdaptiveCard, cardElement: HTMLElement, actionPerformedClassName?: string) => { + () => (adaptiveCard: AdaptiveCard, cardElement: HTMLElement, actionPerformedClassName?: string | undefined) => { const undoStack: UndoFunction[] = []; Array.from(cardElement.querySelectorAll('button.ac-pushButton') as NodeListOf).forEach( diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx index ea94c1d459..b69f34eafd 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx @@ -1,19 +1,20 @@ /* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 2] }] */ -import { AdaptiveCard, Action as AdaptiveCardAction, OpenUrlAction, SubmitAction } from 'adaptivecards'; +import { type Action as AdaptiveCardAction, type OpenUrlAction, type SubmitAction } from 'adaptivecards'; +import { validateProps } from 'botframework-webchat-api/internal'; import { Components, getTabIndex, hooks } from 'botframework-webchat-component'; -import type { DirectLineCardAction } from 'botframework-webchat-core'; +import { type DirectLineCardAction } from 'botframework-webchat-core'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React, { - KeyboardEventHandler, - MouseEventHandler, - VFC, + memo, useCallback, useLayoutEffect, useMemo, - useRef + useRef, + type KeyboardEventHandler, + type MouseEventHandler } from 'react'; +import { any, boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../../hooks/useStyleSet'; import useAdaptiveCardsHostConfig from '../hooks/useAdaptiveCardsHostConfig'; @@ -25,6 +26,7 @@ import useActiveElementModEffect from './AdaptiveCardHacks/useActiveElementModEf import useDisabledModEffect from './AdaptiveCardHacks/useDisabledModEffect'; import usePersistValuesModEffect from './AdaptiveCardHacks/usePersistValuesModEffect'; import useRoleModEffect from './AdaptiveCardHacks/useRoleModEffect'; +import { directLineCardActionSchema } from './private/directLineSchema'; import renderAdaptiveCard from './private/renderAdaptiveCard'; const { ErrorBox } = Components; @@ -32,19 +34,26 @@ const { useLocalizer, usePerformCardAction, useRenderMarkdownAsHTML, useScrollTo const node_env = process.env.node_env || process.env.NODE_ENV; -type AdaptiveCardRendererProps = { - actionPerformedClassName?: string; - adaptiveCard: AdaptiveCard; - disabled?: boolean; - tapAction?: DirectLineCardAction; -}; - -const AdaptiveCardRenderer: VFC = ({ - actionPerformedClassName, - adaptiveCard, - disabled: disabledFromProps, - tapAction -}) => { +const adaptiveCardRendererPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + disabled: optional(boolean()), + adaptiveCard: any(), + tapAction: optional(directLineCardActionSchema) + }), + readonly() +); + +type AdaptiveCardRendererProps = InferInput; + +function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) { + const { + actionPerformedClassName, + adaptiveCard, + disabled: disabledFromProps, + tapAction + } = validateProps(adaptiveCardRendererPropsSchema, props); + const [{ adaptiveCardRenderer: adaptiveCardRendererStyleSet }] = useStyleSet(); const [{ GlobalSettings, HostConfig }] = useAdaptiveCardsPackage(); const [adaptiveCardsHostConfig] = useAdaptiveCardsHostConfig(); @@ -99,7 +108,7 @@ const AdaptiveCardRenderer: VFC = ({ } } - performCardAction(tapActionRef.current); + performCardAction(tapActionRef.current as DirectLineCardAction); scrollToEnd(); }, [contentRef, performCardAction, scrollToEnd, tapActionRef] @@ -247,27 +256,7 @@ const AdaptiveCardRenderer: VFC = ({ ref={contentRef} /> ); -}; - -AdaptiveCardRenderer.defaultProps = { - actionPerformedClassName: '', - disabled: undefined, - tapAction: undefined -}; - -AdaptiveCardRenderer.propTypes = { - actionPerformedClassName: PropTypes.string, - adaptiveCard: PropTypes.any.isRequired, - disabled: PropTypes.bool, - - // TypeScript class is not mappable to PropTypes.func - // @ts-ignore - tapAction: PropTypes.shape({ - image: PropTypes.string, - title: PropTypes.string, - type: PropTypes.string.isRequired, - value: PropTypes.string - }) -}; - -export default AdaptiveCardRenderer; +} + +export default memo(AdaptiveCardRenderer); +export { adaptiveCardRendererPropsSchema, type AdaptiveCardRendererProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx index 943d9300ce..9f16b09e72 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardContent.tsx @@ -1,22 +1,30 @@ /* eslint react/no-array-index-key: "off" */ +import { validateProps } from 'botframework-webchat-api/internal'; import { Components } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; -import type { DirectLineAnimationCard } from 'botframework-webchat-core'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import CommonCard from './CommonCard'; import useStyleSet from '../../hooks/useStyleSet'; +import CommonCard from './CommonCard'; +import { directLineMediaCardSchema } from './private/directLineSchema'; const { ImageContent, VideoContent } = Components; -type AnimationCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineAnimationCard; - disabled?: boolean; -}; +const animationCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineMediaCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type AnimationCardContentProps = InferInput; + +function AnimationCardContent(props: AnimationCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(animationCardContentPropsSchema, props); -const AnimationCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const { media = [] } = content; const [{ animationCardAttachment: animationCardAttachmentStyleSet }] = useStyleSet(); @@ -32,26 +40,7 @@ const AnimationCardContent: FC = ({ actionPerformedCl ); -}; - -AnimationCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -AnimationCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - media: PropTypes.arrayOf( - PropTypes.shape({ - profile: PropTypes.string, - url: PropTypes.string.isRequired - }) - ).isRequired - }).isRequired, - disabled: PropTypes.bool -}; - -export default AnimationCardContent; +} + +export default memo(AnimationCardContent); +export { animationCardContentPropsSchema, type AnimationCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx index a280290e4b..5392e982a4 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AudioCardContent.tsx @@ -1,22 +1,30 @@ /* eslint react/no-array-index-key: "off" */ +import { validateProps } from 'botframework-webchat-api/internal'; import { Components } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; -import type { DirectLineAudioCard } from 'botframework-webchat-core'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import CommonCard from './CommonCard'; import useStyleSet from '../../hooks/useStyleSet'; +import CommonCard from './CommonCard'; +import { directLineMediaCardSchema } from './private/directLineSchema'; const { AudioContent } = Components; -type AudioCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineAudioCard; - disabled?: boolean; -}; +const audioCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineMediaCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type AudioCardContentProps = InferInput; + +function AudioCardContent(props: AudioCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(audioCardContentPropsSchema, props); -const AudioCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const [{ audioCardAttachment: audioCardAttachmentStyleSet }] = useStyleSet(); const { autostart = false, autoloop = false, image: { url: imageURL = '' } = {}, media = [] } = content; @@ -32,30 +40,7 @@ const AudioCardContent: FC = ({ actionPerformedClassName, ); -}; - -AudioCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -AudioCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - autostart: PropTypes.bool, - autoloop: PropTypes.bool, - image: PropTypes.shape({ - url: PropTypes.string.isRequired - }), - media: PropTypes.arrayOf( - PropTypes.shape({ - url: PropTypes.string.isRequired - }).isRequired - ).isRequired - }).isRequired, - disabled: PropTypes.bool -}; - -export default AudioCardContent; +} + +export default memo(AudioCardContent); +export { audioCardContentPropsSchema, type AudioCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/HeroCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/HeroCardContent.tsx index 7cbfa47aca..9ce50a725d 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/HeroCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/HeroCardContent.tsx @@ -1,22 +1,31 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import { hooks } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC, useMemo } from 'react'; -import type { DirectLineHeroCard } from 'botframework-webchat-core'; +import { type DirectLineCardAction } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import useStyleOptions from '../../hooks/useStyleOptions'; +import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; import AdaptiveCardBuilder from './AdaptiveCardBuilder'; import AdaptiveCardRenderer from './AdaptiveCardRenderer'; -import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; -import useStyleOptions from '../../hooks/useStyleOptions'; +import { directLineBasicCardSchema } from './private/directLineSchema'; const { useDirection } = hooks; -type HeroCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineHeroCard; - disabled?: boolean; -}; +const heroCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineBasicCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type HeroCardContentProps = InferInput; + +function HeroCardContent(props: HeroCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(heroCardContentPropsSchema, props); -const HeroCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const [adaptiveCardsPackage] = useAdaptiveCardsPackage(); const [styleOptions] = useStyleOptions(); const [direction] = useDirection(); @@ -25,9 +34,13 @@ const HeroCardContent: FC = ({ actionPerformedClassName, c const builder = new AdaptiveCardBuilder(adaptiveCardsPackage, styleOptions, direction); if (content) { - (content.images || []).forEach(image => builder.addImage(image.url, null, image.tap, image.alt)); + // TODO: Need to build `directLineCardActionSchema`. + (content.images || []).forEach(image => + builder.addImage(image.url, null, image.tap as DirectLineCardAction, image.alt) + ); - builder.addCommon(content); + // TODO: Need to build `directLineCardActionSchema`. + builder.addCommon(content as typeof content & { buttons: readonly DirectLineCardAction[] }); return builder.card; } @@ -41,28 +54,7 @@ const HeroCardContent: FC = ({ actionPerformedClassName, c tapAction={content && content.tap} /> ); -}; - -HeroCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -HeroCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - images: PropTypes.arrayOf( - PropTypes.shape({ - alt: PropTypes.string.isRequired, - tap: PropTypes.any, - url: PropTypes.string.isRequired - }) - ), - tap: PropTypes.any - }).isRequired, - disabled: PropTypes.bool -}; +} -export default HeroCardContent; +export default memo(HeroCardContent); +export { heroCardContentPropsSchema, type HeroCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/OAuthCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/OAuthCardContent.tsx index 24b6b8fb18..ab2d0b74a5 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/OAuthCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/OAuthCardContent.tsx @@ -1,22 +1,30 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import { hooks } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC, useMemo } from 'react'; -import type { DirectLineOAuthCard } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import useStyleOptions from '../../hooks/useStyleOptions'; +import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; import AdaptiveCardBuilder from './AdaptiveCardBuilder'; import AdaptiveCardRenderer from './AdaptiveCardRenderer'; -import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; -import useStyleOptions from '../../hooks/useStyleOptions'; +import { directLineSignInCardSchema } from './private/directLineSchema'; const { useDirection } = hooks; -type OAuthCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineOAuthCard; - disabled?: boolean; -}; +const oauthCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineSignInCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type OAuthCardContentProps = InferInput; + +function OAuthCardContent(props: OAuthCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(oauthCardContentPropsSchema, props); -const OAuthCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const [adaptiveCardsPackage] = useAdaptiveCardsPackage(); const [direction] = useDirection(); const [styleOptions] = useStyleOptions(); @@ -25,8 +33,8 @@ const OAuthCardContent: FC = ({ actionPerformedClassName, if (content) { const builder = new AdaptiveCardBuilder(adaptiveCardsPackage, styleOptions, direction); - builder.addCommonHeaders(content); - builder.addButtons(content.buttons, true); + builder.addCommonHeaders(content as any); + builder.addButtons(content.buttons as any, true); return builder.card; } @@ -39,21 +47,7 @@ const OAuthCardContent: FC = ({ actionPerformedClassName, disabled={disabled} /> ); -}; - -OAuthCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -OAuthCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - buttons: PropTypes.array - }).isRequired, - disabled: PropTypes.bool -}; - -export default OAuthCardContent; +} + +export default memo(OAuthCardContent); +export { oauthCardContentPropsSchema, type OAuthCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardContent.tsx index efc3a3266d..17fb2ffab0 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/ReceiptCardContent.tsx @@ -1,14 +1,16 @@ /* eslint no-magic-numbers: ["error", { "ignore": [0, 1, 10, 15, 25, 50, 75] }] */ +import { validateProps } from 'botframework-webchat-api/internal'; import { hooks } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC, useMemo } from 'react'; -import type { DirectLineReceiptCard } from 'botframework-webchat-core'; +import { type DirectLineCardAction } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import useStyleOptions from '../../hooks/useStyleOptions'; +import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; import AdaptiveCardBuilder from './AdaptiveCardBuilder'; import AdaptiveCardRenderer from './AdaptiveCardRenderer'; -import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; -import useStyleOptions from '../../hooks/useStyleOptions'; +import { directLineReceiptCardSchema } from './private/directLineSchema'; const { useDirection, useLocalizer } = hooks; @@ -16,13 +18,20 @@ function nullOrUndefined(obj) { return obj === null || typeof obj === 'undefined'; } -type ReceiptCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineReceiptCard; - disabled?: boolean; -}; +const receiptCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineReceiptCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type ReceiptCardContentProps = InferInput; + +function ReceiptCardContent(props: ReceiptCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(receiptCardContentPropsSchema, props); -const ReceiptCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const [adaptiveCardsPackage] = useAdaptiveCardsPackage(); const [direction] = useDirection(); const [styleOptions] = useStyleOptions(); @@ -55,16 +64,16 @@ const ReceiptCardContent: FC = ({ actionPerformedClassN } items && - items.map(({ image: { alt, tap: imageTap, url } = {}, price, quantity, subtitle, tap, text, title }) => { + items.map(({ image, price, quantity, subtitle, tap, text, title }) => { let itemColumns; - if (url) { + if (image?.url) { const [itemImageColumn, ...columns] = builder.addColumnSet([15, 75, 10]); itemColumns = columns; - builder.addImage(url, itemImageColumn, imageTap, alt); + builder.addImage(image?.url, itemImageColumn, image?.tap as DirectLineCardAction, image?.alt); } else { - itemColumns = builder.addColumnSet([75, 25], undefined, tap && tap); + itemColumns = builder.addColumnSet([75, 25], undefined, tap && (tap as DirectLineCardAction)); } const [itemTitleColumn, itemPriceColumn] = itemColumns; @@ -121,47 +130,7 @@ const ReceiptCardContent: FC = ({ actionPerformedClassN tapAction={content && content.tap} /> ); -}; - -ReceiptCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -ReceiptCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - buttons: PropTypes.array, - facts: PropTypes.arrayOf( - PropTypes.shape({ - key: PropTypes.string, - value: PropTypes.string - }) - ), - items: PropTypes.arrayOf( - PropTypes.shape({ - image: PropTypes.shape({ - alt: PropTypes.string.isRequired, - tap: PropTypes.any, - url: PropTypes.string.isRequired - }), - price: PropTypes.string.isRequired, - quantity: PropTypes.string, - subtitle: PropTypes.string, - tap: PropTypes.any, - text: PropTypes.string, - title: PropTypes.string.isRequired - }) - ), - tap: PropTypes.any, - tax: PropTypes.string, - title: PropTypes.string, - total: PropTypes.string, - vat: PropTypes.string - }).isRequired, - disabled: PropTypes.bool -}; - -export default ReceiptCardContent; +} + +export default memo(ReceiptCardContent); +export { receiptCardContentPropsSchema, type ReceiptCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx index 637fd73981..6c7efc8ea6 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/SignInCardContent.tsx @@ -1,17 +1,25 @@ -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; -import type { DirectLineSignInCard } from 'botframework-webchat-core'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import CommonCard from './CommonCard'; import useStyleSet from '../../hooks/useStyleSet'; +import CommonCard from './CommonCard'; +import { directLineSignInCardSchema } from './private/directLineSchema'; + +const signInCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineSignInCardSchema, + disabled: optional(boolean()) + }), + readonly() +); -type SignInCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineSignInCard; - disabled?: boolean; -}; +type SignInCardContentProps = InferInput; + +function SignInCardContent(props: SignInCardContentProps) { + const { actionPerformedClassName, content, disabled } = validateProps(signInCardContentPropsSchema, props); -const SignInCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const [{ animationCardAttachment: animationCardAttachmentStyleSet }] = useStyleSet(); return ( @@ -19,17 +27,7 @@ const SignInCardContent: FC = ({ actionPerformedClassNam ); -}; - -SignInCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -SignInCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - content: PropTypes.any.isRequired, - disabled: PropTypes.bool -}; +} -export default SignInCardContent; +export default memo(SignInCardContent); +export { signInCardContentPropsSchema, type SignInCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/ThumbnailCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/ThumbnailCardContent.tsx index 085de2d2b2..72fe9cb8d9 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/ThumbnailCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/ThumbnailCardContent.tsx @@ -1,24 +1,31 @@ /* eslint no-magic-numbers: ["error", { "ignore": [25, 75] }] */ import { hooks } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC, useMemo } from 'react'; -import type { DirectLineThumbnailCard } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { boolean, object, optional, parse, pipe, readonly, string, type InferInput } from 'valibot'; +import useStyleOptions from '../../hooks/useStyleOptions'; +import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; import AdaptiveCardBuilder from './AdaptiveCardBuilder'; import AdaptiveCardRenderer from './AdaptiveCardRenderer'; -import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; -import useStyleOptions from '../../hooks/useStyleOptions'; +import { directLineBasicCardSchema } from './private/directLineSchema'; const { useDirection } = hooks; -type ThumbnailCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineThumbnailCard; - disabled?: boolean; -}; +const thumbnailCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineBasicCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type ThumbnailCardContentProps = InferInput; + +function ThumbnailCardContent(props: ThumbnailCardContentProps) { + const { actionPerformedClassName, content, disabled } = parse(thumbnailCardContentPropsSchema, props); -const ThumbnailCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const [adaptiveCardsPackage] = useAdaptiveCardsPackage(); const [direction] = useDirection(); const [styleOptions] = useStyleOptions(); @@ -41,11 +48,11 @@ const ThumbnailCardContent: FC = ({ actionPerformedCl ); builder.addTextBlock(subtitle, { isSubtle: true, wrap: richCardWrapTitle }, firstColumn); - builder.addImage(url, lastColumn, tap, alt); + builder.addImage(url, lastColumn, tap as any, alt); builder.addTextBlock(text, { wrap: true }); - builder.addButtons(buttons); + builder.addButtons(buttons as any); } else { - builder.addCommon(content); + builder.addCommon(content as any); } return builder.card; } @@ -59,32 +66,7 @@ const ThumbnailCardContent: FC = ({ actionPerformedCl tapAction={content && content.tap} /> ); -}; - -ThumbnailCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -ThumbnailCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - buttons: PropTypes.array, - images: PropTypes.arrayOf( - PropTypes.shape({ - alt: PropTypes.string.isRequired, - tap: PropTypes.any, - url: PropTypes.string.isRequired - }) - ), - subtitle: PropTypes.string, - tap: PropTypes.any, - text: PropTypes.string, - title: PropTypes.string - }).isRequired, - disabled: PropTypes.bool -}; +} -export default ThumbnailCardContent; +export default memo(ThumbnailCardContent); +export { thumbnailCardContentPropsSchema, type ThumbnailCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx b/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx index d2e24cfbe6..27e71ccb4e 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/VideoCardContent.tsx @@ -1,27 +1,29 @@ /* eslint react/no-array-index-key: "off" */ import { Components } from 'botframework-webchat-component'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; -import type { DirectLineVideoCard } from 'botframework-webchat-core'; +import React, { memo } from 'react'; +import { boolean, object, optional, parse, pipe, readonly, string, type InferInput } from 'valibot'; -import CommonCard from './CommonCard'; import useStyleSet from '../../hooks/useStyleSet'; +import CommonCard from './CommonCard'; +import { directLineMediaCardSchema } from './private/directLineSchema'; const { VideoContent } = Components; -type VideoCardContentProps = { - actionPerformedClassName?: string; - content: DirectLineVideoCard & { - autoloop?: boolean; - autostart?: boolean; - image?: { url?: string }; - media?: { profile?: string; url?: string }[]; - }; - disabled?: boolean; -}; +const videoCardContentPropsSchema = pipe( + object({ + actionPerformedClassName: optional(string()), + content: directLineMediaCardSchema, + disabled: optional(boolean()) + }), + readonly() +); + +type VideoCardContentProps = InferInput; + +function VideoCardContent(props: VideoCardContentProps) { + const { actionPerformedClassName, content, disabled } = parse(videoCardContentPropsSchema, props); -const VideoCardContent: FC = ({ actionPerformedClassName, content, disabled }) => { const { autoloop, autostart, image: { url: imageURL } = { url: undefined }, media } = content; const [{ audioCardAttachment: audioCardAttachmentStyleSet }] = useStyleSet(); @@ -37,31 +39,7 @@ const VideoCardContent: FC = ({ actionPerformedClassName, ); -}; - -VideoCardContent.defaultProps = { - actionPerformedClassName: '', - disabled: undefined -}; - -VideoCardContent.propTypes = { - actionPerformedClassName: PropTypes.string, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - content: PropTypes.shape({ - autoloop: PropTypes.bool, - autostart: PropTypes.bool, - image: PropTypes.shape({ - url: PropTypes.string.isRequired - }), - media: PropTypes.arrayOf( - PropTypes.shape({ - profile: PropTypes.string, - url: PropTypes.string.isRequired - }) - ).isRequired - }).isRequired, - disabled: PropTypes.bool -}; +} -export default VideoCardContent; +export default memo(VideoCardContent); +export { videoCardContentPropsSchema, type VideoCardContentProps }; diff --git a/packages/bundle/src/adaptiveCards/Attachment/private/directLineSchema.ts b/packages/bundle/src/adaptiveCards/Attachment/private/directLineSchema.ts new file mode 100644 index 0000000000..df373468cb --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/private/directLineSchema.ts @@ -0,0 +1,151 @@ +import { + any, + array, + boolean, + literal, + looseObject, + number, + object, + optional, + pipe, + readonly, + string, + union +} from 'valibot'; + +// TODO: Should build a better `directLineCardActionSchema`. +const directLineCardActionSchema = pipe( + looseObject({ + image: optional(string()), + title: optional(string()), + type: string(), + value: optional(any()) + }), + readonly() +); + +// https://github.com/microsoft/botframework-sdk/blob/master/specs/botframework-activity/botframework-cards.md#media-cards +const directLineMediaCardSchema = pipe( + object({ + aspect: optional(union([literal('4:3'), literal('16:9')])), + autoloop: optional(boolean()), + autostart: optional(boolean()), + buttons: optional(pipe(array(directLineCardActionSchema), readonly())), + duration: optional(string()), + // In the spec, "image" is of type "thumbnailUrl", which is simply a string. + image: optional( + pipe( + object({ + url: string() + }), + readonly() + ) + ), + media: pipe(array(pipe(object({ profile: optional(string()), url: string() }), readonly())), readonly()), + shareable: optional(boolean()), + subtitle: optional(string()), + text: optional(string()), // TODO: Media cards should not have `text` field. + title: optional(string()), + value: optional(any()) + }), + readonly() +); + +// https://github.com/microsoft/botframework-sdk/blob/master/specs/botframework-activity/botframework-cards.md#basic-cards +const directLineBasicCardSchema = pipe( + object({ + buttons: optional(pipe(array(directLineCardActionSchema), readonly())), + images: optional( + pipe( + array( + pipe( + object({ + alt: optional(string()), + tap: optional(directLineCardActionSchema), + url: optional(string()) + }), + readonly() + ) + ), + readonly() + ) + ), + subtitle: optional(string()), + tap: optional(directLineCardActionSchema), + text: optional(string()), + title: optional(string()) + }), + readonly() +); + +// https://github.com/microsoft/botframework-sdk/blob/master/specs/botframework-activity/botframework-cards.md#receipt-card +const directLineReceiptCardSchema = pipe( + object({ + buttons: optional(pipe(array(any()), readonly())), + facts: optional( + pipe( + array( + pipe( + object({ + key: optional(string()), + value: optional(string()) + }), + readonly() + ) + ), + readonly() + ) + ), + items: optional( + pipe( + array( + pipe( + object({ + image: optional( + pipe( + object({ + alt: string(), + tap: optional(directLineCardActionSchema), + url: string() + }), + readonly() + ) + ), + price: string(), + quantity: optional(union([number(), string()])), // TODO: Should be string only. + subtitle: optional(string()), + tap: optional(directLineCardActionSchema), + text: optional(string()), + title: string() + }), + readonly() + ) + ), + readonly() + ) + ), + tap: optional(directLineCardActionSchema), + tax: optional(string()), + title: optional(string()), + total: optional(string()), + vat: optional(string()) + }), + readonly() +); + +// https://github.com/microsoft/botframework-sdk/blob/master/specs/botframework-activity/botframework-cards.md#Signin-card +const directLineSignInCardSchema = pipe( + object({ + buttons: pipe(array(directLineCardActionSchema), readonly()), + text: optional(string()) + }), + readonly() +); + +export { + directLineBasicCardSchema, + directLineCardActionSchema, + directLineMediaCardSchema, + directLineReceiptCardSchema, + directLineSignInCardSchema +}; diff --git a/packages/component/package.json b/packages/component/package.json index 4e52a6fcbe..31b00b80ad 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -116,10 +116,6 @@ ], "react-scroll-to-bottom": [ "main" - ], - "valibot": [ - "0", - "valibot@0 until they finalize @1" ] }, "devDependencies": { @@ -169,7 +165,7 @@ "use-propagate": "0.2.1", "use-ref-from": "0.1.0", "use-state-with-ref": "0.1.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "peerDependencies": { "react": ">= 16.8.6", diff --git a/packages/component/src/Activity/Avatar.tsx b/packages/component/src/Activity/Avatar.tsx index e58673f589..a422c97581 100644 --- a/packages/component/src/Activity/Avatar.tsx +++ b/packages/component/src/Activity/Avatar.tsx @@ -1,33 +1,30 @@ -import PropTypes from 'prop-types'; -import React, { VFC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import { DefaultAvatar } from '../Middleware/Avatar/createCoreMiddleware'; -type AvatarProps = { - 'aria-hidden'?: boolean; - className?: string; - fromUser?: boolean; -}; +const avatarPropsSchema = pipe( + object({ + 'aria-hidden': optional(boolean()), + className: optional(string()), + fromUser: optional(boolean()) + }), + readonly() +); + +type AvatarProps = InferInput; /** @deprecated Please use `useRenderAvatar` hook instead. */ -const Avatar: VFC = ({ 'aria-hidden': ariaHidden, className, fromUser }) => { +function Avatar(props: AvatarProps) { + const { 'aria-hidden': ariaHidden = false, className, fromUser } = validateProps(avatarPropsSchema, props); + console.warn( 'botframework-webchat: component is deprecated and will be removed on or after 2022-02-25. Please use `useRenderAvatar` hook instead.' ); return ; -}; - -Avatar.defaultProps = { - 'aria-hidden': false, - className: '', - fromUser: false -}; - -Avatar.propTypes = { - 'aria-hidden': PropTypes.bool, - className: PropTypes.string, - fromUser: PropTypes.bool -}; +} -export default Avatar; +export default memo(Avatar); +export { avatarPropsSchema, type AvatarProps }; diff --git a/packages/component/src/Activity/Bubble.tsx b/packages/component/src/Activity/Bubble.tsx index df6db61196..1abfc60ee1 100644 --- a/packages/component/src/Activity/Bubble.tsx +++ b/packages/component/src/Activity/Bubble.tsx @@ -1,13 +1,15 @@ /* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 1, 2, 10] }] */ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC, ReactNode, memo } from 'react'; +import React, { memo } from 'react'; +import { boolean, literal, object, optional, pipe, readonly, string, union, type InferInput } from 'valibot'; -import isZeroOrPositive from '../Utils/isZeroOrPositive'; -import useStyleSet from '../hooks/useStyleSet'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useStyleSet from '../hooks/useStyleSet'; +import reactNode from '../types/internal/reactNode'; +import isZeroOrPositive from '../Utils/isZeroOrPositive'; const { useDirection, useStyleOptions } = hooks; @@ -62,15 +64,28 @@ function acuteNubSVG(nubSize, strokeWidth, side, upSideDown = false) { ); } -type BubbleProps = { - 'aria-hidden'?: boolean; - children?: ReactNode; - className?: string; - fromUser?: boolean; - nub?: boolean | 'hidden'; -}; +const bubblePropsSchema = pipe( + object({ + 'aria-hidden': optional(boolean()), + children: optional(reactNode()), + className: optional(string()), + fromUser: optional(boolean()), + nub: optional(union([boolean(), literal('hidden')])) + }), + readonly() +); + +type BubbleProps = InferInput; + +function Bubble(props: BubbleProps) { + const { + 'aria-hidden': ariaHidden, + children, + className, + fromUser, + nub = false + } = validateProps(bubblePropsSchema, props); -const Bubble: FC = ({ 'aria-hidden': ariaHidden, children, className, fromUser, nub }) => { const [{ bubble: bubbleStyleSet }] = useStyleSet(); const [direction] = useDirection(); const [ @@ -113,7 +128,7 @@ const Bubble: FC = ({ 'aria-hidden': ariaHidden, children, classNam }, rootClassName, bubbleStyleSet + '', - (className || '') + '' + className )} >
@@ -121,24 +136,7 @@ const Bubble: FC = ({ 'aria-hidden': ariaHidden, children, classNam {nub === true && acuteNubSVG(nubSize, borderWidth, side, !isZeroOrPositive(nubOffset))}
); -}; - -Bubble.defaultProps = { - 'aria-hidden': undefined, - children: undefined, - className: '', - fromUser: false, - nub: false -}; - -Bubble.propTypes = { - 'aria-hidden': PropTypes.bool, - children: PropTypes.any, - className: PropTypes.string, - fromUser: PropTypes.bool, - nub: PropTypes.oneOf([true, false, 'hidden']) -}; - -Bubble.displayName = 'Bubble'; +} export default memo(Bubble); +export { bubblePropsSchema, type BubbleProps }; diff --git a/packages/component/src/Activity/SayAlt.js b/packages/component/src/Activity/SayAlt.js deleted file mode 100644 index af2357797f..0000000000 --- a/packages/component/src/Activity/SayAlt.js +++ /dev/null @@ -1,26 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; - -// TODO: [P3] Although this is for development purpose, prettify it -const ROOT_STYLE = { - color: 'Red', - margin: 0 -}; - -const SayAlt = ({ speak }) => { - const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; - - return !!speak &&
{speak}
; -}; - -SayAlt.defaultProps = { - speak: '' -}; - -SayAlt.propTypes = { - speak: PropTypes.string -}; - -export default SayAlt; diff --git a/packages/component/src/Activity/SayAlt.tsx b/packages/component/src/Activity/SayAlt.tsx new file mode 100644 index 0000000000..c944cb892a --- /dev/null +++ b/packages/component/src/Activity/SayAlt.tsx @@ -0,0 +1,30 @@ +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { literal, object, pipe, readonly, string, union, type InferInput } from 'valibot'; + +import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; + +// TODO: [P3] Although this is for development purpose, prettify it +const ROOT_STYLE = { + color: 'Red', + margin: 0 +}; + +const sayAltPropsSchema = pipe( + object({ + speak: union([literal(false), string()]) + }), + readonly() +); + +type SayAltProps = InferInput; + +const SayAlt = (props: SayAltProps) => { + const { speak } = validateProps(sayAltPropsSchema, props); + const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; + + return !!speak &&
{speak}
; +}; + +export default memo(SayAlt); +export { sayAltPropsSchema, type SayAltProps }; diff --git a/packages/component/src/Activity/Speak.tsx b/packages/component/src/Activity/Speak.tsx index e28d6fad40..ca5386ee31 100644 --- a/packages/component/src/Activity/Speak.tsx +++ b/packages/component/src/Activity/Speak.tsx @@ -1,8 +1,10 @@ import { hooks } from 'botframework-webchat-api'; -import type { WebChatActivity } from 'botframework-webchat-core'; -import PropTypes from 'prop-types'; -import React, { FC, memo, useCallback, useMemo } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useCallback, useMemo } from 'react'; import ReactSay, { SayUtterance } from 'react-say'; +import { useRefFrom } from 'use-ref-from'; +import { any, array, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import SayAlt from './SayAlt'; @@ -13,18 +15,62 @@ const { useMarkActivityAsSpoken, useStyleOptions, useVoiceSelector } = hooks; // TODO: [P4] Consider moving this feature into BasicActivity // And it has better DOM position for showing visual spoken text -type SpeakProps = { - activity: WebChatActivity; -}; +const speakableActivitySchema = pipe( + object({ + attachments: optional( + pipe( + array( + pipe( + object({ + content: optional(any()), + contentType: string(), + speak: optional(string()), + subtitle: optional(string()), + text: optional(string()), + title: optional(string()) + }), + readonly() + ) + ), + readonly() + ) + ), + channelData: optional( + pipe( + object({ + speechSynthesisUtterance: optional(any()) + }), + readonly() + ) + ), + speak: optional(string()), + text: optional(string()), + type: string() + }), + readonly() +); + +const speakPropsSchema = pipe( + object({ + activity: speakableActivitySchema + }), + readonly() +); + +type SpeakProps = InferInput; + +function Speak(props: SpeakProps) { + const { activity } = validateProps(speakPropsSchema, props); -const Speak: FC = ({ activity }) => { const [{ showSpokenText }] = useStyleOptions(); + const activityRef = useRefFrom(activity); const markActivityAsSpoken = useMarkActivityAsSpoken(); const selectVoice = useVoiceSelector(activity); - const markAsSpoken = useCallback(() => { - markActivityAsSpoken(activity); - }, [activity, markActivityAsSpoken]); + const markAsSpoken = useCallback( + () => markActivityAsSpoken(activityRef.current as WebChatActivity), + [activityRef, markActivityAsSpoken] + ); const singleLine: false | string = useMemo(() => { if (activity.type !== 'message') { @@ -36,7 +82,14 @@ const Speak: FC = ({ activity }) => { return [ speak || text, ...attachments - .filter(({ contentType }) => contentType === 'application/vnd.microsoft.card.adaptive') + .filter( + ( + attachment + ): attachment is { + content: { speak?: string }; + contentType: 'application/vnd.microsoft.card.adaptive'; + } => attachment.contentType === 'application/vnd.microsoft.card.adaptive' && attachment.content + ) .map(attachment => attachment?.content?.speak) ] .filter(line => line) @@ -58,29 +111,7 @@ const Speak: FC = ({ activity }) => { ) ); -}; - -Speak.propTypes = { - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - activity: PropTypes.shape({ - attachments: PropTypes.arrayOf( - PropTypes.shape({ - speak: PropTypes.string, - subtitle: PropTypes.string, - text: PropTypes.string, - title: PropTypes.string - }) - ), - channelData: PropTypes.shape({ - speechSynthesisUtterance: PropTypes.any - }), - speak: PropTypes.string, - text: PropTypes.string, - type: PropTypes.string.isRequired - }).isRequired -}; - -Speak.displayName = 'SpeakActivity'; +} export default memo(Speak); +export { speakPropsSchema, type SpeakProps }; diff --git a/packages/component/src/ActivityStatus/SendStatus/SendStatus.tsx b/packages/component/src/ActivityStatus/SendStatus/SendStatus.tsx index d99e8ec64b..0dec092c9d 100644 --- a/packages/component/src/ActivityStatus/SendStatus/SendStatus.tsx +++ b/packages/component/src/ActivityStatus/SendStatus/SendStatus.tsx @@ -1,24 +1,29 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC, useCallback } from 'react'; -import type { WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useCallback } from 'react'; +import { any, literal, object, pipe, readonly, union, type InferInput } from 'valibot'; -import { SENDING, SEND_FAILED, SENT } from '../../types/internal/SendStatus'; -import SendFailedRetry from './private/SendFailedRetry'; import useFocus from '../../hooks/useFocus'; import useStyleSet from '../../hooks/useStyleSet'; - -import type { SendStatus as SendStatusType } from '../../types/internal/SendStatus'; +import { SENDING, SEND_FAILED, SENT } from '../../types/internal/SendStatus'; +import SendFailedRetry from './private/SendFailedRetry'; const { useLocalizer, usePostActivity } = hooks; -type SendStatusProps = { - activity: WebChatActivity; - sendStatus: SendStatusType; -}; +const sendStatusPropsSchema = pipe( + object({ + activity: any(), + sendStatus: union([literal(SENDING), literal(SEND_FAILED), literal(SENT)]) + }), + readonly() +); + +type SendStatusProps = InferInput; + +function SendStatus(props: SendStatusProps) { + const { activity, sendStatus } = validateProps(sendStatusPropsSchema, props); -const SendStatus: FC = ({ activity, sendStatus }) => { const [{ sendStatus: sendStatusStyleSet }] = useStyleSet(); const focus = useFocus(); const localize = useLocalizer(); @@ -46,13 +51,7 @@ const SendStatus: FC = ({ activity, sendStatus }) => { ); -}; - -SendStatus.propTypes = { - activity: PropTypes.any.isRequired, - // PropTypes cannot fully capture TypeScript types. - // @ts-ignore - sendStatus: PropTypes.oneOf([SENDING, SEND_FAILED, SENT]).isRequired -}; +} -export default SendStatus; +export default memo(SendStatus); +export { sendStatusPropsSchema, type SendStatusProps }; diff --git a/packages/component/src/ActivityStatus/Timestamp.tsx b/packages/component/src/ActivityStatus/Timestamp.tsx index e811618976..82c1888ce0 100644 --- a/packages/component/src/ActivityStatus/Timestamp.tsx +++ b/packages/component/src/ActivityStatus/Timestamp.tsx @@ -1,24 +1,29 @@ import { hooks } from 'botframework-webchat-api'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { date, number, object, pipe, readonly, string, union, type InferInput } from 'valibot'; import AbsoluteTime from './AbsoluteTime'; import RelativeTime from './private/RelativeTime'; const { useStyleOptions } = hooks; -type TimestampProps = { - timestamp: string; -}; +const timestampPropsSchema = pipe( + object({ + timestamp: union([date(), number(), string()]) // TODO: Should limit to `Date`. + }), + readonly() +); + +type TimestampProps = InferInput; + +function Timestamp(props: TimestampProps) { + const { timestamp } = validateProps(timestampPropsSchema, props); -const Timestamp: FC = ({ timestamp }) => { const [{ timestampFormat }] = useStyleOptions(); return timestampFormat === 'relative' ? : ; -}; - -Timestamp.propTypes = { - timestamp: PropTypes.string.isRequired -}; +} -export default Timestamp; +export default memo(Timestamp); +export { timestampPropsSchema, type TimestampProps }; diff --git a/packages/component/src/Attachment/Assets/DownloadIcon.js b/packages/component/src/Attachment/Assets/DownloadIcon.js deleted file mode 100644 index ec369dd5af..0000000000 --- a/packages/component/src/Attachment/Assets/DownloadIcon.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -const ICON_SIZE_FACTOR = 22; - -const DownloadIcon = ({ className, size }) => ( - - - -); - -DownloadIcon.defaultProps = { - className: '', - size: 1 -}; - -DownloadIcon.propTypes = { - className: PropTypes.string, - size: PropTypes.number -}; - -export default DownloadIcon; diff --git a/packages/component/src/Attachment/Assets/DownloadIcon.tsx b/packages/component/src/Attachment/Assets/DownloadIcon.tsx new file mode 100644 index 0000000000..4bfa4b3185 --- /dev/null +++ b/packages/component/src/Attachment/Assets/DownloadIcon.tsx @@ -0,0 +1,35 @@ +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { number, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; + +const ICON_SIZE_FACTOR = 22; + +const downloadIconPropsSchema = pipe( + object({ + className: optional(string()), + size: optional(number(), 1) + }), + readonly() +); + +type DownloadIconProps = InferInput; + +const DownloadIcon = (props: DownloadIconProps) => { + const { className, size } = validateProps(downloadIconPropsSchema, props, 'strict'); + + return ( + + + + ); +}; + +export default memo(DownloadIcon); +export { downloadIconPropsSchema, type DownloadIconProps }; diff --git a/packages/component/src/Attachment/AudioContent.tsx b/packages/component/src/Attachment/AudioContent.tsx index 05c92a6450..71b7c1b1d3 100644 --- a/packages/component/src/Attachment/AudioContent.tsx +++ b/packages/component/src/Attachment/AudioContent.tsx @@ -1,17 +1,24 @@ -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../hooks/useStyleSet'; -type AudioContentProps = { - alt?: string; - autoPlay?: boolean; - loop?: boolean; - poster?: string; - src: string; -}; +const audioContentPropsSchema = pipe( + object({ + alt: optional(string()), + autoPlay: optional(boolean()), + loop: optional(boolean()), + poster: optional(string()), + src: string() + }), + readonly() +); -const AudioContent: FC = ({ alt, autoPlay, loop, src }) => { +type AudioContentProps = InferInput; + +function AudioContent(props: AudioContentProps) { + const { alt, autoPlay = false, loop = false, src } = validateProps(audioContentPropsSchema, props); const [{ audioContent: audioContentStyleSet }] = useStyleSet(); return ( @@ -24,23 +31,7 @@ const AudioContent: FC = ({ alt, autoPlay, loop, src }) => { src={src} /> ); -}; - -AudioContent.defaultProps = { - alt: '', - autoPlay: false, - loop: false, - poster: '' -}; - -AudioContent.propTypes = { - alt: PropTypes.string, - autoPlay: PropTypes.bool, - loop: PropTypes.bool, - // We will keep the "poster" prop for #3315. - // eslint-disable-next-line react/no-unused-prop-types - poster: PropTypes.string, - src: PropTypes.string.isRequired -}; +} -export default AudioContent; +export default memo(AudioContent); +export { audioContentPropsSchema, type AudioContentProps }; diff --git a/packages/component/src/Attachment/FileContent.tsx b/packages/component/src/Attachment/FileContent.tsx index 288a14509d..8d642486b6 100644 --- a/packages/component/src/Attachment/FileContent.tsx +++ b/packages/component/src/Attachment/FileContent.tsx @@ -1,11 +1,12 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import React, { memo } from 'react'; +import { boolean, number, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import DownloadIcon from './Assets/DownloadIcon'; -import useStyleSet from '../hooks/useStyleSet'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useStyleSet from '../hooks/useStyleSet'; +import DownloadIcon from './Assets/DownloadIcon'; const { useByteFormatter, useDirection, useLocalizer } = hooks; @@ -34,7 +35,20 @@ function isAllowedProtocol(url) { } } -const FileContentBadge = ({ downloadIcon, fileName, size }) => { +const fileContentBadgePropsSchema = pipe( + object({ + downloadIcon: optional(boolean()), + fileName: string(), + size: optional(number()) + }), + readonly() +); + +type FileContentBadgeProps = InferInput; + +const FileContentBadge = (props: FileContentBadgeProps) => { + const { downloadIcon = false, fileName, size } = validateProps(fileContentBadgePropsSchema, props); + const [direction] = useDirection(); const formatByte = useByteFormatter(); @@ -59,25 +73,21 @@ const FileContentBadge = ({ downloadIcon, fileName, size }) => { ); }; -FileContentBadge.defaultProps = { - downloadIcon: false, - size: undefined -}; +type FileContentProps = InferInput; -FileContentBadge.propTypes = { - downloadIcon: PropTypes.bool, - fileName: PropTypes.string.isRequired, - size: PropTypes.number -}; +const fileContentPropsSchema = pipe( + object({ + className: optional(string()), + fileName: string(), + href: optional(string()), + size: optional(number()) + }), + readonly() +); -type FileContentProps = { - className?: string; - fileName: string; - href?: string; - size?: number; -}; +function FileContent(props: FileContentProps) { + const { className, href, fileName, size } = validateProps(fileContentPropsSchema, props); -const FileContent: FC = ({ className, href, fileName, size }) => { const [{ fileContent: fileContentStyleSet }] = useStyleSet(); const localize = useLocalizer(); const localizeBytes = useByteFormatter(); @@ -85,10 +95,10 @@ const FileContent: FC = ({ className, href, fileName, size }) const localizedSize = typeof size === 'number' && localizeBytes(size); - href = href && isAllowedProtocol(href) ? href : undefined; + const allowedHref = href && isAllowedProtocol(href) ? href : undefined; const alt = localize( - href + allowedHref ? localizedSize ? 'FILE_CONTENT_DOWNLOADABLE_WITH_SIZE_ALT' : 'FILE_CONTENT_DOWNLOADABLE_ALT' @@ -100,15 +110,13 @@ const FileContent: FC = ({ className, href, fileName, size }) ); return ( -
- {href ? ( + ); -}; - -FileContent.defaultProps = { - className: '', - href: undefined, - size: undefined -}; - -FileContent.propTypes = { - className: PropTypes.string, - fileName: PropTypes.string.isRequired, - href: PropTypes.string, - size: PropTypes.number -}; +} -export default FileContent; +export default memo(FileContent); +export { fileContentPropsSchema, type FileContentProps }; diff --git a/packages/component/src/Attachment/HTMLVideoContent.tsx b/packages/component/src/Attachment/HTMLVideoContent.tsx index dcd7e8aabc..38f945cf3d 100644 --- a/packages/component/src/Attachment/HTMLVideoContent.tsx +++ b/packages/component/src/Attachment/HTMLVideoContent.tsx @@ -1,17 +1,25 @@ -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../hooks/useStyleSet'; -type HTMLVideoContentProps = { - alt?: string; - autoPlay?: boolean; - loop?: boolean; - poster?: string; - src: string; -}; +const htmlVideoContentPropsSchema = pipe( + object({ + alt: optional(string()), + autoPlay: optional(boolean()), + loop: optional(boolean()), + poster: optional(string()), + src: string() + }), + readonly() +); + +type HTMLVideoContentProps = InferInput; + +function HTMLVideoContent(props: HTMLVideoContentProps) { + const { alt, autoPlay = false, loop = false, poster, src } = validateProps(htmlVideoContentPropsSchema, props); -const HTMLVideoContent: FC = ({ alt, autoPlay, loop, poster, src }) => { const [{ videoContent: videoContentStyleSet }] = useStyleSet(); return ( @@ -25,21 +33,7 @@ const HTMLVideoContent: FC = ({ alt, autoPlay, loop, post src={src} /> ); -}; - -HTMLVideoContent.defaultProps = { - alt: '', - autoPlay: false, - loop: false, - poster: '' -}; - -HTMLVideoContent.propTypes = { - alt: PropTypes.string, - autoPlay: PropTypes.bool, - loop: PropTypes.bool, - poster: PropTypes.string, - src: PropTypes.string.isRequired -}; +} -export default HTMLVideoContent; +export default memo(HTMLVideoContent); +export { htmlVideoContentPropsSchema, type HTMLVideoContentProps }; diff --git a/packages/component/src/Attachment/Text/TextAttachment.tsx b/packages/component/src/Attachment/Text/TextAttachment.tsx index 5788029d45..f78e4dd52d 100644 --- a/packages/component/src/Attachment/Text/TextAttachment.tsx +++ b/packages/component/src/Attachment/Text/TextAttachment.tsx @@ -1,20 +1,44 @@ -import { type WebChatActivity } from 'botframework-webchat-core'; -import React, { memo, type FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { any, custom, object, optional, pipe, readonly, safeParse, startsWith, string, type InferInput } from 'valibot'; -import { type WebChatAttachment } from '../private/types/WebChatAttachment'; import TextContent from './TextContent'; -type Props = Readonly<{ - activity: WebChatActivity; - attachment: WebChatAttachment & { - contentType: `text/${string}`; - }; -}>; +const directLineAttachmentSchema = pipe( + object({ + content: optional(string()), + contentType: string(), + contentUrl: optional(string()), + name: optional(string()), + thumbnailUrl: optional(string()) + }), + readonly() +); -const TextAttachment: FC = memo(({ activity, attachment: { content, contentType } }: Props) => ( - -)); +const textAttachmentPropsSchema = pipe( + object({ + activity: any(), + attachment: pipe( + object({ + ...directLineAttachmentSchema.entries, + contentType: custom<`text/${string}`>(value => safeParse(pipe(string(), startsWith('text/')), value).success) + }), + readonly() + ) + }), + readonly() +); -TextAttachment.displayName = 'TextAttachment'; +type TextAttachmentProps = InferInput; -export default TextAttachment; +function TextAttachment(props: TextAttachmentProps) { + const { + activity, + attachment: { content, contentType } + } = validateProps(textAttachmentPropsSchema, props); + + return ; +} + +export default memo(TextAttachment); +export { textAttachmentPropsSchema, type TextAttachmentProps }; diff --git a/packages/component/src/Attachment/Text/TextContent.tsx b/packages/component/src/Attachment/Text/TextContent.tsx index 580788b108..a2b213a998 100644 --- a/packages/component/src/Attachment/Text/TextContent.tsx +++ b/packages/component/src/Attachment/Text/TextContent.tsx @@ -1,22 +1,28 @@ -import React, { memo, useMemo } from 'react'; -import classNames from 'classnames'; import { hooks } from 'botframework-webchat-api'; -import { type WebChatActivity } from 'botframework-webchat-core'; +import { validateProps } from 'botframework-webchat-api/internal'; +import classNames from 'classnames'; +import React, { memo, useMemo } from 'react'; +import { any, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import { useStyleToEmotionObject } from '../../hooks/internal/styleToEmotionObject'; +import useRenderMarkdownAsHTML from '../../hooks/useRenderMarkdownAsHTML'; +import CustomPropertyNames from '../../Styles/CustomPropertyNames'; import isAIGeneratedActivity from './private/isAIGeneratedActivity'; import MarkdownTextContent from './private/MarkdownTextContent'; import PlainTextContent from './private/PlainTextContent'; -import CustomPropertyNames from '../../Styles/CustomPropertyNames'; -import { useStyleToEmotionObject } from '../../hooks/internal/styleToEmotionObject'; -import useRenderMarkdownAsHTML from '../../hooks/useRenderMarkdownAsHTML'; const { useLocalizer } = hooks; -type Props = Readonly<{ - activity: WebChatActivity; - contentType?: string; - text: string; -}>; +const textContentPropsSchema = pipe( + object({ + activity: any(), + contentType: optional(string()), + text: string() + }), + readonly() +); + +type TextContentProps = InferInput; const generatedBadgeStyle = { '&.webchat__text-content__generated-badge': { @@ -25,7 +31,9 @@ const generatedBadgeStyle = { } }; -const TextContent = memo(({ activity, contentType = 'text/plain', text }: Props) => { +function TextContent(props: TextContentProps) { + const { activity, contentType = 'text/plain', text } = validateProps(textContentPropsSchema, props); + const supportMarkdown = !!useRenderMarkdownAsHTML('message activity'); const localize = useLocalizer(); const generatedBadgeClassName = useStyleToEmotionObject()(generatedBadgeStyle) + ''; @@ -49,8 +57,7 @@ const TextContent = memo(({ activity, contentType = 'text/plain', text }: Props) {generatedBadge} ) ) : null; -}); - -TextContent.displayName = 'TextContent'; +} -export default TextContent; +export default memo(TextContent); +export { textContentPropsSchema, type TextContentProps }; diff --git a/packages/component/src/Attachment/Text/private/ActivityButton.tsx b/packages/component/src/Attachment/Text/private/ActivityButton.tsx index b8ad09078e..96f7003d27 100644 --- a/packages/component/src/Attachment/Text/private/ActivityButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityButton.tsx @@ -1,43 +1,72 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import React, { forwardRef, memo, useCallback, type ReactNode } from 'react'; +import React, { forwardRef, memo, useCallback } from 'react'; import { useRefFrom } from 'use-ref-from'; +import { + boolean, + custom, + function_, + object, + optional, + pipe, + readonly, + safeParse, + string, + type InferInput +} from 'valibot'; + import useStyleSet from '../../../hooks/useStyleSet'; +import reactNode from '../../../types/internal/reactNode'; import MonochromeImageMasker from '../../../Utils/MonochromeImageMasker'; -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( - ({ children, className, 'data-testid': dataTestId, disabled, iconURL, onClick, text }, ref) => { - const [{ activityButton }] = useStyleSet(); - const onClickRef = useRefFrom(onClick); - - const handleClick = useCallback(() => onClickRef.current?.(), [onClickRef]); - - return ( - - ); - } +const activityButtonPropsSchema = pipe( + object({ + children: optional(reactNode()), + className: optional(string()), + 'data-testid': optional(string()), + disabled: optional(boolean()), + iconURL: optional(string()), + onClick: optional(custom<() => void>(value => safeParse(function_(), value).success)), + text: optional(string()) + }), + readonly() ); +type ActivityButtonProps = InferInput; + +const ActivityButton = forwardRef((props, ref) => { + const { + children, + className, + 'data-testid': dataTestId, + disabled, + iconURL, + onClick, + text + } = validateProps(activityButtonPropsSchema, props); + + const [{ activityButton }] = useStyleSet(); + const onClickRef = useRefFrom(onClick); + + const handleClick = useCallback(() => onClickRef.current?.(), [onClickRef]); + + return ( + + ); +}); + export default memo(ActivityButton); +export { activityButtonPropsSchema, type ActivityButtonProps }; diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 3457c114ec..54f09562a6 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -1,20 +1,31 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import React, { memo, useCallback, useEffect, useRef, useState, type RefObject } from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { instance, nullable, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; + import useStyleSet from '../../../hooks/useStyleSet'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; +import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; const { useLocalizer, useUIState } = hooks; -type Props = Readonly<{ - className?: string | undefined; - targetRef?: RefObject; -}>; +const activityCopyButtonPropsSchema = pipe( + object({ + className: optional(string()), + targetRef: refObject(nullable(instance(HTMLElement))) + }), + readonly() +); + +type ActivityCopyButtonProps = InferInput; const COPY_ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent('')}`; -const ActivityCopyButton = ({ className, targetRef }: Props) => { +const ActivityCopyButton = (props: ActivityCopyButtonProps) => { + const { className, targetRef } = validateProps(activityCopyButtonPropsSchema, props); + const [{ activityButton, activityCopyButton }] = useStyleSet(); const [permissionGranted, setPermissionGranted] = useState(false); const [uiState] = useUIState(); @@ -100,3 +111,4 @@ const ActivityCopyButton = ({ className, targetRef }: Props) => { ActivityCopyButton.displayName = 'ActivityCopyButton'; export default memo(ActivityCopyButton); +export { activityCopyButtonPropsSchema, type ActivityCopyButtonProps }; diff --git a/packages/component/src/Attachment/Text/private/ActivityViewCodeButton.tsx b/packages/component/src/Attachment/Text/private/ActivityViewCodeButton.tsx index a34be6d48f..311bdc71f3 100644 --- a/packages/component/src/Attachment/Text/private/ActivityViewCodeButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityViewCodeButton.tsx @@ -1,6 +1,8 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; import React, { memo, useCallback } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../../../hooks/useStyleSet'; import useShowModal from '../../../providers/ModalDialog/useShowModal'; @@ -12,15 +14,22 @@ const CODE_ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent('; +const activityViewCodeButtonPropsSchema = pipe( + object({ + className: optional(string()), + code: string(), + language: optional(string()), + isAIGenerated: boolean(), + title: string() + }), + readonly() +); + +type ActivityViewCodeButtonProps = InferInput; + +const ActivityViewCodeButton = (props: ActivityViewCodeButtonProps) => { + const { className, code, language, title, isAIGenerated } = validateProps(activityViewCodeButtonPropsSchema, props); -const ViewCodeButton = ({ className, code, language, title = '', isAIGenerated = false }: Props) => { const [{ activityButton, viewCodeDialog }] = useStyleSet(); const showModal = useShowModal(); const localize = useLocalizer(); @@ -54,4 +63,5 @@ const ViewCodeButton = ({ className, code, language, title = '', isAIGenerated = ); }; -export default memo(ViewCodeButton); +export default memo(ActivityViewCodeButton); +export { activityViewCodeButtonPropsSchema, type ActivityViewCodeButtonProps }; diff --git a/packages/component/src/Attachment/Text/private/CitationModalContent.tsx b/packages/component/src/Attachment/Text/private/CitationModalContent.tsx index 38186a38a0..a8ac8d0e06 100644 --- a/packages/component/src/Attachment/Text/private/CitationModalContent.tsx +++ b/packages/component/src/Attachment/Text/private/CitationModalContent.tsx @@ -1,15 +1,24 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; import React, { Fragment, memo, useMemo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useRenderMarkdownAsHTML from '../../../hooks/useRenderMarkdownAsHTML'; import useStyleSet from '../../../hooks/useStyleSet'; -type Props = Readonly<{ - headerText?: string; - markdown: string; -}>; +const citationModalContentPropsSchema = pipe( + object({ + headerText: optional(string()), + markdown: string() + }), + readonly() +); + +type CitationModalContentProps = InferInput; + +function CitationModalContent(props: CitationModalContentProps) { + const { headerText, markdown } = validateProps(citationModalContentPropsSchema, props); -const CitationModalContent = memo(({ headerText, markdown }: Props) => { const [{ renderMarkdown: renderMarkdownStyleSet }] = useStyleSet(); const renderMarkdownAsHTML = useRenderMarkdownAsHTML('citation modal'); @@ -34,8 +43,7 @@ const CitationModalContent = memo(({ headerText, markdown }: Props) => { )} ); -}); - -CitationModalContent.displayName = 'CitationModalContent'; +} -export default CitationModalContent; +export default memo(CitationModalContent); +export { citationModalContentPropsSchema, type CitationModalContentProps }; diff --git a/packages/component/src/Attachment/Text/private/CodeContent.tsx b/packages/component/src/Attachment/Text/private/CodeContent.tsx index 3a720909c9..06c5fdc501 100644 --- a/packages/component/src/Attachment/Text/private/CodeContent.tsx +++ b/packages/component/src/Attachment/Text/private/CodeContent.tsx @@ -1,18 +1,29 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import React, { Fragment, memo, ReactNode } from 'react'; +import React, { Fragment, memo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useCodeBlockTag from '../../../providers/CustomElements/useCodeBlockTagName'; +import reactNode from '../../../types/internal/reactNode'; -type Props = Readonly<{ - children?: ReactNode | undefined; - className?: string | undefined; - code: string; - language: string; - title: string; -}>; +const codeContentPropsSchema = pipe( + object({ + children: optional(reactNode()), + className: optional(string()), + code: string(), + language: string(), + title: string() + }), + readonly() +); + +type CodeContentProps = InferInput; + +function CodeContent(props: CodeContentProps) { + const { children, className, code, language, title } = validateProps(codeContentPropsSchema, props); -const CodeContent = memo(({ children, className, code, language, title }: Props) => { const [, CodeBlock] = useCodeBlockTag(); + return (
@@ -24,8 +35,7 @@ const CodeContent = memo(({ children, className, code, language, title }: Props) {children} ); -}); - -CodeContent.displayName = 'CodeContent'; +} export default memo(CodeContent); +export { codeContentPropsSchema, type CodeContentProps }; diff --git a/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx b/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx index fff901740d..36a803642f 100644 --- a/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx +++ b/packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx @@ -1,4 +1,5 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import { getOrgSchemaMessage, onErrorResumeNext, @@ -9,8 +10,9 @@ import { import classNames from 'classnames'; import type { Definition } from 'mdast'; import { fromMarkdown } from 'mdast-util-from-markdown'; -import React, { memo, useCallback, useMemo, useRef, type MouseEventHandler, type ReactNode } from 'react'; +import React, { memo, useCallback, useMemo, useRef, type MouseEventHandler } from 'react'; import { useRefFrom } from 'use-ref-from'; +import { custom, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import ActivityFeedback from '../../../ActivityFeedback/ActivityFeedback'; import { LinkDefinitionItem, LinkDefinitions } from '../../../LinkDefinition/index'; @@ -19,6 +21,7 @@ import useRenderMarkdownAsHTML from '../../../hooks/useRenderMarkdownAsHTML'; import useStyleSet from '../../../hooks/useStyleSet'; import useShowModal from '../../../providers/ModalDialog/useShowModal'; import { type PropsOf } from '../../../types/PropsOf'; +import reactNode from '../../../types/internal/reactNode'; import ActivityCopyButton from './ActivityCopyButton'; import ActivityViewCodeButton from './ActivityViewCodeButton'; import CitationModalContext from './CitationModalContent'; @@ -37,17 +40,24 @@ type Entry = { url?: string | undefined; }; -type Props = Readonly<{ - activity: WebChatActivity; - children?: ReactNode | undefined; - markdown: string; -}>; +const markdownTextContentPropsSchema = pipe( + object({ + activity: custom(() => true), + children: optional(reactNode()), + markdown: string() + }), + readonly() +); + +type MarkdownTextContentProps = InferInput; function isCitationURL(url: string): boolean { return onErrorResumeNext(() => new URL(url))?.protocol === 'cite:'; } -const MarkdownTextContent = memo(({ activity, children, markdown }: Props) => { +function MarkdownTextContent(props: MarkdownTextContentProps) { + const { activity, children, markdown } = validateProps(markdownTextContentPropsSchema, props); + const [{ feedbackActionsPlacement }] = useStyleOptions(); const [ { @@ -252,8 +262,7 @@ const MarkdownTextContent = memo(({ activity, children, markdown }: Props) => {
); -}); - -MarkdownTextContent.displayName = 'MarkdownTextContent'; +} -export default MarkdownTextContent; +export default memo(MarkdownTextContent); +export { markdownTextContentPropsSchema, type MarkdownTextContentProps }; diff --git a/packages/component/src/Attachment/Text/private/Markdownable.tsx b/packages/component/src/Attachment/Text/private/Markdownable.tsx index bb784ed294..842a6a82b6 100644 --- a/packages/component/src/Attachment/Text/private/Markdownable.tsx +++ b/packages/component/src/Attachment/Text/private/Markdownable.tsx @@ -1,10 +1,17 @@ import React, { memo, useMemo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; + import { useRenderMarkdownAsHTML } from '../../../hooks'; -type MarkdownableProps = Readonly<{ - className?: string | undefined; - text: string; -}>; +const markdownablePropsSchema = pipe( + object({ + className: optional(string()), + text: string() + }), + readonly() +); + +type MarkdownableProps = InferInput; function Markdownable({ className, text }: MarkdownableProps) { const renderMarkdownAsHTML = useRenderMarkdownAsHTML('message activity'); @@ -23,4 +30,4 @@ function Markdownable({ className, text }: MarkdownableProps) { } export default memo(Markdownable); -export { type MarkdownableProps }; +export { markdownablePropsSchema, type MarkdownableProps }; diff --git a/packages/component/src/Attachment/Text/private/MessageSensitivityLabel.tsx b/packages/component/src/Attachment/Text/private/MessageSensitivityLabel.tsx index 0f98d289b5..2fad265832 100644 --- a/packages/component/src/Attachment/Text/private/MessageSensitivityLabel.tsx +++ b/packages/component/src/Attachment/Text/private/MessageSensitivityLabel.tsx @@ -1,38 +1,46 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; import React, { memo, useMemo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import ShieldIcon from './ShieldIcon'; -type MessageSensitivityLabelProps = Readonly<{ - className?: string | undefined; - color?: string | undefined; - isEncrypted?: boolean | undefined; - name?: string | undefined; - title?: string | undefined; -}>; +const messageSensitivityLabelPropsSchema = pipe( + object({ + className: optional(string()), + color: optional(string()), + isEncrypted: optional(boolean()), + name: optional(string()), + title: optional(string()) + }), + readonly() +); -const MessageSensitivityLabel = memo(({ className, color, isEncrypted, name, title }: MessageSensitivityLabelProps) => ( -
[name, title].filter(Boolean).join('\n\n'), [name, title])} - > - - {name} -
-)); +type MessageSensitivityLabelProps = InferInput; -MessageSensitivityLabel.displayName = 'MessageSensitivityLabel'; +function MessageSensitivityLabel(props: MessageSensitivityLabelProps) { + const { className, color, isEncrypted, name, title } = validateProps(messageSensitivityLabelPropsSchema, props); -export default MessageSensitivityLabel; + return ( +
[name, title].filter(Boolean).join('\n\n'), [name, title])} + > + + {name} +
+ ); +} -export type { MessageSensitivityLabelProps }; +export default memo(MessageSensitivityLabel); +export { messageSensitivityLabelPropsSchema, type MessageSensitivityLabelProps }; diff --git a/packages/component/src/Attachment/Text/private/PlainTextContent.tsx b/packages/component/src/Attachment/Text/private/PlainTextContent.tsx index e7e674cce7..11bc8aad44 100644 --- a/packages/component/src/Attachment/Text/private/PlainTextContent.tsx +++ b/packages/component/src/Attachment/Text/private/PlainTextContent.tsx @@ -1,11 +1,24 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import React, { Fragment, memo, type ReactNode } from 'react'; +import React, { Fragment, memo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../../../hooks/useStyleSet'; +import reactNode from '../../../types/internal/reactNode'; -type Props = Readonly<{ children?: ReactNode | undefined; text: string }>; +const plainTextContentPropsSchema = pipe( + object({ + children: optional(reactNode()), + text: string() + }), + readonly() +); + +type PlainTextContentProps = InferInput; + +function PlainTextContent(props: PlainTextContentProps) { + const { children, text } = validateProps(plainTextContentPropsSchema, props); -const PlainTextContent = memo(({ children, text }: Props) => { const [{ textContent: textContentStyleSet }] = useStyleSet(); return ( @@ -27,8 +40,7 @@ const PlainTextContent = memo(({ children, text }: Props) => { )} ); -}); - -PlainTextContent.displayName = 'PlainTextContent'; +} -export default PlainTextContent; +export default memo(PlainTextContent); +export { plainTextContentPropsSchema, type PlainTextContentProps }; diff --git a/packages/component/src/Attachment/Text/private/ShieldIcon.tsx b/packages/component/src/Attachment/Text/private/ShieldIcon.tsx index 8c52605091..1eaa3266d6 100644 --- a/packages/component/src/Attachment/Text/private/ShieldIcon.tsx +++ b/packages/component/src/Attachment/Text/private/ShieldIcon.tsx @@ -1,42 +1,60 @@ +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import React, { Fragment } from 'react'; +import React, { Fragment, memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -type Props = Readonly<{ - className?: string; - fillColor?: string; - hasLock?: boolean; -}>; +const shieldIconPropsSchema = pipe( + object({ + className: optional(string()), + fillColor: optional(string()), + hasLock: optional(boolean()) + }), + readonly() +); + +type ShieldIconProps = InferInput; -const ShieldIcon = ({ className, fillColor, hasLock }: Props) => ( - - {hasLock ? ( - - {fillColor && ( +function ShieldIcon(props: ShieldIconProps) { + const { className, fillColor, hasLock } = validateProps(shieldIconPropsSchema, props); + + return ( + + {hasLock ? ( + + {fillColor && ( + + )} - )} - - - ) : ( - - {fillColor && ( + + ) : ( + + {fillColor && ( + + )} - )} - - - )} - -); + + )} + + ); +} -export default ShieldIcon; +export default memo(ShieldIcon); +export { shieldIconPropsSchema, type ShieldIconProps }; diff --git a/packages/component/src/Attachment/VideoContent.tsx b/packages/component/src/Attachment/VideoContent.tsx index 8872d91229..42eb770db3 100644 --- a/packages/component/src/Attachment/VideoContent.tsx +++ b/packages/component/src/Attachment/VideoContent.tsx @@ -1,5 +1,6 @@ -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import HTMLVideoContent from './HTMLVideoContent'; import VimeoContent from './VimeoContent'; @@ -30,15 +31,22 @@ function parseURL(url) { return { hostname, pathname, search }; } -type VideoContentProps = { - alt?: string; - autoPlay?: boolean; - loop?: boolean; - poster?: string; - src: string; -}; +const videoContentPropsSchema = pipe( + object({ + alt: optional(string()), + autoPlay: optional(boolean()), + loop: optional(boolean()), + poster: optional(string()), + src: string() + }), + readonly() +); + +type VideoContentProps = InferInput; + +function VideoContent(props: VideoContentProps) { + const { alt, autoPlay = false, loop = false, poster, src } = validateProps(videoContentPropsSchema, props); -const VideoContent: FC = ({ alt, autoPlay, loop, poster, src }) => { const { hostname, pathname, search } = parseURL(src); const lastSegment = pathname.split('/').pop(); const searchParams = new URLSearchParams(search); @@ -59,21 +67,7 @@ const VideoContent: FC = ({ alt, autoPlay, loop, poster, src default: return ; } -}; - -VideoContent.defaultProps = { - alt: '', - autoPlay: false, - loop: false, - poster: '' -}; - -VideoContent.propTypes = { - alt: PropTypes.string, - autoPlay: PropTypes.bool, - loop: PropTypes.bool, - poster: PropTypes.string, - src: PropTypes.string.isRequired -}; +} -export default VideoContent; +export default memo(VideoContent); +export { videoContentPropsSchema, type VideoContentProps }; diff --git a/packages/component/src/Attachment/VimeoContent.tsx b/packages/component/src/Attachment/VimeoContent.tsx index 1a1dd474d2..95eda248ef 100644 --- a/packages/component/src/Attachment/VimeoContent.tsx +++ b/packages/component/src/Attachment/VimeoContent.tsx @@ -1,19 +1,27 @@ import { hooks } from 'botframework-webchat-api'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../hooks/useStyleSet'; const { useLocalizer } = hooks; -type VimeoContentProps = { - alt?: string; - autoPlay?: boolean; - embedID: string; - loop?: boolean; -}; +const vimeoContentPropsSchema = pipe( + object({ + alt: optional(string()), + autoPlay: optional(boolean()), + embedID: string(), + loop: optional(boolean()) + }), + readonly() +); + +type VimeoContentProps = InferInput; + +function VimeoContent(props: VimeoContentProps) { + const { alt, autoPlay = false, embedID, loop = false } = validateProps(vimeoContentPropsSchema, props); -const VimeoContent: FC = ({ alt, autoPlay, embedID, loop }) => { const [{ vimeoContent: vimeoContentStyleSet }] = useStyleSet(); const localize = useLocalizer(); @@ -37,19 +45,7 @@ const VimeoContent: FC = ({ alt, autoPlay, embedID, loop }) = title={title} /> ); -}; - -VimeoContent.defaultProps = { - alt: '', - autoPlay: false, - loop: false -}; - -VimeoContent.propTypes = { - alt: PropTypes.string, - autoPlay: PropTypes.bool, - embedID: PropTypes.string.isRequired, - loop: PropTypes.bool -}; - -export default VimeoContent; +} + +export default memo(VimeoContent); +export { vimeoContentPropsSchema, type VimeoContentProps }; diff --git a/packages/component/src/Attachment/YouTubeContent.tsx b/packages/component/src/Attachment/YouTubeContent.tsx index e07c6a5f72..60715cc62c 100644 --- a/packages/component/src/Attachment/YouTubeContent.tsx +++ b/packages/component/src/Attachment/YouTubeContent.tsx @@ -1,19 +1,27 @@ import { hooks } from 'botframework-webchat-api'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../hooks/useStyleSet'; const { useLocalizer } = hooks; -type YouTubeContentProps = { - alt?: string; - autoPlay?: boolean; - embedID: string; - loop?: boolean; -}; +const youTubeContentPropsSchema = pipe( + object({ + alt: optional(string()), + autoPlay: optional(boolean()), + embedID: string(), + loop: optional(boolean()) + }), + readonly() +); + +type YouTubeContentProps = InferInput; + +function YouTubeContent(props: YouTubeContentProps) { + const { alt, autoPlay = false, embedID, loop = false } = validateProps(youTubeContentPropsSchema, props); -const YouTubeContent: FC = ({ alt, autoPlay, embedID, loop }) => { const [{ youTubeContent: youTubeContentStyleSet }] = useStyleSet(); const localize = useLocalizer(); @@ -35,19 +43,7 @@ const YouTubeContent: FC = ({ alt, autoPlay, embedID, loop title={title} /> ); -}; - -YouTubeContent.defaultProps = { - alt: '', - autoPlay: false, - loop: false -}; - -YouTubeContent.propTypes = { - alt: PropTypes.string, - autoPlay: PropTypes.bool, - embedID: PropTypes.string.isRequired, - loop: PropTypes.bool -}; - -export default YouTubeContent; +} + +export default memo(YouTubeContent); +export { youTubeContentPropsSchema, type YouTubeContentProps }; diff --git a/packages/component/src/Avatar/ImageAvatar.tsx b/packages/component/src/Avatar/ImageAvatar.tsx index 89578baa7c..bb9e19ee57 100644 --- a/packages/component/src/Avatar/ImageAvatar.tsx +++ b/packages/component/src/Avatar/ImageAvatar.tsx @@ -1,9 +1,11 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; import React, { memo } from 'react'; +import { boolean, object, pipe, readonly, type InferInput } from 'valibot'; -import useStyleSet from '../hooks/useStyleSet'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useStyleSet from '../hooks/useStyleSet'; const { useAvatarForBot, useAvatarForUser } = hooks; @@ -14,7 +16,18 @@ const ROOT_STYLE = { } }; -const ImageAvatar = memo(({ fromUser }: Readonly<{ fromUser: boolean }>) => { +const imageAvatarPropsSchema = pipe( + object({ + fromUser: boolean() + }), + readonly() +); + +type ImageAvatarProps = InferInput; + +const ImageAvatar = (props: ImageAvatarProps) => { + const { fromUser } = validateProps(imageAvatarPropsSchema, props); + const [{ image: avatarImageForBot }] = useAvatarForBot(); const [{ image: avatarImageForUser }] = useAvatarForUser(); const [{ imageAvatar: imageAvatarStyleSet }] = useStyleSet(); @@ -29,6 +42,7 @@ const ImageAvatar = memo(({ fromUser }: Readonly<{ fromUser: boolean }>) => { ) ); -}); +}; -export default ImageAvatar; +export default memo(ImageAvatar); +export { imageAvatarPropsSchema, type ImageAvatarProps }; diff --git a/packages/component/src/Avatar/InitialsAvatar.js b/packages/component/src/Avatar/InitialsAvatar.tsx similarity index 63% rename from packages/component/src/Avatar/InitialsAvatar.js rename to packages/component/src/Avatar/InitialsAvatar.tsx index 7704c188a2..cf2054b809 100644 --- a/packages/component/src/Avatar/InitialsAvatar.js +++ b/packages/component/src/Avatar/InitialsAvatar.tsx @@ -1,10 +1,11 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, type InferInput } from 'valibot'; -import useStyleSet from '../hooks/useStyleSet'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useStyleSet from '../hooks/useStyleSet'; const { useAvatarForBot, useAvatarForUser } = hooks; @@ -17,7 +18,18 @@ const ROOT_STYLE = { } }; -const InitialsAvatar = ({ fromUser }) => { +const initialsAvatarPropsSchema = pipe( + object({ + fromUser: optional(boolean()) + }), + readonly() +); + +type InitialsAvatarProps = InferInput; + +function InitialsAvatar(props: InitialsAvatarProps) { + const { fromUser = false } = validateProps(initialsAvatarPropsSchema, props); + const [{ initials: avatarInitialsForBot }] = useAvatarForBot(); const [{ initials: avatarInitialsForUser }] = useAvatarForUser(); const [{ initialsAvatar: initialsAvatarStyleSet }] = useStyleSet(); @@ -37,14 +49,7 @@ const InitialsAvatar = ({ fromUser }) => {
{fromUser ? avatarInitialsForUser : avatarInitialsForBot}
); -}; - -InitialsAvatar.defaultProps = { - fromUser: false -}; - -InitialsAvatar.propTypes = { - fromUser: PropTypes.bool -}; +} -export default InitialsAvatar; +export default memo(InitialsAvatar); +export { initialsAvatarPropsSchema, type InitialsAvatarProps }; diff --git a/packages/component/src/BasicWebChat.tsx b/packages/component/src/BasicWebChat.tsx index 345765ff32..922823d0a0 100644 --- a/packages/component/src/BasicWebChat.tsx +++ b/packages/component/src/BasicWebChat.tsx @@ -2,16 +2,17 @@ /* eslint react/no-unsafe: off */ import { SendBoxMiddlewareProxy, hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import React, { memo } from 'react'; +import { fallback, literal, object, optional, pipe, readonly, string, union, type InferInput } from 'valibot'; import BasicConnectivityStatus from './BasicConnectivityStatus'; import BasicToaster from './BasicToaster'; import BasicTranscript from './BasicTranscript'; -import AccessKeySinkSurface from './Utils/AccessKeySink/Surface'; import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject'; import useStyleSet from './hooks/useStyleSet'; +import AccessKeySinkSurface from './Utils/AccessKeySink/Surface'; const { useStyleOptions } = hooks; @@ -38,15 +39,26 @@ const TRANSCRIPT_STYLE = { } }; -// Subset of landmark roles: https://w3.org/TR/wai-aria/#landmark_roles -const ARIA_LANDMARK_ROLES = ['complementary', 'contentinfo', 'form', 'main', 'region']; - -type BasicWebChatProps = { - className?: string; - role?: 'complementary' | 'contentinfo' | 'form' | 'main' | 'region'; -}; +const basicWebChatPropsSchema = pipe( + object({ + className: optional(string()), + role: fallback( + optional( + // Subset of landmark roles: https://w3.org/TR/wai-aria/#landmark_roles + union([literal('complementary'), literal('contentinfo'), literal('form'), literal('main'), literal('region')]) + ), + // Fallback to "complementary" if specified is not a valid landmark role. + 'complementary' + ) + }), + readonly() +); + +type BasicWebChatProps = InferInput; + +function BasicWebChat(props: BasicWebChatProps) { + const { className, role } = validateProps(basicWebChatPropsSchema, props, 'strict'); -const BasicWebChat: FC = ({ className, role }) => { const [{ root: rootStyleSet }] = useStyleSet(); const [options] = useStyleOptions(); const styleToEmotionObject = useStyleToEmotionObject(); @@ -57,14 +69,9 @@ const BasicWebChat: FC = ({ className, role }) => { const toasterClassName = styleToEmotionObject(TOASTER_STYLE) + ''; const transcriptClassName = styleToEmotionObject(TRANSCRIPT_STYLE) + ''; - // Fallback to "complementary" if specified is not a valid landmark role. - if (!ARIA_LANDMARK_ROLES.includes(role)) { - role = 'complementary'; - } - return ( {!options.hideToaster && } @@ -73,20 +80,7 @@ const BasicWebChat: FC = ({ className, role }) => { ); -}; - -BasicWebChat.defaultProps = { - className: '', - role: 'complementary' -}; - -BasicWebChat.propTypes = { - className: PropTypes.string, - // Ignoring deficiencies with TypeScript/PropTypes inference. - // @ts-ignore - role: PropTypes.oneOf(ARIA_LANDMARK_ROLES) -}; - -export default BasicWebChat; +} -export type { BasicWebChatProps }; +export default memo(BasicWebChat); +export { basicWebChatPropsSchema, type BasicWebChatProps }; diff --git a/packages/component/src/ChatHistory/ChatHistoryBox.tsx b/packages/component/src/ChatHistory/ChatHistoryBox.tsx index 92fdf1f205..35060edad9 100644 --- a/packages/component/src/ChatHistory/ChatHistoryBox.tsx +++ b/packages/component/src/ChatHistory/ChatHistoryBox.tsx @@ -1,11 +1,28 @@ -import React, { memo, ReactNode } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; +import React, { memo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import { useStyleSet } from '../hooks'; +import reactNode from '../types/internal/reactNode'; + +const chatHistoryBoxPropsSchema = pipe( + object({ + children: optional(reactNode()), + className: optional(string()) + }), + readonly() +); + +type ChatHistoryBoxProps = InferInput; + +function ChatHistoryBox(props: ChatHistoryBoxProps) { + const { children, className } = validateProps(chatHistoryBoxPropsSchema, props); -function ChatHistoryBox({ children, className }: Readonly<{ children: ReactNode; className?: string | undefined }>) { const [{ chatHistoryBox }] = useStyleSet(); + return
{children}
; } export default memo(ChatHistoryBox); +export { chatHistoryBoxPropsSchema, type ChatHistoryBoxProps }; diff --git a/packages/component/src/ChatHistory/ChatHistoryToolbar.tsx b/packages/component/src/ChatHistory/ChatHistoryToolbar.tsx index 41d8f59cb4..736b4e0043 100644 --- a/packages/component/src/ChatHistory/ChatHistoryToolbar.tsx +++ b/packages/component/src/ChatHistory/ChatHistoryToolbar.tsx @@ -1,10 +1,25 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import React, { memo, ReactNode } from 'react'; +import React, { memo } from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +import reactNode from '../types/internal/reactNode'; const { useDirection } = hooks; -function ChatHistoryToolbar({ children }: Readonly<{ children: ReactNode }>) { +const chatHistoryToolbarPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type ChatHistoryToolbarProps = InferInput; + +function ChatHistoryToolbar(props: ChatHistoryToolbarProps) { + const { children } = validateProps(chatHistoryToolbarPropsSchema, props); + const [direction] = useDirection(); return ( @@ -20,3 +35,4 @@ function ChatHistoryToolbar({ children }: Readonly<{ children: ReactNode }>) { } export default memo(ChatHistoryToolbar); +export { chatHistoryToolbarPropsSchema, type ChatHistoryToolbarProps }; diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index 0aae0a6986..af8889a2f4 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -450,7 +450,7 @@ const Composer = ({ cardActionMiddleware={patchedCardActionMiddleware} downscaleImageToDataURL={downscaleImageToDataURL} // Under dev server of create-react-app, "NODE_ENV" will be set to "development". - internalErrorBoxClass={node_env === 'development' ? ErrorBox : undefined} + {...(node_env === 'development' ? { internalErrorBoxClass: ErrorBox } : {})} nonce={nonce} scrollToEndButtonMiddleware={patchedScrollToEndButtonMiddleware} sendBoxMiddleware={sendBoxMiddleware} diff --git a/packages/component/src/ConnectivityStatus/Connecting.js b/packages/component/src/ConnectivityStatus/Connecting.tsx similarity index 72% rename from packages/component/src/ConnectivityStatus/Connecting.js rename to packages/component/src/ConnectivityStatus/Connecting.tsx index 156b383a88..dfd1b68301 100644 --- a/packages/component/src/ConnectivityStatus/Connecting.js +++ b/packages/component/src/ConnectivityStatus/Connecting.tsx @@ -1,18 +1,30 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { useState } from 'react'; +import React, { Fragment, memo, useState } from 'react'; +import { boolean, object, optional, pipe, readonly, type InferInput } from 'valibot'; -import ScreenReaderText from '../ScreenReaderText'; -import SpinnerAnimation from './Assets/SpinnerAnimation'; import useForceRender from '../hooks/internal/useForceRender'; -import useStyleSet from '../hooks/useStyleSet'; import useTimer from '../hooks/internal/useTimer'; +import useStyleSet from '../hooks/useStyleSet'; +import ScreenReaderText from '../ScreenReaderText'; +import SpinnerAnimation from './Assets/SpinnerAnimation'; import WarningNotificationIcon from './Assets/WarningNotificationIcon'; const { useDirection, useLocalizer, usePonyfill, useStyleOptions } = hooks; -const ConnectivityStatusConnecting = ({ reconnect }) => { +const connectivityStatusConnectingPropsSchema = pipe( + object({ + reconnect: optional(boolean()) + }), + readonly() +); + +type ConnectivityStatusConnectingProps = InferInput; + +function ConnectivityStatusConnecting(props: ConnectivityStatusConnectingProps) { + const { reconnect = false } = validateProps(connectivityStatusConnectingPropsSchema, props); + const [{ Date }] = usePonyfill(); const [{ slowConnectionAfter }] = useStyleOptions(); const [ @@ -33,7 +45,7 @@ const ConnectivityStatusConnecting = ({ reconnect }) => { const slow = now >= initialRenderAt + slowConnectionAfter; return slow ? ( - +
{ {slowConnectionText}
-
+ ) : ( - + @@ -57,16 +69,9 @@ const ConnectivityStatusConnecting = ({ reconnect }) => { {reconnect ? interruptedConnectionText : initialConnectionText} - + ); -}; - -ConnectivityStatusConnecting.defaultProps = { - reconnect: false -}; - -ConnectivityStatusConnecting.propTypes = { - reconnect: PropTypes.bool -}; +} -export default ConnectivityStatusConnecting; +export default memo(ConnectivityStatusConnecting); +export { connectivityStatusConnectingPropsSchema, type ConnectivityStatusConnectingProps }; diff --git a/packages/component/src/ErrorBox.tsx b/packages/component/src/ErrorBox.tsx index 0c462d06c1..ceed803c1c 100644 --- a/packages/component/src/ErrorBox.tsx +++ b/packages/component/src/ErrorBox.tsx @@ -1,20 +1,28 @@ /* eslint no-console: "off" */ import { hooks } from 'botframework-webchat-api'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { instance, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import ScreenReaderText from './ScreenReaderText'; import useStyleSet from './hooks/useStyleSet'; +import ScreenReaderText from './ScreenReaderText'; const { useLocalizer } = hooks; -type ErrorBoxProps = { - error: Error; - type?: string; -}; +const errorBoxPropsSchema = pipe( + object({ + error: instance(Error), + type: optional(string()) + }), + readonly() +); + +type ErrorBoxProps = InferInput; + +function ErrorBox(props: ErrorBoxProps) { + const { error, type = '' } = validateProps(errorBoxPropsSchema, props); -const ErrorBox: FC = ({ error, type }) => { const [{ errorBox: errorBoxStyleSet }] = useStyleSet(); const localize = useLocalizer(); @@ -31,15 +39,7 @@ const ErrorBox: FC = ({ error, type }) => { ); -}; - -ErrorBox.defaultProps = { - type: '' -}; - -ErrorBox.propTypes = { - error: PropTypes.instanceOf(Error).isRequired, - type: PropTypes.string -}; +} -export default ErrorBox; +export default memo(ErrorBox); +export { errorBoxPropsSchema, type ErrorBoxProps }; diff --git a/packages/component/src/LiveRegion/LiveRegionActivity.tsx b/packages/component/src/LiveRegion/LiveRegionActivity.tsx index 8fd87e3aa8..6ae0c07499 100644 --- a/packages/component/src/LiveRegion/LiveRegionActivity.tsx +++ b/packages/component/src/LiveRegion/LiveRegionActivity.tsx @@ -1,18 +1,16 @@ /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React, { Fragment, useMemo } from 'react'; +import { any, object, pipe, readonly, type InferInput } from 'valibot'; +import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML'; import activityAltText from '../Utils/activityAltText'; import LiveRegionAttachments from './private/LiveRegionAttachments'; import LiveRegionSuggestedActions from './private/LiveRegionSuggestedActions'; -import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML'; -import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; - -import type { VFC } from 'react'; -import type { WebChatActivity } from 'botframework-webchat-core'; const { useAvatarForBot, useLocalizer } = hooks; @@ -29,11 +27,18 @@ const ROOT_STYLE = { } }; -type LiveRegionActivityProps = { - activity: WebChatActivity; -}; +const liveRegionActivityPropsSchema = pipe( + object({ + activity: any() + }), + readonly() +); + +type LiveRegionActivityProps = InferInput; + +function LiveRegionActivity(props: LiveRegionActivityProps) { + const { activity } = validateProps(liveRegionActivityPropsSchema, props); -const LiveRegionActivity: VFC = ({ activity }) => { const [{ initials: botInitials }] = useAvatarForBot(); const { from: { role }, @@ -71,12 +76,7 @@ const LiveRegionActivity: VFC = ({ activity }) => { )} ); -}; - -LiveRegionActivity.propTypes = { - activity: PropTypes.any.isRequired -}; +} export default LiveRegionActivity; - -export type { LiveRegionActivityProps }; +export { liveRegionActivityPropsSchema, type LiveRegionActivityProps }; diff --git a/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx b/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx index 39f37bdfcc..02ac2d4b56 100644 --- a/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx +++ b/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx @@ -1,32 +1,41 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo } from 'react'; +import { any, array, object, pipe, readonly, type InferInput } from 'valibot'; import computeSuggestedActionText from '../../Utils/computeSuggestedActionText'; -import type { DirectLineSuggestedAction } from 'botframework-webchat-core'; -import type { VFC } from 'react'; +const liveRegionSuggestedActionsPropSchema = pipe( + object({ + suggestedActions: pipe( + object({ + // TODO: Should built `directLineCardActionSchema`. + actions: pipe(array(any()), readonly()) + }), + readonly() + ) + }), + readonly() +); -type LiveRegionSuggestedActionsProps = { - suggestedActions: DirectLineSuggestedAction; -}; +type LiveRegionSuggestedActionsProps = InferInput; -const LiveRegionSuggestedActions: VFC = ({ suggestedActions }) => - suggestedActions.actions?.length && ( -

- {suggestedActions.actions.map((action, index) => ( - // Direct Line schema does not have key other than index. - // eslint-disable-next-line react/no-array-index-key - - ))} -

- ); +function LiveRegionSuggestedActions(props: LiveRegionSuggestedActionsProps) { + const { suggestedActions } = validateProps(liveRegionSuggestedActionsPropSchema, props); -LiveRegionSuggestedActions.propTypes = { - suggestedActions: PropTypes.shape({ - actions: PropTypes.array - }).isRequired -}; + return ( + suggestedActions.actions?.length && ( +

+ {suggestedActions.actions.map((action, index) => ( + // Direct Line schema does not have key other than index. + // eslint-disable-next-line react/no-array-index-key + + ))} +

+ ) + ); +} -export default LiveRegionSuggestedActions; +export default memo(LiveRegionSuggestedActions); +export { liveRegionSuggestedActionsPropSchema, type LiveRegionSuggestedActionsProps }; diff --git a/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx b/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx index 07863132f0..712bf40aba 100644 --- a/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx +++ b/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx @@ -1,12 +1,13 @@ import { AvatarMiddleware } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import React, { memo } from 'react'; +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import ImageAvatar from '../../Avatar/ImageAvatar'; import InitialsAvatar from '../../Avatar/InitialsAvatar'; -import useStyleSet from '../../hooks/useStyleSet'; import { useStyleToEmotionObject } from '../../hooks/internal/styleToEmotionObject'; +import useStyleSet from '../../hooks/useStyleSet'; const ROOT_STYLE = { overflow: ['hidden', 'clip'], @@ -19,13 +20,20 @@ const ROOT_STYLE = { } }; -type DefaultAvatarProps = { - 'aria-hidden'?: boolean; - className?: string; - fromUser: boolean; -}; +const defaultAvatarPropsSchema = pipe( + object({ + 'aria-hidden': optional(boolean()), + className: optional(string()), + fromUser: boolean() + }), + readonly() +); + +type DefaultAvatarProps = InferInput; + +function DefaultAvatar(props: DefaultAvatarProps) { + const { 'aria-hidden': ariaHidden = true, className, fromUser } = validateProps(defaultAvatarPropsSchema, props); -const DefaultAvatar: FC = ({ 'aria-hidden': ariaHidden, className, fromUser }) => { const [{ avatar: avatarStyleSet }] = useStyleSet(); const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; @@ -37,25 +45,14 @@ const DefaultAvatar: FC = ({ 'aria-hidden': ariaHidden, clas { 'webchat__defaultAvatar--fromUser': fromUser }, rootClassName, avatarStyleSet + '', - (className || '') + '' + className )} > ); -}; - -DefaultAvatar.defaultProps = { - 'aria-hidden': true, - className: '' -}; - -DefaultAvatar.propTypes = { - 'aria-hidden': PropTypes.bool, - className: PropTypes.string, - fromUser: PropTypes.bool.isRequired -}; +} export default function createCoreAvatarMiddleware(): AvatarMiddleware[] { return [ @@ -73,4 +70,6 @@ export default function createCoreAvatarMiddleware(): AvatarMiddleware[] { ]; } -export { DefaultAvatar }; +const MemoizedDefaultAvatar = memo(DefaultAvatar); + +export { MemoizedDefaultAvatar as DefaultAvatar, defaultAvatarPropsSchema, type DefaultAvatarProps }; diff --git a/packages/component/src/ScreenReaderText.tsx b/packages/component/src/ScreenReaderText.tsx index a5b0802a52..53f386265d 100644 --- a/packages/component/src/ScreenReaderText.tsx +++ b/packages/component/src/ScreenReaderText.tsx @@ -1,10 +1,9 @@ /* eslint react/forbid-dom-props: ["off"] */ -import PropTypes from 'prop-types'; +import { validateProps } from 'botframework-webchat-api/internal'; import React, { forwardRef, memo } from 'react'; -import type { VFC } from 'react'; - +import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject'; const ROOT_STYLE = { @@ -23,41 +22,35 @@ const ROOT_STYLE = { width: 1 }; -type ScreenReaderTextProps = { - 'aria-hidden'?: boolean; - id?: string; - text: string; -}; +const screenReaderTextPropsSchema = pipe( + object({ + 'aria-hidden': optional(boolean()), + id: optional(string()), + text: string() + }), + readonly() +); -const ScreenReaderText: VFC = forwardRef( - ({ 'aria-hidden': ariaHidden, id, text }, ref) => { - const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; +type ScreenReaderTextProps = InferInput; - if (ariaHidden && !id) { - console.warn( - 'botframework-webchat assertion: when "aria-hidden" is set, the screen reader text should be read by "aria-labelledby". Thus, "id" must be set.' - ); - } +// eslint-disable-next-line prefer-arrow-callback +const ScreenReaderText = forwardRef(function ScreenReaderText(props, ref) { + const { 'aria-hidden': ariaHidden, id, text } = validateProps(screenReaderTextPropsSchema, props); - return ( -
- {text} -
+ const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; + + if (ariaHidden && !id) { + console.warn( + 'botframework-webchat assertion: when "aria-hidden" is set, the screen reader text should be read by "aria-labelledby". Thus, "id" must be set.' ); } -); - -ScreenReaderText.defaultProps = { - 'aria-hidden': undefined, - id: undefined -}; - -ScreenReaderText.propTypes = { - 'aria-hidden': PropTypes.bool, - id: PropTypes.string, - text: PropTypes.string.isRequired -}; -ScreenReaderText.displayName = 'ScreenReaderText'; + return ( +
+ {text} +
+ ); +}); export default memo(ScreenReaderText); +export { screenReaderTextPropsSchema, type ScreenReaderTextProps }; diff --git a/packages/component/src/SendBox/BasicSendBox.tsx b/packages/component/src/SendBox/BasicSendBox.tsx index 864de878b2..b4621fcee5 100644 --- a/packages/component/src/SendBox/BasicSendBox.tsx +++ b/packages/component/src/SendBox/BasicSendBox.tsx @@ -1,7 +1,9 @@ import { SendBoxToolbarMiddlewareProxy, hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import { Constants } from 'botframework-webchat-core'; import classNames from 'classnames'; -import React, { FC } from 'react'; +import React, { memo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; import useStyleSet from '../hooks/useStyleSet'; @@ -35,11 +37,18 @@ function useSendBoxSpeechInterimsVisible(): [boolean] { return [dictateState === STARTING || dictateState === DICTATING]; } -type BasicSendBoxProps = Readonly<{ - className?: string; -}>; +const basicSendBoxPropsSchema = pipe( + object({ + className: optional(string()) + }), + readonly() +); + +type BasicSendBoxProps = InferInput; + +function BasicSendBox(props: BasicSendBoxProps) { + const { className } = validateProps(basicSendBoxPropsSchema, props); -const BasicSendBox: FC = ({ className }) => { const [{ sendBoxButtonAlignment }] = useStyleOptions(); const [{ sendBox: sendBoxStyleSet }] = useStyleSet(); const [{ SpeechRecognition = undefined } = {}] = useWebSpeechPonyfill(); @@ -83,8 +92,7 @@ const BasicSendBox: FC = ({ className }) => { ); -}; - -export default BasicSendBox; +} -export { useSendBoxSpeechInterimsVisible }; +export default memo(BasicSendBox); +export { basicSendBoxPropsSchema, useSendBoxSpeechInterimsVisible, type BasicSendBoxProps }; diff --git a/packages/component/src/SendBox/DictationInterims.tsx b/packages/component/src/SendBox/DictationInterims.tsx index 340af54cda..d9583d98c8 100644 --- a/packages/component/src/SendBox/DictationInterims.tsx +++ b/packages/component/src/SendBox/DictationInterims.tsx @@ -1,10 +1,11 @@ /* eslint react/no-array-index-key: "off" */ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import { Constants } from 'botframework-webchat-core'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC } from 'react'; +import React, { memo } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; import useStyleSet from '../hooks/useStyleSet'; @@ -21,11 +22,18 @@ const ROOT_STYLE = { display: 'flex' }; -type DictationInterimsProps = { - className?: string; -}; +const dictationInterimsPropsSchema = pipe( + object({ + className: optional(string()) + }), + readonly() +); + +type DictationInterimsProps = InferInput; + +function DictationInterims(props: DictationInterimsProps) { + const { className } = validateProps(dictationInterimsPropsSchema, props); -const DictationInterims: FC = ({ className }) => { const [dictateInterims] = useDictateInterims(); const [dictateState] = useDictateState(); const [{ dictationInterims: dictationInterimsStyleSet }] = useStyleSet(); @@ -34,7 +42,7 @@ const DictationInterims: FC = ({ className }) => { return dictateState === STARTING || dictateState === STOPPING ? (

{dictateState === STARTING && localize('SPEECH_INPUT_STARTING')} @@ -43,7 +51,7 @@ const DictationInterims: FC = ({ className }) => { dictateState === DICTATING && (dictateInterims.length ? (

{dictateInterims.map((interim, index) => ( @@ -55,24 +63,17 @@ const DictationInterims: FC = ({ className }) => {

) : (

{localize('SPEECH_INPUT_LISTENING')}

)) ); -}; - -DictationInterims.defaultProps = { - className: '' -}; - -DictationInterims.propTypes = { - className: PropTypes.string -}; +} // TODO: [P3] After speech started, when clicking on the transcript, it should // stop the dictation and allow the user to type-correct the transcript -export default DictationInterims; +export default memo(DictationInterims); +export { dictationInterimsPropsSchema, type DictationInterimsProps }; diff --git a/packages/component/src/SendBox/IconButton.tsx b/packages/component/src/SendBox/IconButton.tsx index 4c1ee33960..5a6c1a4189 100644 --- a/packages/component/src/SendBox/IconButton.tsx +++ b/packages/component/src/SendBox/IconButton.tsx @@ -1,23 +1,43 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { FC, MouseEventHandler, ReactNode, useRef } from 'react'; +import React, { memo, useRef, type MouseEventHandler } from 'react'; +import { + boolean, + custom, + function_, + object, + optional, + pipe, + readonly, + safeParse, + string, + type InferInput +} from 'valibot'; -import AccessibleButton from '../Utils/AccessibleButton'; import useFocusVisible from '../hooks/internal/useFocusVisible'; import useStyleSet from '../hooks/useStyleSet'; +import reactNode from '../types/internal/reactNode'; +import AccessibleButton from '../Utils/AccessibleButton'; const { useStyleOptions } = hooks; -type IconButtonProps = { - alt?: string; - children?: ReactNode; - className?: string; - disabled?: boolean; - onClick?: MouseEventHandler; -}; +const iconButtonPropsSchema = pipe( + object({ + alt: optional(string()), + children: optional(reactNode()), + className: optional(string()), + disabled: optional(boolean()), + onClick: optional(custom>(value => safeParse(function_(), value).success)) + }), + readonly() +); + +type IconButtonProps = InferInput; + +function IconButton(props: IconButtonProps) { + const { alt, children, className, disabled, onClick } = validateProps(iconButtonPropsSchema, props); -const IconButton: FC = ({ alt, children, className, disabled, onClick }) => { const [{ sendBoxButton: sendBoxButtonStyleSet }] = useStyleSet(); const [{ sendBoxButtonAlignment }] = useStyleOptions(); const buttonRef = useRef(); @@ -33,7 +53,7 @@ const IconButton: FC = ({ alt, children, className, disabled, o 'webchat__icon-button--focus-visible': focusVisible, 'webchat__icon-button--stretch': sendBoxButtonAlignment !== 'bottom' && sendBoxButtonAlignment !== 'top' }, - className + '' + className )} disabled={disabled} onClick={disabled ? undefined : onClick} @@ -46,22 +66,7 @@ const IconButton: FC = ({ alt, children, className, disabled, o
); -}; - -IconButton.defaultProps = { - alt: '', - children: undefined, - className: '', - disabled: false, - onClick: undefined -}; - -IconButton.propTypes = { - alt: PropTypes.string, - children: PropTypes.any, - className: PropTypes.string, - disabled: PropTypes.bool, - onClick: PropTypes.func -}; +} -export default IconButton; +export default memo(IconButton); +export { iconButtonPropsSchema, type IconButtonProps }; diff --git a/packages/component/src/SendBox/SendButton.tsx b/packages/component/src/SendBox/SendButton.tsx index f4feab786f..983cab99eb 100644 --- a/packages/component/src/SendBox/SendButton.tsx +++ b/packages/component/src/SendBox/SendButton.tsx @@ -1,21 +1,27 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React, { useCallback } from 'react'; +import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useSubmit from '../providers/internal/SendBox/useSubmit'; import SendIcon from './Assets/SendIcon'; import IconButton from './IconButton'; -import type { FC } from 'react'; - const { useLocalizer, useUIState } = hooks; -type SendButtonProps = { - className?: string; -}; +const sendButtonPropsSchema = pipe( + object({ + className: optional(string()) + }), + readonly() +); + +type SendButtonProps = InferInput; + +function SendButton(props: SendButtonProps) { + const { className } = validateProps(sendButtonPropsSchema, props); -const SendButton: FC = ({ className }) => { const [uiState] = useUIState(); const localize = useLocalizer(); const submit = useSubmit(); @@ -32,14 +38,7 @@ const SendButton: FC = ({ className }) => { ); -}; - -SendButton.defaultProps = { - className: undefined -}; - -SendButton.propTypes = { - className: PropTypes.string -}; +} export default SendButton; +export { sendButtonPropsSchema, type SendButtonProps }; diff --git a/packages/component/src/SendBox/SuggestedAction.tsx b/packages/component/src/SendBox/SuggestedAction.tsx index 21b4244c32..a3b4616af4 100644 --- a/packages/component/src/SendBox/SuggestedAction.tsx +++ b/packages/component/src/SendBox/SuggestedAction.tsx @@ -1,12 +1,13 @@ import { hooks } from 'botframework-webchat-api'; -import type { DirectLineCardAction } from 'botframework-webchat-core'; +import { validateProps } from 'botframework-webchat-api/internal'; +import { type DirectLineCardAction } from 'botframework-webchat-core'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { MouseEventHandler, useCallback, VFC } from 'react'; +import React, { memo, useCallback, type MouseEventHandler } from 'react'; +import { any, literal, number, object, optional, pipe, readonly, string, union, type InferInput } from 'valibot'; +import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; import useFocusVisible from '../hooks/internal/useFocusVisible'; import useLocalizeAccessKey from '../hooks/internal/useLocalizeAccessKey'; -import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; import useSuggestedActionsAccessKey from '../hooks/internal/useSuggestedActionsAccessKey'; import useFocus from '../hooks/useFocus'; import useScrollToEnd from '../hooks/useScrollToEnd'; @@ -24,41 +25,51 @@ const ROOT_STYLE = { } }; -type SuggestedActionProps = { - buttonText: string; - className?: string; - displayText?: string; - image?: string; - imageAlt?: string; - itemIndex: number; - text?: string; - textClassName?: string; - type?: - | 'call' - | 'downloadFile' - | 'imBack' - | 'messageBack' - | 'openUrl' - | 'playAudio' - | 'playVideo' - | 'postBack' - | 'showImage' - | 'signin'; - value?: any; -}; +const suggestedActionPropsSchema = pipe( + object({ + buttonText: string(), + className: optional(string()), + displayText: optional(string()), + image: optional(string()), + imageAlt: optional(string()), + itemIndex: number(), + text: optional(string()), + textClassName: optional(string()), + type: optional( + union([ + literal('call'), + literal('downloadFile'), + literal('imBack'), + literal('messageBack'), + literal('openUrl'), + literal('playAudio'), + literal('playVideo'), + literal('postBack'), + literal('showImage'), + literal('signin') + ]) + ), + value: any() + }), + readonly() +); + +type SuggestedActionProps = InferInput; + +function SuggestedAction(props: SuggestedActionProps) { + const { + buttonText, + className, + displayText = '', + image = '', + imageAlt, + itemIndex, + text = '', + textClassName, + type, + value + } = validateProps(suggestedActionPropsSchema, props); -const SuggestedAction: VFC = ({ - buttonText, - className, - displayText, - image, - imageAlt, - itemIndex, - text, - textClassName, - type, - value -}) => { const [_, setSuggestedActions] = useSuggestedActions(); const [{ suggestedActionsStackedLayoutButtonTextWrap }] = useStyleOptions(); const [{ suggestedAction: suggestedActionStyleSet }] = useStyleSet(); @@ -109,7 +120,7 @@ const SuggestedAction: VFC = ({ }, rootClassName, suggestedActionStyleSet + '', - (className || '') + '' + className )} disabled={uiState === 'disabled'} onClick={handleClick} @@ -126,36 +137,11 @@ const SuggestedAction: VFC = ({ src={image} /> )} - {buttonText} + {buttonText}
); -}; - -SuggestedAction.defaultProps = { - className: '', - displayText: '', - image: '', - imageAlt: undefined, - text: '', - textClassName: '', - type: undefined, - value: undefined -}; - -SuggestedAction.propTypes = { - buttonText: PropTypes.string.isRequired, - className: PropTypes.string, - displayText: PropTypes.string, - image: PropTypes.string, - imageAlt: PropTypes.string, - itemIndex: PropTypes.number.isRequired, - text: PropTypes.string, - textClassName: PropTypes.string, - // TypeScript class is not mappable to PropTypes. - // @ts-ignore - type: PropTypes.string, - value: PropTypes.any -}; +} -export default SuggestedAction; +export default memo(SuggestedAction); +export { suggestedActionPropsSchema, type SuggestedActionProps }; diff --git a/packages/component/src/SendBoxToolbar/UploadButton.tsx b/packages/component/src/SendBoxToolbar/UploadButton.tsx index 137c0cb824..ed7ce4434f 100644 --- a/packages/component/src/SendBoxToolbar/UploadButton.tsx +++ b/packages/component/src/SendBoxToolbar/UploadButton.tsx @@ -1,13 +1,14 @@ import { hooks } from 'botframework-webchat-api'; +import { validateProps } from 'botframework-webchat-api/internal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { useCallback, useRef, type FC, type FormEventHandler, type MouseEventHandler } from 'react'; +import React, { memo, useCallback, useRef, type FormEventHandler, type MouseEventHandler } from 'react'; import { useRefFrom } from 'use-ref-from'; +import { object, pipe, readonly, string, type InferInput } from 'valibot'; import IconButton from '../SendBox/IconButton'; -import useMakeThumbnail from '../hooks/useMakeThumbnail'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; import useFocus from '../hooks/useFocus'; +import useMakeThumbnail from '../hooks/useMakeThumbnail'; import useStyleSet from '../hooks/useStyleSet'; import useSubmit from '../providers/internal/SendBox/useSubmit'; import AttachmentIcon from './Assets/AttachmentIcon'; @@ -33,11 +34,18 @@ const ROOT_STYLE = { const PREVENT_DEFAULT_HANDLER = event => event.preventDefault(); -type UploadButtonProps = { - className?: string; -}; +const uploadButtonPropsSchema = pipe( + object({ + className: string() + }), + readonly() +); + +type UploadButtonProps = InferInput; + +function UploadButton(props: UploadButtonProps) { + const { className } = validateProps(uploadButtonPropsSchema, props); -const UploadButton: FC = ({ className }) => { const [{ sendAttachmentOn, uploadAccept, uploadMultiple }] = useStyleOptions(); const [{ uploadButton: uploadButtonStyleSet }] = useStyleSet(); const [sendBoxAttachments, setSendBoxAttachments] = useSendBoxAttachments(); @@ -98,14 +106,7 @@ const UploadButton: FC = ({ className }) => {
); -}; - -UploadButton.defaultProps = { - className: undefined -}; - -UploadButton.propTypes = { - className: PropTypes.string -}; +} -export default UploadButton; +export default memo(UploadButton); +export { uploadButtonPropsSchema, type UploadButtonProps }; diff --git a/packages/component/src/Transcript/ActivityRow.tsx b/packages/component/src/Transcript/ActivityRow.tsx index d54ecfe4d6..d823436520 100644 --- a/packages/component/src/Transcript/ActivityRow.tsx +++ b/packages/component/src/Transcript/ActivityRow.tsx @@ -3,17 +3,17 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; -import { android } from '../Utils/detectBrowser'; -import FocusTrap from './FocusTrap'; -import ScreenReaderText from '../ScreenReaderText'; import SpeakActivity from '../Activity/Speak'; import useActiveDescendantId from '../providers/TranscriptFocus/useActiveDescendantId'; -import useActivityAccessibleName from './useActivityAccessibleName'; import useFocusByActivityKey from '../providers/TranscriptFocus/useFocusByActivityKey'; import useGetDescendantIdByActivityKey from '../providers/TranscriptFocus/useGetDescendantIdByActivityKey'; +import ScreenReaderText from '../ScreenReaderText'; +import { android } from '../Utils/detectBrowser'; +import FocusTrap from './FocusTrap'; +import useActivityAccessibleName from './useActivityAccessibleName'; -import type { MouseEventHandler, PropsWithChildren } from 'react'; import type { WebChatActivity } from 'botframework-webchat-core'; +import type { MouseEventHandler, PropsWithChildren } from 'react'; import { useRefFrom } from 'use-ref-from'; const { useActivityKeysByRead, useGetHasAcknowledgedByActivityKey, useGetKeyByActivity } = hooks; @@ -145,7 +145,10 @@ const ActivityRow = forwardRef(({ activity, child > {focusTrapChildren} - {shouldSpeak && } + {shouldSpeak && ( + // TODO: Should build `webChatActivitySchema`. + + )}
void; - redirectRef?: MutableRefObject; -}; +const focusRedirectorPropsSchema = pipe( + object({ + className: optional(string()), + onFocus: optional(custom<() => void>(value => safeParse(function_(), value).success)), + redirectRef: optional(mutableRefObject(undefinedable(instance(HTMLElement)))) + }), + readonly() +); + +type FocusRedirectorProps = InferInput; + +function FocusRedirector(props: FocusRedirectorProps) { + const { className, onFocus, redirectRef } = validateProps(focusRedirectorPropsSchema, props); -const FocusRedirector: FC = ({ className, onFocus, redirectRef }) => { const handleFocus = useCallback(() => { redirectRef?.current?.focus(); onFocus && onFocus(); @@ -32,22 +52,7 @@ const FocusRedirector: FC = ({ className, onFocus, redirec // However, reacting with browse mode is currently okay. Just better to leave it alone. return
; -}; - -FocusRedirector.defaultProps = { - className: undefined, - onFocus: undefined, - redirectRef: undefined -}; - -FocusRedirector.propTypes = { - className: PropTypes.string, - onFocus: PropTypes.func, - // PropTypes is not fully compatible with TypeScript. - // @ts-ignore - redirectRef: PropTypes.shape({ - current: PropTypes.instanceOf(HTMLElement) - }) -}; +} export default FocusRedirector; +export { focusRedirectorPropsSchema, type FocusRedirectorProps }; diff --git a/packages/component/src/internal.ts b/packages/component/src/internal.ts index 04fcfb4737..c018992c7c 100644 --- a/packages/component/src/internal.ts +++ b/packages/component/src/internal.ts @@ -1,12 +1,12 @@ -import parseDocumentFragmentFromString from './Utils/parseDocumentFragmentFromString'; -import serializeDocumentFragmentIntoString from './Utils/serializeDocumentFragmentIntoString'; import { - useCodeHighlighter, CodeHighlighterComposer, + useCodeHighlighter, type HighlightCodeFn } from './hooks/internal/codeHighlighter/index'; import useInjectStyles from './hooks/internal/useInjectStyles'; import { useLiveRegion } from './providers/LiveRegionTwin/index'; +import parseDocumentFragmentFromString from './Utils/parseDocumentFragmentFromString'; +import serializeDocumentFragmentIntoString from './Utils/serializeDocumentFragmentIntoString'; export { CodeHighlighterComposer, @@ -17,4 +17,4 @@ export { useLiveRegion }; -export type { HighlightCodeFn }; +export { type HighlightCodeFn }; diff --git a/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx b/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx index 0f571c79a2..86869f1c6a 100644 --- a/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx +++ b/packages/component/src/providers/LiveRegionTwin/private/LiveRegionTwinContainer.tsx @@ -1,29 +1,35 @@ -import PropTypes from 'prop-types'; +import { validateProps } from 'botframework-webchat-api/internal'; import React, { Fragment } from 'react'; - -import type { VFC } from 'react'; +import { literal, object, optional, pipe, readonly, string, union, type InferInput } from 'valibot'; import useMarkAllAsRenderedEffect from './useMarkAllAsRenderedEffect'; import useStaticElementEntries from './useStaticElementEntries'; -type LiveRegionTwinContainerProps = { - 'aria-label'?: string; - 'aria-live': 'assertive' | 'polite'; - 'aria-roledescription'?: string; - className?: string; - role?: string; - textElementClassName?: string; -}; +const liveRegionTwinContainerPropsSchema = pipe( + object({ + 'aria-label': optional(string()), + 'aria-live': union([literal('assertive'), literal('polite')]), + 'aria-roledescription': optional(string()), + className: optional(string()), + role: optional(string()), + textElementClassName: optional(string()) + }), + readonly() +); + +type LiveRegionTwinContainerProps = InferInput; // This container is marked as private because we assume there is only one instance under the . -const LiveRegionTwinContainer: VFC = ({ - 'aria-label': ariaLabel, - 'aria-live': ariaLive, - 'aria-roledescription': ariaRoleDescription, - className, - role, - textElementClassName -}) => { +function LiveRegionTwinContainer(props: LiveRegionTwinContainerProps) { + const { + 'aria-label': ariaLabel, + 'aria-live': ariaLive, + 'aria-roledescription': ariaRoleDescription, + className, + role, + textElementClassName + } = validateProps(liveRegionTwinContainerPropsSchema, props); + const [staticElementEntries] = useStaticElementEntries(); // We assume there is only one instance under the . @@ -52,25 +58,7 @@ const LiveRegionTwinContainer: VFC = ({ })}
); -}; - -LiveRegionTwinContainer.defaultProps = { - 'aria-label': undefined, - 'aria-roledescription': undefined, - className: undefined, - role: undefined, - textElementClassName: undefined -}; - -LiveRegionTwinContainer.propTypes = { - 'aria-label': PropTypes.string, - // PropTypes.oneOf() returns type of `string`, but not `'assertive' | 'polite'`. - // @ts-ignore - 'aria-live': PropTypes.oneOf(['assertive', 'polite']).isRequired, - 'aria-roledescription': PropTypes.string, - className: PropTypes.string, - role: PropTypes.string, - textElementClassName: PropTypes.string -}; +} export default LiveRegionTwinContainer; +export { liveRegionTwinContainerPropsSchema, type LiveRegionTwinContainerProps }; diff --git a/packages/component/src/providers/RovingTabIndex/RovingTabIndexComposer.tsx b/packages/component/src/providers/RovingTabIndex/RovingTabIndexComposer.tsx index 28c06db473..7e4bd546eb 100644 --- a/packages/component/src/providers/RovingTabIndex/RovingTabIndexComposer.tsx +++ b/packages/component/src/providers/RovingTabIndex/RovingTabIndexComposer.tsx @@ -1,21 +1,39 @@ /* eslint complexity: ["error", 50] */ -import PropTypes from 'prop-types'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { memo, useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import { + custom, + function_, + literal, + object, + optional, + pipe, + readonly, + safeParse, + union, + type InferInput +} from 'valibot'; + +import reactNode from '../../types/internal/reactNode'; +import RovingTabIndexContext, { type RovingTabIndexContextType } from './private/Context'; -import RovingTabIndexContext from './private/Context'; +type ItemRef = MutableRefObject; -import type { FC, MutableRefObject, PropsWithChildren } from 'react'; -import type { RovingTabIndexContextType } from './private/Context'; +const rovingTabIndexContextProps = pipe( + object({ + children: optional(reactNode()), + onEscapeKey: optional(custom<() => void>(value => safeParse(function_(), value).success)), + orientation: optional(union([literal('horizontal'), literal('vertical')]), 'horizontal') + }), + readonly() +); -type ItemRef = MutableRefObject; +type RovingTabIndexContextProps = InferInput; -type RovingTabIndexContextProps = PropsWithChildren<{ - onEscapeKey?: () => void; - orientation?: 'horizontal' | 'vertical'; -}>; +function RovingTabIndexComposer(props: RovingTabIndexContextProps) { + const { children, onEscapeKey, orientation } = validateProps(rovingTabIndexContextProps, props); -const RovingTabIndexComposer: FC = ({ children, onEscapeKey, orientation }) => { const activeItemIndexRef = useRef(0); const itemRefsRef = useRef([]); @@ -179,16 +197,7 @@ const RovingTabIndexComposer: FC = ({ children, onEs }); return {children}; -}; - -RovingTabIndexComposer.defaultProps = { - onEscapeKey: undefined, - orientation: 'horizontal' -}; - -RovingTabIndexComposer.propTypes = { - onEscapeKey: PropTypes.func, - orientation: PropTypes.oneOf(['horizontal', 'vertical']) -}; +} -export default RovingTabIndexComposer; +export default memo(RovingTabIndexComposer); +export { rovingTabIndexContextProps, type RovingTabIndexContext }; diff --git a/packages/component/src/types/internal/mutableRefObject.ts b/packages/component/src/types/internal/mutableRefObject.ts index efe7758fa8..053e54c78e 100644 --- a/packages/component/src/types/internal/mutableRefObject.ts +++ b/packages/component/src/types/internal/mutableRefObject.ts @@ -7,23 +7,23 @@ import { type BaseIssue, type BaseSchema, type ErrorMessage, - type ObjectIssue, - type ObjectSchema + type InferOutput, + type ObjectIssue } from 'valibot'; function mutableRefObject>>( baseSchema: TInput -): ObjectSchema<{ current: TInput }, undefined>; +): BaseSchema }, BaseIssue>; function mutableRefObject< TInput extends BaseSchema>, const TMessage extends ErrorMessage | undefined ->(baseSchema: TInput, message: TMessage): ObjectSchema<{ current: TInput }, TMessage>; +>(baseSchema: TInput, message: TMessage): BaseSchema }, BaseIssue>; function mutableRefObject< TInput extends BaseSchema>, const TMessage extends ErrorMessage | undefined ->(baseSchema: TInput, message?: TMessage): BaseSchema> { +>(baseSchema: TInput, message?: TMessage): BaseSchema }, BaseIssue> { return pipe( any(), check(value => safeParse(object({ current: baseSchema }, message), value).success) diff --git a/packages/component/src/types/internal/refObject.ts b/packages/component/src/types/internal/refObject.ts new file mode 100644 index 0000000000..12b19ceb94 --- /dev/null +++ b/packages/component/src/types/internal/refObject.ts @@ -0,0 +1,40 @@ +import { + any, + check, + object, + pipe, + readonly, + safeParse, + type BaseIssue, + type BaseSchema, + type ErrorMessage, + type InferOutput, + type ObjectIssue +} from 'valibot'; + +function refObject>>( + baseSchema: TInput +): BaseSchema }, BaseIssue>; + +function refObject< + TInput extends BaseSchema>, + const TMessage extends ErrorMessage | undefined +>( + baseSchema: TInput, + message: TMessage +): BaseSchema }, BaseIssue>; + +function refObject< + TInput extends BaseSchema>, + const TMessage extends ErrorMessage | undefined +>( + baseSchema: TInput, + message?: TMessage +): BaseSchema }, BaseIssue> { + return pipe( + any(), + check(value => safeParse(pipe(object({ current: baseSchema }, message), readonly()), value).success) + ); +} + +export default refObject; diff --git a/packages/core/package.json b/packages/core/package.json index 30a81eaff0..14e8dbd32f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -88,10 +88,6 @@ "@babel/runtime": [ "7.19.0", "@babel/*@7.21 is causing out-of-memory (OOM) issues" - ], - "valibot": [ - "0", - "valibot@0 until they finalize @1" ] }, "devDependencies": { @@ -120,6 +116,6 @@ "redux": "5.0.1", "redux-saga": "1.3.0", "simple-update-in": "2.2.0", - "valibot": "0.42.1" + "valibot": "1.1.0" } } diff --git a/packages/fluent-theme/package.json b/packages/fluent-theme/package.json index 9b8fb18601..5d8ccf7774 100644 --- a/packages/fluent-theme/package.json +++ b/packages/fluent-theme/package.json @@ -61,10 +61,6 @@ "@types/react": [ "16", "react@16.8.6 is our baseline" - ], - "valibot": [ - "0", - "valibot@0 until they finalize @1" ] }, "devDependencies": { @@ -85,7 +81,7 @@ "inject-meta-tag": "0.0.1", "math-random": "2.0.1", "use-ref-from": "0.1.0", - "valibot": "0.42.1" + "valibot": "1.1.0" }, "peerDependencies": { "react": ">= 16.8.6" diff --git a/samples/01.getting-started/l.sharepoint-web-part/src/spfx/src/webparts/webChat/components/WebChat.tsx b/samples/01.getting-started/l.sharepoint-web-part/src/spfx/src/webparts/webChat/components/WebChat.tsx index e5339a54de..4ff07d4ab4 100644 --- a/samples/01.getting-started/l.sharepoint-web-part/src/spfx/src/webparts/webChat/components/WebChat.tsx +++ b/samples/01.getting-started/l.sharepoint-web-part/src/spfx/src/webparts/webChat/components/WebChat.tsx @@ -1,13 +1,10 @@ -import { useEffect, useState } from 'react'; -import * as React from 'react'; import ReactWebChat, { createDirectLine, createDirectLineAppServiceExtension } from 'botframework-webchat'; +import React, { memo, useEffect, useState } from 'react'; import { IWebChatProps } from './IWebChatProps'; import styles from './WebChat.module.scss'; -import type { VFC } from 'react'; - -const WebChat: VFC = ({ domain, token }) => { +function WebChat({ domain, token }: IWebChatProps) { const [directLine, setDirectLine] = useState(); useEffect(() => { @@ -19,6 +16,6 @@ const WebChat: VFC = ({ domain, token }) => { }, [setDirectLine]); return
{!!directLine && }
; -}; +} -export default WebChat; +export default memo(WebChat);