diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 22e62ca64..638b2cb10 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -2,9 +2,10 @@ name: Deploy Development on: push: branches: + - main - development jobs: - test: + validate: runs-on: ubuntu-latest env: REACT_APP_INFURA_URL: ${{secrets.INFURA_URL}} @@ -15,9 +16,11 @@ jobs: node-version: '16' - run: echo '//npm.pkg.github.com/:_authToken=${{secrets.GITHUB_TOKEN}}' >> .npmrc - run: npm install + - run: npm run lint + - run: npx tsc - run: npm run test deploy: - needs: test + needs: validate runs-on: ubuntu-latest environment: development env: diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 3923c9ec0..a12c3b197 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -1,21 +1,10 @@ name: Deploy Production on: - push: - branches: - - main + release: + types: + - published jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '16' - - run: echo '//npm.pkg.github.com/:_authToken=${{secrets.GITHUB_TOKEN}}' >> .npmrc - - run: npm install - - run: npm run test deploy: - needs: test runs-on: ubuntu-latest environment: production env: diff --git a/.github/workflows/run-build.yml b/.github/workflows/run-build.yml new file mode 100644 index 000000000..c97e46959 --- /dev/null +++ b/.github/workflows/run-build.yml @@ -0,0 +1,18 @@ +name: run-build +on: + pull_request: {} + +jobs: + run-build: + runs-on: ubuntu-latest + env: + REACT_APP_INFURA_URL: ${{secrets.INFURA_URL}} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '16' + - run: echo '//npm.pkg.github.com/:_authToken=${{secrets.GITHUB_TOKEN}}' >> .npmrc + - run: npm install + - name: Build project + run: npm run build diff --git a/.github/workflows/run-lint.yml b/.github/workflows/run-lint.yml new file mode 100644 index 000000000..3d06bb7b5 --- /dev/null +++ b/.github/workflows/run-lint.yml @@ -0,0 +1,18 @@ +name: run-lint +on: + pull_request: {} + +jobs: + run-lint: + runs-on: ubuntu-latest + env: + REACT_APP_INFURA_URL: ${{secrets.INFURA_URL}} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '16' + - run: echo '//npm.pkg.github.com/:_authToken=${{secrets.GITHUB_TOKEN}}' >> .npmrc + - run: npm install + - name: Build project + run: npm run lint diff --git a/.github/workflows/run-typecheck.yml b/.github/workflows/run-typecheck.yml new file mode 100644 index 000000000..07b924343 --- /dev/null +++ b/.github/workflows/run-typecheck.yml @@ -0,0 +1,16 @@ +name: run-typecheck +on: + pull_request: {} + +jobs: + run-typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '16' + - run: echo '//npm.pkg.github.com/:_authToken=${{secrets.GITHUB_TOKEN}}' >> .npmrc + - run: npm install + - name: Build project + run: npx tsc diff --git a/package-lock.json b/package-lock.json index fd4701b4b..b45d82f0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@web3-react/portis-connector": "^6.2.11", "@web3-react/walletconnect-connector": "^6.2.13", "@web3-react/walletlink-connector": "^6.2.13", - "@zer0-os/zos-component-library": "0.18.5", - "@zer0-os/zos-feed": "^1.30.0", + "@zer0-os/zos-component-library": "0.18.7", + "@zer0-os/zos-feed": "^1.29.2", "@zer0-os/zos-zns": "2.3.3", "@zero-tech/zapp-buy-domains": "^0.2.1", "@zero-tech/zapp-daos": "^0.4.0", @@ -7633,9 +7633,9 @@ } }, "node_modules/@zer0-os/zos-component-library": { - "version": "0.18.5", - "resolved": "https://npm.pkg.github.com/download/@zer0-os/zos-component-library/0.18.5/2c0bc1147ab1337d92c53a72998f0b96423b2672", - "integrity": "sha512-/NzWKvttu/ijMXrNE+f3iW1PtEFytsXrk8N4OXSeTU/VRHwj+dO/UtpjN0MZTKE2FUiHUa957NXFTfjEc2l6vg==", + "version": "0.18.7", + "resolved": "https://npm.pkg.github.com/download/@zer0-os/zos-component-library/0.18.7/c730a4b1d829a1aabc12fe27f3ecb17a977f7941", + "integrity": "sha512-YYJI5B37d9bFXH7FMO7pdmp6qcnNdzLvLYvGJOLrOmjoupoDIqfDvecLLGUgqW0d8NAL+0/O9KKmJiFaKJa9rA==", "license": "ISC", "dependencies": { "@giphy/js-types": "^4.2.1", @@ -8665,7 +8665,6 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@ethersproject/address": "^5.4.0", "@ethersproject/bignumber": "^5.4.0", @@ -8690,18 +8689,19 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@ethersproject/logger": "^5.4.0" } }, "node_modules/@zero-tech/zero-contracts": { "version": "0.0.4", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@zero-tech/zero-contracts/-/zero-contracts-0.0.4.tgz", + "integrity": "sha512-nqDoLsUlTf7shanR9mzi8E7OzfSWzlaZtLrpwgCIojYnDjf/oUJ4N3iF00vCt1jmDRzP61EoNo3I0yQFg6m60Q==" }, "node_modules/@zero-tech/zfi-sdk": { "version": "0.2.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@zero-tech/zfi-sdk/-/zfi-sdk-0.2.1.tgz", + "integrity": "sha512-NQplN7XPNQXjp6qKxUR9bTMt4YhSEJ4JtUs8Y7frgLOT/Dmqt3Dit2arGWwlfGY9F3PzC2TtBkRDLbzc2l2JEw==", "dependencies": { "@apollo/client": "3.4.10", "@ethersproject/abi": "5.4.1", @@ -8719,6 +8719,8 @@ }, "node_modules/@zero-tech/zfi-sdk/node_modules/@ethersproject/abi": { "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.4.1.tgz", + "integrity": "sha512-9mhbjUk76BiSluiiW4BaYyI58KSbDMMQpCLdsAR+RsT2GyATiNYxVv+pGWRrekmsIdY3I+hOqsYQSTkc8L/mcg==", "funding": [ { "type": "individual", @@ -8729,7 +8731,6 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@ethersproject/address": "^5.4.0", "@ethersproject/bignumber": "^5.4.0", @@ -8744,6 +8745,8 @@ }, "node_modules/@zero-tech/zfi-sdk/node_modules/@ethersproject/bytes": { "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.4.0.tgz", + "integrity": "sha512-H60ceqgTHbhzOj4uRc/83SCN9d+BSUnOkrr2intevqdtEMO1JFVZ1XL84OEZV+QjV36OaZYxtnt4lGmxcGsPfA==", "funding": [ { "type": "individual", @@ -8754,14 +8757,14 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@ethersproject/logger": "^5.4.0" } }, "node_modules/@zero-tech/zns-sdk": { "version": "0.8.19", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@zero-tech/zns-sdk/-/zns-sdk-0.8.19.tgz", + "integrity": "sha512-mw+6xRwZayRkfL5D4qZpT1lEHig9AkBL2EmXJRfbPDMBfQ3B4HzP/h1a+cunh2zC2rHDGcIW/SjkkSrZoKL1oQ==", "dependencies": { "@apollo/client": "3.4.10", "@ethersproject/abi": "5.4.1", @@ -8783,6 +8786,8 @@ }, "node_modules/@zero-tech/zns-sdk/node_modules/@ethersproject/abi": { "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.4.1.tgz", + "integrity": "sha512-9mhbjUk76BiSluiiW4BaYyI58KSbDMMQpCLdsAR+RsT2GyATiNYxVv+pGWRrekmsIdY3I+hOqsYQSTkc8L/mcg==", "funding": [ { "type": "individual", @@ -8793,7 +8798,6 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@ethersproject/address": "^5.4.0", "@ethersproject/bignumber": "^5.4.0", @@ -8808,6 +8812,8 @@ }, "node_modules/@zero-tech/zns-sdk/node_modules/@ethersproject/bytes": { "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.4.0.tgz", + "integrity": "sha512-H60ceqgTHbhzOj4uRc/83SCN9d+BSUnOkrr2intevqdtEMO1JFVZ1XL84OEZV+QjV36OaZYxtnt4lGmxcGsPfA==", "funding": [ { "type": "individual", @@ -8818,14 +8824,14 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", "dependencies": { "@ethersproject/logger": "^5.4.0" } }, "node_modules/@zero-tech/zns-sdk/node_modules/dotenv": { "version": "16.0.1", - "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz", + "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==", "engines": { "node": ">=12" } @@ -38450,9 +38456,9 @@ } }, "@zer0-os/zos-component-library": { - "version": "0.18.5", - "resolved": "https://npm.pkg.github.com/download/@zer0-os/zos-component-library/0.18.5/2c0bc1147ab1337d92c53a72998f0b96423b2672", - "integrity": "sha512-/NzWKvttu/ijMXrNE+f3iW1PtEFytsXrk8N4OXSeTU/VRHwj+dO/UtpjN0MZTKE2FUiHUa957NXFTfjEc2l6vg==", + "version": "0.18.7", + "resolved": "https://npm.pkg.github.com/download/@zer0-os/zos-component-library/0.18.7/c730a4b1d829a1aabc12fe27f3ecb17a977f7941", + "integrity": "sha512-YYJI5B37d9bFXH7FMO7pdmp6qcnNdzLvLYvGJOLrOmjoupoDIqfDvecLLGUgqW0d8NAL+0/O9KKmJiFaKJa9rA==", "requires": { "@giphy/js-types": "^4.2.1", "classnames": "^2.3.1", @@ -38473,7 +38479,7 @@ "requires": { "@cloudinary/react": "^1.3.0", "@cloudinary/url-gen": "^1.7.0", - "@zer0-os/zos-component-library": "0.18.5", + "@zer0-os/zos-component-library": "0.18.7", "@zer0-os/zos-zns": "2.3.3", "@zero-tech/zapp-utils": "^0.5.1", "classnames": "^2.3.1", @@ -38981,7 +38987,7 @@ "@babel/plugin-proposal-class-properties": "^7.18.6", "@enzoferey/ethers-error-parser": "^0.2.2", "@ethersproject/units": "^5.6.1", - "@zer0-os/zos-component-library": "0.18.5", + "@zer0-os/zos-component-library": "0.18.7", "@zero-tech/zapp-utils": "^0.5.0", "@zero-tech/zfi-sdk": "0.2.1", "@zero-tech/zns-sdk": "0.6.1", @@ -39175,10 +39181,14 @@ } }, "@zero-tech/zero-contracts": { - "version": "0.0.4" + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@zero-tech/zero-contracts/-/zero-contracts-0.0.4.tgz", + "integrity": "sha512-nqDoLsUlTf7shanR9mzi8E7OzfSWzlaZtLrpwgCIojYnDjf/oUJ4N3iF00vCt1jmDRzP61EoNo3I0yQFg6m60Q==" }, "@zero-tech/zfi-sdk": { "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@zero-tech/zfi-sdk/-/zfi-sdk-0.2.1.tgz", + "integrity": "sha512-NQplN7XPNQXjp6qKxUR9bTMt4YhSEJ4JtUs8Y7frgLOT/Dmqt3Dit2arGWwlfGY9F3PzC2TtBkRDLbzc2l2JEw==", "requires": { "@apollo/client": "3.4.10", "@ethersproject/abi": "5.4.1", @@ -39190,6 +39200,8 @@ "dependencies": { "@ethersproject/abi": { "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.4.1.tgz", + "integrity": "sha512-9mhbjUk76BiSluiiW4BaYyI58KSbDMMQpCLdsAR+RsT2GyATiNYxVv+pGWRrekmsIdY3I+hOqsYQSTkc8L/mcg==", "requires": { "@ethersproject/address": "^5.4.0", "@ethersproject/bignumber": "^5.4.0", @@ -39204,6 +39216,8 @@ }, "@ethersproject/bytes": { "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.4.0.tgz", + "integrity": "sha512-H60ceqgTHbhzOj4uRc/83SCN9d+BSUnOkrr2intevqdtEMO1JFVZ1XL84OEZV+QjV36OaZYxtnt4lGmxcGsPfA==", "requires": { "@ethersproject/logger": "^5.4.0" } @@ -39212,6 +39226,8 @@ }, "@zero-tech/zns-sdk": { "version": "0.8.19", + "resolved": "https://registry.npmjs.org/@zero-tech/zns-sdk/-/zns-sdk-0.8.19.tgz", + "integrity": "sha512-mw+6xRwZayRkfL5D4qZpT1lEHig9AkBL2EmXJRfbPDMBfQ3B4HzP/h1a+cunh2zC2rHDGcIW/SjkkSrZoKL1oQ==", "requires": { "@apollo/client": "3.4.10", "@ethersproject/abi": "5.4.1", @@ -39229,6 +39245,8 @@ "dependencies": { "@ethersproject/abi": { "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.4.1.tgz", + "integrity": "sha512-9mhbjUk76BiSluiiW4BaYyI58KSbDMMQpCLdsAR+RsT2GyATiNYxVv+pGWRrekmsIdY3I+hOqsYQSTkc8L/mcg==", "requires": { "@ethersproject/address": "^5.4.0", "@ethersproject/bignumber": "^5.4.0", @@ -39243,12 +39261,16 @@ }, "@ethersproject/bytes": { "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.4.0.tgz", + "integrity": "sha512-H60ceqgTHbhzOj4uRc/83SCN9d+BSUnOkrr2intevqdtEMO1JFVZ1XL84OEZV+QjV36OaZYxtnt4lGmxcGsPfA==", "requires": { "@ethersproject/logger": "^5.4.0" } }, "dotenv": { - "version": "16.0.1" + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz", + "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==" } } }, diff --git a/package.json b/package.json index e49eb904f..55a0c5aeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zOS", - "version": "0.2.15", + "version": "0.3.0", "private": true, "main": "./public/electron.js", "engines": { @@ -23,8 +23,8 @@ "@web3-react/portis-connector": "^6.2.11", "@web3-react/walletconnect-connector": "^6.2.13", "@web3-react/walletlink-connector": "^6.2.13", - "@zer0-os/zos-component-library": "0.18.5", - "@zer0-os/zos-feed": "^1.30.0", + "@zer0-os/zos-component-library": "0.18.7", + "@zer0-os/zos-feed": "^1.29.2", "@zer0-os/zos-zns": "2.3.3", "@zero-tech/zapp-buy-domains": "^0.2.1", "@zero-tech/zapp-daos": "^0.4.0", diff --git a/src/Main.test.tsx b/src/Main.test.tsx index 4325c14c9..8870e7f80 100644 --- a/src/Main.test.tsx +++ b/src/Main.test.tsx @@ -10,8 +10,12 @@ import { AddressBarContainer } from './components/address-bar/container'; describe('Main', () => { const subject = (props: Partial = {}) => { const allProps = { + isSidekickOpen: false, hasContextPanel: false, isContextPanelOpen: false, + context: { + isAuthenticated: false, + }, ...props, }; diff --git a/src/Main.tsx b/src/Main.tsx index 0977e296d..e53c5509d 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -10,10 +10,16 @@ import { Logo } from './components/logo'; import './main.scss'; import classNames from 'classnames'; +import { Sidekick } from './components/sidekick/index'; +import { withContext as withAuthenticationContext } from './components/authentication/context'; export interface Properties { hasContextPanel: boolean; isContextPanelOpen: boolean; + isSidekickOpen: boolean; + context: { + isAuthenticated: boolean; + }; } export class Container extends React.Component { @@ -23,6 +29,7 @@ export class Container extends React.Component { return { hasContextPanel: layout.hasContextPanel, isContextPanelOpen: layout.isContextPanelOpen, + isSidekickOpen: layout.isSidekickOpen, }; } @@ -33,6 +40,7 @@ export class Container extends React.Component { render() { const mainClassName = classNames('main', { 'context-panel-open': this.props.isContextPanelOpen, + 'sidekick-panel-open': this.props.isSidekickOpen && this.props.context.isAuthenticated, 'has-context-panel': this.props.hasContextPanel, }); @@ -57,10 +65,11 @@ export class Container extends React.Component { + ); } } -export const Main = connectContainer<{}>(Container); +export const Main = withAuthenticationContext<{}>(connectContainer<{}>(Container)); diff --git a/src/_layout.scss b/src/_layout.scss index bd422a01b..d0eadd501 100644 --- a/src/_layout.scss +++ b/src/_layout.scss @@ -9,7 +9,7 @@ $width-application-navigation: 224px; $width-navigation: calc($width-platform-navigation + $width-world-navigation); $width-collapsed-navigation: calc($width-world-navigation + $width-collapsed-platform-navigation); $width-application-content: 736px; -$width-sidekick: 117px; +$width-sidekick: 220px; $height-main: 65px; $breakpoint-collapse-menu: 1294px; @@ -17,10 +17,15 @@ $breakpoint-collapse-menu: 1294px; @mixin root-layout-vars { --layout-transition-easing-function: ease-in; --layout-app-content-right-padding: 64px; + --sidekick-transition-easing-function: ease-out; &.context-panel-open { --layout-transition-easing-function: ease-out; } + + &.sidekick-panel-open { + --sidekick-transition-easing-function: ease-in; + } } @mixin layout-transition($first-prop, $second-prop: null) { @@ -32,6 +37,10 @@ $breakpoint-collapse-menu: 1294px; } } +@mixin layout-sidekick-transition($first-prop) { + transition: $first-prop animation.$animation-duration-double var(--sidekick-transition-easing-function); +} + @mixin for-left-collapse { @media (max-width: $breakpoint-collapse-menu) { @content; diff --git a/src/app-sandbox/container.test.tsx b/src/app-sandbox/container.test.tsx index 3921e200c..f4acc7141 100644 --- a/src/app-sandbox/container.test.tsx +++ b/src/app-sandbox/container.test.tsx @@ -8,6 +8,9 @@ import { Chains, ConnectionStatus, Connectors } from '../lib/web3'; import { ProviderService } from '../lib/web3/provider-service'; import { AppLayout } from '../store/layout'; +// Don't load full external projects +jest.mock('.', () => ({ AppSandbox: () => <> })); + describe('AppSandboxContainer', () => { const subject = (props: Partial = {}) => { const allProps: Properties = { @@ -150,10 +153,7 @@ describe('AppSandboxContainer', () => { }); it('passes layout to child', () => { - const layout = { - isContextPanelOpen: false, - hasContextPanel: true, - }; + const layout = {} as AppLayout; const wrapper = subject({ layout }); diff --git a/src/app-sandbox/index.test.tsx b/src/app-sandbox/index.test.tsx index 6080fbd94..86487865c 100644 --- a/src/app-sandbox/index.test.tsx +++ b/src/app-sandbox/index.test.tsx @@ -9,6 +9,13 @@ import { Channels } from '../platform-apps/channels'; import { AppLayoutContextProvider } from '@zer0-os/zos-component-library'; import { AppLayout } from '../store/layout'; +// Don't load full external projects +jest.mock('@zero-tech/zapp-nfts', () => ({})); +jest.mock('@zero-tech/zapp-staking', () => ({})); +jest.mock('@zero-tech/zapp-daos', () => ({})); +jest.mock('@zero-tech/zapp-buy-domains', () => ({})); +jest.mock('@zer0-os/zos-feed', () => ({ App: () => <> })); + describe('AppSandbox', () => { const subject = (props: any = {}) => { const allProps = { diff --git a/src/app-sandbox/index.tsx b/src/app-sandbox/index.tsx index 64a432e58..7226c7f56 100644 --- a/src/app-sandbox/index.tsx +++ b/src/app-sandbox/index.tsx @@ -107,10 +107,11 @@ export class AppSandbox extends React.Component { } render() { - const { hasContextPanel, isContextPanelOpen } = this.props.layout; + const { hasContextPanel, isContextPanelOpen, isSidekickOpen } = this.props.layout; const className = classNames('app-sandbox', { 'context-panel-open': isContextPanelOpen, + 'sidekick-panel-open': isSidekickOpen && this.props.authenticationContext.isAuthenticated, 'has-context-panel': hasContextPanel, }); diff --git a/src/app-sandbox/styles.scss b/src/app-sandbox/styles.scss index 184141a27..9b4d14236 100644 --- a/src/app-sandbox/styles.scss +++ b/src/app-sandbox/styles.scss @@ -13,7 +13,12 @@ $speed: animation.$animation-duration; left: $width-navigation; right: 0; + &.sidekick-panel-open { + right: $width-sidekick; + } + @include layout-transition(left); + @include layout-sidekick-transition(right); @include for-left-collapse() { left: $width-collapsed-navigation; diff --git a/src/components/autocomplete-dropdown/autocomplete-dropdown.test.tsx b/src/components/autocomplete-dropdown/autocomplete-dropdown.test.tsx index 387da8f53..246b757de 100644 --- a/src/components/autocomplete-dropdown/autocomplete-dropdown.test.tsx +++ b/src/components/autocomplete-dropdown/autocomplete-dropdown.test.tsx @@ -1,28 +1,23 @@ -/** - * @jest-environment jsdom - */ - import React from 'react'; -import { AutocompleteDropdown, Properties, Result } from './'; -import { shallow, mount } from 'enzyme'; +import { AutocompleteDropdown, AutocompleteItem, Properties, Result, ResultProperties } from './'; +import { shallow } from 'enzyme'; +import { Key } from '../../lib/keyboard-search'; -let findMatches; let onSelect; -let onCloseBar; +let onCancel; describe('autocomplete-dropdown', () => { beforeEach(() => { - findMatches = jest.fn(); onSelect = jest.fn(); - onCloseBar = jest.fn(); + onCancel = jest.fn(); }); function subject(initialData: Partial = {}) { const state: Properties = { - findMatches, + findMatches: null, onSelect, - onCloseBar, + onCancel, value: null, ...initialData, }; @@ -30,18 +25,6 @@ describe('autocomplete-dropdown', () => { return shallow(); } - function subjectMount(initialData: Partial = {}) { - const state: Properties = { - findMatches, - onSelect, - onCloseBar, - value: null, - ...initialData, - }; - - return mount(); - } - it('it renders input', () => { const wrapper = subject({ placeholder: 'TYPE STUFF', @@ -65,59 +48,21 @@ describe('autocomplete-dropdown', () => { }); it('it renders match suggestions', async () => { - findMatches = () => { - return [ - { - id: 'result-first-id', - value: 'result-first-value', - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - ]; - }; + const searchResults = stubResults(2); - const wrapper = subject({ findMatches }); - - const input = wrapper.find('input'); + const wrapper = subject({ findMatches: stubSearchFor('anything', searchResults) }); - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); + await performSearch(wrapper, 'anything'); - await new Promise(setImmediate); - - expect(wrapper.find(Result).map((r) => r.prop('item'))).toEqual(findMatches()); + expect(wrapper.find(Result).map((r) => r.prop('item'))).toEqual(searchResults); }); it('it sets the first item found to the "focused" one', async () => { - findMatches = () => { - return [ - { - id: 'result-first-id', - value: 'result-first-value', - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - ]; - }; + const findMatches = stubSearchFor('anything', stubResults(2)); const wrapper = subject({ findMatches }); - const input = wrapper.find('input'); - - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); - - await new Promise(setImmediate); + await performSearch(wrapper, 'anything'); expect(wrapper.find(Result).map((r) => r.prop('isFocused'))).toEqual([ true, @@ -126,78 +71,33 @@ describe('autocomplete-dropdown', () => { }); it('it sets the next item as to the "focused" one when hitting "down"', async () => { - findMatches = () => { - return [ - { - id: 'result-first-id', - value: 'result-first-value', - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - ]; - }; + const findMatches = stubSearchFor('anything', stubResults(3)); const wrapper = subject({ findMatches }); - const input = wrapper.find('input'); - - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); - - await new Promise(setImmediate); + await performSearch(wrapper, 'anything'); expect(wrapper.find(Result).map((r) => r.prop('isFocused'))).toEqual([ true, false, + false, ]); - input.simulate('keydown', { - key: 'ArrowDown', - preventDefault: () => {}, - stopPropagation: () => {}, - }); + pressKey(wrapper, Key.ArrowDown); expect(wrapper.find(Result).map((r) => r.prop('isFocused'))).toEqual([ false, true, + false, ]); }); it('it sets the last item as to the "focused" one when hitting "up"', async () => { - findMatches = () => { - return [ - { - id: 'result-first-id', - value: 'result-first-value', - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - { - id: 'result-third-id', - value: 'result-third-value', - route: 'result-third-route', - }, - ]; - }; + const findMatches = stubSearchFor('anything', stubResults(3)); const wrapper = subject({ findMatches }); - const input = wrapper.find('input'); - - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); - - await new Promise(setImmediate); + await performSearch(wrapper, 'anything'); expect(wrapper.find(Result).map((r) => r.prop('isFocused'))).toEqual([ true, @@ -205,11 +105,7 @@ describe('autocomplete-dropdown', () => { false, ]); - input.simulate('keydown', { - key: 'ArrowUp', - preventDefault: () => {}, - stopPropagation: () => {}, - }); + pressKey(wrapper, Key.ArrowUp); expect(wrapper.find(Result).map((r) => r.prop('isFocused'))).toEqual([ false, @@ -219,205 +115,129 @@ describe('autocomplete-dropdown', () => { }); it('it selects the currently focused option when pressing "Enter"', async () => { - findMatches = () => { - return [ - { - id: 'result-first-id', - value: 'result-first-value', - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - ]; - }; + const searchResults = stubResults(2); + const findMatches = stubSearchFor('anything', searchResults); const wrapper = subject({ findMatches }); - const input = wrapper.find('input'); + await performSearch(wrapper, 'anything'); - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); + pressKey(wrapper, Key.ArrowUp); + pressKey(wrapper, Key.Enter); - await new Promise(setImmediate); - - input.simulate('keydown', { - key: 'ArrowUp', - preventDefault: () => {}, - stopPropagation: () => {}, - }); - input.simulate('keydown', { - key: 'Enter', - preventDefault: () => {}, - stopPropagation: () => {}, - }); - - expect(onSelect).toHaveBeenCalledWith(findMatches()[1]); + expect(wrapper.find('input').prop('value')).toEqual(searchResults[1].value); + expect(onSelect).toHaveBeenCalledWith(searchResults[1]); }); - it('selecting a match triggers change event', async () => { - const expectation = 'result-first-value'; - - findMatches = () => { - return [ - { - id: 'result-first-id', - value: expectation, - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - ]; - }; - - const wrapper = subjectMount({ findMatches }); + it('it selects the item via mouse', async () => { + const searchResults = stubResults(2); + const valueToSelect = searchResults[0].value; + const findMatches = stubSearchFor('anything', searchResults); - const input = wrapper.find('input'); - - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); - - await new Promise(setImmediate); + const wrapper = subject({ findMatches }); + await performSearch(wrapper, 'anything'); wrapper.update(); + selectOption(wrapper, valueToSelect); - const option = wrapper.find('[className*="-item"]').filterWhere((n) => n.text() === expectation); - option.simulate('mouseDown'); - - expect(onSelect).toHaveBeenCalledWith(findMatches()[0]); + expect(wrapper.find('input').prop('value')).toEqual(searchResults[0].value); + expect(onSelect).toHaveBeenCalledWith(searchResults[0]); }); - it('selecting an option verifies value and closes dropdown', async () => { - const expectation = 'result-value'; + it('selecting an option closes results dropdown', async () => { + const searchResults = stubResults(1); + const valueToSelect = searchResults[0].value; + const wrapper = subject({ findMatches: stubSearchFor('anything', searchResults) }); - findMatches = () => { - return [{ id: 'result-id', value: expectation, route: 'result-route' }]; - }; - const wrapper = subjectMount({ findMatches }); + await performSearch(wrapper, 'anything'); - let input = wrapper.find('input'); + wrapper.update(); - jest.useFakeTimers(); - input.simulate('change', { target: { value: expectation } }); - jest.runAllTimers(); + selectOption(wrapper, valueToSelect); - await new Promise(setImmediate); + expect(wrapper.find('.autocomplete-dropdown__results').exists()).toBe(false); + }); - wrapper.update(); + describe('when focus is lost', () => { + it('it resets the input', async () => { + const findMatches = stubSearchFor('someSearch', stubResults(1)); + const wrapper = subject({ findMatches, value: 'original value' }); + await performSearch(wrapper, 'someSearch'); - const option = wrapper.find('[className*="-item"]').filterWhere((n) => n.text() === expectation); - option.simulate('mouseDown'); + wrapper.find('input').simulate('blur'); - input = wrapper.find('input'); + expect(wrapper.find('input').prop('value')).toEqual('original value'); + }); - expect(input.prop('value')).toEqual(expectation); - expect(wrapper.find('[className*="__items"]').exists()).toBe(false); - }); + it('it closes dropdown', async () => { + const findMatches = stubSearchFor('someSearch', stubResults(1)); + const wrapper = subject({ findMatches, value: 'original value' }); + await performSearch(wrapper, 'someSearch'); + expect(wrapper.find('.autocomplete-dropdown__results').exists()).toBe(true); - it('it closes dropdown when focus lost', async () => { - findMatches = () => { - return [{ id: 'result-id', value: 'result-value', route: 'result-route' }]; - }; - const wrapper = subject({ findMatches, value: 'original value' }); + wrapper.find('input').simulate('blur'); - const input = wrapper.find('input'); + expect(wrapper.find('.autocomplete-dropdown__results').exists()).toBe(false); + }); - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'someSearch' } }); - jest.runAllTimers(); + it('it announces cancel', async () => { + const findMatches = stubSearchFor('someSearch', stubResults(1)); + const wrapper = subject({ findMatches, value: 'original value' }); + await performSearch(wrapper, 'someSearch'); - input.simulate('blur'); + wrapper.find('input').simulate('blur'); - expect(input.prop('value')).toEqual('original value'); - expect(wrapper.find('[className*="__items"]').exists()).toBe(false); + expect(onCancel).toHaveBeenCalled(); + }); }); it('it displays "No results found" when there are no matches', async () => { - findMatches = () => { - return []; - }; + const findMatches = stubSearchFor('someSearch', []); const wrapper = subject({ findMatches }); - const input = wrapper.find('input'); - - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'someSearch' } }); - jest.runAllTimers(); - - await new Promise(setImmediate); + await performSearch(wrapper, 'someSearch'); expect(wrapper.text()).toEqual('No results found'); }); - it('hides search bar when pressing "escape"', async () => { - findMatches = () => { - return []; - }; + it('announces cancel when "escape" is pressed', async () => { + const findMatches = stubSearchFor('someSearch', stubResults(1)); const wrapper = subject({ findMatches }); - const input = wrapper.find('input'); + pressKeyOn(wrapper, Key.Escape); - jest.useFakeTimers(); - input.simulate('keydown', { - key: 'ArrowUp', - preventDefault: () => {}, - stopPropagation: () => {}, - }); - input.simulate('keydown', { - key: 'Enter', - preventDefault: () => {}, - stopPropagation: () => {}, - }); - jest.runAllTimers(); + expect(onCancel).toHaveBeenCalled(); + }); + + it('closes search results when input cleared', async () => { + const findMatches = stubSearchFor('anything', stubResults(1)); + const wrapper = subject({ findMatches }); + await performSearch(wrapper, 'anything'); + + expect(wrapper.find('.autocomplete-dropdown__item-container').exists()).toBe(true); - await new Promise(setImmediate); + wrapper.find('input').simulate('change', { target: { value: '' } }); expect(wrapper.find('.autocomplete-dropdown__item-container').exists()).toBe(false); }); - it('does not close search bar when empty value', async () => { + it('does not announce cancel when value cleared', async () => { const wrapper = subject({ value: 'lets delete this' }); let input = wrapper.find('input'); input.simulate('change', { target: { value: '' } }); - expect(onCloseBar).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); }); it('set min height to results wrapper', async () => { - findMatches = () => { - return [ - { - id: 'result-first-id', - value: 'result-first-value', - route: 'result-first-route', - }, - { - id: 'result-second-id', - value: 'result-second-value', - route: 'result-second-route', - }, - ]; - }; - - const wrapper = subjectMount({ findMatches }); - - const input = wrapper.find('input'); + const findMatches = stubSearchFor('anything', []); + const wrapper = subject({ findMatches }); expect(wrapper.find('.autocomplete-dropdown__results').exists()).toBe(false); - jest.useFakeTimers(); - input.simulate('change', { target: { value: 'anything' } }); - jest.runAllTimers(); - await new Promise(setImmediate); + await performSearch(wrapper, 'anything'); expect(wrapper.find('.autocomplete-dropdown__results').prop('style').height).toEqual(35); }); @@ -427,9 +247,9 @@ describe('autocomplete-dropdown', () => { onSelect = jest.fn(); }); - function subject(initialData = {}) { - const props: Properties = { - item: {}, + function subject(initialData: Partial = {}) { + const props: ResultProperties = { + item: {} as AutocompleteItem, isFocused: false, onSelect, ...initialData, @@ -439,17 +259,83 @@ describe('autocomplete-dropdown', () => { } it('verifies expected attributes are present', () => { - const expectation = { + const item = { + id: 'result-id', value: 'result-value', route: 'result-route', summary: 'result-summary', }; - const wrapper = subject({ item: expectation }); + const wrapper = subject({ item }); - Object.values(expectation).forEach((value) => { - expect(wrapper.html().includes(value)).toBe(true); - }); + expect(wrapper.find('.autocomplete-dropdown-item__value').text()).toEqual(item.value); + expect(wrapper.find('.autocomplete-dropdown-item__route').text()).toEqual(item.route); + expect(wrapper.find('.autocomplete-dropdown-item__text').prop('title')).toEqual(item.summary); }); }); }); + +function inputEvent(attrs = {}) { + return { + preventDefault: () => {}, + stopPropagation: () => {}, + ...attrs, + }; +} + +async function performSearch(dropdown, searchString) { + const input = dropdown.find('input'); + // Fake the timers because we debounce search requests + jest.useFakeTimers(); + input.simulate('change', { target: { value: searchString } }); + jest.runAllTimers(); + // Release the thread so the async search can complete + await new Promise(setImmediate); +} + +function stubSearchFor(expectedSearch, results) { + return (search) => { + if (search === expectedSearch) { + return results; + } + return []; + }; +} + +function stubResult(prefix) { + return { + id: `${prefix}-id`, + value: `${prefix}-value`, + route: `${prefix}-route`, + }; +} + +function stubResults(num) { + const results = []; + for (let i = 1; i <= num; i++) { + results.push(stubResult(i)); + } + return results; +} + +function pressKey(wrapper, key) { + pressKeyOn(wrapper.find('input'), key); +} + +function pressKeyOn(node, key) { + node.simulate('keydown', { + key, + preventDefault: () => {}, + stopPropagation: () => {}, + }); +} + +function selectOption(component, value) { + component + .find(Result) + .findWhere((n) => (n.prop('item') as any).value === value) + .first() + .shallow() + .find('.autocomplete-dropdown-item') + .simulate('mouseDown', inputEvent()); +} diff --git a/src/components/autocomplete-dropdown/index.tsx b/src/components/autocomplete-dropdown/index.tsx index 86fdde2d5..243cac0b9 100644 --- a/src/components/autocomplete-dropdown/index.tsx +++ b/src/components/autocomplete-dropdown/index.tsx @@ -23,7 +23,7 @@ export interface Properties { itemContainerClassName?: string; findMatches: (term: string) => Promise; onSelect: (item: AutocompleteItem) => void; - onCloseBar: () => void; + onCancel: () => void; } interface State { @@ -62,7 +62,7 @@ export class AutocompleteDropdown extends React.Component { escFunction = (event): void => { if (event.key === Key.Escape) { - this.props.onCloseBar(); + this.abortChange(); } }; @@ -72,9 +72,6 @@ export class AutocompleteDropdown extends React.Component { componentDidMount() { this._isMounted = true; - if (this.anchorElement) { - this.anchorElement.addEventListener('keydown', this.escFunction, false); - } } componentDidUpdate(prevProps, prevState) { @@ -87,9 +84,6 @@ export class AutocompleteDropdown extends React.Component { componentWillUnmount() { this._isMounted = false; - if (this.anchorElement) { - this.anchorElement.removeEventListener('keydown', this.escFunction, false); - } } UNSAFE_componentWillReceiveProps(nextProps: Properties) { @@ -103,7 +97,7 @@ export class AutocompleteDropdown extends React.Component { this.setState({ value: searchTerm }); if (searchTerm.trim() === '') { - this.close(false); + this.closeResults(); return; } @@ -134,22 +128,21 @@ export class AutocompleteDropdown extends React.Component { }); this.props.onSelect(item); - this.close(); + this.closeResults(); } }; - close(closeBar = true): void { - if (this._isMounted) { - this.setState({ - matches: [], - searchComplete: false, - inProgress: false, - currentFocusIndex: 0, - }); - if (closeBar) { - this.props.onCloseBar(); - } + closeResults(): void { + if (!this._isMounted) { + return; } + + this.setState({ + matches: [], + searchComplete: false, + inProgress: false, + currentFocusIndex: 0, + }); } abortChange = (): void => { @@ -158,7 +151,8 @@ export class AutocompleteDropdown extends React.Component { searchComplete: false, inProgress: false, }); - this.close(); + this.closeResults(); + this.props.onCancel(); }; setAnchorElements = (ref: HTMLElement): void => { @@ -215,7 +209,7 @@ export class AutocompleteDropdown extends React.Component { } closestScrollableParent.onscroll = () => { - this.close(); + this.abortChange(); closestScrollableParent.onscroll = null; }; } @@ -281,6 +275,7 @@ export class AutocompleteDropdown extends React.Component {
{ } } -export class Result extends React.Component< - { item: AutocompleteItem; isFocused: boolean; onSelect(AutocompleteItem) }, - undefined -> { +export interface ResultProperties { + item: AutocompleteItem; + isFocused: boolean; + onSelect(AutocompleteItem); +} +export class Result extends React.Component { onSelect = (event): void => { // Prevent further events from happening, such as: onBlur of the input event.stopPropagation(); diff --git a/src/components/error-boundary/index.test.tsx b/src/components/error-boundary/index.test.tsx index a21579cc8..ba9d958db 100644 --- a/src/components/error-boundary/index.test.tsx +++ b/src/components/error-boundary/index.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { ErrorBoundary, Properties } from './'; -import { ErrorBoundary as SentryErrorBoundary } from '@sentry/react'; +import { ErrorBoundary as SentryErrorBoundary, Scope } from '@sentry/react'; import { mount } from 'enzyme'; describe('error-boundary', () => { @@ -20,6 +20,8 @@ describe('error-boundary', () => { function subject(props: Partial = {}) { const allProps: Properties = { + children: null, + boundary: '', ...props, }; @@ -52,12 +54,12 @@ describe('error-boundary', () => { }, }); - const setTags = jest.fn(); + const setTags: Scope['setTags'] = jest.fn(); const wrapper = subject({ boundary }); const child = wrapper.find(SentryErrorBoundary); - child.prop('beforeCapture')({ setTags }); + child.prop('beforeCapture')({ setTags } as Scope, null, null); await wrapper.find(ChildComponent).simulateError(new Error('New Error')); @@ -72,12 +74,12 @@ describe('error-boundary', () => { 'application.name': undefined, }; - const setTags = jest.fn(); + const setTags: Scope['setTags'] = jest.fn(); const wrapper = subject({ boundary }); const child = wrapper.find(SentryErrorBoundary); - child.prop('beforeCapture')({ setTags }); + child.prop('beforeCapture')({ setTags } as Scope, null, null); await wrapper.find(ChildComponent).simulateError(new Error('New Error')); diff --git a/src/components/message-input/index.test.tsx b/src/components/message-input/index.test.tsx index d7f46cd7e..dcf6444ab 100644 --- a/src/components/message-input/index.test.tsx +++ b/src/components/message-input/index.test.tsx @@ -1,10 +1,6 @@ -/** - * @jest-environment jsdom - */ - import React from 'react'; import { MentionsInput } from 'react-mentions'; -import { mount, shallow } from 'enzyme'; +import { shallow } from 'enzyme'; import { MessageInput, Properties } from '.'; import { Key } from '../../lib/keyboard-search'; @@ -21,24 +17,15 @@ describe('MessageInput', () => { getUsersForMentions: () => undefined, onMessageInputRendered: () => undefined, renderAfterInput: () => undefined, + clipboard: { + addPasteListener: (_) => {}, + removePasteListener: (_) => {}, + }, ...props, }; return shallow({child}); }; - const subjectMount = (props: Partial, child: any =
) => { - const allProps: Properties = { - className: '', - placeholder: '', - users: [], - onSubmit: () => undefined, - getUsersForMentions: () => undefined, - onMessageInputRendered: () => undefined, - ...props, - }; - - return mount({child}); - }; it('adds className', () => { const wrapper = subject({ className: 'message-input' }); @@ -47,9 +34,10 @@ describe('MessageInput', () => { }); it('adds placeholder', () => { - const wrapper = subjectMount({ placeholder: 'Speak' }); + const wrapper = subject({ placeholder: 'Speak' }); + const dropzone = wrapper.find(Dropzone).shallow(); - expect(wrapper.find(MentionsInput).prop('placeholder')).toEqual('Speak'); + expect(dropzone.find(MentionsInput).prop('placeholder')).toEqual('Speak'); }); it('it renders the messageInput', function () { @@ -60,18 +48,20 @@ describe('MessageInput', () => { it('should call editActions', function () { const renderAfterInput = jest.fn(); - subjectMount({ renderAfterInput, className: 'chat' }); + const wrapper = subject({ renderAfterInput, className: 'chat' }); + const _dropzone = wrapper.find(Dropzone).shallow(); expect(renderAfterInput).toHaveBeenCalled(); }); - it('submit message when click on textearea', () => { + it('submit message when click on textarea', () => { const onSubmit = jest.fn(); - const wrapper = subjectMount({ onSubmit, placeholder: 'Speak' }); + const wrapper = subject({ onSubmit, placeholder: 'Speak' }); + const dropzone = wrapper.find(Dropzone).shallow(); - const textarea = wrapper.find(MentionsInput).find('textarea'); - textarea.simulate('change', { target: { value: 'Hello' } }); - textarea.simulate('keydown', { preventDefault() {}, key: Key.Enter, shiftKey: false }); + const input = dropzone.find(MentionsInput); + input.simulate('change', { target: { value: 'Hello' } }); + input.simulate('keydown', { preventDefault() {}, key: Key.Enter, shiftKey: false }); expect(onSubmit).toHaveBeenCalledOnce(); }); diff --git a/src/components/message-input/index.tsx b/src/components/message-input/index.tsx index 3f42be623..9ed09e44e 100644 --- a/src/components/message-input/index.tsx +++ b/src/components/message-input/index.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { userMentionsConfig } from './mentions-config'; import { Key } from '../../lib/keyboard-search'; import { User } from '../../store/channels'; -import { UserForMention, getUsersForMentions, Media, dropzoneToMedia, addImagePreview } from './utils'; +import { UserForMention, getUsersForMentions, Media, dropzoneToMedia, addImagePreview, windowClipboard } from './utils'; import Menu from './menu'; import ImageCards from '../../platform-apps/channels/image-cards'; import { config } from '../../config'; @@ -21,6 +21,10 @@ export interface Properties { getUsersForMentions: (search: string, users: User[]) => UserForMention[]; onMessageInputRendered?: (textareaRef: RefObject) => void; renderAfterInput?: (value: string, mentionedUserIds: User['id'][]) => React.ReactNode; + clipboard?: { + addPasteListener: (listener: EventListenerOrEventListenerObject) => void; + removePasteListener: (listener: EventListenerOrEventListenerObject) => void; + }; } interface State { @@ -46,7 +50,11 @@ export class MessageInput extends React.Component { if (this.props.onMessageInputRendered) { this.props.onMessageInputRendered(this.textareaRef); } - window.addEventListener('paste', this.clipboardEvent); + this.clipboard.addPasteListener(this.clipboardEvent); + } + + get clipboard() { + return this.props.clipboard || windowClipboard(); } componentDidUpdate() { @@ -56,7 +64,7 @@ export class MessageInput extends React.Component { } componentWillUnmount() { - window.removeEventListener('paste', this.clipboardEvent); + this.clipboard.removePasteListener(this.clipboardEvent); } get images() { diff --git a/src/components/message-input/utils.ts b/src/components/message-input/utils.ts index 78e367879..a457d378f 100644 --- a/src/components/message-input/utils.ts +++ b/src/components/message-input/utils.ts @@ -73,3 +73,14 @@ export const getUsersForMentions = (search: string, users: User[]): UserForMenti } return []; }; + +export function windowClipboard() { + return { + addPasteListener: (handler) => { + window.addEventListener('paste', handler); + }, + removePasteListener: (handler) => { + window.removeEventListener('paste', handler); + }, + }; +} diff --git a/src/components/sidekick/index.test.tsx b/src/components/sidekick/index.test.tsx new file mode 100644 index 000000000..5c38c05ce --- /dev/null +++ b/src/components/sidekick/index.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Container } from '.'; +import { IfAuthenticated } from '../authentication/if-authenticated'; + +describe('Sidekick', () => { + const subject = (props: any = {}) => { + const allProps = { + className: '', + updateLayout: () => undefined, + ...props, + }; + + return shallow(); + }; + + it('adds className', () => { + const wrapper = subject({ className: 'todo' }); + + const ifAuthenticated = wrapper.find(IfAuthenticated).find({ showChildren: true }); + + expect(ifAuthenticated.find('.todo').exists()).toBe(true); + }); + + it('renders sidekick panel', () => { + const wrapper = subject(); + + const ifAuthenticated = wrapper.find(IfAuthenticated).find({ showChildren: true }); + + expect(ifAuthenticated.find('.app-sidekick-panel__target').exists()).toBe(true); + }); + + it('renders sidekick when panel tab is clicked', () => { + const updateLayout = jest.fn(); + const wrapper = subject(updateLayout); + + const ifAuthenticated = wrapper.find(IfAuthenticated).find({ showChildren: true }); + ifAuthenticated.find('.app-sidekick-panel__target').simulate('click'); + + expect(ifAuthenticated.find('.sidekick__slide-out').exists()).toBe(false); + }); +}); diff --git a/src/components/sidekick/index.tsx b/src/components/sidekick/index.tsx new file mode 100644 index 000000000..99e0fcf00 --- /dev/null +++ b/src/components/sidekick/index.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { RootState } from '../../store'; +import { connectContainer } from '../../store/redux-container'; + +import { IfAuthenticated } from '../authentication/if-authenticated'; +import { IconButton, Icons } from '@zer0-os/zos-component-library'; +import classNames from 'classnames'; +import { AuthenticationState } from '../../store/authentication/types'; +import { AppLayout, update as updateLayout } from '../../store/layout'; + +require('./styles.scss'); + +interface PublicProperties { + className?: string; +} + +export interface Properties extends PublicProperties { + user: AuthenticationState['user']; + updateLayout: (layout: Partial) => void; +} + +export interface State { + isOpen: boolean; +} + +export class Container extends React.Component { + static mapState(state: RootState): Partial { + const { + authentication: { user }, + } = state; + + return { + user, + }; + } + + static mapActions(_props: Properties): Partial { + return { updateLayout }; + } + + state = { isOpen: true }; + + slideAnimationEnded = () => { + if (!this.state.isOpen) { + this.setState({ isOpen: false }); + } + }; + + clickTab = () => {}; + + handleSidekickPanel = () => { + this.props.updateLayout({ isSidekickOpen: !this.state.isOpen }); + this.setState({ isOpen: !this.state.isOpen }); + }; + + renderSidekickPanel() { + return ( +
+ + + +
+ + + +
+
+ ); + } + + render() { + return ( + +
+ {this.renderSidekickPanel()} +
+
+ + + +
+
+
+
+ ); + } +} + +export const Sidekick = connectContainer(Container); diff --git a/src/components/sidekick/styles.scss b/src/components/sidekick/styles.scss new file mode 100644 index 000000000..9934053f0 --- /dev/null +++ b/src/components/sidekick/styles.scss @@ -0,0 +1,128 @@ +@use '../../shared-components/theme-engine/theme' as theme; +@use '../../modules/animation' as animation; + +@import '../../layout'; + +.sidekick { + position: relative; + background-color: theme.$background-color-tertiary-hover; + width: $width-sidekick; + height: 100%; + animation: sidekick-slide-in animation.$animation-duration-double ease-in forwards; + + &__tabs { + position: absolute; + top: 0; + right: 0; + left: 0; + width: 100%; + + font-size: 16px; + height: 48px; + display: flex; + justify-content: space-evenly; + align-items: center; + + .icon-button { + padding: 7px; + &__icon { + vertical-align: middle; + height: 16px; + width: 16px; + } + & PATH { + fill: theme.$font-color-primary; + } + &:hover { + background-color: theme.$background-color-app-panel-hover; + border-radius: 9999px; + transition-duration: animation.$animation-duration-double; + } + } + } + + &__slide-out { + animation: sidekick-slide-out animation.$animation-duration-double ease-out forwards; + } + + .scroll-container__gradient { + background: linear-gradient(to bottom, transparent, theme.$background-color-tertiary-hover 100%); + } + .scroll-container__gradient-top { + background: linear-gradient(to top, transparent, theme.$background-color-tertiary-hover 100%); + } + + &__invitation-button { + .button-component { + position: absolute; + bottom: 22px; + left: 40px; + + .button-component__label { + min-height: 36px; + line-height: 36px; + vertical-align: middle; + text-transform: capitalize; + } + } + } +} + +.app-sidekick-panel__target { + position: absolute; + left: -16px; + top: 0; + cursor: pointer; +} + +.sidekick-panel-tab__tab { + height: 104px; + width: 16px; + transform: rotate(180deg); +} + +.sidekick-panel-tab__tab PATH { + fill: theme.$background-color-tertiary-hover; +} + +.sidekick-panel-tab__opacity-gradient-first-stop { + stop-color: theme.$background-color-app-panel; +} + +.sidekick-panel-tab__opacity-gradient-second-stop, +.sidekick-panel-tab__opacity-gradient-third-stop { + stop-color: theme.$actionable-hover-background; +} + +.sidekick-panel-tab__icon { + position: absolute; + top: 44px; + left: 0px; + width: 14px; +} + +.sidekick-panel-tab__icon-item { + background: theme.$actionable-color; + height: 2px; + display: block; + border-radius: 3px; + margin-bottom: 2px; +} + +@keyframes sidekick-slide-in { + 0% { + margin-right: -$width-sidekick; + } + 100% { + margin-right: 0; + } +} + +@keyframes sidekick-slide-out { + 0% { + margin-right: 0; + } + 100% { + margin-right: -$width-sidekick; + } +} diff --git a/src/components/theme-engine/index.test.tsx b/src/components/theme-engine/index.test.tsx index 87559666e..2a2069b2a 100644 --- a/src/components/theme-engine/index.test.tsx +++ b/src/components/theme-engine/index.test.tsx @@ -17,6 +17,10 @@ describe('ThemeEngine', () => { getItem(key) { return this.state[key]; }, + removeItem(_) {}, + length: 0, + clear: () => {}, + key: (_) => '', }; }); diff --git a/src/components/web3-connect/index.test.tsx b/src/components/web3-connect/index.test.tsx index 1ac7c46dd..fda3b1d38 100644 --- a/src/components/web3-connect/index.test.tsx +++ b/src/components/web3-connect/index.test.tsx @@ -30,6 +30,9 @@ describe('Web3Connect', () => { removeItem(key) { this.state[key] = false; }, + length: 0, + clear: () => {}, + key: (_) => '', }; }); diff --git a/src/components/zns-dropdown/index.test.tsx b/src/components/zns-dropdown/index.test.tsx index fe69cf6f5..69cbdc06e 100644 --- a/src/components/zns-dropdown/index.test.tsx +++ b/src/components/zns-dropdown/index.test.tsx @@ -4,17 +4,20 @@ import { ZNSDropdown, Properties } from './'; import { shallow } from 'enzyme'; let onSelect; +let onCloseBar; let api; describe('zns-dropdown', () => { beforeEach(() => { onSelect = jest.fn(); + onCloseBar = jest.fn(); api = jest.fn(); }); function subject(initialData: Partial = {}) { const state: Properties = { onSelect, + onCloseBar, api, ...initialData, }; @@ -34,13 +37,16 @@ describe('zns-dropdown', () => { const wrapper = subject({ api: { - search: async () => { - return apiResults; + search: async (searchString) => { + if (searchString === 'search-string') { + return apiResults; + } + return []; }, }, }); - const mappedResults = await wrapper.instance().findMatches(); + const mappedResults = await dropdownInstance(wrapper).findMatches('search-string'); expect([ { @@ -67,7 +73,29 @@ describe('zns-dropdown', () => { znsRoute: 'zns-route-second', }, ]; + const wrapper = subject({ + api: { + search: async () => { + return apiResults; + }, + }, + }); + await dropdownInstance(wrapper).findMatches('search-string'); + + wrapper.find('AutocompleteDropdown').simulate('select', { id: 'zns-id-first' }); + + expect(onSelect).toHaveBeenCalledWith(apiResults[0].znsRoute); + }); + it('announces close event when result is selected', async () => { + const apiResults = [ + { + id: 'zns-id-first', + title: 'zns-title-first', + description: 'zns-description-first', + znsRoute: 'zns-route-first', + }, + ]; const wrapper = subject({ api: { search: async () => { @@ -75,11 +103,22 @@ describe('zns-dropdown', () => { }, }, }); + await dropdownInstance(wrapper).findMatches('search-string'); - await wrapper.instance().findMatches(); + wrapper.find('AutocompleteDropdown').simulate('select', { id: 'zns-id-first' }); - wrapper.instance().onSelect(apiResults[0]); + expect(onCloseBar).toHaveBeenCalled(); + }); - expect(onSelect).toHaveBeenCalledWith(apiResults[0].znsRoute); + it('announces close event when dropdown edit is cancelled', async () => { + const wrapper = subject(); + + wrapper.find('AutocompleteDropdown').simulate('cancel'); + + expect(onCloseBar).toHaveBeenCalled(); }); }); + +function dropdownInstance(wrapper) { + return wrapper.instance() as ZNSDropdown; +} diff --git a/src/components/zns-dropdown/index.tsx b/src/components/zns-dropdown/index.tsx index acaea0eb1..d6b7a9d33 100644 --- a/src/components/zns-dropdown/index.tsx +++ b/src/components/zns-dropdown/index.tsx @@ -48,6 +48,7 @@ export class ZNSDropdown extends React.Component { onSelect = (item: AutocompleteItem) => { this.props.onSelect(this.state.results.find((p) => p.id === item.id).znsRoute); + this.props.onCloseBar(); }; render() { @@ -58,7 +59,7 @@ export class ZNSDropdown extends React.Component { itemContainerClassName={this.props.itemContainerClassName} findMatches={this.findMatches} onSelect={this.onSelect} - onCloseBar={this.props.onCloseBar} + onCancel={this.props.onCloseBar} /> ); } diff --git a/src/main.scss b/src/main.scss index 79cddd3ea..8361b84c3 100644 --- a/src/main.scss +++ b/src/main.scss @@ -55,6 +55,10 @@ } } + &.sidekick-panel-open &__header { + right: $width-sidekick; + } + &__header { position: absolute; display: flex; @@ -62,6 +66,8 @@ background: linear-gradient(180deg, #0a0a0a 27.08%, rgba(10, 10, 10, 0) 84.9%); @include layout-transition(left); + @include layout-sidekick-transition(right); + left: $width-navigation; right: 0; height: 96px; @@ -104,14 +110,12 @@ } &__sidekick { - padding: 12px 16px 0 0; box-sizing: border-box; - + position: relative; pointer-events: auto; width: $width-sidekick; display: flex; - justify-content: flex-end; } &__logo { @@ -137,3 +141,21 @@ } } } + +@keyframes sidekick-slide-in { + 0% { + right: 0; + } + 100% { + right: $width-sidekick; + } +} + +@keyframes sidekick-slide-out { + 0% { + right: $width-sidekick; + } + 100% { + right: 0; + } +} diff --git a/src/setupTests.ts b/src/setupTests.ts index 46844dce0..2fa390a15 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,8 +1,5 @@ -import * as matchers from 'jest-extended'; +import 'jest-extended/all'; import { configure } from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; -// add all jest-extended matchers -expect.extend(matchers); - configure({ adapter: new Adapter() }); diff --git a/src/shared-components/theme-engine/_theme.scss b/src/shared-components/theme-engine/_theme.scss index 384915dce..bacaac15c 100644 --- a/src/shared-components/theme-engine/_theme.scss +++ b/src/shared-components/theme-engine/_theme.scss @@ -21,6 +21,7 @@ $background-color-platform-navigation: var(--background-color-platform-navigatio $primary-border-color: var(--primary-border-color, #3c3c3c); $secondary-border-color: var(--secondary-border-color, #3c3c3c); +$sidekick-background-color: var(--sidekick-background-color, #0f0f0f); $address-bar-border-color-outer: var(--address-bar-border-color-outer, #23142f); $address-bar-border-color-inner: var(--address-bar-border-color-inner, #472e5b); diff --git a/src/shared-components/theme-engine/theme.json b/src/shared-components/theme-engine/theme.json index 486c97c93..c2a119296 100644 --- a/src/shared-components/theme-engine/theme.json +++ b/src/shared-components/theme-engine/theme.json @@ -72,6 +72,8 @@ "dialogBackgroundColor": "#CCCCCC", "dialogDropShadowColor": "#CCCCCC", + "sidekickbackgroundcolor": "#F6F6F6", + "underlayBackgroundColor": "rgba(245, 245, 245, 0.95)", "glowTextColor": "#F0F0F0", @@ -157,6 +159,8 @@ "dialogBackgroundColor": "#10052e", "dialogDropShadowColor": "rgb(196 29 255 / 20%)", + "sidekickbackgroundcolor": "#0F0F0F", + "underlayBackgroundColor": "rgba(0, 0, 0, 0.95)", "underlayTooltipBackgroundColor": "rgba(24, 24, 28, 0.95)", diff --git a/src/store/layout/index.test.ts b/src/store/layout/index.test.ts index 4ce8744a9..c9b4a8677 100644 --- a/src/store/layout/index.test.ts +++ b/src/store/layout/index.test.ts @@ -4,6 +4,7 @@ describe('layout reducer', () => { const initialExistingState: LayoutState = { value: { isContextPanelOpen: false, + isSidekickOpen: true, hasContextPanel: false, }, }; @@ -12,6 +13,7 @@ describe('layout reducer', () => { expect(reducer(undefined, { type: 'unknown' })).toEqual({ value: { isContextPanelOpen: false, + isSidekickOpen: true, hasContextPanel: false, }, }); @@ -26,6 +28,16 @@ describe('layout reducer', () => { }); }); + it('should update isSidekickOpen', () => { + const actual = reducer(initialExistingState, update({ isSidekickOpen: false })); + + expect(actual.value).toMatchObject({ + isContextPanelOpen: false, + isSidekickOpen: false, + hasContextPanel: false, + }); + }); + it('should update hasContextPanel', () => { const actual = reducer(initialExistingState, update({ hasContextPanel: true })); diff --git a/src/store/layout/index.ts b/src/store/layout/index.ts index 445cbf8b2..14f1724ec 100644 --- a/src/store/layout/index.ts +++ b/src/store/layout/index.ts @@ -3,6 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export interface AppLayout { hasContextPanel: boolean; isContextPanelOpen: boolean; + isSidekickOpen: boolean; } export interface LayoutState { @@ -12,6 +13,7 @@ export interface LayoutState { const initialState: LayoutState = { value: { isContextPanelOpen: false, + isSidekickOpen: true, hasContextPanel: false, }, }; diff --git a/src/store/theme/saga.test.ts b/src/store/theme/saga.test.ts index badb09184..9b3b396f1 100644 --- a/src/store/theme/saga.test.ts +++ b/src/store/theme/saga.test.ts @@ -9,6 +9,11 @@ describe('viewMode saga', () => { beforeAll(() => { global.localStorage = { setItem: jest.fn(), + getItem: (_) => '', + removeItem: () => {}, + length: 0, + clear: () => {}, + key: (_) => '', }; });