diff --git a/.eslintrc.json b/.eslintrc.json index 19f44d6305..2459289b23 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -43,11 +43,8 @@ { "groups": ["builtin", "external", "parent", ["index", "sibling"]], "pathGroups": [ - { - "pattern": "@/**", - "group": "external", - "position": "after" - } + { "pattern": "@test/**", "position": "after", "group": "builtin" }, + { "pattern": "@/**", "position": "after", "group": "external" } ], "pathGroupsExcludedImportTypes": ["builtin"], "newlines-between": "always", diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c96071ee4c..bd2384af15 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,26 +1,154 @@ name: CI on: - push: - branches: - - main + push: { branches: [dev, main] } pull_request: jobs: - test: + install: + timeout-minutes: 10 + strategy: + matrix: { node: ["18.x"], os: [ubuntu-latest] } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - if: ${{ steps.yarn-cache.outputs.cache-hit != 'true' }} + name: Install dependencies + run: yarn --immutable + + ui-unit-tests: + needs: install + timeout-minutes: 60 + strategy: + matrix: { node: ["18.x"], os: [ubuntu-latest] } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn --immutable + + - name: Verify linting + run: yarn lint + + - name: Build Pioneer + run: yarn build + + - name: Run tests + run: node --max_old_space_size=7000 --expose-gc $(yarn bin jest) --logHeapUsage --silent + working-directory: packages/ui + + interaction-tests: + needs: install timeout-minutes: 60 strategy: - matrix: - node: ['14.x'] - os: [ubuntu-latest] + matrix: { node: ["18.x"], os: [ubuntu-latest] } runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} - - run: yarn install --frozen-lockfile - - run: yarn lint - - run: yarn build - - run: yarn ci-test --silent + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn --immutable + + - name: Install Playwright + run: npx playwright install --with-deps + + - name: Get the Storybook preview deployment url + run: | + if [ "$PREFIX" == 'git-fork' ]; then + BRANCH=$(echo -n '${{ env.LABEL }}' | sed 's/:/-/') + else + BRANCH=$(echo -n '${{ env.LABEL }}' | cut -d ':' -f2-) + fi + + URL_BRANCH=$(echo -n "$BRANCH" | tr -d '#' | tr -c '[:alnum:]' '-' | tr '[:upper:]' '[:lower:]') + SUBDOMAIN="${{ env.PROJECT }}-${{ env.PREFIX }}-$URL_BRANCH-joystream" + + if [ ${#SUBDOMAIN} -gt 63 ]; then + HASH=$(echo -n "${{ env.PREFIX }}-${BRANCH}${{ env.PROJECT }}" | sha256sum | head -c 6) + SUBDOMAIN="$(echo -n "$SUBDOMAIN" | head -c 46)-$HASH-joystream" + fi + + echo "VERCEL_DEPLOYMENT_URL=$SUBDOMAIN.vercel.app" >> "$GITHUB_ENV" + env: + PROJECT: pioneer-2-storybook + PREFIX: ${{ github.event.pull_request.head.repo.fork && 'git-fork' || 'git' }} + LABEL: ${{ github.event.pull_request.head.label || github.ref_name }} + + - name: Wait for the deployment to complete + run: | + while true; do + RES=$(curl -L 'https://api.vercel.com/v13/deployments/${{ env.VERCEL_DEPLOYMENT_URL }}') + STATUS=$(echo -n "$RES" | jq -r '.status') + SHA=$(echo -n "$RES" | jq -r '.meta.githubCommitSha') + + + if [ "$SHA" == 'null' ] || [ "$STATUS" == 'null' ]; then + echo -e "\nError the JSON response is missing expected fields:\n\n$RES\n" >&2 + exit 5 + fi + + if [ "$SHA" == '${{ env.COMMIT_SHA }}' ] && [ "$STATUS" == 'READY' ]; then + exit 0 + fi + + echo -e '\nWait for the Storybook deployment...\n\n' + sleep 20 + done + env: + COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Run Storybook tests + run: yarn workspace @joystream/pioneer test-storybook + env: + TARGET_URL: https://${{ env.VERCEL_DEPLOYMENT_URL }} diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml deleted file mode 100644 index f0c8a39550..0000000000 --- a/.github/workflows/gh-pages.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Publish GH pages - -on: - push: - branches: - - main - -jobs: - build-and-deploy: - strategy: - matrix: - node: ['14.x'] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - persist-credentials: false - - - name: Use Node.js - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node }} - - - name: Install and Build - run: | - yarn install --frozen-lockfile - yarn build - - - name: Deploy - uses: JamesIves/github-pages-deploy-action@3.7.1 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: gh-pages - FOLDER: packages/ui/build - CLEAN: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eebab3011..71e88c0fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1] - 2023-07-25 + +### Fixed +- Do not ask for confirmation to close transaction success modals. +- Correctly match questions and answers in opening application pages. +- Do not keep showing the total balance after disconnecting a wallet. +- Show all applicants on closed opening pages. + +### Changed +- Clarify the label copy of the council budget increment proposal input field. +- Just show past elections cycle id without "round". + ## [1.5.0] - 2023-06-06 ### Added @@ -161,7 +173,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.1] - 2022-12-02 -[unreleased]: https://github.com/Joystream/pioneer/compare/v1.5.0...HEAD +[unreleased]: https://github.com/Joystream/pioneer/compare/v1.5.1...HEAD +[1.5.1]: https://github.com/Joystream/pioneer/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/Joystream/pioneer/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/Joystream/pioneer/compare/v1.3.2...v1.4.0 [1.3.2]: https://github.com/Joystream/pioneer/compare/v1.3.1...v1.3.2 diff --git a/docs/mocks.md b/docs/mocks.md index a4f00e38e8..e892fea9d8 100644 --- a/docs/mocks.md +++ b/docs/mocks.md @@ -11,18 +11,20 @@ To test most of the extrinsics requires existing on-chain data. To create some o Available commands: -- `yarn workspace @joystream/pioneer node-mocks council:elect [-d BLOCK_TIME] [--to ELECTION_STAGE]` - Run an election until the specified stage: VOTE, REVEAL, or IDLE (default) +- `yarn workspace @joystream/pioneer node-mocks council:elect [-d BLOCK_TIME¹] [--to ELECTION_STAGE]` - Run an election until the specified stage: VOTE, REVEAL, or IDLE (default) - `yarn workspace @joystream/pioneer node-mocks council:announce` - Announce enough candidacies to start the voting stage when the announcing stage ends - `yarn workspace @joystream/pioneer node-mocks council:vote` - Vote for the announced by the previous command candidate to start the revealing stage next - `yarn workspace @joystream/pioneer node-mocks council:reveal` - Reveal the votes casted by the previous command to start elect a new council and start the idle stage next - `yarn workspace @joystream/pioneer node-mocks members:create` - generate memberships using query-node mocks data - `yarn workspace @joystream/pioneer node-mocks set-budget` - Set membership Working Group budget -- `yarn workspace @joystream/pioneer node-mocks opening:create` - Create an opening +- `yarn workspace @joystream/pioneer node-mocks opening:create [-d BLOCK_TIME¹]` - Create an opening - `yarn workspace @joystream/pioneer node-mocks opening:fill` - Fill existing opening - `yarn workspace @joystream/pioneer node-mocks upcoming-opening:create` - Create an upcoming opening - `yarn workspace @joystream/pioneer node-mocks forumCategory:create` - Create a forum category - `yarn workspace @joystream/pioneer node-mocks transfer` - Transfer tokens between accounts +**(¹)** `BLOCK_TIME` is the time between each block. It is 6000ms by default but on testing chain it is 1000ms. Therefore when running some of the scripts on these testing chain `-d 1000` should be added for the command to succeed. + To show help: ```shell diff --git a/package.json b/package.json index efd04de9bf..a3e69939b7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "build:storybook": "wsrun --fast-exit --stages -c --exclude @joystream/pioneer --exclude-missing build && wsrun --package @joystream/pioneer -c build:storybook", "storybook": "wsrun --exclude-missing -c storybook", "test": "wsrun --fast-exit --package @joystream/pioneer -c test", - "ci-test": "NODE_OPTIONS=--max_old_space_size=7000 wsrun --fast-exit --package @joystream/pioneer -c test", "start": "wsrun --package @joystream/pioneer -c start", "prepare": "husky install" }, @@ -31,6 +30,12 @@ "lint-staged": ">=10" }, "resolutions": { + "@babel/core": "~7.21.0", + "@babel/preset-env": "~7.21.0", + "@babel/generator": "~7.21.0", + "@babel/parser": "~7.21.0", + "@babel/traverse": "~7.21.0", + "@babel/types": "~7.21.0", "@polkadot/api": "8.9.1", "@polkadot/api-contract": "8.9.1", "@polkadot/api-derive": "8.9.1", diff --git a/packages/markdown-editor/package.json b/packages/markdown-editor/package.json index 7600b09f10..55427573a8 100644 --- a/packages/markdown-editor/package.json +++ b/packages/markdown-editor/package.json @@ -38,13 +38,13 @@ "@ckeditor/ckeditor5-dev-webpack-plugin": "^25.4.5", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", - "prettier": "^2.4.1", + "prettier": "^2.8.8", "raw-loader": "^4.0.2", "terser-webpack-plugin": "^5.2.5", "ts-loader": "^9.2.6", "ts-node": "^10.4.0", - "typescript": "4.4.3", - "webpack": "^5.64.1", - "webpack-cli": "^4.9.1" + "typescript": "5", + "webpack": "5", + "webpack-cli": "5" } } diff --git a/packages/ui/.eslintrc.js b/packages/ui/.eslintrc.js index 25818eb12b..cd3ec8b674 100644 --- a/packages/ui/.eslintrc.js +++ b/packages/ui/.eslintrc.js @@ -2,6 +2,7 @@ const baseConfig = require('../../.eslintrc.json') const config = { ...baseConfig, + extends: ['plugin:storybook/recommended'], } const domains = ['accounts', 'memberships', 'working-groups'] @@ -26,6 +27,14 @@ config.rules['import/no-restricted-paths'] = [ }, ] +config.overrides = [ + ...config.overrides, + { + files: ['**/*.stories.tsx'], + rules: { 'react-hooks/rules-of-hooks': 'off' }, + }, +] + config.ignorePatterns = [...config.ignorePatterns, 'src/bounty/**/*', 'test/bounty/**/*'] module.exports = config diff --git a/packages/ui/.storybook/main.js b/packages/ui/.storybook/main.js index a26f7f701a..58b3eda2ce 100644 --- a/packages/ui/.storybook/main.js +++ b/packages/ui/.storybook/main.js @@ -22,6 +22,7 @@ module.exports = { { alias: { '@/common/utils/crypto/worker$': path.resolve(__dirname, '../src/common/utils/crypto'), + '@apollo/client$': path.resolve(__dirname, '../src/mocks/providers/query-node'), }, }, shared.resolve @@ -31,20 +32,21 @@ module.exports = { return config }, - core: { - builder: 'webpack5', + framework: { + name: '@storybook/react-webpack5', + options: {}, }, typescript: { reactDocgen: 'none', }, - stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(ts|tsx)'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-actions'], + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-links'], } function isCssRule(rule) { - return rule.test.toString().indexOf('css') > -1 + return rule.test?.toString().includes('css') } function isSvgRule(rule) { - return rule.test.toString().indexOf('svg') > -1 + return rule.test?.toString().includes('svg') } diff --git a/packages/ui/.storybook/preview.jsx b/packages/ui/.storybook/preview.jsx deleted file mode 100644 index e92877779d..0000000000 --- a/packages/ui/.storybook/preview.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { GlobalStyle } from '@/app/providers/GlobalStyle' -import { Colors } from '@/common/constants' -import { I18nextProvider } from 'react-i18next' -import { i18next } from '../src/services/i18n' -import { useForm, FormProvider } from 'react-hook-form' -import { MemoryRouter } from 'react-router' - -const stylesWrapperDecorator = (styleFn) => ( -
- - {styleFn()} -
-) - -const i18nextDecorator = (Story) => ( - - - - - -) - -const RHFDecorator = (Story) => { - const form = useForm() - return ( - - - - - - ) -} - -const RouterDecorator = (Story) => - -export const decorators = [stylesWrapperDecorator, i18nextDecorator, RHFDecorator, RouterDecorator] -window.jest = { - fn: (callback) => callback, -} -export const parameters = { - actions: { argTypesRegex: '^on[A-Z].*' }, - backgrounds: { - default: 'White', - values: [ - { - name: 'White', - value: Colors.White, - }, - { - name: 'Black', - value: Colors.Black[900], - }, - { - name: 'Modal', - value: Colors.Black[50], - }, - { - name: 'Disabled', - value: Colors.Black[75], - }, - { - name: 'Around modal glass', - value: Colors.Black[700.85], - }, - ], - }, - options: { - storySort: { - method: 'alphabetical', - order: ['Common'], - }, - }, -} diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx new file mode 100644 index 0000000000..fc6b5ec906 --- /dev/null +++ b/packages/ui/.storybook/preview.tsx @@ -0,0 +1,109 @@ +import { Decorator } from '@storybook/react' +import { configure } from '@storybook/testing-library' +import React from 'react' +import { I18nextProvider } from 'react-i18next' +import { useForm, FormProvider } from 'react-hook-form' +import { createGlobalStyle } from 'styled-components' + +import { GlobalModals } from '../src/app/GlobalModals' +import { GlobalStyle } from '../src/app/providers/GlobalStyle' +import { OnBoardingProvider } from '../src/common/providers/onboarding/provider' +import { NotificationsHolder } from '../src/common/components/page/SideNotification' +import { TransactionStatus } from '../src/common/components/TransactionStatus/TransactionStatus' +import { Colors } from '../src/common/constants' +import { ModalContextProvider } from '../src/common/providers/modal/provider' +import { TransactionStatusProvider } from '../src/common/providers/transactionStatus/provider' +import { MockProvidersDecorator, MockRouterDecorator } from '../src/mocks/providers' +import { i18next } from '../src/services/i18n' + +configure({ testIdAttribute: 'id' }) + +const stylesWrapperDecorator: Decorator = (Story) => ( + <> + + + {Story()} + +) +const StoryStyles = createGlobalStyle` + body, html { + overflow: visible + } +` + +const i18nextDecorator: Decorator = (Story) => ( + + + + + +) + +const RHFDecorator: Decorator = (Story) => { + const form = useForm() + return ( + + + + + + ) +} + +const ModalDecorator: Decorator = (Story) => ( + + + + + + + + + + + +) + +export const decorators = [ + ModalDecorator, + stylesWrapperDecorator, + i18nextDecorator, + RHFDecorator, + MockProvidersDecorator, + MockRouterDecorator, +] + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + backgrounds: { + default: 'White', + values: [ + { + name: 'White', + value: Colors.White, + }, + { + name: 'Black', + value: Colors.Black[900], + }, + { + name: 'Modal', + value: Colors.Black[50], + }, + { + name: 'Disabled', + value: Colors.Black[75], + }, + { + name: 'Around modal glass', + value: Colors.Black[700.85], + }, + ], + }, + options: { + storySort: { + method: 'alphabetical', + order: ['App', 'Pages', 'Common'], + }, + }, +} diff --git a/packages/ui/dev/node-mocks/commands/council/announce.ts b/packages/ui/dev/node-mocks/commands/council/announce.ts index 93420e3490..bc61c4ecbf 100644 --- a/packages/ui/dev/node-mocks/commands/council/announce.ts +++ b/packages/ui/dev/node-mocks/commands/council/announce.ts @@ -1,14 +1,17 @@ import { ApiPromise } from '@polkadot/api' import { uniq } from 'lodash' -import { lockLookup } from '../../../../src/accounts/model/lockTypes' -import { flatMapP, mapP } from '../../../../src/common/utils' -import memberData from '../../../../src/mocks/data/raw/members.json' +import { lockLookup } from '@/accounts/model/lockTypes' +import { flatMapP, mapP } from '@/common/utils' +import memberData from '@/mocks/data/raw/members.json' + import { accountsMap } from '../../data/addresses' import { signAndSend, withApi } from '../../lib/api' import { createMembersCommand } from '../members/create' export const announceCandidaciesCommand = async (api: ApiPromise) => { + await createMembersCommand(api) + const candidateCount = api.consts.council.councilSize.toNumber() + 1 const announceStake = api.consts.council.minCandidateStake @@ -60,13 +63,8 @@ export const announceCandidaciesCommand = async (api: ApiPromise) => { }) } -const handler = async () => { - await createMembersCommand() - await withApi(announceCandidaciesCommand) -} - export const announceCandidaciesModule = { command: 'council:announce', describe: 'Announce council candidates', - handler: handler, + handler: () => withApi(announceCandidaciesCommand), } diff --git a/packages/ui/dev/node-mocks/commands/council/elect.ts b/packages/ui/dev/node-mocks/commands/council/elect.ts index bf24047001..7d1d28a842 100644 --- a/packages/ui/dev/node-mocks/commands/council/elect.ts +++ b/packages/ui/dev/node-mocks/commands/council/elect.ts @@ -1,15 +1,16 @@ +import { ApiPromise } from '@polkadot/api' import yargs from 'yargs' -import { MILLISECONDS_PER_BLOCK } from '../../../../src/common/model/formatters' +import { MILLISECONDS_PER_BLOCK } from '@/common/model/formatters' + import { nextCouncilStageCommand } from '../../../helpers/nextCouncilStage' import { withApi } from '../../lib/api' -import { createMembersCommand } from '../members/create' import { announceCandidaciesCommand } from './announce' import { revealVotesCommand } from './reveal' import { castVotesCommand } from './vote' -const options = { +export const electCouncilOptions = { blockTime: { number: true, default: MILLISECONDS_PER_BLOCK, @@ -22,22 +23,33 @@ const options = { }, } +type Args = yargs.InferredOptionTypes +type Props = Partial & { replaceCurrent?: boolean } + +export const electCouncilCommand = async ( + api: ApiPromise, + { blockTime = MILLISECONDS_PER_BLOCK, to = 'IDLE', replaceCurrent = true }: Props +) => { + if (!replaceCurrent) { + const councilors = await api.query.council.councilMembers() + if (councilors.length > 0) return + } + + await announceCandidaciesCommand(api) + await nextCouncilStageCommand(api, blockTime) + if (to === 'VOTE') return + + await castVotesCommand(api) + await nextCouncilStageCommand(api, blockTime) + if (to === 'REVEAL') return + + await revealVotesCommand(api) + await nextCouncilStageCommand(api, blockTime) +} + export const electCouncilModule = { command: 'council:elect', describe: 'Elect a full council', - handler: async ({ blockTime, to }: yargs.InferredOptionTypes) => { - await createMembersCommand() - await withApi(async (api) => { - await announceCandidaciesCommand(api) - if (to === 'VOTE') return - await nextCouncilStageCommand(api, blockTime) - - await castVotesCommand(api) - if (to === 'REVEAL') return - await nextCouncilStageCommand(api, blockTime) - - await revealVotesCommand(api) - }) - }, - builder: (argv: yargs.Argv) => argv.options(options), + handler: ({ blockTime, to }: Args) => withApi((api) => electCouncilCommand(api, { blockTime, to })), + builder: (argv: yargs.Argv) => argv.options(electCouncilOptions), } diff --git a/packages/ui/dev/node-mocks/commands/members/create.ts b/packages/ui/dev/node-mocks/commands/members/create.ts index 8f3f5211a2..ac64b2c2c7 100644 --- a/packages/ui/dev/node-mocks/commands/members/create.ts +++ b/packages/ui/dev/node-mocks/commands/members/create.ts @@ -1,52 +1,52 @@ /* eslint-disable no-console */ import { MembershipMetadata } from '@joystream/metadata-protobuf' +import { ApiPromise } from '@polkadot/api' + +import { metadataToBytes } from '@/common/model/JoystreamNode' +import members from '@/mocks/data/raw/members.json' -import { metadataToBytes } from '../../../../src/common/model/JoystreamNode' -import members from '../../../../src/mocks/data/raw/members.json' import { getSudoAccount } from '../../data/addresses' import { signAndSend, withApi } from '../../lib/api' -export const createMembersCommand = async () => { - await withApi(async (api) => { - const nextId = await api.query.members.nextMemberId() - - if (Number(nextId) > 0) { - console.log('Some members were already added') - return - } - - const createMembers = members.map((member) => { - return api.tx.members.buyMembership({ - handle: member.handle, - metadata: metadataToBytes(MembershipMetadata, { - name: member.metadata.name, - about: member.metadata.about, - }), - rootAccount: member.rootAccount, - controllerAccount: member.controllerAccount, - }) +export const createMembersCommand = async (api: ApiPromise) => { + const nextId = await api.query.members.nextMemberId() + + if (Number(nextId) > 0) { + console.log('Some members were already added') + return + } + + const createMembers = members.map((member) => { + return api.tx.members.buyMembership({ + handle: member.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: member.metadata.name, + about: member.metadata.about, + }), + rootAccount: member.rootAccount, + controllerAccount: member.controllerAccount, }) + }) - const tx = api.tx.utility.batch(createMembers) + const tx = api.tx.utility.batch(createMembers) - await signAndSend(tx, getSudoAccount()) + await signAndSend(tx, getSudoAccount()) - await Promise.all( - members.map(async ({ id, boundAccounts, controllerAccount }) => { - for (const boundAccount of boundAccounts) { - // Bind staking account - await signAndSend(api.tx.members.addStakingAccountCandidate(id), boundAccount) + await Promise.all( + members.map(async ({ id, boundAccounts, controllerAccount }) => { + for (const boundAccount of boundAccounts) { + // Bind staking account + await signAndSend(api.tx.members.addStakingAccountCandidate(id), boundAccount) - // Confirm staking account - await signAndSend(api.tx.members.confirmStakingAccount(id, boundAccount), controllerAccount) - } - }) - ) - }) + // Confirm staking account + await signAndSend(api.tx.members.confirmStakingAccount(id, boundAccount), controllerAccount) + } + }) + ) } export const createMembersModule = { command: 'members:create', describe: 'Create member accounts from mocks', - handler: createMembersCommand, + handler: () => withApi(createMembersCommand), } diff --git a/packages/ui/dev/node-mocks/commands/opening/create.ts b/packages/ui/dev/node-mocks/commands/opening/create.ts index 19bd5fe818..0853053453 100644 --- a/packages/ui/dev/node-mocks/commands/opening/create.ts +++ b/packages/ui/dev/node-mocks/commands/opening/create.ts @@ -1,11 +1,24 @@ import { OpeningMetadata } from '@joystream/metadata-protobuf' +import { pick } from 'lodash' +import yargs from 'yargs' + +import { createType } from '@/common/model/createType' +import { MILLISECONDS_PER_BLOCK } from '@/common/model/formatters' +import { metadataToBytes } from '@/common/model/JoystreamNode' +import { isDefined } from '@/common/utils' -import { getDataFromEvent, metadataToBytes } from '../../../../src/common/model/JoystreamNode' import { GROUP, GroupIdName } from '../../consts' -import { getSudoAccount } from '../../data/addresses' -import { signAndSend, withApi } from '../../lib/api' +import { withApi } from '../../lib/api' +import { electCouncilCommand, electCouncilOptions } from '../council/elect' +import { approveProposal } from '../proposals/approve' +import { createProposal } from '../proposals/create' + +const addOpeningOptions = pick(electCouncilOptions, 'blockTime') -export const addOpeningCommand = async ({ group = GROUP }: { group?: GroupIdName } = {}) => { +type Args = yargs.InferredOptionTypes +type Props = Partial & { group?: GroupIdName } + +export const addOpeningCommand = async ({ group = GROUP, blockTime = MILLISECONDS_PER_BLOCK }: Props) => { const title = `Test ${group} opening` const openingMetadata = { @@ -22,23 +35,31 @@ export const addOpeningCommand = async ({ group = GROUP }: { group?: GroupIdName } return await withApi(async (api) => { - const tx = api.tx[group].addOpening( - metadataToBytes(OpeningMetadata, openingMetadata), - 'Leader', - { stakeAmount: api.consts[group].minimumApplicationStake, leavingUnstakingPeriod: 360_000 }, - '1337' - ) + const { minimumApplicationStake, minUnstakingPeriodLimit } = api.consts[group] + const proposalDetails = createType('PalletProposalsCodexProposalDetails', { + CreateWorkingGroupLeadOpening: { + description: metadataToBytes(OpeningMetadata, openingMetadata), + stakePolicy: { stakeAmount: minimumApplicationStake, leavingUnstakingPeriod: minUnstakingPeriodLimit }, + rewardPerBlock: '1337', + group: 'membership', + }, + }) + + // 1. Elect a council + await electCouncilCommand(api, { blockTime, replaceCurrent: false }) - const events = await signAndSend(api.tx.sudo.sudo(tx), getSudoAccount()) + // 2. Create a working lead opening proposal + const [proposalId] = await createProposal(api, proposalDetails) + if (!isDefined(proposalId)) throw 'Failed to create the proposal' - return String(getDataFromEvent(events, group, 'OpeningAdded')) + // 3. Approve the proposal + await approveProposal(api, proposalId) }) } export const createOpeningModule = { command: 'opening:create', describe: 'Create new opening', - handler: async () => { - await addOpeningCommand() - }, + handler: ({ blockTime }: Args) => addOpeningCommand({ blockTime }), + builder: (argv: yargs.Argv) => argv.options(addOpeningOptions), } diff --git a/packages/ui/dev/node-mocks/commands/proposals/approve.ts b/packages/ui/dev/node-mocks/commands/proposals/approve.ts new file mode 100644 index 0000000000..2e72439d68 --- /dev/null +++ b/packages/ui/dev/node-mocks/commands/proposals/approve.ts @@ -0,0 +1,21 @@ +import { ApiPromise } from '@polkadot/api' + +import memberData from '@/mocks/data/raw/members.json' + +import { signAndSend } from '../../lib/api' + +export const approveProposal = async (api: ApiPromise, proposalId: number) => { + const councilors = await api.query.council.councilMembers() + await Promise.all( + councilors.map((councilor) => { + const memberId = councilor.membershipId.toString() + const member = memberData.find(({ id }) => id === memberId) + if (!member) throw `Couldn't find the councilor ${memberId} controller account` + + const tx = api.tx.proposalsEngine.vote(councilor.membershipId, proposalId, 'Approve', 'LGTM') + + // Assume councilors stacking account are their controller accounts too + return signAndSend(tx, member.controllerAccount) + }) + ) +} diff --git a/packages/ui/dev/node-mocks/commands/proposals/create.ts b/packages/ui/dev/node-mocks/commands/proposals/create.ts index 07590a066b..81b7e43cfa 100644 --- a/packages/ui/dev/node-mocks/commands/proposals/create.ts +++ b/packages/ui/dev/node-mocks/commands/proposals/create.ts @@ -2,12 +2,18 @@ import { access, readFile } from 'fs/promises' import { isAbsolute, resolve } from 'path' +import { ApiPromise } from '@polkadot/api' +import { PalletProposalsCodexProposalDetails } from '@polkadot/types/lookup' +import { mnemonicGenerate } from '@polkadot/util-crypto' import yargs from 'yargs' -import { createType } from '../../../../src/common/model/createType' -import { getDataFromEvent } from '../../../../src/common/model/JoystreamNode' -import memberData from '../../../../src/mocks/data/raw/members.json' -import { signAndSend, withApi } from '../../lib/api' +import { createType } from '@/common/model/createType' +import { getDataFromEvent } from '@/common/model/JoystreamNode' +import memberData from '@/mocks/data/raw/members.json' +import { proposalConstants } from '@/proposals/hooks/useProposalConstants' +import { typenameToProposalDetails } from '@/proposals/model/proposalDetails' + +import { keyring, signAndSend, withApi } from '../../lib/api' import { createMembersCommand } from '../members/create' const options = { @@ -21,36 +27,37 @@ const options = { type CommandOptions = yargs.InferredOptionTypes export type Args = yargs.Arguments -const createProposal = (args: Args) => { - withApi(async (api) => { - const aliceMember = memberData[0] +export const createProposal = async (api: ApiPromise, proposalDetails: PalletProposalsCodexProposalDetails) => { + const { id: memberId, controllerAccount } = memberData[0] - // Create accounts - const nextId = await api.query.members.nextMemberId() - if (Number(nextId) < 1) { - await createMembersCommand() - } + const proposalType = typenameToProposalDetails(proposalDetails.type) + const requiredStake = proposalConstants(api, proposalType)?.requiredStake + if (!requiredStake) throw `Not supported proposal ${proposalDetails.type}` - // Create proposal - const id = aliceMember.id - const address = aliceMember.controllerAccount + // 1. Create and fund the staking account + const stakingSigner = keyring.createFromUri(mnemonicGenerate(), { name: 'proposal author' }) + const stackingAccount = stakingSigner.address + const fundingTx = api.tx.balances.transfer(stackingAccount, requiredStake.muln(1.5)) + await signAndSend(fundingTx, controllerAccount) - const commonParams = { - memberId: id, - title: `Lorem ${Object.keys(args)[0]}`, - description: JSON.stringify(args, null, 2), - stakingAccountId: address, - } - const proposalDetails = await specificParams(args) + // 2. Bind the staking account to Alice + await signAndSend(api.tx.members.addStakingAccountCandidate(memberId), stakingSigner) + await signAndSend(api.tx.members.confirmStakingAccount(memberId, stackingAccount), controllerAccount) - const tx = api.tx.proposalsCodex.createProposal(commonParams, proposalDetails) - const events = await signAndSend(tx, address) + // 3. Create the proposal + const commonParams = { + memberId, + title: `Lorem ${proposalDetails.type}`, + description: JSON.stringify(proposalDetails.toJSON(), null, 2), + stakingAccountId: stackingAccount, + } + const tx = api.tx.proposalsCodex.createProposal(commonParams, proposalDetails) + const events = await signAndSend(tx, controllerAccount) - const proposalId = Number(getDataFromEvent(events, 'proposalsCodex', 'ProposalCreated')) - const proposalData = getDataFromEvent(events, 'proposalsCodex', 'ProposalCreated', 1) - const threadId = Number(getDataFromEvent(events, 'proposalsDiscussion', 'ThreadCreated')) - console.log({ proposalId, ...proposalData?.toJSON(), threadId }) - }) + const proposalId = Number(getDataFromEvent(events, 'proposalsCodex', 'ProposalCreated')) + const proposalData = getDataFromEvent(events, 'proposalsCodex', 'ProposalCreated', 1) + const threadId = Number(getDataFromEvent(events, 'proposalsDiscussion', 'ThreadCreated')) + return [proposalId, threadId, proposalData] as const } const specificParams = async (args: Args) => { @@ -82,6 +89,13 @@ const filePath = async (path: string) => { export const createProposalModule = { command: 'proposal:create', describe: 'Create a proposal', - handler: createProposal, + handler: async (args: Args) => { + withApi(async (api) => { + await createMembersCommand(api) + const proposalDetails = await specificParams(args) + const [proposalId, threadId, proposalData] = await createProposal(api, proposalDetails) + console.log({ proposalId, ...(proposalData?.toJSON() ?? []), threadId }) + }) + }, builder: (argv: yargs.Argv) => argv.options(options), } diff --git a/packages/ui/dev/node-mocks/commands/setLead.ts b/packages/ui/dev/node-mocks/commands/setLead.ts index 51a16ef048..64cbf65f53 100644 --- a/packages/ui/dev/node-mocks/commands/setLead.ts +++ b/packages/ui/dev/node-mocks/commands/setLead.ts @@ -1,6 +1,5 @@ import { GROUP, GroupIdName } from '../consts' -import { createMembersCommand } from './members/create' import { applyOnOpeningCommand } from './opening/apply' import { addOpeningCommand } from './opening/create' import { fillOpeningCommand } from './opening/fill' @@ -10,11 +9,9 @@ interface Params { } export const setLeadCommand = async ({ group = GROUP }: Params = {}) => { - // Add members mock data - await createMembersCommand() - // Make Alice the group leader - const openingId = await addOpeningCommand({ group }) + await addOpeningCommand({ group }) + const openingId = '0' // TODO find out the opening id somehow const applicationId = await applyOnOpeningCommand({ group, openingId }) await fillOpeningCommand({ group, openingId, applicationId }) } diff --git a/packages/ui/dev/node-mocks/lib/api.ts b/packages/ui/dev/node-mocks/lib/api.ts index 90786d0f3e..8f3a36b960 100644 --- a/packages/ui/dev/node-mocks/lib/api.ts +++ b/packages/ui/dev/node-mocks/lib/api.ts @@ -3,9 +3,11 @@ import '@joystream/types' import { ApiPromise, ApiRx, WsProvider } from '@polkadot/api' import { ApiTypes, SubmittableExtrinsic } from '@polkadot/api/types' import { createTestKeyring } from '@polkadot/keyring/testing' +import { KeyringPair } from '@polkadot/keyring/types' import { DispatchError, EventRecord } from '@polkadot/types/interfaces/system' import { ISubmittableResult, ITuple } from '@polkadot/types/types' import chalk from 'chalk' +import { isString } from 'lodash' import { firstValueFrom } from 'rxjs' const isError = ({ event: { method } }: EventRecord) => method === 'ExtrinsicFailed' || method === 'BatchInterrupted' @@ -27,7 +29,7 @@ export const getApi = async (apiType: T, endpoint = 'ws://12 return api as Api } -const keyring = createTestKeyring() +export const keyring = createTestKeyring() const trim = (message: string, maxLength = 80) => message.length > maxLength ? message.slice(0, maxLength) + '...' : message @@ -40,15 +42,17 @@ const describeTx = (tx: SubmittableExtrinsic<'promise'>) => { export async function signAndSend( tx: SubmittableExtrinsic<'promise'>, - signer: string, + signer: string | KeyringPair, innerTx?: SubmittableExtrinsic<'promise'> ) { let unsubCb: () => void - describeTx(innerTx ? innerTx : tx) + describeTx(innerTx ?? tx) + + const keyPair = isString(signer) ? keyring.getPair(signer) : signer return new Promise((resolve) => { - tx.signAndSend(keyring.getPair(signer), function ({ events = [], status }: ISubmittableResult) { + tx.signAndSend(keyPair, function ({ events = [], status }: ISubmittableResult) { console.log(`Transaction status: ${chalk.blue(status.type)}`) if (status.isInBlock) { diff --git a/packages/ui/jest.config.js b/packages/ui/jest.config.js index 0a616dd324..abaeb65d1a 100644 --- a/packages/ui/jest.config.js +++ b/packages/ui/jest.config.js @@ -84,6 +84,7 @@ module.exports = { moduleNameMapper: { '\\.(svg|css|md)$': '/test/_mocks/imports/fileMock.js', '^@/(.*)$': '/src/$1', + '^@test/(.*)$': '/test/$1', // since jest doesn't support importing modules by `exports` in package.json // we are forced to make mapping manually '^@joystream/js/content': '@joystream/js/lib/mjs/content', diff --git a/packages/ui/package.json b/packages/ui/package.json index 0d15faf2fc..3d7fede27e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,20 +4,22 @@ "license": "GPL-3.0-only", "scripts": { "build": "node --max_old_space_size=4096 ./build.js", - "build:storybook": "build-storybook -o build-storybook", + "build:storybook": "storybook build -o build-storybook", "lint": "yarn lint:prettier --check && yarn lint:eslint --max-warnings=0", "lint:eslint": "eslint \"./{src,test}/**/*.{ts,tsx}\"", "lint:fix": "yarn lint:eslint --fix && yarn lint:prettier --write --loglevel warn", "lint:prettier": "yarn prettier \"./{src,test}/**/*.{ts,tsx,html,graphql}\"", "lint:post-codegen": "eslint \"./{src,test}/**/*.generated.{ts,tsx}\" --fix && yarn prettier \"./{src,test}/**/*.generated.{ts,tsx,html,graphql}\" --write --loglevel warn", - "node-mocks": "ts-node --transpile-only dev/node-mocks/index.ts", + "ts-node": "ts-node --transpile-only -r tsconfig-paths/register", + "node-mocks": "yarn ts-node --transpile-only dev/node-mocks/index.ts", "queries:generate": "graphql-codegen --config codegen.config.yml && yarn lint:post-codegen", - "query-node-mocks": "ts-node --transpile-only dev/query-node-mocks/generateMocks.ts", - "helpers": "ts-node --transpile-only dev/helpers/index.ts", + "query-node-mocks": "yarn ts-node --transpile-only dev/query-node-mocks/generateMocks.ts", + "helpers": "yarn ts-node --transpile-only dev/helpers/index.ts", "start": "cross-env NODE_OPTIONS=--max_old_space_size=8192 webpack serve --mode development --progress", - "storybook": "start-storybook -p 6006 -s public --no-manager-cache", + "storybook": "storybook dev -p 6006", "i18n:json2csv": "node ./src/services/i18n/utils/converter.js --input=\"./src/services/i18n/dict\" --output=\"./src/services/i18n/utils/csv\"", "i18n:csv2json": "node ./src/services/i18n/utils/converter.js --from=csv --input=\"./src/services/i18n/utils/csv\" --output=\"./src/services/i18n/dict\" && yarn prettier \"./src/services/i18n/dict/**/*.json\" --print-width 20 --write --loglevel warn", + "test-storybook": "test-storybook", "test": "jest" }, "dependencies": { @@ -48,7 +50,6 @@ "@types/react-transition-group": "^4.4.3", "@types/styled-components": "^5.1.15", "@xstate/react": "^1.6.1", - "babel-plugin-styled-components": "^1.13.2", "copy-webpack-plugin": "^9.0.1", "crypto-browserify": "^3.12.0", "date-fns": "^2.25.0", @@ -86,10 +87,10 @@ "yup": "^0.32.9" }, "devDependencies": { - "@babel/core": "7.15.8", - "@babel/preset-env": "7.15.8", - "@babel/preset-react": "7.14.5", - "@babel/preset-typescript": "7.15.0", + "@babel/core": "7", + "@babel/preset-env": "7", + "@babel/preset-react": "7", + "@babel/preset-typescript": "7", "@graphql-codegen/cli": "^2.2.0", "@graphql-codegen/near-operation-file-preset": "^2.1.4", "@graphql-codegen/typescript": "^2.2.2", @@ -97,13 +98,15 @@ "@graphql-codegen/typescript-react-apollo": "^3.1.4", "@jest/types": "^27.2.5", "@miragejs/graphql": "^0.1.13", - "@storybook/addon-actions": "^6.3.10", - "@storybook/addon-essentials": "^6.3.10", - "@storybook/addon-links": "^6.3.10", - "@storybook/builder-webpack5": "^6.3.10", - "@storybook/manager-webpack5": "^6.3.10", - "@storybook/react": "^6.3.10", - "@storybook/theming": "^6.3.10", + "@storybook/addon-essentials": "^7.0.18", + "@storybook/addon-interactions": "^7.0.22", + "@storybook/addon-links": "^7.0.18", + "@storybook/jest": "^0.1.0", + "@storybook/react": "^7.0.18", + "@storybook/react-webpack5": "^7.0.18", + "@storybook/test-runner": "^0.10.0", + "@storybook/testing-library": "^0.1.0", + "@storybook/theming": "^7.0.18", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.1.2", "@testing-library/react-hooks": "^7.0.2", @@ -113,18 +116,18 @@ "@types/yargs": "^17.0.3", "@typescript-eslint/eslint-plugin": "^5.50.0", "@typescript-eslint/parser": "^5.50.0", - "babel": "^6.23.0", "babel-jest": "^27.2.5", "babel-loader": "^8.2.2", "babel-plugin-import-graphql": "^2.8.1", - "babel-plugin-styled-components": "^1.13.2", + "babel-plugin-styled-components": "^2.1.3", "chalk": "^4.1.2", "clean-webpack-plugin": "^4.0.0", "cross-env": "^7.0.3", "eslint": "^8.33.0", "eslint-plugin-react-hooks": "^4.2.0", + "eslint-plugin-storybook": "^0.6.12", "faker": "^5.5.3", - "fork-ts-checker-webpack-plugin": "^6.4.0", + "fork-ts-checker-webpack-plugin": "^8.0.0", "html-webpack-plugin": "^5.3.2", "i18next-json-csv-converter": "^0.2.0", "jest": "^27.2.5", @@ -134,13 +137,15 @@ "loader": "^2.1.1", "miragejs": "^0.1.42", "mock-local-storage": "^1.1.17", - "prettier": "^2.4.1", + "prettier": "^2.8.8", "raw-loader": "^4.0.2", + "storybook": "^7.0.18", "ts-loader": "^9.2.6", "ts-node": "^10.4.0", - "typescript": "4.4.3", - "webpack": "^5.64.1", - "webpack-cli": "^4.9.1", + "tsconfig-paths": "^3.11.0", + "typescript": "5", + "webpack": "5", + "webpack-cli": "5", "webpack-dev-server": "^4.5.0", "yargs": "^17.2.1" } diff --git a/packages/ui/src/accounts/components/AccountItem/components/styles.ts b/packages/ui/src/accounts/components/AccountItem/components/styles.ts index ceb57da9ba..28702f87dc 100644 --- a/packages/ui/src/accounts/components/AccountItem/components/styles.ts +++ b/packages/ui/src/accounts/components/AccountItem/components/styles.ts @@ -41,7 +41,7 @@ export const DetailsName = styled.h6` ` export const ValueCell = styled.div<{ isRecoverable?: boolean }>` - grid-column: ${({ isRecoverable }) => (isRecoverable ? 4 : 3)}; ; + grid-column: ${({ isRecoverable }) => (isRecoverable ? 4 : 3)}; ` export const ButtonsCell = styled.div` diff --git a/packages/ui/src/accounts/hooks/useHasRequiredStake.ts b/packages/ui/src/accounts/hooks/useHasRequiredStake.ts index a73e223c47..b8756711ae 100644 --- a/packages/ui/src/accounts/hooks/useHasRequiredStake.ts +++ b/packages/ui/src/accounts/hooks/useHasRequiredStake.ts @@ -59,7 +59,7 @@ export const useHasRequiredStake = (stake: BN, lock: LockType) => { } } - const accountsWithLockedFounds = compatibleAccounts.reduce((acc, [compatibleAccountAddress, balances]) => { + const accountsWithLockedFunds = compatibleAccounts.reduce((acc, [compatibleAccountAddress, balances]) => { const total = balances.total let otherAccountsSum = BN_ZERO @@ -85,7 +85,7 @@ export const useHasRequiredStake = (stake: BN, lock: LockType) => { return { hasRequiredStake, accountsWithCompatibleLocks: - accountsWithLockedFounds && isEmptyObject(accountsWithLockedFounds) ? null : accountsWithLockedFounds, + accountsWithLockedFunds && isEmptyObject(accountsWithLockedFunds) ? null : accountsWithLockedFunds, accountsWithTransferableBalance: null, } } diff --git a/packages/ui/src/accounts/modals/MoveFoundsModal/MoveFundsModal.tsx b/packages/ui/src/accounts/modals/MoveFundsModal/MoveFundsModal.tsx similarity index 100% rename from packages/ui/src/accounts/modals/MoveFoundsModal/MoveFundsModal.tsx rename to packages/ui/src/accounts/modals/MoveFundsModal/MoveFundsModal.tsx diff --git a/packages/ui/src/accounts/modals/MoveFoundsModal/MoveFundsModalButtons.tsx b/packages/ui/src/accounts/modals/MoveFundsModal/MoveFundsModalButtons.tsx similarity index 100% rename from packages/ui/src/accounts/modals/MoveFoundsModal/MoveFundsModalButtons.tsx rename to packages/ui/src/accounts/modals/MoveFundsModal/MoveFundsModalButtons.tsx diff --git a/packages/ui/src/accounts/modals/MoveFoundsModal/MoveFundsModalInfo.tsx b/packages/ui/src/accounts/modals/MoveFundsModal/MoveFundsModalInfo.tsx similarity index 100% rename from packages/ui/src/accounts/modals/MoveFoundsModal/MoveFundsModalInfo.tsx rename to packages/ui/src/accounts/modals/MoveFundsModal/MoveFundsModalInfo.tsx diff --git a/packages/ui/src/accounts/modals/MoveFoundsModal/index.ts b/packages/ui/src/accounts/modals/MoveFundsModal/index.ts similarity index 100% rename from packages/ui/src/accounts/modals/MoveFoundsModal/index.ts rename to packages/ui/src/accounts/modals/MoveFundsModal/index.ts diff --git a/packages/ui/src/accounts/modals/MoveFoundsModal/styles.ts b/packages/ui/src/accounts/modals/MoveFundsModal/styles.ts similarity index 100% rename from packages/ui/src/accounts/modals/MoveFoundsModal/styles.ts rename to packages/ui/src/accounts/modals/MoveFundsModal/styles.ts diff --git a/packages/ui/src/accounts/modals/MoveFoundsModal/types.ts b/packages/ui/src/accounts/modals/MoveFundsModal/types.ts similarity index 100% rename from packages/ui/src/accounts/modals/MoveFoundsModal/types.ts rename to packages/ui/src/accounts/modals/MoveFundsModal/types.ts diff --git a/packages/ui/src/accounts/providers/balances/provider.tsx b/packages/ui/src/accounts/providers/balances/provider.tsx index 763e6dbf14..54c2af7551 100644 --- a/packages/ui/src/accounts/providers/balances/provider.tsx +++ b/packages/ui/src/accounts/providers/balances/provider.tsx @@ -25,6 +25,8 @@ export const BalancesContextProvider = (props: Props) => { ) const balances = useMemo(() => { + if (!addresses.length) return {} + if (!isLoading && result) return result.reduce((acc, balance, index) => { return { @@ -32,7 +34,7 @@ export const BalancesContextProvider = (props: Props) => { ...acc, } }, {} as AddressToBalanceMap) - }, [result]) + }, [result, addresses]) return {props.children} } diff --git a/packages/ui/src/accounts/types/balances.ts b/packages/ui/src/accounts/types/balances.ts index 0474467e9c..4bdf4b20ae 100644 --- a/packages/ui/src/accounts/types/balances.ts +++ b/packages/ui/src/accounts/types/balances.ts @@ -21,7 +21,7 @@ export const WorkerLocks = [ 'Distribution Worker', ] as const -export type WorkerLockType = typeof WorkerLocks[number] +export type WorkerLockType = (typeof WorkerLocks)[number] export type LockType = | PolkadotStakingLock diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx new file mode 100644 index 0000000000..23008abecd --- /dev/null +++ b/packages/ui/src/app/App.stories.tsx @@ -0,0 +1,410 @@ +import { metadataToBytes } from '@joystream/js/utils' +import { MembershipMetadata } from '@joystream/metadata-protobuf' +import { expect } from '@storybook/jest' +import { Meta, StoryContext, StoryObj } from '@storybook/react' +import { userEvent, waitFor, within } from '@storybook/testing-library' +import React, { FC } from 'react' +import { createGlobalStyle } from 'styled-components' + +import { Page, Screen } from '@/common/components/page/Page' +import { Colors } from '@/common/constants' +import { GetMemberDocument } from '@/memberships/queries' +import { Membership, member } from '@/mocks/data/members' +import { Container, getButtonByText, joy, selectFromDropdown, withinModal } from '@/mocks/helpers' +import { MocksParameters } from '@/mocks/providers' + +import { App } from './App' +import { OnBoardingOverlay } from './components/OnboardingOverlay/OnBoardingOverlay' +import { SideBar } from './components/SideBar' + +type Args = { + isLoggedIn: boolean + hasMemberships: boolean + hasFunds: boolean + hasAccounts: boolean + hasWallet: boolean + isRPCNodeConnected: boolean + onBuyMembership: CallableFunction + onTransfer: CallableFunction +} + +type Story = StoryObj> + +const alice = member('alice') +const bob = member('bob') +const charlie = member('charlie') + +const MEMBER_DATA = { + id: '12', + handle: 'realbobbybob', + metadata: { + name: 'BobbyBob', + about: 'Lorem ipsum...', + avatar: { avatarUri: 'https://api.dicebear.com/6.x/bottts-neutral/svg?seed=bob' }, + }, +} + +const NoPaddingStyle = createGlobalStyle` + html, body { + padding: 0 !important; + } +` + +export default { + title: 'App', + component: App, + + argTypes: { + onBuyMembership: { action: 'BuyMembership' }, + onTransfer: { action: 'BalanceTransfer' }, + }, + + args: { + isLoggedIn: true, + hasMemberships: true, + hasAccounts: true, + hasFunds: true, + hasWallet: true, + isRPCNodeConnected: true, + }, + + parameters: { + totalBalance: 100, + + mocks: ({ args, parameters }: StoryContext): MocksParameters => { + const account = (member: Membership) => ({ + balances: args.hasFunds ? parameters.totalBalance : 0, + ...(args.hasMemberships ? { member } : { account: { name: member.handle, address: member.controllerAccount } }), + }) + + return { + accounts: { + active: args.isLoggedIn ? 'alice' : undefined, + list: args.hasMemberships || args.hasAccounts ? [account(alice), account(bob), account(charlie)] : [], + hasWallet: args.hasWallet, + }, + + chain: !args.isRPCNodeConnected + ? undefined + : { + query: { + members: { membershipPrice: joy(20) }, + council: { stage: { stage: { isIdle: true }, changedAt: 123 } }, + referendum: { stage: {} }, + }, + + tx: { + balances: { + transfer: { + event: 'Transfer', + onSend: args.onTransfer, + }, + }, + members: { + buyMembership: { + event: 'MembershipBought', + data: [MEMBER_DATA.id], + onSend: args.onBuyMembership, + failure: parameters.txFailure, + }, + }, + }, + }, + + queryNode: [ + { + query: GetMemberDocument, + data: { membershipByUniqueInput: { ...bob, ...MEMBER_DATA, invitees: [] } }, + }, + ], + } + }, + }, + + render: ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + args // This parameter is needs for the controls to appear in stories + ) => ( + + + + + + + + ), +} satisfies Meta + +export const Default: Story = {} + +export const UnreachableRPCNode: Story = { args: { isRPCNodeConnected: false } } + +// ---------------------------------------------------------------------------- +// Test Switch membership modal +// ---------------------------------------------------------------------------- + +export const SwitchMembership: Story = { + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await step('Switch active membership to bob', async () => { + expect(screen.queryByText('bob')).toBeNull() + await userEvent.click(screen.getByText('alice')) + + await userEvent.click(modal.getByText('bob')) + expect(screen.queryByText('alice')).toBeNull() + expect(screen.getByText('bob')) + }) + + await step('Sign out', async () => { + await userEvent.click(screen.getByText('bob')) + await userEvent.click(modal.getByText('Sign Out')) + expect(modal.getByText('Sign out of bob ?')) + await userEvent.click(getButtonByText(modal, 'Sign Out')) + + expect(getButtonByText(screen, 'Select membership')) + }) + }, +} + +// ---------------------------------------------------------------------------- +// Test On Boarding Overlay +// ---------------------------------------------------------------------------- + +export const OnBoardingOverlayStory: Story = { + args: { hasWallet: false, hasAccounts: false, hasFunds: false, hasMemberships: false, isLoggedIn: false }, + + name: 'On Boarding Overlay', + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + + expect(screen.getByText('Become a member')) + userEvent.click(screen.getByText('Show how')) + expect(screen.getByText('What are the benefits?')) + expect(screen.getByText('How to become a member?')) + + expect(screen.getByText('Connect wallet', { selector: 'h6' })) + expect(screen.getByText('Connect account', { selector: 'h6' })) + expect(screen.getByText('Create free membership', { selector: 'h6' })) + }, +} + +// ---------------------------------------------------------------------------- +// Test On Boarding flow +// ---------------------------------------------------------------------------- + +const expectActiveStepToBe = (modal: Container, text: string) => + expect(modal.getByText(text, { selector: 'h6' }).parentElement?.previousElementSibling).toHaveStyle( + `background-color: ${Colors.Blue[500]}` + ) + +export const ConnectWallet: Story = { + args: { hasWallet: false, hasAccounts: false, hasFunds: false, hasMemberships: false, isLoggedIn: false }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + + expect(screen.getByText('Become a member')) + + await userEvent.click(getButtonByText(screen, 'Connect Wallet', { selector: 'nav *' })) + + const modal = withinModal(canvasElement) + expectActiveStepToBe(modal, 'Connect wallet') + expect(modal.getByText('Select Wallet')) + const pluginButton = getButtonByText(modal, 'Install extension') + expect(pluginButton).toBeDisabled() + await userEvent.click(modal.getByText('Polkadot.js')) + expect(pluginButton).toBeEnabled() + }, +} + +export const NoAccount: Story = { + args: { hasAccounts: false, hasFunds: false, hasMemberships: false, isLoggedIn: false }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + + expect(screen.getByText('Become a member')) + + await userEvent.click(getButtonByText(screen, 'Join Now', { selector: 'nav *' })) + + const modal = withinModal(canvasElement) + expectActiveStepToBe(modal, 'Connect account') + expect(modal.getByText('Connect account', { selector: '[class^=ModalBody] *' })) + expect(getButtonByText(modal, 'Return to wallet selection')).toBeEnabled() + expect(getButtonByText(modal, 'Connect Account')).toBeDisabled() + expect(modal.queryByText('alice')).toBeNull() + }, +} + +export const FaucetMembership: Story = { + args: { hasFunds: false, hasMemberships: false, isLoggedIn: false }, + + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.getByText('Become a member')) + + await userEvent.click(getButtonByText(screen, 'Join Now', { selector: 'nav *' })) + + await step('Connect account', async () => { + expectActiveStepToBe(modal, 'Connect account') + expect(modal.getByText('Connect account', { selector: '[class^=ModalBody] *' })) + + expect(getButtonByText(modal, 'Return to wallet selection')).toBeEnabled() + + const connectAccountButton = getButtonByText(modal, 'Connect Account') + expect(connectAccountButton).toBeDisabled() + await userEvent.click(modal.getByText('alice')) + expect(connectAccountButton).toBeEnabled() + + expect(localStorage.getItem('onboarding-membership-account')).toBeNull() + await userEvent.click(connectAccountButton) + expect(localStorage.getItem('onboarding-membership-account')).toBe(JSON.stringify(alice.controllerAccount)) + }) + + await step('Create free membership', async () => { + await waitFor(() => expectActiveStepToBe(modal, 'Create free membership')) + expect(modal.getByText('Please fill in all the details below.')) + + // Check that the CAPTCHA blocks the next step + await userEvent.type(modal.getByLabelText('Member Name'), MEMBER_DATA.metadata.name) + await userEvent.type(modal.getByLabelText('Membership handle'), MEMBER_DATA.handle) + await userEvent.type(modal.getByLabelText('About member'), MEMBER_DATA.metadata.about) + await userEvent.type(modal.getByLabelText('Member Avatar'), MEMBER_DATA.metadata.avatar.avatarUri) + await userEvent.click(modal.getByLabelText(/^I agree to the/)) + expect(getButtonByText(modal, 'Create a Membership')).toBeDisabled() + }) + }, +} + +// ---------------------------------------------------------------------------- +// Test Buy Membership Modal +// ---------------------------------------------------------------------------- +const fillMembershipForm = async (modal: Container) => { + await selectFromDropdown(modal, 'Root account', 'alice') + await selectFromDropdown(modal, 'Controller account', 'bob') + await userEvent.type(modal.getByLabelText('Member Name'), MEMBER_DATA.metadata.name) + await userEvent.type(modal.getByLabelText('Membership handle'), MEMBER_DATA.handle) + await userEvent.type(modal.getByLabelText('About member'), MEMBER_DATA.metadata.about) + await userEvent.type(modal.getByLabelText('Member Avatar'), MEMBER_DATA.metadata.avatar.avatarUri) + await userEvent.click(modal.getByLabelText(/^I agree to the/)) +} + +export const BuyMembershipHappy: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + expect(screen.queryByText('Become a member')).toBeNull() + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await step('Form', async () => { + const createButton = getButtonByText(modal, 'Create a Membership') + + await step('Fill', async () => { + expect(createButton).toBeDisabled() + await fillMembershipForm(modal) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await step('Disables button on incorrect email address', async () => { + await userEvent.click(modal.getByText('Email')) + const emailInput = modal.getByPlaceholderText('Enter Email') + + await userEvent.type(emailInput, 'bobby@bob') + await waitFor(() => expect(createButton).toBeDisabled()) + await userEvent.type(emailInput, '.com') + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + expect(modal.getByText('You intend to create a new membership.')) + expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20') + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and create a member')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText(MEMBER_DATA.handle)) + + expect(args.onBuyMembership).toHaveBeenCalledWith({ + rootAccount: alice.controllerAccount, + controllerAccount: bob.controllerAccount, + handle: MEMBER_DATA.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: MEMBER_DATA.metadata.name, + about: MEMBER_DATA.metadata.about, + avatarUri: MEMBER_DATA.metadata.avatar.avatarUri, + externalResources: [{ type: MembershipMetadata.ExternalResource.ResourceType.EMAIL, value: 'bobby@bob.com' }], + }), + invitingMemberId: undefined, + referrerId: undefined, + }) + + const viewProfileButton = getButtonByText(modal, 'View my profile') + expect(viewProfileButton).toBeEnabled() + userEvent.click(viewProfileButton) + + expect(modal.getByText('Profile')) + expect(modal.getByText(MEMBER_DATA.handle)) + }) + }, +} + +export const BuyMembershipNotEnoughFund: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { totalBalance: 20 }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await fillMembershipForm(modal) + const createButton = getButtonByText(modal, 'Create a Membership') + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + + expect(modal.getByText('Insufficient funds to cover the membership creation.')) + expect(getButtonByText(modal, 'Sign and create a member')).toBeDisabled() + }, +} + +export const BuyMembershipTxFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { txFailure: 'Some error message' }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await fillMembershipForm(modal) + const createButton = getButtonByText(modal, 'Create a Membership') + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + + await userEvent.click(getButtonByText(modal, 'Sign and create a member')) + + expect(await screen.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }, +} diff --git a/packages/ui/src/app/GlobalModals.tsx b/packages/ui/src/app/GlobalModals.tsx index 0896b1ccde..7029d7e97d 100644 --- a/packages/ui/src/app/GlobalModals.tsx +++ b/packages/ui/src/app/GlobalModals.tsx @@ -1,11 +1,11 @@ import { get } from 'lodash' -import React, { memo, ReactElement, useMemo } from 'react' +import React, { memo, ReactElement, useEffect, useMemo, useState } from 'react' import ReactDOM from 'react-dom' import styled from 'styled-components' import { ClaimVestingModalCall } from '@/accounts/modals/ClaimVestingModal' import { ClaimVestingModal } from '@/accounts/modals/ClaimVestingModal/ClaimVestingModal' -import { MoveFundsModal, MoveFundsModalCall } from '@/accounts/modals/MoveFoundsModal' +import { MoveFundsModal, MoveFundsModalCall } from '@/accounts/modals/MoveFundsModal' import { RecoverBalanceModal, RecoverBalanceModalCall } from '@/accounts/modals/RecoverBalance' import { TransferModal, TransferModalCall } from '@/accounts/modals/TransferModal' // import { AddBountyModal, AddBountyModalCall } from '@/bounty/modals/AddBountyModal' @@ -205,6 +205,12 @@ export const GlobalModals = () => { const { status } = useTransactionStatus() const Modal = useMemo(() => (modal && modal in modals ? memo(() => modals[modal as ModalNames]) : null), [modal]) + const [container, setContainer] = useState(document.body) + useEffect(() => { + const container = document.getElementById('modal-container') + if (container) setContainer(container) + }, []) + const potentialFallback = useGlobalModalHandler(currentModalMachine, hideModal) if (modal && !GUEST_ACCESSIBLE_MODALS.includes(modal as ModalNames) && !activeMember) { @@ -226,7 +232,7 @@ export const GlobalModals = () => { {isClosing && } {status === 'loadingFees' && } , - document.body + container ) } diff --git a/packages/ui/src/app/components/OnboardingOverlay/OnboardingOverlay.stories.tsx b/packages/ui/src/app/components/OnboardingOverlay/OnboardingOverlay.stories.tsx deleted file mode 100644 index af850f3d55..0000000000 --- a/packages/ui/src/app/components/OnboardingOverlay/OnboardingOverlay.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Meta, Story } from '@storybook/react' -import React, { useEffect, useState } from 'react' - -import { AccountsContext } from '@/accounts/providers/accounts/context' -import { UseAccounts } from '@/accounts/providers/accounts/provider' -import { ApiContext } from '@/api/providers/context' -import { OnBoardingOverlay } from '@/app/components/OnboardingOverlay/OnBoardingOverlay' -import { TemplateBlock } from '@/common/components/storybookParts/previewStyles' -import { OnBoardingProvider } from '@/common/providers/onboarding/provider' -import { MembershipContext } from '@/memberships/providers/membership/context' -import { MyMemberships } from '@/memberships/providers/membership/provider' -import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvider' - -export default { - title: 'App/OnboardingOverlay', - component: OnBoardingOverlay, -} as Meta - -const useApi = { - isConnected: true, - api: undefined, - connectionState: 'connecting', - qnConnectionState: 'connecting', - setQnConnectionState: () => undefined, -} - -const useMyAccounts: UseAccounts = { - isLoading: false, - hasAccounts: false, - allAccounts: [], - error: undefined, -} -const useMyMemberships: MyMemberships = { - active: undefined, - members: [], - setActive: (member) => (useMyMemberships.active = member), - isLoading: false, - hasMembers: false, - helpers: { - getMemberIdByBoundAccountAddress: () => undefined, - }, -} - -interface Props { - extension: boolean - account: boolean - membership: boolean -} - -const Template: Story = ({ extension, membership, account }: Props) => { - const [state, setState] = useState({ - useApi, - useMyMemberships, - useMyAccounts, - }) - - useEffect(() => { - if (extension) { - setState({ - useApi, - useMyMemberships, - useMyAccounts: { ...useMyAccounts, error: 'EXTENSION' }, - }) - return - } - - if (account) { - setState({ - useApi, - useMyMemberships, - useMyAccounts: { ...useMyAccounts }, - }) - return - } - - if (membership) { - setState({ - useApi, - useMyMemberships, - useMyAccounts: { ...useMyAccounts, hasAccounts: true }, - }) - } - }, [membership, account, extension]) - - return ( - - - - - - - - - - - - - - ) -} - -export const Default = Template.bind({}) -Default.args = { - extension: true, - account: false, - membership: false, -} diff --git a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.tsx b/packages/ui/src/app/pages/Bounty/components/BountiesHeader.tsx deleted file mode 100644 index 14c3868439..0000000000 --- a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -import { PageHeader } from '@/app/components/PageHeader' -import { AddBountyButton } from '@/bounty/components/modalsButtons/AddBountyButton' - -import { BountiesTabs } from './BountiesTabs' - -export const BountiesHeader = () => { - return } buttons={} /> -} diff --git a/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx b/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx index 85acbeff7e..43fea31824 100644 --- a/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx +++ b/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react' import { PageLayout } from '@/app/components/PageLayout' import { BountyEmptyFilter, BountyFilters } from '@/bounty/components/BountiesFilters' +import { BountiesHeader } from '@/bounty/components/BountiesHeader' import { BountiesList } from '@/bounty/components/BountiesList' import { BountyStatus, QueryExtraFilter, useBounties } from '@/bounty/hooks/useBounties' import { BountyOrderByInput } from '@/common/api/queries' @@ -11,8 +12,6 @@ import { MainPanel } from '@/common/components/page/PageContent' import { Pagination } from '@/common/components/Pagination' import { useSort } from '@/common/hooks/useSort' -import { BountiesHeader } from './BountiesHeader' - export interface LayoutProps { tilesComponent?: React.ReactNode extraFilter?: QueryExtraFilter diff --git a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx new file mode 100644 index 0000000000..20eac6baec --- /dev/null +++ b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx @@ -0,0 +1,1412 @@ +import { OpeningMetadata } from '@joystream/metadata-protobuf' +import { linkTo } from '@storybook/addon-links' +import { expect, jest } from '@storybook/jest' +import { Meta, ReactRenderer, StoryContext, StoryObj } from '@storybook/react' +import { userEvent, waitFor, waitForElementToBeRemoved, within } from '@storybook/testing-library' +import { PlayFunction, PlayFunctionContext, StepFunction } from '@storybook/types' +import { FC } from 'react' + +import { metadataFromBytes } from '@/common/model/JoystreamNode/metadataFromBytes' +import { GetMemberDocument, SearchMembersDocument } from '@/memberships/queries' +import { member } from '@/mocks/data/members' +import { generateProposals, MAX_ACTIVE_PROPOSAL, proposalsPagesChain } from '@/mocks/data/proposals' +import { + Container, + getButtonByText, + getEditorByLabel, + isoDate, + joy, + selectFromDropdown, + withinModal, +} from '@/mocks/helpers' +import { MocksParameters } from '@/mocks/providers' +import { + GetProposalsEventsDocument, + GetProposalVotesDocument, + GetProposalsCountDocument, + GetProposalsDocument, +} from '@/proposals/queries' +import { + GetWorkingGroupApplicationsDocument, + GetWorkingGroupDocument, + GetWorkingGroupOpeningsDocument, + GetWorkingGroupsDocument, +} from '@/working-groups/queries' + +import { Proposals } from './Proposals' + +const PROPOSAL_DATA = { + title: 'Foo bar', + description: '## est minus rerum sed\n\nAssumenda et laboriosam minus accusantium. Sed in quo illum.', +} + +const OPENING_DATA = { + id: 'storageWorkingGroup-12', + runtimeId: 12, + groupId: 'storageWorkingGroup', + group: { + name: 'storageWorkingGroup', + budget: '962651993476422', + leaderId: 'storageWorkingGroup-0', + }, + type: 'LEADER', + stakeAmount: '2500000000000000', + rewardPerBlock: '1930000000', + createdInEvent: { inBlock: 123, createdAt: isoDate('2023/01/02') }, + metadata: { + title: 'Hire Storage Working Group Lead', + applicationDetails: 'answers to questions', + shortDescription: 'Hire Storage Working Group Lead', + description: 'Lorem ipsum...', + hiringLimit: 1, + expectedEnding: null, + }, + status: { __typename: 'OpeningStatusOpen' }, +} + +type Args = { + isCouncilMember: boolean + proposalCount: number + onAddStakingAccountCandidate: jest.Mock + onConfirmStakingAccount: jest.Mock + onCreateProposal: jest.Mock + onChangeThreadMode: jest.Mock + onVote: jest.Mock +} +type Story = StoryObj> + +export default { + title: 'Pages/Proposals/ProposalList/Current', + component: Proposals, + + argTypes: { + proposalCount: { control: { type: 'range', max: MAX_ACTIVE_PROPOSAL } }, + onAddStakingAccountCandidate: { action: 'Members.StakingAccountAdded' }, + onConfirmStakingAccount: { action: 'Members.StakingAccountConfirmed' }, + onCreateProposal: { action: 'ProposalsCodex.ProposalCreated' }, + onChangeThreadMode: { action: 'proposalsDiscussion.ThreadModeChanged' }, + onVote: { action: 'ProposalsEngine.Voted' }, + }, + + args: { + isCouncilMember: false, + proposalCount: 15, + }, + + parameters: { + router: { + href: '/proposals/current', + actions: { + '/proposals/past': linkTo('Pages/Proposals/ProposalList/Past'), + }, + }, + + isLoggedIn: true, + balance: 100, + + stakingAccountIdMemberStatus: { + memberId: 0, + confirmed: { isTrue: true }, + size: 1, + }, + + mocks: ({ args, parameters }: StoryContext): MocksParameters => { + const alice = member('alice', { isCouncilMember: args.isCouncilMember }) + + const forumWG = { + id: 'forumWorkingGroup', + name: 'forumWorkingGroup', + budget: joy(100), + workers: [{ stake: joy(parameters.wgLeadStake ?? 0) }, { stake: joy(50) }], + leader: parameters.wgLeadStake + ? { + id: 'forumWorkingGroup-10', + runtimeId: '10', + stake: joy(parameters.wgLeadStake), + rewardPerBlock: joy(5), + membershipId: alice.id, + isActive: true, + } + : undefined, + } + const storageWG = { id: 'storageWorkingGroup', name: 'storageWorkingGroup', budget: joy(100), workers: [] } + + return { + accounts: parameters.isLoggedIn + ? { active: { member: alice, balances: parameters.balance } } + : { list: [{ member: alice }] }, + + chain: proposalsPagesChain( + { + activeProposalCount: args.proposalCount, + minimumValidatorCount: parameters.minimumValidatorCount, + setMaxValidatorCountProposalMaxValidators: parameters.setMaxValidatorCountProposalMaxValidators, + initialInvitationCount: parameters.initialInvitationCount, + initialInvitationBalance: parameters.initialInvitationBalance, + + councilSize: parameters.councilSize, + councilBudget: parameters.councilBudget, + councilorReward: parameters.councilorReward, + nextRewardPayments: parameters.nextRewardPayments, + + onAddStakingAccountCandidate: args.onAddStakingAccountCandidate, + onConfirmStakingAccount: args.onConfirmStakingAccount, + onCreateProposal: args.onCreateProposal, + onChangeThreadMode: args.onChangeThreadMode, + + addStakingAccountCandidateFailure: parameters.addStakingAccountCandidateFailure, + confirmStakingAccountFailure: parameters.confirmStakingAccountFailure, + createProposalFailure: parameters.createProposalFailure, + changeThreadModeFailure: parameters.changeThreadModeFailure, + }, + { + query: { + members: { + stakingAccountIdMemberStatus: parameters.stakingAccountIdMemberStatus, + }, + }, + tx: { + proposalsEngine: { + vote: { event: 'Voted', onSend: args.onVote }, + }, + }, + } + ), + + queryNode: [ + { + query: GetProposalsCountDocument, + data: { proposalsConnection: { totalCount: args.proposalCount } }, + }, + + { + query: GetProposalsDocument, + resolver: ({ variables } = {}) => ({ + loading: false, + data: { + proposals: generateProposals( + { + title: PROPOSAL_DATA.title, + description: PROPOSAL_DATA.description, + creator: alice, + statuses: ['ProposalStatusGracing', 'ProposalStatusDormant', 'ProposalStatusDeciding'], + limit: variables?.limit, + offset: variables?.offset, + }, + args.proposalCount + ), + }, + }), + }, + + { + query: GetProposalVotesDocument, + data: { + proposalVotedEvents: [], + }, + }, + + { + query: GetProposalsEventsDocument, + data: { events: [] }, + }, + + { + query: SearchMembersDocument, + data: { + memberships: [alice], + }, + }, + { + query: GetMemberDocument, + data: { + membershipByUniqueInput: alice, + }, + }, + + { + query: GetWorkingGroupsDocument, + data: { + workingGroups: [forumWG, storageWG], + }, + }, + { + query: GetWorkingGroupDocument, + data: { + workingGroupByUniqueInput: forumWG, + }, + }, + { + query: GetWorkingGroupOpeningsDocument, + data: { + workingGroupOpenings: [OPENING_DATA], + }, + }, + { + query: GetWorkingGroupApplicationsDocument, + data: { + workingGroupApplications: [ + { + id: 'storageWorkingGroup-15', + runtimeId: 15, + opening: OPENING_DATA, + answers: [ + { answer: 'Foo', question: { question: '🐁?' } }, + { answer: 'Bar', question: { question: '🐘?' } }, + ], + status: { __typename: 'ApplicationStatusPending' }, + applicant: alice, + createdInEvent: { inBlock: 234, createdAt: isoDate('2023/01/04') }, + }, + ], + }, + }, + ], + } + }, + }, +} satisfies Meta + +export const Default: Story = {} + +// ---------------------------------------------------------------------------- +// Create proposal: Happy case +// ---------------------------------------------------------------------------- + +const alice = member('alice') +const waitForModal = (modal: Container, name: string) => modal.findByRole('heading', { name }) + +const fillSetReferralCutStep = async (modal: Container, step: StepFunction) => { + await step('Specific parameters', async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + await userEvent.type(modal.getByTestId('amount-input'), '40') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) +} + +export const AddNewProposalHappy: Story = { + parameters: { + isLoggedIn: false, + + stakingAccountIdMemberStatus: { + memberId: 0, + confirmed: { isTrue: false }, + size: 0, + }, + }, + + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + const closeModal = async (heading: string | HTMLElement) => { + const headingElement = heading instanceof HTMLElement ? heading : modal.getByRole('heading', { name: heading }) + await userEvent.click(headingElement.nextElementSibling as HTMLElement) + await userEvent.click(getButtonByText(modal, 'Close')) + } + + await step('Select Membership Modal', async () => { + await userEvent.click(screen.getByText('Add new proposal')) + expect(modal.getByText('Select Membership')) + await userEvent.click(modal.getByText('alice')) + }) + + await step('Warning Modal', async () => { + const createProposalButton = getButtonByText(screen, 'Add new proposal') + + await step('Temporarily close ', async () => { + await waitForModal(modal, 'Caution') + + const nextButton = getButtonByText(modal, 'Create A Proposal') + expect(nextButton).toBeDisabled() + await userEvent.click( + modal.getByLabelText("I'm aware of the possible risks associated with creating a proposal.") + ) + await userEvent.click(nextButton) + + await closeModal('Creating new proposal') + + expect(localStorage.getItem('proposalCaution')).toBe(null) + }) + + await step('Permanently close ', async () => { + await userEvent.click(createProposalButton) + await waitForModal(modal, 'Caution') + + const nextButton = getButtonByText(modal, 'Create A Proposal') + await userEvent.click(modal.getByLabelText('Do not show this message again.')) + expect(nextButton).toBeDisabled() + await userEvent.click( + modal.getByLabelText("I'm aware of the possible risks associated with creating a proposal.") + ) + await userEvent.click(nextButton) + + await closeModal('Creating new proposal') + + await userEvent.click(createProposalButton) + await closeModal(await waitForModal(modal, 'Creating new proposal')) + + expect(localStorage.getItem('proposalCaution')).toBe('true') + }) + }) + + await step('General parameters', async () => { + let nextButton: HTMLElement + + await step('Proposal type', async () => { + const createProposalButton = getButtonByText(screen, 'Add new proposal') + await userEvent.click(createProposalButton) + await waitForModal(modal, 'Creating new proposal') + nextButton = getButtonByText(modal, 'Next step') + + expect(nextButton).toBeDisabled() + await userEvent.click(modal.getByText('Set Referral Cut')) + await waitFor(() => expect(nextButton).not.toBeDisabled()) + await userEvent.click(nextButton) + }) + + await step('Staking account', async () => { + expect(nextButton).toBeDisabled() + await selectFromDropdown(modal, 'Select account for Staking', 'alice') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + + await step('Proposal details', async () => { + const titleField = modal.getByLabelText('Proposal title') + const rationaleEditor = await getEditorByLabel(modal, 'Rationale') + + expect(nextButton).toBeDisabled() + + // Invalid title + rationaleEditor.setData(PROPOSAL_DATA.description) + await userEvent.clear(titleField) + await userEvent.type( + titleField, + 'Reprehenderit laborum veniam est ut magna velit velit deserunt reprehenderit dolore.' + ) + const titleValidation = await modal.findByText('Title exceeds maximum length') + expect(nextButton).toBeDisabled() + + // Invalid rational + await userEvent.clear(titleField) + await userEvent.type(titleField, PROPOSAL_DATA.title) + rationaleEditor.setData(PROPOSAL_DATA.description.padEnd(3002, ' baz')) + const rationaleValidation = await modal.findByText('Rationale exceeds maximum length') + expect(titleValidation).not.toBeInTheDocument() + expect(nextButton).toBeDisabled() + + // Valid + rationaleEditor.setData(PROPOSAL_DATA.description) + await waitForElementToBeRemoved(rationaleValidation) + expect(nextButton).toBeEnabled() + + await userEvent.click(nextButton) + }) + + await step('Trigger & Discussion', async () => { + await step('Trigger', async () => { + expect(nextButton).toBeEnabled() + + await userEvent.click(modal.getByText('Yes')) + expect(nextButton).toBeDisabled() + + const blockInput = modal.getByRole('textbox') + + // Invalid: too low + await userEvent.type(blockInput, '10') + expect(await modal.findByText(/The minimum block number is \d+/)) + expect(nextButton).toBeDisabled() + + // Invalid: too high + await userEvent.type(blockInput, '999999999') + await waitFor(() => expect(modal.queryByText(/The minimum block number is \d+/)).toBeNull()) + expect(await modal.findByText(/The maximum block number is \d+/)) + expect(nextButton).toBeDisabled() + + // Valid + await userEvent.clear(blockInput) + await userEvent.type(blockInput, '9999') + await waitFor(() => expect(modal.queryByText(/The maximum block number is \d+/)).toBeNull()) + expect(await modal.findByText(/^≈.*/)) + await waitFor(() => expect(nextButton).toBeEnabled()) + }) + + await step('Discussion Mode', async () => { + await userEvent.click(modal.getByText('Closed')) + + await waitFor(() => expect(nextButton).toBeDisabled()) + await selectFromDropdown(modal, 'Add member to whitelist', 'alice') + + expect(await modal.findByText('alice')) + expect(nextButton).toBeEnabled() + + userEvent.click(screen.getByTestId('removeMember')) + expect(modal.queryByText('alice')).toBeNull() + await waitFor(() => expect(nextButton).toBeEnabled()) + + await userEvent.click(nextButton) + + expect(modal.getByText('Specific parameters', { selector: 'h4' })) + }) + + await fillSetReferralCutStep(modal, step) + }) + + await step('Bind Staking Account', async () => { + expect(modal.getByText('You intend to bind account for staking')) + expect(modal.getAllByText('alice')).toHaveLength(2) + await userEvent.click(modal.getByText('Sign transaction and Bind Staking Account')) + }) + + await step('Sign Create Proposal transaction', async () => { + expect(await modal.findByText('You intend to create a proposal.')) + await userEvent.click(modal.getByText('Sign transaction and Create')) + }) + + await step('Sign set discussion mode transaction', async () => { + expect(await modal.findByText('You intend to change the proposal discussion thread mode.')) + await userEvent.click(modal.getByText('Sign transaction and change mode')) + expect(await waitForModal(modal, 'Success')) + }) + + step('Transaction parameters', () => { + expect(args.onAddStakingAccountCandidate).toHaveBeenCalledWith(alice.id) + + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount) + + const [generalParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(generalParameters).toEqual({ + memberId: alice.id, + title: PROPOSAL_DATA.title, + description: PROPOSAL_DATA.description, + stakingAccountId: alice.controllerAccount, + exactExecutionBlock: 9999, + }) + + const changeModeTxParams = args.onChangeThreadMode.mock.calls.at(-1) + expect(changeModeTxParams.length).toBe(3) + const [memberId, threadId, mode] = changeModeTxParams + expect(memberId).toBe(alice.id) + expect(typeof threadId).toBe('number') + expect(mode.toJSON()).toEqual({ closed: [] }) + }) + }) + }, +} + +// ---------------------------------------------------------------------------- +// Create proposal: Failure cases +// ---------------------------------------------------------------------------- + +export const NotEnoughFunds: Story = { + parameters: { balance: 1 }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + await userEvent.click(screen.getByText('Add new proposal')) + expect( + await modal.findByText( + /^Unfortunately the account associated with the currently selected membership has insufficient balance/ + ) + ) + expect(modal.getByText('Move funds')) + }, +} + +const fillGeneralParameters = async ( + modal: Container, + step: StepFunction, + proposalType: string, + closeDiscussion = false +) => { + let nextButton: HTMLElement + + await step('Fill General Parameters', async () => { + await step('Proposal type', async () => { + await waitForModal(modal, 'Creating new proposal') + nextButton = getButtonByText(modal, 'Next step') + + await userEvent.click(modal.getByText(proposalType)) + await waitFor(() => expect(nextButton).not.toBeDisabled()) + await userEvent.click(nextButton) + }) + + await step('Staking account', async () => { + await selectFromDropdown(modal, 'Select account for Staking', 'alice') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + + await step('Proposal details', async () => { + const rationaleEditor = await getEditorByLabel(modal, 'Rationale') + await userEvent.type(modal.getByLabelText('Proposal title'), PROPOSAL_DATA.title) + rationaleEditor.setData(PROPOSAL_DATA.description) + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + + await step('Trigger & Discussion', async () => { + if (closeDiscussion) await userEvent.click(modal.getByText('Closed')) + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + }) +} + +const completeForms = async (canvasElement: HTMLElement, step: StepFunction) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + localStorage.setItem('proposalCaution', 'true') + await userEvent.click(getButtonByText(screen, 'Add new proposal')) + await fillGeneralParameters(modal, step, 'Set Referral Cut', true) + await fillSetReferralCutStep(modal, step) +} + +export const BindAccountFailure: Story = { + parameters: { + stakingAccountIdMemberStatus: { + memberId: 0, + confirmed: { isTrue: false }, + size: 0, + }, + addStakingAccountCandidateFailure: 'It failed 🙀', + }, + + play: async ({ args, canvasElement, step }) => { + const modal = withinModal(canvasElement) + await completeForms(canvasElement, step) + await userEvent.click(modal.getByText('Sign transaction and Bind Staking Account')) + + expect(await modal.findByText('It failed 🙀')) + within(document.body).getByText('Transaction failed') + + expect(args.onAddStakingAccountCandidate).toHaveBeenCalled() + expect(args.onConfirmStakingAccount).not.toHaveBeenCalled() + expect(args.onCreateProposal).not.toHaveBeenCalled() + expect(args.onChangeThreadMode).not.toHaveBeenCalled() + }, +} + +export const BindAccountThenCreateProposalFailure: Story = { + parameters: { + stakingAccountIdMemberStatus: { + memberId: 0, + confirmed: { isTrue: false }, + size: 0, + }, + createProposalFailure: 'It failed 🙀', + }, + + play: async ({ args, canvasElement, step }) => { + const modal = withinModal(canvasElement) + await completeForms(canvasElement, step) + await userEvent.click(modal.getByText('Sign transaction and Bind Staking Account')) + await userEvent.click(await modal.findByText('Sign transaction and Create')) + + expect(await modal.findByText('It failed 🙀')) + within(document.body).getByText('Transaction failed') + + expect(args.onAddStakingAccountCandidate).toHaveBeenCalled() + expect(args.onConfirmStakingAccount).toHaveBeenCalled() + expect(args.onCreateProposal).toHaveBeenCalled() + expect(args.onChangeThreadMode).not.toHaveBeenCalled() + }, +} + +export const ConfirmAccountThenCreateProposalFailure: Story = { + parameters: { + stakingAccountIdMemberStatus: { + memberId: 0, + confirmed: { isTrue: false }, + size: 1, + }, + createProposalFailure: 'It failed 🙀', + }, + + play: async ({ args, canvasElement, step }) => { + const modal = withinModal(canvasElement) + await completeForms(canvasElement, step) + await userEvent.click(await modal.findByText('Sign transaction and Create')) + + expect(await modal.findByText('It failed 🙀')) + within(document.body).getByText('Transaction failed') + + expect(args.onAddStakingAccountCandidate).not.toHaveBeenCalled() + expect(args.onConfirmStakingAccount).toHaveBeenCalled() + expect(args.onCreateProposal).toHaveBeenCalled() + expect(args.onChangeThreadMode).not.toHaveBeenCalled() + }, +} + +export const CreateProposalFailure: Story = { + parameters: { + createProposalFailure: 'It failed 🙀', + }, + + play: async ({ args, canvasElement, step }) => { + const modal = withinModal(canvasElement) + await completeForms(canvasElement, step) + await userEvent.click(await modal.findByText('Sign transaction and Create')) + + expect(await modal.findByText('It failed 🙀')) + within(document.body).getByText('Transaction failed') + + expect(args.onAddStakingAccountCandidate).not.toHaveBeenCalled() + expect(args.onConfirmStakingAccount).not.toHaveBeenCalled() + expect(args.onCreateProposal).toHaveBeenCalled() + expect(args.onChangeThreadMode).not.toHaveBeenCalled() + }, +} + +export const ChangeThreadModeFailure: Story = { + parameters: { + changeThreadModeFailure: 'It failed 🙀', + }, + + play: async ({ args, canvasElement, step }) => { + const modal = withinModal(canvasElement) + await completeForms(canvasElement, step) + await userEvent.click(await modal.findByText('Sign transaction and Create')) + await userEvent.click(await modal.findByText('Sign transaction and change mode')) + + expect(await modal.findByText('It failed 🙀')) + // within(document.body).getByText('Transaction failed') + + expect(args.onAddStakingAccountCandidate).not.toHaveBeenCalled() + expect(args.onConfirmStakingAccount).not.toHaveBeenCalled() + expect(args.onCreateProposal).toHaveBeenCalled() + expect(args.onChangeThreadMode).toHaveBeenCalled() + }, +} + +// ---------------------------------------------------------------------------- +// Create proposal: Specific parameters tests +// ---------------------------------------------------------------------------- + +const EXECUTION_WARNING_BOX = 'I understand the implications of overriding the execution constraints validation.' +type SpecificParametersTestFunction = ( + args: Pick, 'args' | 'parameters' | 'step'> & { + modal: Container + createProposal: (create: () => Promise) => Promise + } +) => Promise +const specificParametersTest = + (proposalType: string, specificStep: SpecificParametersTestFunction): PlayFunction => + async ({ args, parameters, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + const createProposal = async (create: () => Promise) => { + localStorage.setItem('proposalCaution', 'true') + + await userEvent.click(getButtonByText(screen, 'Add new proposal')) + + await fillGeneralParameters(modal, step, proposalType) + + await step(`Specific parameters: ${proposalType}`, create) + + await step('Sign transaction and Create', async () => { + await waitFor(async () => { + const createButton = modal.queryByText('Create proposal') + if (createButton) { + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + } + await userEvent.click(modal.getByText('Sign transaction and Create')) + }) + expect(await waitForModal(modal, 'Success')) + }) + } + + await specificStep({ args, parameters, createProposal, modal, step }) + } + +export const SpecificParametersSignal: Story = { + play: specificParametersTest('Signal', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const editor = await getEditorByLabel(modal, 'Signal') + + // Invalid + editor.setData('') + const validation = await modal.findByText('Field is required') + expect(nextButton).toBeDisabled() + + // Valid + editor.setData('Lorem ipsum...') + await waitForElementToBeRemoved(validation) + expect(nextButton).toBeEnabled() + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toHuman()).toEqual({ Signal: 'Lorem ipsum...' }) + }) + }), +} + +export const SpecificParametersFundingRequest: Story = { + play: specificParametersTest('Funding Request', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Invalid + await selectFromDropdown(modal, 'Recipient account', 'alice') + await userEvent.clear(amountField) + await userEvent.type(amountField, '166667') + expect(await modal.findByText(/Maximal amount allowed is \d+/)) + expect(nextButton).toBeDisabled() + + // Valid again + await userEvent.clear(amountField) + await userEvent.type(amountField, '100') + await waitFor(() => expect(modal.queryByText(/Maximal amount allowed is \d+/)).toBeNull()) + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ + fundingRequest: [{ account: alice.controllerAccount, amount: 100_0000000000 }], + }) + }) + }), +} + +export const SpecificParametersSetReferralCut: Story = { + play: specificParametersTest('Set Referral Cut', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Valid + await userEvent.type(amountField, '40') + await waitFor(() => expect(nextButton).toBeEnabled()) + + // Invalid: creation constraints + await userEvent.clear(amountField) + await userEvent.type(amountField, '200') + await waitFor(() => expect(nextButton).toBeDisabled()) + + // Execution constraints warning + await userEvent.clear(amountField) + await userEvent.type(amountField, '100') + expect(await modal.findByText('Input must be equal or less than 50% for proposal to execute')) + expect(nextButton).toBeDisabled() + userEvent.click(modal.getByText(EXECUTION_WARNING_BOX)) + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setReferralCut: 100 }) + }) + }), +} + +export const SpecificParametersDecreaseWorkingGroupLeadStake: Story = { + parameters: { wgLeadStake: 1000 }, + + play: specificParametersTest('Decrease Working Group Lead Stake', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const body = within(document.body) + + // WGs without a lead are disabled + await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name')) + const storageWG = body.getByText('Storage') + expect(storageWG.nextElementSibling?.firstElementChild?.textContent).toMatch(/This group has no lead/) + expect(storageWG).toHaveStyle({ 'pointer-events': 'none' }) + + // NOTE: This should be valid but here the button is still disabled + userEvent.click(body.getByText('Forum')) + const stakeMessage = modal.getByText(/The actual stake for Forum Working Group Lead is/) + expect(within(stakeMessage).getByText('1,000')) + + const amountField = modal.getByTestId('amount-input') + + await waitFor(() => expect(amountField).toHaveValue('500')) + + // Invalid: stake set to 0 + await userEvent.clear(amountField) + expect(await modal.findByText('Amount must be greater than zero')) + expect(nextButton).toBeDisabled() + + // Valid 1/3 + userEvent.click(modal.getByText('By 1/3')) + waitFor(() => expect(modal.queryByText('Amount must be greater than zero')).toBeNull()) + expect(amountField).toHaveValue('333.3333333333') + + // Valid 1/2 + userEvent.click(modal.getByText('By half')) + expect(amountField).toHaveValue('500') + }) + + step('Transaction parameters', () => { + const leaderId = 10 // Set on the mock QN query + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ + decreaseWorkingGroupLeadStake: [leaderId, 500_0000000000, 'Forum'], + }) + }) + }), +} + +export const SpecificParametersTerminateWorkingGroupLead: Story = { + parameters: { wgLeadStake: 1000 }, + + play: specificParametersTest('Terminate Working Group Lead', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const body = within(document.body) + + // WGs without a lead are disabled + await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name')) + const storageWG = body.getByText('Storage') + expect(storageWG.nextElementSibling?.firstElementChild?.textContent).toMatch(/This group has no lead/) + expect(storageWG).toHaveStyle({ 'pointer-events': 'none' }) + + // Valid: Don't Slash lead + userEvent.click(body.getByText('Forum')) + expect(await modal.findByText('alice')) + await waitFor(() => expect(nextButton).toBeEnabled()) + + // Valid: Slash the lead 2000 JOY + userEvent.click(modal.getByText('Yes')) + const amountField = modal.getByTestId('amount-input') + expect(amountField).toHaveValue('') + userEvent.type(amountField, '2000') + }) + + step('Transaction parameters', () => { + const leaderId = 10 // Set on the mock QN query + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ + terminateWorkingGroupLead: { + workerId: leaderId, + slashingAmount: 2000_0000000000, + group: 'Forum', + }, + }) + }) + }), +} + +export const SpecificParametersCreateWorkingGroupLeadOpening: Story = { + parameters: { wgLeadStake: 1000 }, + + play: specificParametersTest('Create Working Group Lead Opening', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Next step') + expect(nextButton).toBeDisabled() + + const body = within(document.body) + + // WGs without a lead are enabled + await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name')) + const storageWG = body.getByText('Storage') + expect(storageWG).not.toHaveStyle({ 'pointer-events': 'none' }) + + // Step 1 valid + await userEvent.click(body.getByText('Forum')) + await userEvent.type(modal.getByLabelText('Opening title'), 'Foo') + await userEvent.type(modal.getByLabelText('Short description'), 'Bar') + ;(await getEditorByLabel(modal, 'Description')).setData('Baz') + expect(nextButton).toBeDisabled() + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + + // Step 2 + expect(nextButton).toBeDisabled() + ;(await getEditorByLabel(modal, 'Application process')).setData('Lorem ipsum...') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(modal.getByText('Limited')) + await waitFor(() => expect(nextButton).toBeDisabled()) + await userEvent.type(modal.getByLabelText('Expected length of the application period'), '1000') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + + // Step 3 + expect(nextButton).toBeDisabled() + await userEvent.type(modal.getByRole('textbox'), '🐁?') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(modal.getByText('Add new question')) + await waitFor(() => expect(nextButton).toBeDisabled()) + await userEvent.click(modal.getAllByText('Long answer')[1]) + await userEvent.type(modal.getAllByRole('textbox')[1], '🐘?') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + + // Step 4 + expect(nextButton).toBeDisabled() + await userEvent.type(modal.getByLabelText('Staking amount *'), '100') + await userEvent.type(modal.getByLabelText('Role cooldown period'), '0') + await userEvent.type(modal.getByLabelText('Reward amount per Block'), '0.1') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const { description, ...data } = specificParameters.asCreateWorkingGroupLeadOpening.toJSON() + + expect(data).toEqual({ + rewardPerBlock: 1000000000, + stakePolicy: { + stakeAmount: 100_0000000000, + leavingUnstakingPeriod: 0, + }, + group: 'Forum', + }) + + expect(metadataFromBytes(OpeningMetadata, description)).toEqual({ + title: 'Foo', + shortDescription: 'Bar', + description: 'Baz', + hiringLimit: 1, + expectedEndingTimestamp: 1000, + applicationDetails: 'Lorem ipsum...', + applicationFormQuestions: [ + { question: '🐁?', type: OpeningMetadata.ApplicationFormQuestion.InputType.TEXT }, + { question: '🐘?', type: OpeningMetadata.ApplicationFormQuestion.InputType.TEXTAREA }, + ], + }) + }) + }), +} + +export const SpecificParametersSetWorkingGroupLeadReward: Story = { + parameters: { wgLeadStake: 1000 }, + + play: specificParametersTest('Set Working Group Lead Reward', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const body = within(document.body) + + // WGs without a lead are disabled + await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name')) + const storageWG = body.getByText('Storage') + expect(storageWG.nextElementSibling?.firstElementChild?.textContent).toMatch(/This group has no lead/) + expect(storageWG).toHaveStyle({ 'pointer-events': 'none' }) + + // Valid + userEvent.click(body.getByText('Forum')) + expect(await modal.findByText('alice')) + const stakeMessage = modal.getByText(/Current reward per block for Forum Working Group Lead is/) + expect(within(stakeMessage).getByText('5')) + expect(nextButton).toBeDisabled() + const amountField = modal.getByTestId('amount-input') + await userEvent.type(amountField, '1') + await waitFor(() => expect(nextButton).toBeEnabled()) + + // Invalid + await userEvent.clear(amountField) + await userEvent.type(amountField, '0') + await waitFor(() => expect(nextButton).toBeDisabled()) + + // Valid again + await userEvent.clear(amountField) + await userEvent.type(amountField, '10') + }) + + step('Transaction parameters', () => { + const leaderId = 10 // Set on the mock QN query + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ + setWorkingGroupLeadReward: [leaderId, 10_0000000000, 'Forum'], + }) + }) + }), +} + +export const SpecificParametersSetMaxValidatorCount: Story = { + parameters: { minimumValidatorCount: 4, setMaxValidatorCountProposalMaxValidators: 100 }, + + play: specificParametersTest('Set Max Validator Count', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Invalid: too low + await userEvent.type(amountField, '1') + const validation = await modal.findByText('Minimal amount allowed is 4') + expect(validation) + expect(nextButton).toBeDisabled() + + // Invalid: too high + await userEvent.type(amountField, '999') + // console.log(validation) + await waitFor(() => expect(validation).toHaveTextContent('Maximal amount allowed is 100')) + expect(nextButton).toBeDisabled() + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '10') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setMaxValidatorCount: 10 }) + }) + }), +} + +export const SpecificParametersCancelWorkingGroupLeadOpening: Story = { + play: specificParametersTest('Cancel Working Group Lead Opening', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const body = within(document.body) + + // Valid + await userEvent.click(modal.getByPlaceholderText('Choose opening to cancel')) + userEvent.click(body.getByText('Hire Storage Working Group Lead')) + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ cancelWorkingGroupLeadOpening: [12, 'Storage'] }) + }) + }), +} + +export const SpecificParametersSetCouncilBudgetIncrement: Story = { + play: specificParametersTest('Set Council Budget Increment', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Invalid budget 0 + await userEvent.type(amountField, '1') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.clear(amountField) + await userEvent.type(amountField, '0') + await waitFor(() => expect(nextButton).toBeDisabled()) + + // The value remains less than 2^128 + await userEvent.clear(amountField) + await userEvent.type(amountField, ''.padEnd(39, '9')) + const value = Number((amountField as HTMLInputElement).value.replace(/,/g, '')) + expect(value).toBeLessThan(2 ** 128) + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '500') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setCouncilBudgetIncrement: 500_0000000000 }) + }) + }), +} + +export const SpecificParametersSetCouncilorReward: Story = { + play: specificParametersTest('Set Councilor Reward', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Invalid budget 0 + await userEvent.type(amountField, '1') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.clear(amountField) + await userEvent.type(amountField, '0') + await waitFor(() => expect(nextButton).toBeDisabled()) + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '10') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setCouncilorReward: 10_0000000000 }) + }) + }), +} + +export const SpecificParametersSetMembershipLeadInvitationQuota: Story = { + parameters: { wgLeadStake: 1000 }, + + play: specificParametersTest( + 'Set Membership Lead Invitation Quota', + async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Invalid budget 0 + await userEvent.type(amountField, '1') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.clear(amountField) + await userEvent.type(amountField, '0') + await waitFor(() => expect(nextButton).toBeDisabled()) + + // The value remains less than 2^32 + await userEvent.clear(amountField) + await userEvent.type(amountField, ''.padEnd(39, '9')) + const value = Number((amountField as HTMLInputElement).value.replace(/,/g, '')) + expect(value).toBeLessThan(2 ** 32) + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '3') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setMembershipLeadInvitationQuota: 3 }) + }) + } + ), +} + +export const SpecificParametersFillWorkingGroupLeadOpening: Story = { + play: specificParametersTest('Fill Working Group Lead Opening', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const body = within(document.body) + + // Select Opening + await userEvent.click(modal.getByPlaceholderText('Choose opening to fill')) + userEvent.click(body.getByText('Hire Storage Working Group Lead')) + + // Select Application + const applicationSelector = await modal.findByPlaceholderText('Choose application') + const options = await waitFor(async () => { + await userEvent.click(applicationSelector) + const options = document.getElementById('select-popper-wrapper') + expect(options).not.toBeNull() + return within(options as HTMLElement) + }) + expect(nextButton).toBeDisabled() + userEvent.click(options.getByText('alice')) + + // Check application + expect(await modal.findByText('🐁?')) + expect(modal.getByText('Foo')) + expect(modal.getByText('🐘?')) + expect(modal.getByText('Bar')) + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ + fillWorkingGroupLeadOpening: { + applicationId: 15, + openingId: 12, + workingGroup: 'Storage', + }, + }) + }) + }), +} + +export const SpecificParametersSetInitialInvitationCount: Story = { + parameters: { initialInvitationCount: 5 }, + + play: specificParametersTest('Set Initial Invitation Count', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + expect(modal.getByText('The current initial invitation count is 5.')) + + const countField = modal.getByLabelText('New Count') + + // Invalid 0 invitations + await userEvent.type(countField, '0') + expect(await modal.findByText('Amount must be greater than zero')) + expect(nextButton).toBeDisabled() + + // The value remains less than 2^32 + await userEvent.clear(countField) + await userEvent.type(countField, ''.padEnd(39, '9')) + const value = Number((countField as HTMLInputElement).value.replace(/,/g, '')) + expect(value).toBeLessThan(2 ** 32) + + // Valid + await userEvent.clear(countField) + await userEvent.type(countField, '7') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setInitialInvitationCount: 7 }) + }) + }), +} + +export const SpecificParametersSetInitialInvitationBalance: Story = { + parameters: { initialInvitationBalance: joy(5) }, + + play: specificParametersTest('Set Initial Invitation Balance', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const current = modal.getByText(/The current balance is/) + expect(within(current).getByText('5')) + + const amountField = modal.getByTestId('amount-input') + + // Invalid balance 0 + await userEvent.type(amountField, '0') + expect(await modal.findByText('Amount must be greater than zero')) + expect(nextButton).toBeDisabled() + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '7') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setInitialInvitationBalance: 7_0000000000 }) + }) + }), +} + +export const SpecificParametersSetMembershipPrice: Story = { + play: specificParametersTest('Set Membership Price', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + // Invalid price set to 0 + await userEvent.type(amountField, '0') + expect(await modal.findByText('Amount must be greater than zero')) + expect(nextButton).toBeDisabled() + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '8') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ setMembershipPrice: 8_0000000000 }) + }) + }), +} + +export const SpecificParametersUpdateWorkingGroupBudget: Story = { + parameters: { + councilSize: 3, + councilBudget: joy(2000), + councilorReward: joy(100), + nextRewardPayments: 12345, + }, + + play: specificParametersTest('Update Working Group Budget', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const amountField = modal.getByTestId('amount-input') + + const currentCouncilBudget = modal.getByText(/Current budget for Council is/) + expect(within(currentCouncilBudget).getByText('2,000')) + + const councilSummary = modal.getByText(/Next Council payment is in/) + expect(within(councilSummary).getByText('12,345')) // Next reward payment block + expect(within(councilSummary).getByText(100 * 3)) // Next reward payment block + + expect( + modal.getByText( + 'If the Councils budget is less than provided amount at attempted execution, this proposal will fail to execute, and the budget size will not be changed.' + ) + ) + + userEvent.click(modal.getByText('Yes')) + expect( + modal.getByText( + 'If the budget is less than provided amount at attempted execution, this proposal will fail to execute and the budget size will not be changed' + ) + ) + + // Select working group + await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name')) + userEvent.click(within(document.body).getByText('Forum')) + + const currentWgBudget = modal.getByText(/Current budget for Forum Working Group is/) + expect(within(currentWgBudget).getByText('100')) + + // Invalid price set to 0 + await userEvent.type(amountField, '0') + expect(await modal.findByText('Amount must be greater than zero')) + expect(nextButton).toBeDisabled() + + // Valid + await userEvent.clear(amountField) + await userEvent.type(amountField, '99') + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ + updateWorkingGroupBudget: [99_0000000000, 'Forum', 'Negative'], + }) + }) + }), +} + +export const SpecificParametersRuntimeUpgrade: Story = { + play: specificParametersTest('Runtime Upgrade', async ({ args, createProposal, modal, step }) => { + await createProposal(async () => { + const nextButton = getButtonByText(modal, 'Create proposal') + expect(nextButton).toBeDisabled() + + const uploadField = modal.getByTestId('runtime-upgrade-input') + + // Invalid + await userEvent.upload(uploadField, new File([], 'invalid.wasm', { type: 'application/wasm' })) + const validation = await modal.findByText(/was not loaded because of: "not valid WASM file"./) + expect(within(validation).getByText('invalid.wasm')) + + // Valid + const setIsValidWASM = jest.fn() + const validFile = Object.defineProperties(new File([], 'valid.wasm', { type: 'application/wasm' }), { + isValidWASM: { get: () => true, set: setIsValidWASM }, + arrayBuffer: { value: () => Promise.resolve(new ArrayBuffer(1)) }, + size: { value: 1 }, + }) + await userEvent.upload(uploadField, validFile) + await waitFor(() => expect(setIsValidWASM).toHaveBeenCalledWith(false)) + const confirmation = await modal.findByText(/was loaded successfully!/) + expect(within(confirmation).getByText('valid.wasm')) + }) + + step('Transaction parameters', () => { + const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(specificParameters.toJSON()).toEqual({ runtimeUpgrade: '0x' }) + }) + }), +} diff --git a/packages/ui/src/app/pages/Proposals/PastProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/PastProposals.stories.tsx new file mode 100644 index 0000000000..b9a06ab4fd --- /dev/null +++ b/packages/ui/src/app/pages/Proposals/PastProposals.stories.tsx @@ -0,0 +1,91 @@ +import { linkTo } from '@storybook/addon-links' +import { Meta, StoryContext, StoryObj } from '@storybook/react' +import { random } from 'faker' +import { FC } from 'react' + +import { member } from '@/mocks/data/members' +import { generateProposals, proposalsPagesChain } from '@/mocks/data/proposals' +import { MocksParameters } from '@/mocks/providers' +import { GetProposalsCountDocument, GetProposalsDocument } from '@/proposals/queries' + +import { PastProposals } from './PastProposals' + +import { randomMarkdown } from '@/../dev/query-node-mocks/generators/utils' + +const PROPOSAL_DATA = { + title: random.words(4), + description: randomMarkdown(), +} + +const alice = member('alice') + +type Args = { + proposalCount: number +} +type Story = StoryObj> + +export default { + title: 'Pages/Proposals/ProposalList/Past', + component: PastProposals, + + argTypes: { + proposalCount: { control: { type: 'range', max: 30 } }, + }, + + args: { + proposalCount: 15, + }, + + parameters: { + router: { + href: '/proposals/past', + actions: { + '/proposals/current': linkTo('Pages/Proposals/ProposalList/Current'), + }, + }, + + mocks: ({ args }: StoryContext): MocksParameters => { + return { + chain: proposalsPagesChain({ activeProposalCount: 5 }), + + queryNode: [ + { + query: GetProposalsCountDocument, + data: { proposalsConnection: { totalCount: args.proposalCount } }, + }, + + { + query: GetProposalsDocument, + resolver: ({ variables } = {}) => ({ + loading: false, + data: { + proposals: generateProposals( + { + title: PROPOSAL_DATA.title, + description: PROPOSAL_DATA.description, + creator: alice, + statuses: [ + 'ProposalStatusCanceledByRuntime', + 'ProposalStatusCancelled', + 'ProposalStatusExecuted', + 'ProposalStatusExecutionFailed', + 'ProposalStatusExpired', + 'ProposalStatusRejected', + 'ProposalStatusSlashed', + 'ProposalStatusVetoed', + ], + limit: variables?.limit, + offset: variables?.offset, + }, + args.proposalCount + ), + }, + }), + }, + ], + } + }, + }, +} satisfies Meta + +export const Default: Story = {} diff --git a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx new file mode 100644 index 0000000000..1e9bf67846 --- /dev/null +++ b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx @@ -0,0 +1,602 @@ +import { expect, jest } from '@storybook/jest' +import { Meta, StoryContext, StoryObj } from '@storybook/react' +import { userEvent, within } from '@storybook/testing-library' +import { random } from 'faker' +import { last } from 'lodash' +import { FC } from 'react' + +import { ProposalVoteKind } from '@/common/api/queries' +import { repeat } from '@/common/utils' +import { GetElectedCouncilDocument } from '@/council/queries' +import { member } from '@/mocks/data/members' +import { + ProposalStatus, + proposalDiscussionPosts, + proposalActiveStatus, + generateProposal, + proposalTypes, +} from '@/mocks/data/proposals' +import { getButtonByText, getEditorByLabel, withinModal, isoDate, joy, Container } from '@/mocks/helpers' +import { ProposalDetailsType, proposalDetailsToConstantKey } from '@/mocks/helpers/proposalDetailsToConstantKey' +import { MocksParameters } from '@/mocks/providers' +import { GetProposalDocument } from '@/proposals/queries' + +import { ProposalPreview } from './ProposalPreview' + +import { randomMarkdown } from '@/../dev/query-node-mocks/generators/utils' + +const bob = member('bob', { isCouncilMember: true }) +const charlie = member('charlie', { isCouncilMember: true }) + +const PROPOSAL_DATA = { + id: '123', + title: random.words(4), + description: randomMarkdown(), +} + +const voteArgs = ['None', 'Approve', 'Reject', 'Slash', 'Abstain'] as const +type VoteArg = (typeof voteArgs)[number] +const asVoteKind = (vote?: VoteArg): ProposalVoteKind | undefined => + vote === 'None' ? undefined : vote && ProposalVoteKind[vote] + +type Args = { + isCouncilMember: boolean + isProposer: boolean + isDiscussionOpen: boolean + isInDiscussionWhitelist: boolean + type: ProposalDetailsType + constitutionality: number + vote1: VoteArg + vote2: VoteArg + vote3: VoteArg + onVote: jest.Mock +} +type Story = StoryObj> + +export default { + title: 'Pages/Proposals/ProposalPreview', + component: ProposalPreview, + + argTypes: { + type: { control: { type: 'select' }, options: proposalTypes }, + constitutionality: { control: { type: 'range', min: 1, max: 4 } }, + vote1: { control: { type: 'inline-radio' }, options: voteArgs }, + vote2: { control: { type: 'inline-radio' }, options: voteArgs }, + vote3: { control: { type: 'inline-radio' }, options: voteArgs }, + onVote: { action: 'ProposalsEngine.Voted' }, + }, + + args: { + isCouncilMember: false, + isProposer: false, + isInDiscussionWhitelist: false, + isDiscussionOpen: true, + type: 'SignalProposalDetails', + constitutionality: 1, + vote1: 'None', + vote2: 'None', + vote3: 'None', + }, + + parameters: { + router: { path: '/:id', href: `/${PROPOSAL_DATA.id}` }, + + statuses: ['ProposalStatusDeciding'] satisfies ProposalStatus[], + totalBalance: 100, + + mocks: ({ args, parameters }: StoryContext): MocksParameters => { + const { constitutionality, isCouncilMember } = args + + const alice = member('alice', { isCouncilMember }) + const dave = member('dave', { isCouncilMember: !isCouncilMember }) + + const status = last(parameters.statuses) as ProposalStatus + const updates = parameters.statuses.slice(1, proposalActiveStatus.includes(status) ? undefined : -1) + const councilors = [isCouncilMember ? alice : dave, bob, charlie] + + const paramVotes = parameters.votes as VoteArg[][] | undefined + const votesRounds = paramVotes ?? repeat(() => [args.vote1, args.vote2, args.vote3], constitutionality) + const votes = votesRounds.flatMap((votes, round) => + votes.flatMap((vote, index) => + vote === 'None' ? [] : { voteKind: asVoteKind(vote), voter: councilors[index], votingRound: round + 1 } + ) + ) + + return { + accounts: { active: { member: alice, balances: parameters.totalBalance } }, + + chain: { + consts: { + proposalsCodex: { + [proposalDetailsToConstantKey(args.type)]: { + votingPeriod: 200, + gracePeriod: 100, + approvalQuorumPercentage: 80, + approvalThresholdPercentage: 100, + slashingQuorumPercentage: 60, + slashingThresholdPercentage: 80, + requiredStake: joy(200), + constitutionality, + }, + }, + council: { councilSize: 3, idlePeriodDuration: 1, announcingPeriodDuration: 1 }, + referendum: { voteStageDuration: 1, revealStageDuration: 1 }, + }, + + query: { + members: { membershipPrice: joy(5) }, + council: { + budget: joy(1000), + councilorReward: joy(1), + stage: { stage: { isIdle: true }, changedAt: 123 }, + }, + referendum: { stage: {} }, + }, + + tx: { + proposalsEngine: { + vote: { + event: 'Voted', + onSend: args.onVote, + failure: parameters.txFailure, + }, + }, + }, + }, + + queryNode: [ + { + query: GetProposalDocument, + data: { + proposal: generateProposal({ + id: PROPOSAL_DATA.id, + title: PROPOSAL_DATA.title, + description: PROPOSAL_DATA.description, + status, + type: args.type, + creator: args.isProposer ? alice : bob, + + discussionThread: { + posts: proposalDiscussionPosts, + mode: args.isDiscussionOpen + ? { __typename: 'ProposalDiscussionThreadModeOpen' } + : { + __typename: 'ProposalDiscussionThreadModeClosed', + whitelist: { + __typename: 'ProposalDiscussionWhitelist', + members: args.isInDiscussionWhitelist ? [alice] : [], + }, + }, + }, + + proposalStatusUpdates: updates.map((status: ProposalStatus) => ({ + inBlock: 123, + createdAt: isoDate('2023/01/02'), + newStatus: { __typename: status }, + })), + + councilApprovals: parameters.councilApprovals ?? constitutionality - 1, + votes, + }), + }, + }, + + { + query: GetElectedCouncilDocument, + data: { + electedCouncils: { + id: '0', + electedAtBlock: 123, + electedAtTime: isoDate('2023/01/02'), + councilElections: [{ cycleId: 4 }], + councilMembers: [ + { id: '0', unpaidReward: '0', stake: joy(200), member: councilors[0] }, + { id: '1', unpaidReward: '0', stake: joy(200), member: councilors[1] }, + { id: '2', unpaidReward: '0', stake: joy(200), member: councilors[2] }, + ], + }, + }, + }, + ], + } + }, + }, +} satisfies Meta + +// ---------------------------------------------------------------------------- +// ProposalPreview +// ---------------------------------------------------------------------------- + +export const AmendConstitution: Story = { + args: { type: 'AmendConstitutionProposalDetails', constitutionality: 2 }, + parameters: { + statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[], + }, +} +export const CancelWorkingGroupLeadOpening: Story = { + args: { type: 'CancelWorkingGroupLeadOpeningProposalDetails' }, +} +export const CreateWorkingGroupLeadOpening: Story = { + args: { type: 'CreateWorkingGroupLeadOpeningProposalDetails' }, +} +export const DecreaseWorkingGroupLeadStake: Story = { + args: { type: 'DecreaseWorkingGroupLeadStakeProposalDetails' }, +} +export const FillWorkingGroupLeadOpening: Story = { + args: { type: 'FillWorkingGroupLeadOpeningProposalDetails' }, +} +export const FundingRequest: Story = { + args: { type: 'FundingRequestProposalDetails' }, +} +export const RuntimeUpgrade: Story = { + args: { type: 'RuntimeUpgradeProposalDetails', constitutionality: 2 }, + parameters: { + statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[], + }, +} +export const SetCouncilBudgetIncrement: Story = { + args: { type: 'SetCouncilBudgetIncrementProposalDetails', constitutionality: 2 }, + parameters: { + statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[], + }, +} +export const SetCouncilorReward: Story = { + args: { type: 'SetCouncilorRewardProposalDetails', constitutionality: 2 }, + parameters: { + statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[], + }, +} +export const SetInitialInvitationBalance: Story = { + args: { type: 'SetInitialInvitationBalanceProposalDetails' }, +} +export const SetInitialInvitationCount: Story = { + args: { type: 'SetInitialInvitationCountProposalDetails' }, +} +export const SetMaxValidatorCount: Story = { + args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2 }, + parameters: { + statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[], + }, +} +export const SetMembershipLeadInvitationQuota: Story = { + args: { type: 'SetMembershipLeadInvitationQuotaProposalDetails' }, +} +export const SetMembershipPrice: Story = { + args: { type: 'SetMembershipPriceProposalDetails' }, +} +export const SetReferralCut: Story = { + args: { type: 'SetReferralCutProposalDetails' }, +} +export const SetWorkingGroupLeadReward: Story = { + args: { type: 'SetWorkingGroupLeadRewardProposalDetails' }, +} +export const Signal: Story = { + args: { type: 'SignalProposalDetails' }, +} +export const SlashWorkingGroupLead: Story = { + args: { type: 'SlashWorkingGroupLeadProposalDetails' }, +} +export const TerminateWorkingGroupLead: Story = { + args: { type: 'TerminateWorkingGroupLeadProposalDetails' }, +} +export const UpdateChannelPayouts: Story = { + args: { type: 'UpdateChannelPayoutsProposalDetails' }, +} +export const UpdateWorkingGroupBudget: Story = { + args: { type: 'UpdateWorkingGroupBudgetProposalDetails' }, +} +export const Veto: Story = { + args: { type: 'VetoProposalDetails' }, +} + +// ---------------------------------------------------------------------------- +// VoteForProposalModal +// ---------------------------------------------------------------------------- + +export const VoteForProposalModal: Story = { + args: { type: 'SignalProposalDetails', isCouncilMember: true }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByText('Vote on Proposal')) + }, +} + +// ---------------------------------------------------------------------------- +// Test ProposalPreview +// ---------------------------------------------------------------------------- + +export const TestsIsNotCouncil: Story = { + args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: false, isProposer: true }, + + name: 'Test ProposalPreview > Is not in council', + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + + await step('Main', () => { + expect(screen.getByText(PROPOSAL_DATA.title, { selector: 'header h2' })) + + expect(screen.getByText('Deciding', { selector: 'header *' })) + + expect(screen.getAllByText(/(?:Approval|Slashing) (?:Quorum|Threshold)/)).toHaveLength(4) + + expect(screen.getByText('Set Max Validator Count')) + + expect(screen.getByText('Rationale')) + + expect(screen.getByText('Discussion')) + }) + + await step('Header', () => { + expect(screen.getByText('Round 1')) + expect(screen.getByText('Round 2')) + }) + + await step('Sidebar', () => { + const sideBarElement = screen.getByRole('complementary') + const sideBar = within(sideBarElement) + const proposerSection = within(sideBar.getByText('Proposer').parentElement as HTMLElement) + + expect(proposerSection.getByText('alice')) + + expect(sideBar.getByText('History')) + + for (const name of ['Approved', 'Rejected', 'Slashed', 'Abstained', 'Not Voted']) { + expect(sideBar.getByText(name)) + } + }) + + await step('Member is not a council member', () => { + expect(screen.queryByText(/Vote on Proposal/i)).toBeNull() + expect(screen.queryByText(/Already voted/i)).toBeNull() + }) + }, +} + +export const TestsHasNotVoted: Story = { + args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: true, isProposer: true }, + + name: 'Test ProposalPreview > Has not voted', + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + expect(screen.queryByText(/You voted for:/i)).toBeNull() + }, +} + +export const TestsHasVotedInCurrentRound: Story = { + args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: true, isProposer: true }, + parameters: { + statuses: ['ProposalStatusDeciding'] satisfies ProposalStatus[], + councilApprovals: 0, + votes: [['Reject', 'Approve', 'Approve']] satisfies VoteArg[][], + }, + + name: 'Test ProposalPreview > Has voted in the current round', + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + + expect(screen.getByText(/Already voted/i)) + expect(screen.getByText(/You voted for:/i)).toHaveTextContent('You voted for: Rejected') + }, +} + +export const TestsHasNotVotedInCurrentRound: Story = { + args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: true, isProposer: true }, + parameters: { + statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[], + votes: [ + ['Approve', 'Approve', 'Approve'], + ['None', 'Reject', 'Slash'], + ] satisfies VoteArg[][], + }, + + name: 'Test ProposalPreview > Not voted in the current round', + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + + expect(screen.getByText(/Vote on Proposal/i)) + expect(screen.queryByText(/You voted for:/i)).toBeNull() + await userEvent.click(screen.getByText('Round 1')) + expect(screen.getByText(/You voted for:/i)).toHaveTextContent('You voted for: Approved') + }, +} + +// ---------------------------------------------------------------------------- +// VoteForProposalModal +// ---------------------------------------------------------------------------- + +const fillRationale = async (modal: Container): Promise => + (await getEditorByLabel(modal, /Rationale/i)).setData('Some rationale') + +export const TestVoteHappy: Story = { + args: { type: 'SignalProposalDetails', isCouncilMember: true }, + + name: 'Test VoteForProposalModal Happy cases', + + play: async ({ canvasElement, step, args: { onVote } }) => { + const activeMember = member('alice') + + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + const getButton = (text: string | RegExp) => getButtonByText(modal, text) + + await step('Approve', async () => { + await step('Form', async () => { + await userEvent.click(screen.getByText('Vote on Proposal')) + expect(await modal.findByText('Vote for proposal')) + expect(modal.getByText(PROPOSAL_DATA.title)) + + expect(getButton(/^Reject/i)) + expect(getButton(/^Approve/i)) + expect(getButton(/^Abstain/i)) + + const rationaleEditor = await getEditorByLabel(modal, /Rationale/i) + const nextButton = getButton(/^sign transaction and vote/i) + expect(nextButton).toBeDisabled() + + rationaleEditor.setData('Some rationale') + expect(nextButton).toBeDisabled() + rationaleEditor.setData('') + + await userEvent.click(getButton(/^Approve/i)) + expect(nextButton).toBeDisabled() + + rationaleEditor.setData('Some rationale') + expect(nextButton).toBeEnabled() + + await userEvent.click(nextButton) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`)) + expect(within(signText).getByText('Approve')) + expect(modal.queryByText(/^(.*?)You need at least \d+ tJOY(.*)/i)).toBeNull() + + await userEvent.click(modal.getByText(/^Sign transaction and Vote/)) + }) + + await step('Confirm', async () => { + const confirmText = await modal.findByText( + RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`) + ) + expect(within(confirmText).getByText('Approve')) + + expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Approve', 'Some rationale') + + await userEvent.click(modal.getByText('Back to proposals')) + }) + }) + + await step('Reject', async () => { + await step('Form', async () => { + await userEvent.click(screen.getByText('Vote on Proposal')) + expect(await modal.findByText('Vote for proposal')) + + const nextButton = getButton(/^sign transaction and vote/i) + + await userEvent.click(getButton(/^Reject/i)) + expect(nextButton).toBeDisabled() + + await fillRationale(modal) + await userEvent.click(nextButton) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`)) + expect(within(signText).getByText('Reject')) + + await userEvent.click(modal.getByText(/^Sign transaction and Vote/)) + }) + + await step('Confirm', async () => { + const confirmText = await modal.findByText( + RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`) + ) + expect(within(confirmText).getByText('Reject')) + + expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Reject', 'Some rationale') + + await userEvent.click(modal.getByText('Back to proposals')) + }) + }) + + await step('Slash', async () => { + await step('Form', async () => { + await userEvent.click(screen.getByText('Vote on Proposal')) + expect(await modal.findByText('Vote for proposal')) + + const nextButton = getButton(/^sign transaction and vote/i) + + await userEvent.click(getButton(/^Reject/i)) + const slashToggle = modal.getByLabelText('Slash Proposal') + + userEvent.click(slashToggle) + expect(nextButton).toBeDisabled() + + await fillRationale(modal) + await userEvent.click(nextButton) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`)) + expect(within(signText).getByText('Slash')) + await userEvent.click(modal.getByText(/^Sign transaction and Vote/)) + }) + + await step('Confirm', async () => { + const confirmText = await modal.findByText( + RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`) + ) + expect(within(confirmText).getByText('Slash')) + + expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Slash', 'Some rationale') + + await userEvent.click(modal.getByText('Back to proposals')) + }) + }) + + await step('Abstain', async () => { + await step('Form', async () => { + await userEvent.click(screen.getByText('Vote on Proposal')) + expect(await modal.findByText('Vote for proposal')) + await userEvent.click(getButton(/^Abstain/i)) + await fillRationale(modal) + await userEvent.click(getButton(/^sign transaction and vote/i)) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`)) + expect(within(signText).getByText('Abstain')) + await userEvent.click(modal.getByText(/^Sign transaction and Vote/)) + }) + + await step('Confirm', async () => { + const confirmText = await modal.findByText( + RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`) + ) + expect(within(confirmText).getByText('Abstain')) + + expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Abstain', 'Some rationale') + }) + }) + }, +} + +export const TestVoteInsufficientFunds: Story = { + args: { type: 'SignalProposalDetails', isCouncilMember: true }, + parameters: { totalBalance: 1 }, + + name: 'Test VoteForProposalModal Insufficient Funds', + + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByText('Vote on Proposal')) + expect(await withinModal(canvasElement).findByText('Insufficient Funds')) + }, +} + +export const TestVoteTxFailure: Story = { + args: { type: 'SignalProposalDetails', isCouncilMember: true }, + parameters: { txFailure: 'Some error message' }, + + name: 'Test VoteForProposalModal Transaction Failure', + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + const getButton = (text: string | RegExp) => getButtonByText(modal, text) + + await userEvent.click(screen.getByText('Vote on Proposal')) + + expect(await modal.findByText('Vote for proposal')) + await userEvent.click(getButton(/^Approve/i)) + await fillRationale(modal) + await userEvent.click(getButton(/^sign transaction and vote/i)) + + await userEvent.click(modal.getByText(/^Sign transaction and Vote/)) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }, +} diff --git a/packages/ui/src/app/pages/Proposals/ProposalPreview.tsx b/packages/ui/src/app/pages/Proposals/ProposalPreview.tsx index b98ca1002e..1f9e69c0b1 100644 --- a/packages/ui/src/app/pages/Proposals/ProposalPreview.tsx +++ b/packages/ui/src/app/pages/Proposals/ProposalPreview.tsx @@ -84,7 +84,8 @@ export const ProposalPreview = () => { (vote) => vote.voter.id === active?.id && proposal?.councilApprovals === vote.votingRound - 1 ) - const myVote = proposal?.votes.find((vote) => vote.voter.id === active?.id && vote.votingRound === currentVotingRound) + const myVote = + active && proposal?.votes.find((vote) => vote.voter.id === active.id && vote.votingRound === currentVotingRound + 1) const myVoteStatus = myVote?.voteKind if (!proposal || !votes) { diff --git a/packages/ui/src/app/pages/WorkingGroups/WorkingGroupsOpening.tsx b/packages/ui/src/app/pages/WorkingGroups/WorkingGroupsOpening.tsx index 0f16c2fb61..f5e49198a6 100644 --- a/packages/ui/src/app/pages/WorkingGroups/WorkingGroupsOpening.tsx +++ b/packages/ui/src/app/pages/WorkingGroups/WorkingGroupsOpening.tsx @@ -52,9 +52,9 @@ export const WorkingGroupOpening = () => { } }, [opening?.applications]) - const hiringApplication = useMemo(() => { + const hiredApplicants = useMemo(() => { if (activeApplications) { - return activeApplications.find(({ status }) => status === 'ApplicationStatusAccepted') + return activeApplications.filter(({ status }) => status === 'ApplicationStatusAccepted') } }, [opening?.id]) const myApplication = useMemo(() => { @@ -161,7 +161,11 @@ export const WorkingGroupOpening = () => { tooltipLinkURL="https://joystream.gitbook.io/testnet-workspace/system/working-groups#staking" value={opening.stake} /> - + @@ -174,9 +178,9 @@ export const WorkingGroupOpening = () => { sidebar={ {opening.status === OpeningStatuses.OPEN && !activeApplications?.length && } diff --git a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.stories.tsx b/packages/ui/src/bounty/components/BountiesHeader.stories.tsx similarity index 80% rename from packages/ui/src/app/pages/Bounty/components/BountiesHeader.stories.tsx rename to packages/ui/src/bounty/components/BountiesHeader.stories.tsx index afe1bb6acb..66de59abbc 100644 --- a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.stories.tsx +++ b/packages/ui/src/bounty/components/BountiesHeader.stories.tsx @@ -1,7 +1,7 @@ import { Meta, Story } from '@storybook/react' import React from 'react' -import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvider' +import { MockApolloProvider } from '../../mocks/components/storybook/MockApolloProvider' import { BountiesHeader } from './BountiesHeader' diff --git a/packages/ui/src/bounty/components/BountiesHeader.tsx b/packages/ui/src/bounty/components/BountiesHeader.tsx new file mode 100644 index 0000000000..f22f96b2c7 --- /dev/null +++ b/packages/ui/src/bounty/components/BountiesHeader.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +import { PageHeader } from '../../app/components/PageHeader' +import { BountiesTabs } from '../../app/pages/Bounty/components/BountiesTabs' + +import { AddBountyButton } from './modalsButtons/AddBountyButton' + +export const BountiesHeader = () => { + return } buttons={} /> +} diff --git a/packages/ui/src/bounty/components/BountyFooter.tsx b/packages/ui/src/bounty/components/BountyFooter.tsx index 7acb167196..31bfe23bc7 100644 --- a/packages/ui/src/bounty/components/BountyFooter.tsx +++ b/packages/ui/src/bounty/components/BountyFooter.tsx @@ -18,7 +18,7 @@ export const BountyFooter = ({ bounty }: Props) => { return ( - {t('created')}: {formatDateString(bounty.createdAt, 'l')} + {t('created')}: {formatDateString(bounty.createdAt)} {' | '} {dateLabel && layout == 'row' && dateLabel + ': '} - {formatDateString(block.timestamp, layout === 'column' ? 's' : 'l')} + {formatDateString(block.timestamp)} {layout == 'row' && {' | '}} diff --git a/packages/ui/src/common/components/CKEditor/CKEditor.tsx b/packages/ui/src/common/components/CKEditor/CKEditor.tsx index dfac06bbd0..5cb7ce198d 100644 --- a/packages/ui/src/common/components/CKEditor/CKEditor.tsx +++ b/packages/ui/src/common/components/CKEditor/CKEditor.tsx @@ -20,7 +20,7 @@ export interface BaseCKEditorProps { export const BaseCKEditor = React.forwardRef( ( - { maxRows = 20, minRows = 5, onChange, onBlur, onFocus, onReady, disabled, inline }: CKEditorProps, + { id, maxRows = 20, minRows = 5, onChange, onBlur, onFocus, onReady, disabled, inline }: BaseCKEditorProps, ref?: Ref ) => { const localRef = useRef(null) @@ -77,7 +77,16 @@ export const BaseCKEditor = React.forwardRef( // This value must be kept in sync with the language defined in webpack.config.js. language: 'en', }) - .then((editor: any) => { + .then((editor: Editor) => { + // The component might be unmounted by the time it's initialize + // In this case the editor will be cleaned up when the promise resolves + if (!elementRef.current) return editor + + Object.defineProperty(elementRef.current, 'setData', { + configurable: true, + value: (data: any) => editor.setData(data), + }) + if (onReady) { onReady(editor) } @@ -111,12 +120,12 @@ export const BaseCKEditor = React.forwardRef( return () => { createPromise.then((editor) => editor.destroy()) } - }, [elementRef.current]) + }, []) return ( <> -
+
) } diff --git a/packages/ui/src/common/components/buttons/Toggle.tsx b/packages/ui/src/common/components/buttons/Toggle.tsx index ef0e8edaad..1a4d1cdbce 100644 --- a/packages/ui/src/common/components/buttons/Toggle.tsx +++ b/packages/ui/src/common/components/buttons/Toggle.tsx @@ -1,8 +1,8 @@ +import { isFunction } from 'lodash' import React, { ReactNode } from 'react' import styled, { css } from 'styled-components' import { BorderRad, Colors, Transitions } from '../../constants' -import { isFunction } from '../../utils' import { Arrow, Icon } from '../icons' import { ButtonBareGhost } from './Buttons' diff --git a/packages/ui/src/common/components/forms/InputNumber.tsx b/packages/ui/src/common/components/forms/InputNumber.tsx index 1d15175de4..0959ea2501 100644 --- a/packages/ui/src/common/components/forms/InputNumber.tsx +++ b/packages/ui/src/common/components/forms/InputNumber.tsx @@ -4,6 +4,8 @@ import { useFormContext, Controller } from 'react-hook-form' import NumberFormat, { NumberFormatValues, SourceInfo } from 'react-number-format' import styled from 'styled-components' +import { asBN, whenDefined } from '@/common/utils' + import { Input, InputProps } from './InputComponent' interface BaseNumberInputProps extends Omit { @@ -56,16 +58,14 @@ export const InputNumber = React.memo(({ name, isInBN = false, ...props }: Numbe { - return ( - field.onChange(isInBN ? new BN(String(value)) : value)} - onBlur={field.onBlur} - /> - ) - }} + render={({ field }) => ( + field.onChange(isInBN ? new BN(String(value)) : value)} + onBlur={field.onBlur} + /> + )} /> ) }) diff --git a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx index 39e4ef77d8..954c275680 100644 --- a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx +++ b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx @@ -9,6 +9,7 @@ import { BorderRad, Colors, Fonts, Transitions } from '../../constants' import { Label } from './Label' export interface Props { + id?: string isRequired?: boolean disabled?: boolean checked?: boolean @@ -20,6 +21,7 @@ export interface Props { } function BaseToggleCheckbox({ + id, isRequired, disabled, checked, @@ -40,6 +42,7 @@ function BaseToggleCheckbox({ {trueLabel} = (args) => - -export const NotificationComponent = Template.bind({}) - -NotificationComponent.args = { - title: 'Notification title', - message: - 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.', - link: 'http://example.com/', - isError: false, - showClose: true, +export const NotificationComponent = { + args: { + title: 'Notification title', + message: + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.', + link: 'http://example.com/', + isError: false, + showClose: true, + }, } diff --git a/packages/ui/src/common/components/selects/MultiSelect.tsx b/packages/ui/src/common/components/selects/MultiSelect.tsx index c465bb4878..b61574e851 100644 --- a/packages/ui/src/common/components/selects/MultiSelect.tsx +++ b/packages/ui/src/common/components/selects/MultiSelect.tsx @@ -1,3 +1,4 @@ +import { isNull } from 'lodash' import React, { useCallback } from 'react' import styled from 'styled-components' @@ -38,7 +39,7 @@ export const MultiSelect = ({ const change = useCallback( (pickedOption: T | null) => { - if (pickedOption === null) { + if (isNull(pickedOption)) { onChange([]) } else { const isPickedOption = equals(pickedOption) diff --git a/packages/ui/src/common/components/selects/SimpleSelect.tsx b/packages/ui/src/common/components/selects/SimpleSelect.tsx index 7407ebe7a8..2f0d18c398 100644 --- a/packages/ui/src/common/components/selects/SimpleSelect.tsx +++ b/packages/ui/src/common/components/selects/SimpleSelect.tsx @@ -72,7 +72,7 @@ export const SimpleSelect =