From 672615a5a80c9a341d15e44885c09ca79feb0a3e Mon Sep 17 00:00:00 2001 From: Maryia <103177211+maryia-deriv@users.noreply.github.com> Date: Wed, 5 Jun 2024 04:59:56 +0300 Subject: [PATCH] [DTRA] Kate/Maryia/DTRA-1279/Redesign: Positions page (#15234) * chore: empty commit * feat: tabs + background + initialized EmptyMessage component * Maryia/Positions-redesign/improve EmptyMessage component + add tests (#50) * feat: redirect to trade upon button click on the empty page * test: EmptyMessage * chore: empty commit * DTRA-1279 / Kate / Filter [WIP] (#51) * feat: add dropdowm and action sheet * feat: add apply functionality * chore: remove unused functionality * refactor: separate apply logic * feat: add clear all functionality * feat: apply filtration logic * fix: filtration bug * chore: rename variables * refactor: extract filtration logic into a util function * chore: empty commit * fix: sonarcloud issues * chore: empty commit * chore: add modules store and useclosedposition hook (#52) * refactor: remove todo and change some prop based on quill updates (#54) * DTRA-1279 / Kate / Use hook for real data (#55) * feat: use hook for real data * refactor: apply suggestions * Maryia/positions-redesign/Contract cards [WIP] (#53) * feat: init ContractCard * feat: Contract cards * feat: use ReportsStoreProvider * style: remove unnecessary comment * fix: key * fix: remove old card styles * fix: types * fix: use contract_info in filterPositions * fix: do not show buttons for sold contracts * DTRA-1279 / Kate / Refactor handling open and closed positions and their filtration (#56) * refactor: move stor values to poitions content file * chore: remove code smell * Maryia/positions-redesign/Contract cards improvements + fetching Open positions + formatProfitTableTransactions TS migration (#58) * refactor: contract-card-list and card * feat: buttons demo + animation improvements * feat: finilize Duration component for the card * chore: ts migration for closed positions * fix: console error with remaining time & showing empty message only when empty * feat: connect real open postions + style and filter fixes * fix: style * DTRA-1279/ Kate / Create filter component (#57) * feat: create new filter component * feat: apply radio button * refactor: default time filter logic * feat: add time filtration * refactor: remove unused css * DTRA-1279 / Kate / Refactor: add new content for empty page (#59) * refactor: add new content for empty page * chore: test text update * Maryia/positions-redesign/Contract cards data update fix (#61) * fix: Accumulators tick passed count * fix: contract cards update * fix: show loading only when should not show empty message or cards * chore: update quill version (#63) * Maryia/positions-redesign/Contract card loading state and status timer updates + EmptyPositions update (#64) * feat: loading functionality + fix for status timer * chore: update copy for empty-positions * revert: use hasActionButtons prop instead of impicit onClose * DTRA-1279/ Kate/ Feat: add Date picker (#62) * feat: add second action sheet * feat: add date range formatting and refactored existing code * feat: add range selection filtration * refactor: chip and time filter * fix: empty posituions after filtration * refactor: do clean up * chore: rename variables * chore: localization * DTRA-1279 / Kate/ Add filtration hooks (#65) * feat: create hooks * refactor: rename methods * DTRA-1279 / Kate / Add tests (#66) * refactor: add tests for chip component * refactor: add tests for date picker * refactor: add tests for contract type filter * refactor: add tests for custom time filter button * refactor: add tests for positions utils * DTRA-1279 / Kate / Double filtration and extra filter options (#67) * fix: filtration for today and yersterday * fix: double filter * refactor: change style after design confirmation and sort props * refactor: start adding tets for time filter * chore: apply suggestions * chore: update quill and token library version * DTRA-1279 / Kate / Add section separator (#68) * feat: add sections with date * feat: make filter always visible * refactor: style for date separator * refactor: format time function * refactor: add tests * chore: remove unused wrapper * chore: apply suggestions * DTRA-1279 / Kate / Tech Debt (#69) * refactor: add more test cases for time-filter * refactor: add tests for hooks * refactor: removed some todos * Maryia/positions-redesign/finilise contract card + add total profit loss + initiate pagination in closed positions (#70) * refactor: utilize Tag in ContractCardStatusTimer * chore: add opacity transition to buttons when revealing/hiding them * feat: add total profit + improve card * fix: card deletion transition + total pnl positioning * feat: add pagination on scroll (initial version) * fix: loading state and loading more on infinite scroll in Closed tab (#71) * DTRA-1279 / Kate / Tech Debt part 2 [WIP] (#72) * refactor: add tests for utils functions + removed unused hook * refactor: move total profit loss to a separate folder and add tests * refactor: add tests for positions * refactor: add tests for position content file * Maryia/positions-redesign/test contract card + fix scroll behavior, dates formatting, and filtering Closed positions (#73) * test: contract-card * fix: hide filters on scroll + utilize moment for formatting date in closed tab * refactor: update quill version and refactor * refactor: chip component (#74) * refactor: position content page * Maryia/positions-redesign/test: ContractCardList, ContractCardStatusTimer, PositionsStore, getCurrentTick() + refactoring (#75) * test: contract-card-list * test: ContractCardStatusTimer * test: getCurrentTick() in contract.tsx in shared * test: PositionsStore * test: add more tests to ContractCardList * refactor: desctructure props in mocked component * Maryia/positions-redesign/fix: tests + address sonarcloud + use clsx (#76) * fix: tests + address sonarcloud * refactor: use clsx instead of classnames * refactor: sonarcloud - reduce complexity * fix: cards filtering in PositionsContent + tests + style+bug fixes * build: trigger checks * fix: hasNoActiveFilters condition * fix: update package version and remove prop from action sheet * chore: rename function * Maryia/positions-redesign/feat: display correct active positions count (#77) * feat: display correct active positions count * fix: BottomNav tests * refactor: filter behaviour * chore: add padding * fix: positions count in footer to not show 0 (#78) * refactor: total profit loss * chore: add tests for tpl and refactor date picker * refactor: add loadre inside of empty positions * fix: tests * Maryia/positions-redesign/fix: loader on infinite scroll in Closed tab + make redirectTo prop optional in ContractCard (#79) * fix: place loading after contract cards sections * chore: make redirection optional when clicking on contract card * chore: rename timet * DTRA-1279 / Kate / Add a single date selection (#80) * feat: add partial range * refactor: tests * refactor: callback --------- Co-authored-by: kate-deriv Co-authored-by: kate-deriv <121025168+kate-deriv@users.noreply.github.com> Co-authored-by: balakrishna-deriv <56330681+balakrishna-deriv@users.noreply.github.com> --- package-lock.json | 157 +++++- .../remaining-time/remaining-time.tsx | 6 +- packages/core/package.json | 4 +- .../reports/src/Containers/open-positions.tsx | 3 +- .../reports/src/Stores/useReportsStores.tsx | 5 +- .../utils/contract/__tests__/contract.spec.ts | 73 +++ .../shared/src/utils/contract/contract.tsx | 2 +- packages/stores/src/mockStore.ts | 11 + packages/stores/types.ts | 5 + packages/trader/package.json | 6 +- .../src/App/Components/Routes/binary-link.tsx | 60 +-- packages/trader/src/App/app.tsx | 15 +- .../BottomNav/__tests__/bottom-nav.spec.tsx | 15 +- .../Components/BottomNav/bottom-nav.scss | 1 + .../AppV2/Components/BottomNav/bottom-nav.tsx | 102 ++-- .../Components/Chip/__tests__/chip.spec.tsx | 66 +++ .../src/AppV2/Components/Chip/chip.scss | 87 ++++ .../trader/src/AppV2/Components/Chip/chip.tsx | 43 ++ .../trader/src/AppV2/Components/Chip/index.ts | 4 + .../__tests__/contract-card-list.spec.tsx | 73 +++ .../contract-card-status-timer.spec.tsx | 40 ++ .../__tests__/contract-card.spec.tsx | 455 ++++++++++++++++++ .../contract-cards-sections.spec.tsx | 78 +++ .../ContractCard/contract-card-list.tsx | 64 +++ .../contract-card-status-timer.tsx | 56 +++ .../ContractCard/contract-card.scss | 247 ++++++++++ .../Components/ContractCard/contract-card.tsx | 192 ++++++++ .../ContractCard/contract-cards-sections.tsx | 53 ++ .../AppV2/Components/ContractCard/index.ts | 5 + .../DatePicker/__tests__/date-picker.spec.tsx | 57 +++ .../Components/DatePicker/date-picker.scss | 33 ++ .../Components/DatePicker/date-picker.tsx | 64 +++ .../src/AppV2/Components/DatePicker/index.ts | 4 + .../__tests__/empty-positions.spec.tsx | 64 +++ .../EmptyPositions/empty-positions.scss | 22 + .../EmptyPositions/empty-positions.tsx | 58 +++ .../AppV2/Components/EmptyPositions/index.ts | 4 + .../__tests__/contract-type-filter.spec.tsx | 89 ++++ .../custom-time-filter-button.spec.tsx | 30 ++ .../Filter/__tests__/time-filter.spec.tsx | 90 ++++ .../Filter/contract-type-filter.tsx | 96 ++++ .../Filter/custom-time-filter-button.tsx | 25 + .../src/AppV2/Components/Filter/filter.scss | 45 ++ .../src/AppV2/Components/Filter/index.ts | 4 + .../AppV2/Components/Filter/time-filter.tsx | 162 +++++++ .../__tests__/total-profit-loss.spec.tsx | 38 ++ .../AppV2/Components/TotalProfitLoss/index.ts | 4 + .../TotalProfitLoss/total-profit-loss.scss | 28 ++ .../TotalProfitLoss/total-profit-loss.tsx | 42 ++ .../__tests__/positions-content.spec.tsx | 368 ++++++++++++++ .../Positions/__tests__/positions.spec.tsx | 34 ++ .../Positions/positions-content.tsx | 155 ++++++ .../AppV2/Containers/Positions/positions.scss | 63 +++ .../AppV2/Containers/Positions/positions.tsx | 36 +- .../Hooks/__tests__/useTimeFilter.spec.tsx | 19 + .../__tests__/useTradeTypeFilter.spec.tsx | 27 ++ .../trader/src/AppV2/Hooks/useTimeFilter.ts | 10 + .../src/AppV2/Hooks/useTradeTypeFilter.ts | 16 + .../Utils/__tests__/positions-utils.spec.ts | 315 ++++++++++++ .../trader/src/AppV2/Utils/positions-utils.ts | 37 ++ packages/trader/src/AppV2/app.tsx | 26 +- .../__tests__/positions-store.spec.ts | 49 ++ .../Modules/Positions/positions-store.ts | 41 ++ .../Trading/__tests__/trade-store.spec.ts | 3 +- .../src/Stores/Modules/Trading/trade-store.ts | 5 +- packages/trader/src/Stores/Modules/index.js | 8 - packages/trader/src/Stores/Modules/index.ts | 16 + .../Stores/Providers/modules-providers.tsx | 14 + packages/trader/src/Stores/base-store.ts | 6 +- .../trader/src/Stores/useModulesStores.tsx | 20 + packages/trader/src/Types/common-prop.type.ts | 15 + 71 files changed, 4055 insertions(+), 115 deletions(-) create mode 100644 packages/trader/src/AppV2/Components/Chip/__tests__/chip.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/Chip/chip.scss create mode 100644 packages/trader/src/AppV2/Components/Chip/chip.tsx create mode 100644 packages/trader/src/AppV2/Components/Chip/index.ts create mode 100644 packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-list.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-status-timer.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-cards-sections.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/contract-card-list.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/contract-card-status-timer.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/contract-card.scss create mode 100644 packages/trader/src/AppV2/Components/ContractCard/contract-card.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/contract-cards-sections.tsx create mode 100644 packages/trader/src/AppV2/Components/ContractCard/index.ts create mode 100644 packages/trader/src/AppV2/Components/DatePicker/__tests__/date-picker.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/DatePicker/date-picker.scss create mode 100644 packages/trader/src/AppV2/Components/DatePicker/date-picker.tsx create mode 100644 packages/trader/src/AppV2/Components/DatePicker/index.ts create mode 100644 packages/trader/src/AppV2/Components/EmptyPositions/__tests__/empty-positions.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.scss create mode 100644 packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.tsx create mode 100644 packages/trader/src/AppV2/Components/EmptyPositions/index.ts create mode 100644 packages/trader/src/AppV2/Components/Filter/__tests__/contract-type-filter.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/Filter/__tests__/custom-time-filter-button.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/Filter/__tests__/time-filter.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/Filter/contract-type-filter.tsx create mode 100644 packages/trader/src/AppV2/Components/Filter/custom-time-filter-button.tsx create mode 100644 packages/trader/src/AppV2/Components/Filter/filter.scss create mode 100644 packages/trader/src/AppV2/Components/Filter/index.ts create mode 100644 packages/trader/src/AppV2/Components/Filter/time-filter.tsx create mode 100644 packages/trader/src/AppV2/Components/TotalProfitLoss/__tests__/total-profit-loss.spec.tsx create mode 100644 packages/trader/src/AppV2/Components/TotalProfitLoss/index.ts create mode 100644 packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.scss create mode 100644 packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.tsx create mode 100644 packages/trader/src/AppV2/Containers/Positions/__tests__/positions-content.spec.tsx create mode 100644 packages/trader/src/AppV2/Containers/Positions/__tests__/positions.spec.tsx create mode 100644 packages/trader/src/AppV2/Containers/Positions/positions-content.tsx create mode 100644 packages/trader/src/AppV2/Hooks/__tests__/useTimeFilter.spec.tsx create mode 100644 packages/trader/src/AppV2/Hooks/__tests__/useTradeTypeFilter.spec.tsx create mode 100644 packages/trader/src/AppV2/Hooks/useTimeFilter.ts create mode 100644 packages/trader/src/AppV2/Hooks/useTradeTypeFilter.ts create mode 100644 packages/trader/src/AppV2/Utils/__tests__/positions-utils.spec.ts create mode 100644 packages/trader/src/AppV2/Utils/positions-utils.ts create mode 100644 packages/trader/src/Stores/Modules/Positions/__tests__/positions-store.spec.ts create mode 100644 packages/trader/src/Stores/Modules/Positions/positions-store.ts delete mode 100644 packages/trader/src/Stores/Modules/index.js create mode 100644 packages/trader/src/Stores/Modules/index.ts create mode 100644 packages/trader/src/Stores/Providers/modules-providers.tsx create mode 100644 packages/trader/src/Stores/useModulesStores.tsx diff --git a/package-lock.json b/package-lock.json index 506ecacd9692..ed4191ae24f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,8 @@ "@datadog/browser-logs": "^5.11.0", "@datadog/browser-rum": "^5.11.0", "@deriv-com/analytics": "1.5.9", - "@deriv-com/quill-tokens": "^2.0.2", - "@deriv-com/quill-ui": "^1.10.8", + "@deriv-com/quill-tokens": "^2.0.4", + "@deriv-com/quill-ui": "^1.10.13", "@deriv-com/translations": "^1.2.3", "@deriv-com/ui": "^1.14.5", "@deriv-com/utils": "^0.0.24", @@ -3979,6 +3979,7 @@ }, "node_modules/@isaacs/string-locale-compare": { "version": "1.1.0", + "dev": true, "license": "ISC" }, "node_modules/@istanbuljs/load-nyc-config": { @@ -7482,6 +7483,7 @@ }, "node_modules/@npmcli/arborist": { "version": "5.3.0", + "dev": true, "license": "ISC", "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", @@ -7528,6 +7530,7 @@ }, "node_modules/@npmcli/arborist/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -7538,6 +7541,7 @@ }, "node_modules/@npmcli/arborist/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -7551,6 +7555,7 @@ }, "node_modules/@npmcli/arborist/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -7564,6 +7569,7 @@ }, "node_modules/@npmcli/arborist/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7574,6 +7580,7 @@ }, "node_modules/@npmcli/fs": { "version": "2.1.2", + "dev": true, "license": "ISC", "dependencies": { "@gar/promisify": "^1.1.3", @@ -7585,6 +7592,7 @@ }, "node_modules/@npmcli/fs/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7595,6 +7603,7 @@ }, "node_modules/@npmcli/fs/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -7608,6 +7617,7 @@ }, "node_modules/@npmcli/git": { "version": "3.0.2", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/promise-spawn": "^3.0.0", @@ -7626,6 +7636,7 @@ }, "node_modules/@npmcli/git/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -7639,6 +7650,7 @@ }, "node_modules/@npmcli/git/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7649,6 +7661,7 @@ }, "node_modules/@npmcli/installed-package-contents": { "version": "1.0.7", + "dev": true, "license": "ISC", "dependencies": { "npm-bundled": "^1.1.1", @@ -7663,6 +7676,7 @@ }, "node_modules/@npmcli/map-workspaces": { "version": "2.0.4", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/name-from-folder": "^1.0.1", @@ -7676,6 +7690,7 @@ }, "node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7683,6 +7698,7 @@ }, "node_modules/@npmcli/map-workspaces/node_modules/glob": { "version": "8.0.3", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -7700,6 +7716,7 @@ }, "node_modules/@npmcli/map-workspaces/node_modules/minimatch": { "version": "5.1.1", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -7710,6 +7727,7 @@ }, "node_modules/@npmcli/metavuln-calculator": { "version": "3.1.1", + "dev": true, "license": "ISC", "dependencies": { "cacache": "^16.0.0", @@ -7723,6 +7741,7 @@ }, "node_modules/@npmcli/metavuln-calculator/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7733,6 +7752,7 @@ }, "node_modules/@npmcli/metavuln-calculator/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -7746,6 +7766,7 @@ }, "node_modules/@npmcli/move-file": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "mkdirp": "^1.0.4", @@ -7757,10 +7778,12 @@ }, "node_modules/@npmcli/name-from-folder": { "version": "1.0.1", + "dev": true, "license": "ISC" }, "node_modules/@npmcli/node-gyp": { "version": "2.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -7768,6 +7791,7 @@ }, "node_modules/@npmcli/package-json": { "version": "2.0.0", + "dev": true, "license": "ISC", "dependencies": { "json-parse-even-better-errors": "^2.3.1" @@ -7778,6 +7802,7 @@ }, "node_modules/@npmcli/promise-spawn": { "version": "3.0.0", + "dev": true, "license": "ISC", "dependencies": { "infer-owner": "^1.0.4" @@ -7788,6 +7813,7 @@ }, "node_modules/@npmcli/run-script": { "version": "4.2.1", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/node-gyp": "^2.0.0", @@ -20970,6 +20996,7 @@ }, "node_modules/abbrev": { "version": "1.1.1", + "dev": true, "license": "ISC" }, "node_modules/accepts": { @@ -21087,6 +21114,7 @@ }, "node_modules/agentkeepalive": { "version": "4.2.1", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.0", @@ -21305,6 +21333,7 @@ }, "node_modules/are-we-there-yet": { "version": "3.0.1", + "dev": true, "license": "ISC", "dependencies": { "delegates": "^1.0.0", @@ -22713,6 +22742,7 @@ }, "node_modules/bin-links": { "version": "3.0.3", + "dev": true, "license": "ISC", "dependencies": { "cmd-shim": "^5.0.0", @@ -22728,6 +22758,7 @@ }, "node_modules/bin-links/node_modules/npm-normalize-package-bin": { "version": "2.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -22735,6 +22766,7 @@ }, "node_modules/bin-links/node_modules/write-file-atomic": { "version": "4.0.2", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -23576,6 +23608,7 @@ }, "node_modules/builtins": { "version": "5.0.1", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.0.0" @@ -23583,6 +23616,7 @@ }, "node_modules/builtins/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -23593,6 +23627,7 @@ }, "node_modules/builtins/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -23690,6 +23725,7 @@ }, "node_modules/cacache": { "version": "16.1.3", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/fs": "^2.1.0", @@ -23717,6 +23753,7 @@ }, "node_modules/cacache/node_modules/brace-expansion": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -23724,6 +23761,7 @@ }, "node_modules/cacache/node_modules/glob": { "version": "8.0.3", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -23741,6 +23779,7 @@ }, "node_modules/cacache/node_modules/minimatch": { "version": "5.1.1", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -24521,6 +24560,7 @@ }, "node_modules/cmd-shim": { "version": "5.0.0", + "dev": true, "license": "ISC", "dependencies": { "mkdirp-infer-owner": "^2.0.0" @@ -24641,6 +24681,7 @@ }, "node_modules/common-ancestor-path": { "version": "1.0.1", + "dev": true, "license": "ISC" }, "node_modules/common-tags": { @@ -26876,6 +26917,7 @@ }, "node_modules/debuglog": { "version": "1.0.1", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -27635,6 +27677,7 @@ }, "node_modules/dezalgo": { "version": "1.0.4", + "dev": true, "license": "ISC", "dependencies": { "asap": "^2.0.0", @@ -28430,6 +28473,7 @@ }, "node_modules/err-code": { "version": "2.0.3", + "dev": true, "license": "MIT" }, "node_modules/errno": { @@ -31903,6 +31947,7 @@ }, "node_modules/gauge": { "version": "4.0.4", + "dev": true, "license": "ISC", "dependencies": { "aproba": "^1.0.3 || ^2.0.0", @@ -33155,6 +33200,7 @@ }, "node_modules/hosted-git-info": { "version": "4.1.0", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -33165,6 +33211,7 @@ }, "node_modules/hosted-git-info/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -33572,7 +33619,8 @@ "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true }, "node_modules/http-deceiver": { "version": "1.2.7", @@ -33812,6 +33860,7 @@ }, "node_modules/humanize-ms": { "version": "1.2.1", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.0.0" @@ -33977,6 +34026,7 @@ }, "node_modules/ignore-walk": { "version": "5.0.1", + "dev": true, "license": "ISC", "dependencies": { "minimatch": "^5.0.1" @@ -33987,6 +34037,7 @@ }, "node_modules/ignore-walk/node_modules/brace-expansion": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -33994,6 +34045,7 @@ }, "node_modules/ignore-walk/node_modules/minimatch": { "version": "5.1.1", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -34152,6 +34204,7 @@ }, "node_modules/init-package-json": { "version": "3.0.2", + "dev": true, "license": "ISC", "dependencies": { "npm-package-arg": "^9.0.1", @@ -34168,6 +34221,7 @@ }, "node_modules/init-package-json/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -34178,6 +34232,7 @@ }, "node_modules/init-package-json/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -34191,6 +34246,7 @@ }, "node_modules/init-package-json/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -34204,6 +34260,7 @@ }, "node_modules/init-package-json/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -34716,6 +34773,7 @@ }, "node_modules/is-lambda": { "version": "1.0.1", + "dev": true, "license": "MIT" }, "node_modules/is-lite": { @@ -38832,6 +38890,7 @@ }, "node_modules/json-stringify-nice": { "version": "1.1.4", + "dev": true, "license": "ISC", "funding": { "url": "https://github.com/sponsors/isaacs" @@ -38960,10 +39019,12 @@ }, "node_modules/just-diff": { "version": "5.1.1", + "dev": true, "license": "MIT" }, "node_modules/just-diff-apply": { "version": "5.4.1", + "dev": true, "license": "MIT" }, "node_modules/just-extend": { @@ -39219,6 +39280,7 @@ }, "node_modules/libnpmaccess": { "version": "6.0.4", + "dev": true, "license": "ISC", "dependencies": { "aproba": "^2.0.0", @@ -39232,6 +39294,7 @@ }, "node_modules/libnpmaccess/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -39242,6 +39305,7 @@ }, "node_modules/libnpmaccess/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -39255,6 +39319,7 @@ }, "node_modules/libnpmaccess/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -39268,6 +39333,7 @@ }, "node_modules/libnpmaccess/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -39278,6 +39344,7 @@ }, "node_modules/libnpmpublish": { "version": "6.0.5", + "dev": true, "license": "ISC", "dependencies": { "normalize-package-data": "^4.0.0", @@ -39292,6 +39359,7 @@ }, "node_modules/libnpmpublish/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -39302,6 +39370,7 @@ }, "node_modules/libnpmpublish/node_modules/normalize-package-data": { "version": "4.0.1", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^5.0.0", @@ -39315,6 +39384,7 @@ }, "node_modules/libnpmpublish/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -39328,6 +39398,7 @@ }, "node_modules/libnpmpublish/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -39341,6 +39412,7 @@ }, "node_modules/libnpmpublish/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -39977,6 +40049,7 @@ }, "node_modules/lru-cache": { "version": "7.14.1", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -40017,6 +40090,7 @@ }, "node_modules/make-fetch-happen": { "version": "10.2.1", + "dev": true, "license": "ISC", "dependencies": { "agentkeepalive": "^4.2.1", @@ -40616,6 +40690,7 @@ }, "node_modules/minipass-fetch": { "version": "2.1.2", + "dev": true, "license": "MIT", "dependencies": { "minipass": "^3.1.6", @@ -40641,6 +40716,7 @@ }, "node_modules/minipass-json-stream": { "version": "1.0.1", + "dev": true, "license": "MIT", "dependencies": { "jsonparse": "^1.3.1", @@ -40659,6 +40735,7 @@ }, "node_modules/minipass-sized": { "version": "1.0.3", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -40781,6 +40858,7 @@ }, "node_modules/mkdirp-infer-owner": { "version": "2.0.0", + "dev": true, "license": "ISC", "dependencies": { "chownr": "^2.0.0", @@ -41381,6 +41459,7 @@ }, "node_modules/node-gyp": { "version": "9.3.0", + "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", @@ -41413,6 +41492,7 @@ }, "node_modules/node-gyp/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -41423,6 +41503,7 @@ }, "node_modules/node-gyp/node_modules/nopt": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "abbrev": "^1.0.0" @@ -41436,6 +41517,7 @@ }, "node_modules/node-gyp/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -41609,6 +41691,7 @@ }, "node_modules/nopt": { "version": "5.0.0", + "dev": true, "license": "ISC", "dependencies": { "abbrev": "1" @@ -41622,6 +41705,7 @@ }, "node_modules/normalize-package-data": { "version": "3.0.3", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^4.0.1", @@ -41635,6 +41719,7 @@ }, "node_modules/normalize-package-data/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -41645,6 +41730,7 @@ }, "node_modules/normalize-package-data/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -41840,6 +41926,7 @@ }, "node_modules/npm-bundled": { "version": "1.1.2", + "dev": true, "license": "ISC", "dependencies": { "npm-normalize-package-bin": "^1.0.1" @@ -41847,6 +41934,7 @@ }, "node_modules/npm-install-checks": { "version": "5.0.0", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" @@ -41857,6 +41945,7 @@ }, "node_modules/npm-install-checks/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -41867,6 +41956,7 @@ }, "node_modules/npm-install-checks/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -41880,10 +41970,12 @@ }, "node_modules/npm-normalize-package-bin": { "version": "1.0.1", + "dev": true, "license": "ISC" }, "node_modules/npm-package-arg": { "version": "8.1.1", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^3.0.6", @@ -41896,10 +41988,12 @@ }, "node_modules/npm-package-arg/node_modules/builtins": { "version": "1.0.3", + "dev": true, "license": "MIT" }, "node_modules/npm-package-arg/node_modules/hosted-git-info": { "version": "3.0.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -41910,6 +42004,7 @@ }, "node_modules/npm-package-arg/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -41920,6 +42015,7 @@ }, "node_modules/npm-package-arg/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -41933,6 +42029,7 @@ }, "node_modules/npm-package-arg/node_modules/validate-npm-package-name": { "version": "3.0.0", + "dev": true, "license": "ISC", "dependencies": { "builtins": "^1.0.3" @@ -41940,6 +42037,7 @@ }, "node_modules/npm-packlist": { "version": "5.1.3", + "dev": true, "license": "ISC", "dependencies": { "glob": "^8.0.1", @@ -41956,6 +42054,7 @@ }, "node_modules/npm-packlist/node_modules/brace-expansion": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -41963,6 +42062,7 @@ }, "node_modules/npm-packlist/node_modules/glob": { "version": "8.0.3", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -41980,6 +42080,7 @@ }, "node_modules/npm-packlist/node_modules/minimatch": { "version": "5.1.1", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -41990,6 +42091,7 @@ }, "node_modules/npm-packlist/node_modules/npm-bundled": { "version": "2.0.1", + "dev": true, "license": "ISC", "dependencies": { "npm-normalize-package-bin": "^2.0.0" @@ -42000,6 +42102,7 @@ }, "node_modules/npm-packlist/node_modules/npm-normalize-package-bin": { "version": "2.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -42007,6 +42110,7 @@ }, "node_modules/npm-pick-manifest": { "version": "7.0.2", + "dev": true, "license": "ISC", "dependencies": { "npm-install-checks": "^5.0.0", @@ -42020,6 +42124,7 @@ }, "node_modules/npm-pick-manifest/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -42030,6 +42135,7 @@ }, "node_modules/npm-pick-manifest/node_modules/npm-normalize-package-bin": { "version": "2.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -42037,6 +42143,7 @@ }, "node_modules/npm-pick-manifest/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -42050,6 +42157,7 @@ }, "node_modules/npm-pick-manifest/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -42063,6 +42171,7 @@ }, "node_modules/npm-pick-manifest/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -42073,6 +42182,7 @@ }, "node_modules/npm-registry-fetch": { "version": "13.3.1", + "dev": true, "license": "ISC", "dependencies": { "make-fetch-happen": "^10.0.6", @@ -42089,6 +42199,7 @@ }, "node_modules/npm-registry-fetch/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -42099,6 +42210,7 @@ }, "node_modules/npm-registry-fetch/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -42112,6 +42224,7 @@ }, "node_modules/npm-registry-fetch/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -42125,6 +42238,7 @@ }, "node_modules/npm-registry-fetch/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -44387,6 +44501,7 @@ }, "node_modules/npmlog": { "version": "6.0.2", + "dev": true, "license": "ISC", "dependencies": { "are-we-there-yet": "^3.0.0", @@ -45375,6 +45490,7 @@ }, "node_modules/pacote": { "version": "13.6.2", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/git": "^3.0.0", @@ -45408,6 +45524,7 @@ }, "node_modules/pacote/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -45418,6 +45535,7 @@ }, "node_modules/pacote/node_modules/npm-package-arg": { "version": "9.1.2", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^5.0.0", @@ -45431,6 +45549,7 @@ }, "node_modules/pacote/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -45444,6 +45563,7 @@ }, "node_modules/pacote/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -45536,6 +45656,7 @@ }, "node_modules/parse-conflict-json": { "version": "2.0.2", + "dev": true, "license": "ISC", "dependencies": { "json-parse-even-better-errors": "^2.3.1", @@ -47748,6 +47869,7 @@ }, "node_modules/proc-log": { "version": "2.0.1", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -47783,6 +47905,7 @@ }, "node_modules/promise-all-reject-late": { "version": "1.0.1", + "dev": true, "license": "ISC", "funding": { "url": "https://github.com/sponsors/isaacs" @@ -47790,6 +47913,7 @@ }, "node_modules/promise-call-limit": { "version": "1.0.1", + "dev": true, "license": "ISC", "funding": { "url": "https://github.com/sponsors/isaacs" @@ -47806,6 +47930,7 @@ }, "node_modules/promise-retry": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "err-code": "^2.0.2", @@ -47865,6 +47990,7 @@ }, "node_modules/promzard": { "version": "0.3.0", + "dev": true, "license": "ISC", "dependencies": { "read": "1" @@ -49272,6 +49398,7 @@ }, "node_modules/read": { "version": "1.0.7", + "dev": true, "license": "ISC", "dependencies": { "mute-stream": "~0.0.4" @@ -49298,6 +49425,7 @@ }, "node_modules/read-cmd-shim": { "version": "3.0.1", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -49305,6 +49433,7 @@ }, "node_modules/read-package-json": { "version": "5.0.2", + "dev": true, "license": "ISC", "dependencies": { "glob": "^8.0.1", @@ -49318,6 +49447,7 @@ }, "node_modules/read-package-json-fast": { "version": "2.0.3", + "dev": true, "license": "ISC", "dependencies": { "json-parse-even-better-errors": "^2.3.0", @@ -49329,6 +49459,7 @@ }, "node_modules/read-package-json/node_modules/brace-expansion": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -49336,6 +49467,7 @@ }, "node_modules/read-package-json/node_modules/glob": { "version": "8.0.3", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -49353,6 +49485,7 @@ }, "node_modules/read-package-json/node_modules/hosted-git-info": { "version": "5.2.1", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^7.5.1" @@ -49363,6 +49496,7 @@ }, "node_modules/read-package-json/node_modules/minimatch": { "version": "5.1.1", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -49373,6 +49507,7 @@ }, "node_modules/read-package-json/node_modules/normalize-package-data": { "version": "4.0.1", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^5.0.0", @@ -49386,6 +49521,7 @@ }, "node_modules/read-package-json/node_modules/npm-normalize-package-bin": { "version": "2.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -49393,6 +49529,7 @@ }, "node_modules/read-package-json/node_modules/semver": { "version": "7.3.8", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -49406,6 +49543,7 @@ }, "node_modules/read-package-json/node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -49738,6 +49876,7 @@ }, "node_modules/readdir-scoped-modules": { "version": "1.1.0", + "dev": true, "license": "ISC", "dependencies": { "debuglog": "^1.0.1", @@ -52733,6 +52872,7 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -52956,6 +53096,7 @@ }, "node_modules/socks": { "version": "2.7.1", + "dev": true, "license": "MIT", "dependencies": { "ip": "^2.0.0", @@ -52968,6 +53109,7 @@ }, "node_modules/socks-proxy-agent": { "version": "7.0.0", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^6.0.2", @@ -53240,6 +53382,7 @@ }, "node_modules/ssri": { "version": "9.0.1", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.1.1" @@ -55401,7 +55544,8 @@ }, "node_modules/text-table": { "version": "0.2.0", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/thenify": { "version": "3.3.1", @@ -55651,6 +55795,7 @@ }, "node_modules/treeverse": { "version": "2.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" @@ -56381,6 +56526,7 @@ }, "node_modules/unique-filename": { "version": "2.0.1", + "dev": true, "license": "ISC", "dependencies": { "unique-slug": "^3.0.0" @@ -56391,6 +56537,7 @@ }, "node_modules/unique-slug": { "version": "3.0.0", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" @@ -56930,6 +57077,7 @@ }, "node_modules/validate-npm-package-name": { "version": "4.0.0", + "dev": true, "license": "ISC", "dependencies": { "builtins": "^5.0.0" @@ -57055,6 +57203,7 @@ }, "node_modules/walk-up-path": { "version": "1.0.0", + "dev": true, "license": "ISC" }, "node_modules/walker": { diff --git a/packages/components/src/components/remaining-time/remaining-time.tsx b/packages/components/src/components/remaining-time/remaining-time.tsx index 047336f831dd..293b11be71eb 100644 --- a/packages/components/src/components/remaining-time/remaining-time.tsx +++ b/packages/components/src/components/remaining-time/remaining-time.tsx @@ -6,13 +6,15 @@ import { formatDuration, getDiffDuration } from '@deriv/shared'; import { TGetCardLables } from '../types'; type TRemainingTimeProps = { + as?: React.ElementType; end_time?: number; start_time: moment.Moment; format?: string; getCardLabels: TGetCardLables; }; -const RemainingTime = ({ end_time, format, getCardLabels, start_time }: TRemainingTimeProps) => { +const RemainingTime = ({ as = 'div', end_time, format, getCardLabels, start_time }: TRemainingTimeProps) => { + const Tag = as; if (!end_time || start_time.unix() > +end_time) { return {''}; } @@ -24,7 +26,7 @@ const RemainingTime = ({ end_time, format, getCardLabels, start_time }: TRemaini } const is_zeroes = /^00:00$/.test(remaining_time); - return {!is_zeroes &&
{remaining_time}
}
; + return {!is_zeroes && {remaining_time}}; }; export default RemainingTime; diff --git a/packages/core/package.json b/packages/core/package.json index 89002136ff08..2f4921bd1d23 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -96,8 +96,8 @@ "@babel/polyfill": "^7.4.4", "@datadog/browser-rum": "^5.11.0", "@deriv-com/analytics": "1.5.9", - "@deriv-com/quill-tokens": "^2.0.2", - "@deriv-com/quill-ui": "^1.10.8", + "@deriv-com/quill-tokens": "^2.0.4", + "@deriv-com/quill-ui": "^1.10.13", "@deriv-com/translations": "^1.2.3", "@deriv-com/utils": "^0.0.24", "@deriv/account": "^1.0.0", diff --git a/packages/reports/src/Containers/open-positions.tsx b/packages/reports/src/Containers/open-positions.tsx index d158e5cdd9e1..b6b5bff9c95e 100644 --- a/packages/reports/src/Containers/open-positions.tsx +++ b/packages/reports/src/Containers/open-positions.tsx @@ -655,8 +655,7 @@ const OpenPositions = observer(({ component_icon, ...props }: TOpenPositions) => /> ); }; - // TODO: Uncomment and update this when DTrader 2.0 development starts: - // if (useFeatureFlags().is_dtrader_v2_enabled) return I am Open positions for DTrader 2.0.; + return ( diff --git a/packages/reports/src/Stores/useReportsStores.tsx b/packages/reports/src/Stores/useReportsStores.tsx index 1e4e6bf76950..dd3b45c1a315 100644 --- a/packages/reports/src/Stores/useReportsStores.tsx +++ b/packages/reports/src/Stores/useReportsStores.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { useStore } from '@deriv/stores'; import ProfitStores from './Modules/Profit/profit-store'; import StatementStores from './Modules/Statement/statement-store'; +import { formatProfitTableTransactions } from './Modules/Profit/Helpers/format-response'; type TOverrideProfitStore = Omit & { date_from: number; - data: { [key: string]: string }[]; + data: ReturnType[]; totals: { [key: string]: unknown }; }; @@ -39,7 +40,7 @@ type TOverrideStatementStore = Omit< suffix_icon: string; }; -type TReportsStore = { +export type TReportsStore = { profit_table: TOverrideProfitStore; statement: TOverrideStatementStore; }; diff --git a/packages/shared/src/utils/contract/__tests__/contract.spec.ts b/packages/shared/src/utils/contract/__tests__/contract.spec.ts index e064e24c94f9..1ce02663c06b 100644 --- a/packages/shared/src/utils/contract/__tests__/contract.spec.ts +++ b/packages/shared/src/utils/contract/__tests__/contract.spec.ts @@ -786,3 +786,76 @@ describe('isForwardStartingBuyTransaction', () => { ).toBe(false); }); }); + +describe('getCurrentTick', () => { + const tickStreamWithFourTicks = [ + { + epoch: 1716910930, + tick: 1338.44, + tick_display_value: '1338.44', + }, + { + epoch: 1716910932, + tick: 1338.71, + tick_display_value: '1338.71', + }, + { + epoch: 1716910934, + tick: 1338.69, + tick_display_value: '1338.69', + }, + { + epoch: 1716910936, + tick: 1338.94, + tick_display_value: '1338.94', + }, + ]; + it('should return tick_passed if tick_passed is available for a non-digit/non-asian contract', () => { + const mockedAccuContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.ACCUMULATOR, + tick_passed: 2, + }); + expect(ContractUtils.getCurrentTick(mockedAccuContractInfo)).toEqual(2); + }); + it('should return tick_stream.length - 1 if tick_passed is missing for a non-digit/non-asian contract', () => { + const mockedRiseContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.RISE, + tick_stream: tickStreamWithFourTicks, + }); + expect(ContractUtils.getCurrentTick(mockedRiseContractInfo)).toEqual(3); + }); + it('should return tick_stream.length for a digit/asian contract', () => { + const mockedDigitContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.MATCH_DIFF.MATCH, + tick_stream: tickStreamWithFourTicks, + }); + const mockedAsianContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.ASIAN.UP, + tick_stream: tickStreamWithFourTicks, + }); + expect(ContractUtils.getCurrentTick(mockedDigitContractInfo)).toEqual(4); + expect(ContractUtils.getCurrentTick(mockedAsianContractInfo)).toEqual(4); + }); + it('should return 0 if tick_stream is empty/missing for a digit/asian contract', () => { + const mockedDigitContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.MATCH_DIFF.MATCH, + }); + const mockedAsianContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.ASIAN.UP, + tick_stream: [], + }); + expect(ContractUtils.getCurrentTick(mockedDigitContractInfo)).toEqual(0); + expect(ContractUtils.getCurrentTick(mockedAsianContractInfo)).toEqual(0); + }); + it('should return 0 if both tick_stream and tick_passed are empty/missing for a non-digit/non-asian contract', () => { + const mockedAccuContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.ACCUMULATOR, + }); + const mockedRiseContractInfo = mockContractInfo({ + contract_type: CONTRACT_TYPES.RISE, + tick_stream: [], + }); + expect(ContractUtils.getCurrentTick(mockedAccuContractInfo)).toEqual(0); + expect(ContractUtils.getCurrentTick(mockedRiseContractInfo)).toEqual(0); + }); +}); diff --git a/packages/shared/src/utils/contract/contract.tsx b/packages/shared/src/utils/contract/contract.tsx index 0d100840fbfd..4801a2fdf00a 100644 --- a/packages/shared/src/utils/contract/contract.tsx +++ b/packages/shared/src/utils/contract/contract.tsx @@ -203,7 +203,7 @@ export const getCurrentTick = (contract_info: TContractInfo) => { const current_tick = isDigitContract(contract_info.contract_type) || isAsiansContract(contract_info.contract_type) ? tick_stream.length - : tick_stream.length - 1; + : contract_info.tick_passed ?? tick_stream.length - 1; return !current_tick || current_tick < 0 ? 0 : current_tick; }; diff --git a/packages/stores/src/mockStore.ts b/packages/stores/src/mockStore.ts index bcd70400b34d..baf34934374c 100644 --- a/packages/stores/src/mockStore.ts +++ b/packages/stores/src/mockStore.ts @@ -575,6 +575,7 @@ const mock = (): TStores & { is_mock: boolean } => { barriers: [], error: '', getPositionById: jest.fn(), + is_active_empty: false, is_loading: false, is_accumulator: false, is_multiplier: false, @@ -632,6 +633,16 @@ const mock = (): TStores & { is_mock: boolean } => { setAccountType: jest.fn(), setMigratedMT5Accounts: jest.fn(), }, + positions: { + openContractTypeFilter: [], + closedContractTypeFilter: [], + timeFilter: '', + customTimeRangeFilter: '', + setClosedContractTypeFilter: jest.fn(), + setOpenContractTypeFilter: jest.fn(), + setTimeFilter: jest.fn(), + setCustomTimeRangeFilter: jest.fn(), + }, trade: { accumulator_range_list: [], active_symbols: [], diff --git a/packages/stores/types.ts b/packages/stores/types.ts index e0bc4b675bce..f364eaf46647 100644 --- a/packages/stores/types.ts +++ b/packages/stores/types.ts @@ -152,12 +152,14 @@ type BrandConfig = { }; export type TPortfolioPosition = { + barrier?: number; contract_info: ProposalOpenContract & Portfolio1 & { contract_update?: ContractUpdate; }; details?: string; display_name: string; + entry_spot?: number; id?: number; indicative: number; payout?: number; @@ -167,7 +169,9 @@ export type TPortfolioPosition = { is_unsupported: boolean; contract_update: ProposalOpenContract['limit_order']; is_sell_requested: boolean; + is_valid_to_sell?: boolean; profit_loss: number; + status?: null | string; }; type TAppRoutingHistory = { @@ -801,6 +805,7 @@ type TPortfolioStore = { barriers: TBarriers; error: string; getPositionById: (id: number) => TPortfolioPosition; + is_active_empty: boolean; is_loading: boolean; is_multiplier: boolean; is_accumulator: boolean; diff --git a/packages/trader/package.json b/packages/trader/package.json index fc77e178337b..4b9b356d8860 100644 --- a/packages/trader/package.json +++ b/packages/trader/package.json @@ -89,8 +89,8 @@ "dependencies": { "@cloudflare/stream-react": "^1.9.1", "@deriv-com/analytics": "1.5.9", - "@deriv-com/quill-tokens": "^2.0.2", - "@deriv-com/quill-ui": "^1.10.8", + "@deriv-com/quill-tokens": "^2.0.4", + "@deriv-com/quill-ui": "^1.10.13", "@deriv-com/utils": "^0.0.24", "@deriv/api-types": "1.0.172", "@deriv/components": "^1.0.0", @@ -104,6 +104,7 @@ "@deriv/quill-icons": "^1.22.10", "@types/react-loadable": "^5.5.6", "classnames": "^2.2.6", + "clsx": "^2.1.1", "extend": "^3.0.2", "formik": "^2.1.4", "framer-motion": "^6.5.1", @@ -121,6 +122,7 @@ "react-loadable": "^5.5.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", + "react-swipeable": "^6.2.1", "react-transition-group": "4.4.2" } } diff --git a/packages/trader/src/App/Components/Routes/binary-link.tsx b/packages/trader/src/App/Components/Routes/binary-link.tsx index 71e310ec7dc9..5ecb0a4d3c6e 100644 --- a/packages/trader/src/App/Components/Routes/binary-link.tsx +++ b/packages/trader/src/App/Components/Routes/binary-link.tsx @@ -3,39 +3,45 @@ import { NavLink } from 'react-router-dom'; import { findRouteByPath, normalizePath } from './helpers'; import getRoutesConfig from '../../Constants/routes-config'; -type TBinaryLinkProps = React.PropsWithChildren<{ - active_class?: string; - className?: string; - to?: string; - onClick?: () => void; -}>; +type TBinaryLinkProps = Omit, 'title' | 'ref'> & + React.PropsWithChildren<{ + active_class?: string; + className?: string; + to?: string; + onClick?: () => void; + }>; // TODO: solve circular dependency problem // when binary link is imported into components present in routes config // or into their descendants -const BinaryLink = ({ active_class = '', to, children, ...props }: TBinaryLinkProps) => { - const path = normalizePath(to); - const route = findRouteByPath(path, getRoutesConfig()); +const BinaryLink = React.forwardRef( + ({ active_class = '', to, children, ...props }, ref) => { + const path = normalizePath(to); + const route = findRouteByPath(path, getRoutesConfig()); - if (!route) { - throw new Error(`Route not found: ${to}`); + if (!route) { + throw new Error(`Route not found: ${to}`); + } + + return to ? ( + + {children} + + ) : ( + + {children} + + ); } +); - return to ? ( - - {children} - - ) : ( - - {children} - - ); -}; +BinaryLink.displayName = 'BinaryLink'; export default BinaryLink; diff --git a/packages/trader/src/App/app.tsx b/packages/trader/src/App/app.tsx index 87ce015eddc5..79be0e1500fc 100644 --- a/packages/trader/src/App/app.tsx +++ b/packages/trader/src/App/app.tsx @@ -10,6 +10,7 @@ import initStore from './init-store'; import 'Sass/app.scss'; import type { TCoreStores } from '@deriv/stores/types'; import TraderProviders from '../trader-providers'; +import ModulesProvider from 'Stores/Providers/modules-providers'; type Apptypes = { passthrough: { @@ -31,12 +32,14 @@ const App = ({ passthrough }: Apptypes) => { return ( - - - - - - + + + + + + + + ); }; diff --git a/packages/trader/src/AppV2/Components/BottomNav/__tests__/bottom-nav.spec.tsx b/packages/trader/src/AppV2/Components/BottomNav/__tests__/bottom-nav.spec.tsx index 74be614d911d..86c5ee8cd57d 100644 --- a/packages/trader/src/AppV2/Components/BottomNav/__tests__/bottom-nav.spec.tsx +++ b/packages/trader/src/AppV2/Components/BottomNav/__tests__/bottom-nav.spec.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import BottomNav from '../bottom-nav'; import userEvent from '@testing-library/user-event'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import BottomNav from '../bottom-nav'; jest.mock('../bottom-nav-item', () => { return jest.fn(({ index, setSelectedIndex }) => ( @@ -19,11 +20,13 @@ describe('BottomNav', () => { const mockedMarketsContainer =
MockedMarkets
; const mockedPositionsContainer =
MockedPositions
; const renderedBottomNav = ( - -
{mockedTradeContainer}
-
{mockedMarketsContainer}
-
{mockedPositionsContainer}
-
+ + +
{mockedTradeContainer}
+
{mockedMarketsContainer}
+
{mockedPositionsContainer}
+
+
); it('should render correctly', () => { const { container } = render(renderedBottomNav); diff --git a/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.scss b/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.scss index 6e43cb0f1a75..39dcc8bf3380 100644 --- a/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.scss +++ b/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.scss @@ -10,6 +10,7 @@ gap: var(--core-spacing-500); width: 100%; border-top: 1px solid var(--core-color-opacity-black-100); // waiting on quill tokens in deriv-app + background-color: var(--core-color-solid-slate-50); } &-selection { diff --git a/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.tsx b/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.tsx index 16241d4bf4b9..3b2ba26fa434 100644 --- a/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.tsx +++ b/packages/trader/src/AppV2/Components/BottomNav/bottom-nav.tsx @@ -7,49 +7,81 @@ import { StandaloneChartCandlestickRegularIcon, StandaloneClockThreeRegularIcon, } from '@deriv/quill-icons'; -import BottomNavItem from './bottom-nav-item'; import { Badge } from '@deriv-com/quill-ui'; +import { observer } from 'mobx-react'; +import { useStore } from '@deriv/stores'; +import BottomNavItem from './bottom-nav-item'; type BottomNavProps = { - className?: string; children: React.ReactNode[]; + className?: string; + selectedItemIdx?: number; + setSelectedItemIdx?: React.Dispatch>; }; -const bottomNavItems = [ - { - icon: ( - - ), - label: , - }, - { - icon: ( - - ), - label: , - }, - { - icon: ( - - { + const [selectedIndex, setSelectedIndex] = React.useState(selectedItemIdx); + const { active_positions_count } = useStore().portfolio; + + const bottomNavItems = [ + { + icon: ( + - - ), - label: , - }, - { - icon: , - label: , - }, -]; + ), + label: , + }, + { + icon: ( + + ), + label: , + }, + { + icon: + active_positions_count > 0 ? ( + + + + ) : ( + + ), + label: , + }, + { + icon: ( + + ), + label: , + }, + ]; -const BottomNav = ({ className, children }: BottomNavProps) => { - const [selectedIndex, setSelectedIndex] = React.useState(0); + const handleSelect = (index: number) => { + setSelectedIndex(index); + setSelectedItemIdx?.(index); + }; + + React.useEffect(() => { + setSelectedIndex(selectedItemIdx); + }, [selectedItemIdx]); return (
@@ -62,12 +94,12 @@ const BottomNav = ({ className, children }: BottomNavProps) => { icon={item.icon} selectedIndex={selectedIndex} label={item.label} - setSelectedIndex={setSelectedIndex} + setSelectedIndex={handleSelect} /> ))}
); -}; +}); export default BottomNav; diff --git a/packages/trader/src/AppV2/Components/Chip/__tests__/chip.spec.tsx b/packages/trader/src/AppV2/Components/Chip/__tests__/chip.spec.tsx new file mode 100644 index 000000000000..f8ee8dd78b08 --- /dev/null +++ b/packages/trader/src/AppV2/Components/Chip/__tests__/chip.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Chip from '../chip'; + +const mockProps = { onClick: jest.fn() }; +const label = 'mockLabel'; + +describe('Chip', () => { + it('should render component with default props', () => { + render(); + const chipButton = screen.getByRole('button'); + + expect(chipButton).toBeInTheDocument(); + userEvent.click(chipButton); + expect(mockProps.onClick).toBeCalled(); + }); + + it('should render component with label if it was passed', () => { + render(); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('should render component with applied size for label if label and size were passed', () => { + render(); + + expect(screen.getByText(label)).toHaveClass( + 'quill-typography__body-text__size--sm__weight--regular__decoration--default quill-typography__color--default' + ); + }); + + it('should render component with dropdown and specific className if dropdown was passed', () => { + render(); + + const chipButton = screen.getByRole('button'); + expect(chipButton).toHaveClass('quill-chip__custom-right-padding'); + + const dropdown = screen.getByRole('img'); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveClass('rotate--close'); + }); + + it('should render component with specific className if it was passed', () => { + const className = 'mockClassName'; + render(); + + const chipButton = screen.getByRole('button'); + expect(chipButton).toHaveClass(className); + }); + + it('should render component with specific className if selected was passed', () => { + render(); + + const chipButton = screen.getByRole('button'); + expect(chipButton).toHaveClass('quill-chip--selected'); + }); + + it('should render component with dropdown with specific className if dropdown and isDropdownOpen are true', () => { + render(); + + const dropdown = screen.getByRole('img'); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveClass('rotate--open'); + }); +}); diff --git a/packages/trader/src/AppV2/Components/Chip/chip.scss b/packages/trader/src/AppV2/Components/Chip/chip.scss new file mode 100644 index 000000000000..045f3c64e34b --- /dev/null +++ b/packages/trader/src/AppV2/Components/Chip/chip.scss @@ -0,0 +1,87 @@ +.quill-chip { + display: flex; + align-items: center; + justify-content: center; + border-style: solid; + border-width: var(--component-chip-border-width); + border-color: var(--component-chip-border-color); + border-radius: var(--component-chip-border-radius); + background-color: var(--core-color-solid-slate-50); + padding-inline: var(--component-chip-spacing-padding-lg); + height: var(--component-chip-height-md); + gap: var(--component-chip-spacing-padding-sm); + + &:hover { + background-color: var(--component-chip-bg-hover); + } + + &:active { + background-color: var(--component-chip-bg-active); + } + + & > svg { + fill: var(--component-chip-icon-default); + } + + &--selected { + background-color: var(--component-chip-bg-selected); + + & > p { + color: var(--component-chip-item-color-default); + } + + & > svg { + fill: var(--component-chip-icon-selected); + } + + &:has(.rotate--open) { + background-color: var(--component-chip-bg-selectedExpand); + } + + &:hover { + background-color: var(--component-chip-bg-selectedHover); + } + + &:active { + background-color: var(--component-chip-bg-selectedActive); + } + } + + &:disabled { + pointer-events: none; + user-select: none; + + & > p { + color: var(--component-chip-label-color-disabled); + } + + & > svg { + fill: var(--component-chip-icon-disabled); + } + + &--selected { + background-color: var(--component-chip-bg-selectedDisabled); + + & > p, + svg { + color: var(--component-chip-label-color-disabledWhite); + fill: var(--component-chip-icon-disabledWhite); + } + } + } + + &__custom-right-padding { + padding-inline: var(--component-chip-spacing-padding-lg) var(--component-chip-spacing-padding-sm); + } +} + +.rotate { + transition-property: transform; + transition-timing-function: var(--core-motion-ease-400); + transition-duration: var(--core-motion-duration-200); + transform: rotate(0); + + &--open { + transform: rotate(180deg); + } +} diff --git a/packages/trader/src/AppV2/Components/Chip/chip.tsx b/packages/trader/src/AppV2/Components/Chip/chip.tsx new file mode 100644 index 000000000000..5b65960d9e8e --- /dev/null +++ b/packages/trader/src/AppV2/Components/Chip/chip.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { LabelPairedChevronDownSmRegularIcon } from '@deriv/quill-icons'; +import './chip.scss'; +import clsx from 'clsx'; +import { Text } from '@deriv-com/quill-ui'; +import { TRegularSizes } from '@deriv-com/quill-ui/dist/types'; + +type BaseChipProps = Omit, 'label'> & { + label?: React.ReactNode; + disabled?: boolean; + isDropdownOpen?: boolean; + dropdown?: boolean; + selected?: boolean; + size?: TRegularSizes; + onClick?: () => void; +}; + +const Chip = React.forwardRef( + ({ size = 'md', label, dropdown = false, className, selected, isDropdownOpen = false, onClick, ...rest }, ref) => ( + + ) +); + +Chip.displayName = 'Chip'; +export default Chip; diff --git a/packages/trader/src/AppV2/Components/Chip/index.ts b/packages/trader/src/AppV2/Components/Chip/index.ts new file mode 100644 index 000000000000..e755e0f4bf52 --- /dev/null +++ b/packages/trader/src/AppV2/Components/Chip/index.ts @@ -0,0 +1,4 @@ +import Chip from './chip'; +import './chip.scss'; + +export default Chip; diff --git a/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-list.spec.tsx b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-list.spec.tsx new file mode 100644 index 000000000000..defa49f446e9 --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-list.spec.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TPortfolioPosition } from '@deriv/stores/types'; +import { mockContractInfo } from '@deriv/shared'; +import ContractCardList from '../contract-card-list'; +import ContractCard from '../contract-card'; + +const contractCard = 'Contract Card'; +const cancelButton = 'Cancel'; +const closeButton = 'Close'; + +jest.mock('../contract-card', () => + jest.fn(({ onCancel, onClose }: React.ComponentProps) => ( +
+ {contractCard} + + +
+ )) +); + +const mockProps: React.ComponentProps = { + positions: [ + { + contract_info: mockContractInfo({ + contract_id: 243585717228, + }), + }, + { + contract_info: mockContractInfo({ + contract_id: 243578583348, + }), + }, + ] as TPortfolioPosition[], +}; + +describe('ContractCardList', () => { + it('should not render component if positions are empty/not passed', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + it('should render component if positions are not empty', () => { + render(); + + expect(screen.getAllByText(contractCard)).toHaveLength(2); + }); + it('should call setHasButtonsDemo with false after 720ms if hasButtonsDemo === true', () => { + const mockedSetHasButtonsDemo = jest.fn(); + jest.useFakeTimers(); + render(); + + jest.advanceTimersByTime(720); + expect(mockedSetHasButtonsDemo).toHaveBeenCalledWith(false); + }); + it('should call onClickCancel with contract_id when a Cancel button is clicked on a contract card', () => { + const mockedOnClickCancel = jest.fn(); + render(); + + const firstCardCancelButton = screen.getAllByText(cancelButton)[0]; + userEvent.click(firstCardCancelButton); + expect(mockedOnClickCancel).toHaveBeenCalledWith(243585717228); + }); + it('should call onClickSell with contract_id when a Close button is clicked on a contract card', () => { + const mockedOnClickSell = jest.fn(); + render(); + + const secondCardCloseButton = screen.getAllByText(closeButton)[1]; + userEvent.click(secondCardCloseButton); + expect(mockedOnClickSell).toHaveBeenCalledWith(243578583348); + }); +}); diff --git a/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-status-timer.spec.tsx b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-status-timer.spec.tsx new file mode 100644 index 000000000000..a916beadad99 --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card-status-timer.spec.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { getCardLabels, toMoment } from '@deriv/shared'; +import { ContractCardStatusTimer } from '../contract-card-status-timer'; + +const mockedNow = Math.floor(Date.now() / 1000); + +describe('ContractCardStatusTimer', () => { + const mockProps = { date_expiry: mockedNow + 1000 }; + it('should render Closed status if date_expiry is not passed', () => { + render(); + + expect(screen.getByText(getCardLabels().CLOSED)).toBeInTheDocument(); + }); + it('should not render the component if date_expiry is passed without serverTime and no other props are passed', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + it('should render remaining time if date_expiry and serverTime are passed', () => { + render(); + + expect(screen.getByText('00:16:40')).toBeInTheDocument(); + }); + it('should render ticks progress if currentTick and tick_count are passed', () => { + render(); + + expect(screen.getByText('2/10 ticks')).toBeInTheDocument(); + }); + it('should render Ongoing status if hasNoAutoExpiry === true', () => { + render(); + + expect(screen.getByText('Ongoing')).toBeInTheDocument(); + }); + it('should render Closed status if isSold === true', () => { + render(); + + expect(screen.getByText(getCardLabels().CLOSED)).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card.spec.tsx b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card.spec.tsx new file mode 100644 index 000000000000..bb3d490c991d --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-card.spec.tsx @@ -0,0 +1,455 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createBrowserHistory } from 'history'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getCardLabels, getContractPath, toMoment } from '@deriv/shared'; +import { TPortfolioPosition } from '@deriv/stores/types'; +import { TClosedPosition } from 'AppV2/Containers/Positions/positions-content'; +import ContractCard from '../contract-card'; + +const mockedNow = Math.floor(Date.now() / 1000); + +const closedPositions = [ + { + contract_info: { + app_id: 16929, + buy_price: 10, + contract_id: 243585717228, + contract_type: 'TURBOSLONG', + duration_type: 'minutes', + longcode: + 'You will receive a payout at expiry if the spot price never breaches the barrier. The payout is equal to the payout per point multiplied by the distance between the final price and the barrier.', + payout: 0, + purchase_time: '27 May 2024 09:41:00', + sell_price: 0, + sell_time: '27 May 2024 09:43:36', + shortcode: 'TURBOSLONG_1HZ100V_10.00_1716802860_1716804660_S-237P_3.971435_1716802860', + transaction_id: 485824148848, + underlying_symbol: '1HZ100V', + profit_loss: '-10.00', + display_name: '', + purchase_time_unix: 1716802860, + }, + }, +] as TClosedPosition[]; + +const openPositions = [ + { + contract_info: { + account_id: 112905368, + barrier: '682.60', + barrier_count: 1, + bid_price: 6.38, + buy_price: 9, + contract_id: 242807007748, + contract_type: 'CALL', + currency: 'USD', + current_spot: 681.76, + current_spot_display_value: '681.76', + current_spot_time: 1716220628, + date_expiry: mockedNow + 1000, + date_settlement: mockedNow + 1000, + date_start: 1716220562, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 682.6, + entry_spot_display_value: '682.60', + entry_tick: 682.6, + entry_tick_display_value: '682.60', + entry_tick_time: 1716220563, + expiry_time: mockedNow + 1000, + id: '917d1b48-305b-a2f4-5b9c-7fb1f2c6c145', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 1, + is_path_dependent: 0, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 0, + is_valid_to_sell: 1, + longcode: + 'Win payout if Volatility 100 (1s) Index is strictly higher than entry spot at 2024-05-20 16:05:00 GMT.', + payout: 17.61, + profit: -2.62, + profit_percentage: -29.11, + purchase_time: 1716220562, + shortcode: `CALL_1HZ100V_17.61_1716220562_${mockedNow + 1000}F_S0P_0`, + status: 'open', + transaction_ids: { + buy: 484286139408, + }, + underlying: '1HZ100V', + }, + details: + 'Win payout if Volatility 100 (1s) Index is strictly higher than entry spot at 2024-05-20 16:05:00 GMT.', + display_name: '', + id: 242807007748, + indicative: 6.38, + payout: 17.61, + purchase: 9, + reference: 484286139408, + type: 'CALL', + profit_loss: -2.62, + is_valid_to_sell: true, + status: 'profit', + barrier: 682.6, + entry_spot: 682.6, + }, + { + contract_info: { + account_id: 112905368, + barrier_count: 1, + bid_price: 8.9, + buy_price: 9.39, + cancellation: { + ask_price: 0.39, + date_expiry: 1716224183, + }, + commission: 0.03, + contract_id: 242807045608, + contract_type: 'MULTUP', + currency: 'USD', + current_spot: 681.71, + current_spot_display_value: '681.71', + current_spot_time: 1716220672, + date_expiry: mockedNow + 1000, + date_settlement: mockedNow + 1000, + date_start: 1716220583, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 682.23, + entry_spot_display_value: '682.23', + entry_tick: 682.23, + entry_tick_display_value: '682.23', + entry_tick_time: 1716220584, + expiry_time: mockedNow + 1000, + id: '917d1b48-305b-a2f4-5b9c-7fb1f2c6c145', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 0, + is_path_dependent: 1, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 1, + is_valid_to_sell: 0, + limit_order: { + stop_out: { + display_name: 'Stop out', + order_amount: -9, + order_date: 1716220583, + value: '614.26', + }, + }, + longcode: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 90, minus commissions.", + multiplier: 10, + profit: -0.1, + profit_percentage: -1.11, + purchase_time: 1716220583, + shortcode: `MULTUP_1HZ100V_9.00_10_1716220583_${mockedNow + 1000}_60m_0.00_N1`, + status: 'open', + transaction_ids: { + buy: 484286215128, + }, + underlying: '1HZ100V', + validation_error: + 'The spot price has moved. We have not closed this contract because your profit is negative and deal cancellation is active. Cancel your contract to get your full stake back.', + validation_error_code: 'General', + }, + details: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 90, minus commissions.", + display_name: '', + id: 242807045608, + indicative: 8.9, + purchase: 9.39, + reference: 484286215128, + type: 'MULTUP', + contract_update: { + stop_out: { + display_name: 'Stop out', + order_amount: -9, + order_date: 1716220583, + value: '614.26', + }, + }, + profit_loss: -0.1, + is_valid_to_sell: false, + status: 'profit', + entry_spot: 682.23, + }, + { + contract_info: { + account_id: 112905368, + barrier_count: 2, + barrier_spot_distance: '0.296', + bid_price: 9.84, + buy_price: 9, + contract_id: 242807268688, + contract_type: 'ACCU', + currency: 'USD', + current_spot: 682.72, + current_spot_display_value: '682.72', + current_spot_high_barrier: '683.016', + current_spot_low_barrier: '682.424', + current_spot_time: 1716220720, + date_expiry: mockedNow + 1000, + date_settlement: mockedNow + 1000, + date_start: 1716220710, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 682.58, + entry_spot_display_value: '682.58', + entry_tick: 682.58, + entry_tick_display_value: '682.58', + entry_tick_time: 1716220711, + expiry_time: mockedNow + 1000, + growth_rate: 0.01, + high_barrier: '683.046', + id: '917d1b48-305b-a2f4-5b9c-7fb1f2c6c145', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 0, + is_path_dependent: 1, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 0, + is_valid_to_sell: 1, + longcode: + 'After the entry spot tick, your stake will grow continuously by 1% for every tick that the spot price remains within the ± 0.04331% from the previous spot price.', + low_barrier: '682.454', + profit: 0.84, + profit_percentage: 9.33, + purchase_time: 1716220710, + shortcode: 'ACCU_1HZ100V_9.00_0_0.01_1_0.000433139675_1716220710', + status: 'open', + tick_count: 230, + tick_passed: 9, + tick_stream: [ + { + epoch: 1716220711, + tick: 682.58, + tick_display_value: '682.58', + }, + { + epoch: 1716220712, + tick: 682.71, + tick_display_value: '682.71', + }, + { + epoch: 1716220713, + tick: 682.5, + tick_display_value: '682.50', + }, + { + epoch: 1716220714, + tick: 682.57, + tick_display_value: '682.57', + }, + { + epoch: 1716220715, + tick: 682.57, + tick_display_value: '682.57', + }, + { + epoch: 1716220716, + tick: 682.75, + tick_display_value: '682.75', + }, + { + epoch: 1716220717, + tick: 682.87, + tick_display_value: '682.87', + }, + { + epoch: 1716220718, + tick: 682.74, + tick_display_value: '682.74', + }, + { + epoch: 1716220719, + tick: 682.75, + tick_display_value: '682.75', + }, + { + epoch: 1716220720, + tick: 682.72, + tick_display_value: '682.72', + }, + ], + transaction_ids: { + buy: 484286658868, + }, + underlying: '1HZ100V', + }, + details: + 'After the entry spot tick, your stake will grow continuously by 1% for every tick that the spot price remains within the ± 0.04331% from the previous spot price.', + display_name: '', + id: 242807268688, + indicative: 9.84, + purchase: 9, + reference: 484286658868, + type: 'ACCU', + profit_loss: 0.84, + is_valid_to_sell: true, + current_tick: 9, + status: 'profit', + entry_spot: 682.58, + high_barrier: 683.046, + low_barrier: 682.454, + }, +] as TPortfolioPosition[]; + +const buttonLoaderId = 'dt_button_loader'; +const symbolName = 'Volatility 100 (1s) Index'; + +describe('ContractCard', () => { + const { CANCEL, CLOSE, CLOSED } = getCardLabels(); + const history = createBrowserHistory(); + const mockProps: React.ComponentProps = { + contractInfo: openPositions[0].contract_info, + currency: 'USD', + hasActionButtons: true, + isSellRequested: false, + serverTime: toMoment(mockedNow), + }; + const mockedContractCard = (props = mockProps) => ( + + + + ); + beforeEach(() => { + history.push('/'); + }); + it('should not render component if contractInfo prop is empty/missing contract_type', () => { + const { container } = render(mockedContractCard({ ...mockProps, contractInfo: {} })); + + expect(container).toBeEmptyDOMElement(); + }); + it('should render a card for an open Rise position with a Close button only and with remaining time', () => { + render(mockedContractCard()); + expect(screen.getByText('Rise')).toBeInTheDocument(); + expect(screen.getByText(symbolName)).toBeInTheDocument(); + expect(screen.getByText('9.00 USD')).toBeInTheDocument(); + expect(screen.getByText('00:16:40')).toBeInTheDocument(); + expect(screen.getByText('2.62 USD')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: CLOSE })).toBeEnabled(); + expect(screen.queryByRole('button', { name: CANCEL })).not.toBeInTheDocument(); + }); + it('should render a card for an open Multiplier position with Cancel & Close buttons and with Ongoing status instead of remaining time', () => { + render( + mockedContractCard({ + ...mockProps, + contractInfo: openPositions[1].contract_info, + }) + ); + expect(screen.getByText('Multipliers Up')).toBeInTheDocument(); + expect(screen.getByText(symbolName)).toBeInTheDocument(); + expect(screen.getByText('9.39 USD')).toBeInTheDocument(); + expect(screen.getByText('Ongoing')).toBeInTheDocument(); + expect(screen.getByText('0.49 USD')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: CANCEL })).toBeEnabled(); + expect(screen.getByRole('button', { name: CLOSE })).toBeDisabled(); + }); + it('should render a card for an open Accumulators position with a Close button only and ticks progress', () => { + render( + mockedContractCard({ + ...mockProps, + contractInfo: openPositions[2].contract_info, + }) + ); + expect(screen.getByText('Accumulators')).toBeInTheDocument(); + expect(screen.getByText(symbolName)).toBeInTheDocument(); + expect(screen.getByText('9.00 USD')).toBeInTheDocument(); + expect(screen.getByText('9/230 ticks')).toBeInTheDocument(); + expect(screen.getByText('0.84 USD')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: CLOSE })).toBeEnabled(); + expect(screen.queryByRole('button', { name: CANCEL })).not.toBeInTheDocument(); + }); + it('should show loader when a Close button is clicked and isSellRequested === true', () => { + const mockedOnClose = jest.fn(); + const { rerender } = render( + mockedContractCard({ + ...mockProps, + contractInfo: openPositions[2].contract_info, + onClose: mockedOnClose, + }) + ); + const closeButton = screen.getByRole('button', { name: getCardLabels().CLOSE }); + userEvent.click(closeButton); + expect(mockedOnClose).toHaveBeenCalledTimes(1); + + rerender( + mockedContractCard({ + ...mockProps, + contractInfo: openPositions[2].contract_info, + isSellRequested: true, + }) + ); + expect(screen.getByTestId(buttonLoaderId)).toBeInTheDocument(); + expect(closeButton).toBeDisabled(); + }); + it('should show loader when a Cancel button is clicked and isSellRequested === true', () => { + const mockedOnCancel = jest.fn(); + const { rerender } = render( + mockedContractCard({ + ...mockProps, + contractInfo: openPositions[1].contract_info, + onCancel: mockedOnCancel, + }) + ); + const cancelButton = screen.getByRole('button', { name: CANCEL }); + userEvent.click(cancelButton); + expect(mockedOnCancel).toHaveBeenCalledTimes(1); + + rerender( + mockedContractCard({ + ...mockProps, + contractInfo: openPositions[1].contract_info, + isSellRequested: true, + }) + ); + expect(screen.getByTestId(buttonLoaderId)).toBeInTheDocument(); + expect(cancelButton).toBeDisabled(); + }); + it('should render a card for a closed position with Closed status and with no buttons if hasActionButtons === false', () => { + render( + mockedContractCard({ + ...mockProps, + contractInfo: closedPositions[0].contract_info, + hasActionButtons: false, + }) + ); + expect(screen.getByText(CLOSED)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: CLOSE })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: CANCEL })).not.toBeInTheDocument(); + }); + it('should call onClick when a card is clicked, and should redirect to a correct route if redirectTo is passed', () => { + const mockedOnClick = jest.fn(); + const redirectTo = getContractPath(Number(closedPositions[0].contract_info.contract_id)); + render( + mockedContractCard({ + ...mockProps, + contractInfo: closedPositions[0].contract_info, + onClick: mockedOnClick, + redirectTo, + }) + ); + const card = screen.getByText('Turbos Up'); + userEvent.click(card); + expect(mockedOnClick).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe(redirectTo); + }); + it('should call onClick when a card is clicked, but should not redirect anywhere if redirectTo prop is missing', () => { + const mockedOnClick = jest.fn(); + const redirectTo = getContractPath(Number(closedPositions[0].contract_info.contract_id)); + render( + mockedContractCard({ + ...mockProps, + contractInfo: closedPositions[0].contract_info, + onClick: mockedOnClick, + }) + ); + const card = screen.getByText('Turbos Up'); + userEvent.click(card); + expect(mockedOnClick).toHaveBeenCalledTimes(1); + expect(history.location.pathname).not.toBe(redirectTo); + }); +}); diff --git a/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-cards-sections.spec.tsx b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-cards-sections.spec.tsx new file mode 100644 index 000000000000..013f95decb26 --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/__tests__/contract-cards-sections.spec.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ContractCardsSections from '../contract-cards-sections'; + +const ContractCard = 'Contract Card'; + +jest.mock('../contract-card', () => jest.fn(() =>
{ContractCard}
)); + +const mockProps = { + positions: [ + { + contract_info: { + app_id: 16929, + buy_price: 10, + contract_id: 243585717228, + contract_type: 'TURBOSLONG', + duration_type: 'minutes', + longcode: + 'You will receive a payout at expiry if the spot price never breaches the barrier. The payout is equal to the payout per point multiplied by the distance between the final price and the barrier.', + payout: 0, + purchase_time: '27 May 2024 09:41:00', + sell_price: 0, + sell_time: '27 May 2024 09:43:36', + shortcode: 'TURBOSLONG_1HZ100V_10.00_1716802860_1716804660_S-237P_3.971435_1716802860', + transaction_id: 485824148848, + underlying_symbol: '1HZ100V', + profit_loss: '-10.00', + display_name: '', + purchase_time_unix: 1716802860, + }, + }, + { + contract_info: { + app_id: 16929, + buy_price: 10, + contract_id: 243578583348, + contract_type: 'MULTUP', + duration_type: 'days', + longcode: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 1000, minus commissions.", + multiplier: '100', + payout: 0, + purchase_time: '27 May 2024 08:11:30', + sell_price: 0, + sell_time: '27 May 2024 08:57:17', + shortcode: 'MULTUP_1HZ100V_10.00_100_1716797490_4870454399_0_0.00_N1', + transaction_id: 485810770808, + underlying_symbol: '1HZ100V', + profit_loss: '-10.00', + display_name: '', + purchase_time_unix: 1716797490, + }, + }, + ], +}; + +describe('ContractCardsSections', () => { + it('should not render component if positions prop is empty', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render component if positions prop is not empty', () => { + render(); + + const dateSeparator = screen.getByText('27 May 2024'); + expect(dateSeparator).toBeInTheDocument(); + expect(screen.getAllByText(ContractCard)).toHaveLength(2); + }); + + it('should render Loading when isLoading true', () => { + render(); + + const loader = screen.getByTestId('dt_initial_loader'); + expect(loader).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/ContractCard/contract-card-list.tsx b/packages/trader/src/AppV2/Components/ContractCard/contract-card-list.tsx new file mode 100644 index 000000000000..ad479751a4de --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/contract-card-list.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import clsx from 'clsx'; +import { getContractPath } from '@deriv/shared'; +import { TPortfolioPosition } from '@deriv/stores/types'; +import { TClosedPosition } from 'AppV2/Containers/Positions/positions-content'; +import { TRootStore } from 'Types'; +import ContractCard from './contract-card'; + +export type TContractCardListProps = { + currency?: string; + hasButtonsDemo?: boolean; + onClickCancel?: (contractId: number) => void; + onClickSell?: (contractId: number) => void; + positions?: (TPortfolioPosition | TClosedPosition)[]; + setHasButtonsDemo?: React.Dispatch>; + serverTime?: TRootStore['common']['server_time']; +}; + +const ContractCardList = ({ + hasButtonsDemo, + onClickCancel, + onClickSell, + positions = [], + setHasButtonsDemo, + ...rest +}: TContractCardListProps) => { + React.useEffect(() => { + let demoTimeout: ReturnType; + if (hasButtonsDemo && setHasButtonsDemo) { + demoTimeout = setTimeout(() => setHasButtonsDemo(false), 720); // 720 is the length of demo animation + } + return () => { + if (demoTimeout) clearTimeout(demoTimeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!positions.length) return null; + return ( +
+ {positions.map(position => { + const { contract_id: id } = position.contract_info; + return ( + id && onClickCancel?.(id)} + onClose={() => id && onClickSell?.(id)} + redirectTo={id ? getContractPath(id) : ''} + {...rest} + /> + ); + })} +
+ ); +}; + +export default ContractCardList; diff --git a/packages/trader/src/AppV2/Components/ContractCard/contract-card-status-timer.tsx b/packages/trader/src/AppV2/Components/ContractCard/contract-card-status-timer.tsx new file mode 100644 index 000000000000..29460d56cae7 --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/contract-card-status-timer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { TPortfolioPosition } from '@deriv/stores/types'; +import { Localize } from '@deriv/translations'; +import { Tag } from '@deriv-com/quill-ui'; +import { LabelPairedStopwatchCaptionRegularIcon } from '@deriv/quill-icons'; +import { getCardLabels } from '@deriv/shared'; +import { RemainingTime } from '@deriv/components'; +import { TRootStore } from 'Types'; + +export type TContractCardStatusTimerProps = Pick & { + currentTick?: number | null; + hasNoAutoExpiry?: boolean; + isSold?: boolean; + serverTime?: TRootStore['common']['server_time']; +}; + +export const ContractCardStatusTimer = ({ + currentTick, + date_expiry, + hasNoAutoExpiry, + isSold, + serverTime, + tick_count, +}: TContractCardStatusTimerProps) => { + const getDisplayedDuration = () => { + if (hasNoAutoExpiry) return ; + if (tick_count) { + return `${currentTick ?? 0}/${tick_count} ${getCardLabels().TICKS.toLowerCase()}`; + } + if (date_expiry && serverTime) { + return ( + + ); + } + return null; + }; + const displayedDuration = getDisplayedDuration(); + + if (!date_expiry || (serverTime as moment.Moment)?.unix() > +date_expiry || isSold) { + return ; + } + return displayedDuration ? ( + } + label={displayedDuration} + variant='custom' + size='sm' + /> + ) : null; +}; diff --git a/packages/trader/src/AppV2/Components/ContractCard/contract-card.scss b/packages/trader/src/AppV2/Components/ContractCard/contract-card.scss new file mode 100644 index 000000000000..c61d236cf929 --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/contract-card.scss @@ -0,0 +1,247 @@ +.contract-card { + position: relative; + display: flex; + width: 100%; + cursor: pointer; + background-color: var(--component-modal-bg); + padding: var(--semantic-spacing-general-md); + justify-content: space-between; + height: 10.4rem; + transform: translateX(0); + transition: transform var(--core-motion-duration-200) var(--motion-easing-inandout); + + .dc-icon { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: var(--core-size-1600); + height: var(--core-size-1600); + } + &__body { + display: flex; + flex-direction: column; + gap: var(--semantic-spacing-gap-md); + } + &__body, + &__title { + min-width: 0; + flex-grow: 1; + } + &__details, + .status-and-profit { + display: flex; + gap: var(--core-spacing-400); + align-items: center; + } + .status, + .timer { + background-color: var(--core-color-opacity-black-75); + + .dc-remaining-time { + font-size: unset; + } + } + .status-and-profit { + justify-content: space-between; + } + &__title { + display: flex; + flex-direction: column; + } + .trade-type, + .symbol { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &-icon { + padding: var(--core-size-200); + } + } + .symbol, + .stake { + color: var(--component-textIcon-normal-subtle); + } + .stake { + align-self: flex-end; + } + .buttons { + display: flex; + align-self: center; + justify-content: flex-end; + flex-shrink: 0; + width: fit-content; + height: 100%; + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + inset-block-end: 0; + transform: translateX(100%); + opacity: var(--core-opacity-50); + transition: opacity var(--core-motion-duration-200) var(--motion-easing-inandout); + + button { + display: flex; + justify-content: center; + align-items: center; + width: var(--core-size-3600); + height: 100%; + cursor: pointer; + + .label { + color: var(--core-color-solid-slate-50); + } + &:disabled:not(.loading) { + background-color: var(--core-color-opacity-black-200); + + .label { + color: var(--component-textIcon-normal-disabled); + } + } + .circle-loader { + width: var(--core-size-600); + height: var(--core-size-600); + display: grid; + border-radius: 50%; + mask: radial-gradient(farthest-side, #0000 50%, var(--core-color-solid-slate-1400) 51%); + background: linear-gradient(0deg, rgba(255, 255, 255, 0.5) 50%, var(--core-color-solid-slate-50) 0) + center/1px 100%, + linear-gradient(90deg, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.75) 0) center/100% 1px; + background-repeat: no-repeat; + animation: rotate-loader 1s infinite steps(8); + + &:before, + &:after { + content: ''; + grid-area: 1/1; + border-radius: 50%; + background: inherit; + opacity: 0.915; + transform: rotate(45deg); + } + &:after { + opacity: 0.83; + transform: rotate(90deg); + } + @keyframes rotate-loader { + 100% { + transform: rotate(360deg); + } + } + } + } + } + &.lost { + .profit { + color: var(--core-color-solid-red-600); + } + button:not(:disabled), + button.loading { + background-color: var(--core-color-solid-cherry-700); + } + .status { + color: var(--core-color-solid-red-900); + background-color: var(--core-color-opacity-red-100); + } + } + &.won { + .profit { + color: var(--core-color-solid-green-600); + } + button:not(:disabled), + button.loading { + background-color: var(--core-color-solid-emerald-700); + } + .status { + color: var(--core-color-solid-green-900); + background-color: var(--core-color-opacity-green-100); + } + } + &.show-buttons { + transform: translateX(calc(var(--core-size-3600) * -1)); + + &.has-cancel-button { + transform: translateX(calc(var(--core-size-3600) * -2)); + } + } + &-wrapper { + position: relative; + width: inherit; + overflow: hidden; + flex-shrink: 0; + max-height: 10.4rem; // Update carefully: max-height in exact units is needed for transition below to work + box-shadow: var(--core-elevation-shadow-130); + border-radius: var(--semantic-borderRadius-md); + + &.deleted { + opacity: var(--core-opacity-50); + max-height: 0; + transition: max-height 0.3s, opacity 0.1s; + transition-timing-function: var(--motion-easing-out); + } + } + &-list { + display: flex; + flex-grow: 1; + flex-direction: column; + gap: var(--core-spacing-400); + width: inherit; + padding-inline: var(--core-spacing-400); + + &--has-buttons-demo { + .contract-card-wrapper:first-child { + .contract-card { + animation: var(--motion-duration-relax) var(--motion-easing-inandout) bounce-one-button; + + &.has-cancel-button { + animation: var(--motion-duration-relax) var(--motion-easing-inandout) bounce-two-buttons; + } + @keyframes bounce-one-button { + 0%, + 100% { + transform: translateX(0); + } + 30%, + 70% { + transform: translateX(calc(var(--core-size-3600) * -1)); + } + } + @keyframes bounce-two-buttons { + 0%, + 100% { + transform: translateX(0); + } + 30%, + 70% { + transform: translateX(calc(var(--core-size-3600) * -2)); + } + } + } + } + } + } + &.show-buttons, + &-list--has-buttons-demo { + .buttons { + opacity: var(--core-opacity-1300); + } + } +} + +.contract-cards { + &-sections { + &--has-bottom-margin { + margin-block-end: var(--core-size-1900); + } + } + &-section { + &__title { + padding: var(--core-spacing-400) var(--core-spacing-800); + position: sticky; + top: 0; + z-index: 1; + background-color: var(--core-color-solid-slate-75); + } + } +} diff --git a/packages/trader/src/AppV2/Components/ContractCard/contract-card.tsx b/packages/trader/src/AppV2/Components/ContractCard/contract-card.tsx new file mode 100644 index 000000000000..e31c1660aeda --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/contract-card.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import clsx from 'clsx'; +import { CaptionText, Text } from '@deriv-com/quill-ui'; +import { useSwipeable } from 'react-swipeable'; +import { IconTradeTypes, Money } from '@deriv/components'; +import { + TContractInfo, + getCardLabels, + getCurrentTick, + getMarketName, + getTradeTypeName, + isEnded, + isHighLow, + isMultiplierContract, + isValidToCancel, + isValidToSell, +} from '@deriv/shared'; +import { ContractCardStatusTimer, TContractCardStatusTimerProps } from './contract-card-status-timer'; +import { BinaryLink } from 'App/Components/Routes'; +import { TClosedPosition } from 'AppV2/Containers/Positions/positions-content'; +import { TRootStore } from 'Types'; +import { getProfit } from 'AppV2/Utils/positions-utils'; + +type TContractCardProps = TContractCardStatusTimerProps & { + className?: string; + contractInfo: TContractInfo | TClosedPosition['contract_info']; + currency?: string; + hasActionButtons?: boolean; + isSellRequested?: boolean; + onClick?: (e?: React.MouseEvent) => void; + onCancel?: (e?: React.MouseEvent) => void; + onClose?: (e?: React.MouseEvent) => void; + redirectTo?: string; + serverTime?: TRootStore['common']['server_time']; +}; + +const DIRECTION = { + LEFT: 'left', + RIGHT: 'right', +}; + +const swipeConfig = { + trackMouse: true, + preventScrollOnSwipe: true, +}; + +const ContractCard = ({ + className = 'contract-card', + contractInfo, + currency, + hasActionButtons, + isSellRequested, + onCancel, + onClick, + onClose, + redirectTo, + serverTime, +}: TContractCardProps) => { + const [isDeleted, setIsDeleted] = React.useState(false); + const [isClosing, setIsClosing] = React.useState(false); + const [isCanceling, setIsCanceling] = React.useState(false); + const [shouldShowButtons, setShouldShowButtons] = React.useState(false); + const { buy_price, contract_type, display_name, sell_time, shortcode } = contractInfo; + const contract_main_title = getTradeTypeName(contract_type ?? '', { + isHighLow: isHighLow({ shortcode }), + showMainTitle: true, + }); + const currentTick = 'tick_count' in contractInfo && contractInfo.tick_count ? getCurrentTick(contractInfo) : null; + const tradeTypeName = `${contract_main_title} ${getTradeTypeName(contract_type ?? '', { + isHighLow: isHighLow({ shortcode }), + })}`.trim(); + const symbolName = + 'underlying_symbol' in contractInfo ? getMarketName(contractInfo.underlying_symbol ?? '') : display_name; + const isMultiplier = isMultiplierContract(contract_type); + const isSold = !!sell_time || isEnded(contractInfo as TContractInfo); + const totalProfit = getProfit(contractInfo); + const validToCancel = isValidToCancel(contractInfo as TContractInfo); + const validToSell = isValidToSell(contractInfo as TContractInfo) && !isSellRequested; + const isCancelButtonPressed = isSellRequested && isCanceling; + const isCloseButtonPressed = isSellRequested && isClosing; + const Component = redirectTo ? BinaryLink : 'div'; + + const handleSwipe = (direction: string) => { + const isLeft = direction === DIRECTION.LEFT; + setShouldShowButtons(isLeft); + }; + + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => handleSwipe(DIRECTION.LEFT), + onSwipedRight: () => handleSwipe(DIRECTION.RIGHT), + ...swipeConfig, + }); + + const handleClose = (e: React.MouseEvent, shouldCancel?: boolean) => { + e.preventDefault(); + e.stopPropagation(); + if (shouldCancel) { + onCancel?.(e); + setIsCanceling(true); + } else { + onClose?.(e); + setIsClosing(true); + } + }; + + React.useEffect(() => { + if (isSold && hasActionButtons) { + setIsDeleted(true); + } + }, [isSold, hasActionButtons]); + + if (!contract_type) return null; + return ( +
+ = 0, + })} + onClick={onClick} + onDragStart={e => e.preventDefault()} + to={redirectTo} + > +
+
+ +
+ + {tradeTypeName} + + + {symbolName} + +
+ + + +
+
+ + + + +
+
+ {hasActionButtons && ( +
+ {validToCancel && ( + + )} + +
+ )} +
+
+ ); +}; + +export default ContractCard; diff --git a/packages/trader/src/AppV2/Components/ContractCard/contract-cards-sections.tsx b/packages/trader/src/AppV2/Components/ContractCard/contract-cards-sections.tsx new file mode 100644 index 000000000000..bf2065bf837e --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/contract-cards-sections.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Text } from '@deriv-com/quill-ui'; +import { Loading } from '@deriv/components'; +import { toMoment } from '@deriv/shared'; +import { TClosedPosition } from 'AppV2/Containers/Positions/positions-content'; +import ContractCardList from './contract-card-list'; + +type TContractCardsSections = { + isLoadingMore?: boolean; + hasBottomMargin?: boolean; + positions?: TClosedPosition[]; +}; + +const ContractCardsSections = ({ isLoadingMore, hasBottomMargin, positions }: TContractCardsSections) => { + const formatTime = (time: number) => toMoment(time).format('DD MMM YYYY'); + + const dates = positions?.map(element => { + const purchaseTime = element.contract_info.purchase_time_unix; + return purchaseTime && formatTime(purchaseTime); + }); + + const uniqueDates = [...new Set(dates)]; + + if (!positions?.length) return null; + return ( + +
+ {uniqueDates.map(date => ( +
+ + {date} + + { + const purchaseTime = position.contract_info.purchase_time_unix; + return purchaseTime && formatTime(purchaseTime) === date; + })} + /> +
+ ))} +
+ {isLoadingMore && } +
+ ); +}; + +export default React.memo(ContractCardsSections); diff --git a/packages/trader/src/AppV2/Components/ContractCard/index.ts b/packages/trader/src/AppV2/Components/ContractCard/index.ts new file mode 100644 index 000000000000..bcb9add8f192 --- /dev/null +++ b/packages/trader/src/AppV2/Components/ContractCard/index.ts @@ -0,0 +1,5 @@ +import './contract-card.scss'; + +export { default as ContractCard } from './contract-card'; +export { default as ContractCardList } from './contract-card-list'; +export { default as ContractCardsSections } from './contract-cards-sections'; diff --git a/packages/trader/src/AppV2/Components/DatePicker/__tests__/date-picker.spec.tsx b/packages/trader/src/AppV2/Components/DatePicker/__tests__/date-picker.spec.tsx new file mode 100644 index 000000000000..e0b151fd3def --- /dev/null +++ b/packages/trader/src/AppV2/Components/DatePicker/__tests__/date-picker.spec.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import DateRangePicker from '../date-picker'; + +const header = 'Choose a date range'; +const footer = 'Apply'; +const mockProps = { + isOpen: true, + onClose: jest.fn(), + setCustomTimeRangeFilter: jest.fn(), + handleDateChange: jest.fn(), +}; +const mediaQueryList = { + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +window.matchMedia = jest.fn().mockImplementation(() => mediaQueryList); + +describe('DateRangePicker', () => { + it('should render Action Sheet with Date Picker', () => { + render(); + + expect(screen.getByText(header)).toBeInTheDocument(); + expect(screen.getByText(footer)).toBeInTheDocument(); + }); + + it('should call setCustomTimeRangeFilter, handleDateChange and onClose if user choses some range date and clicks on Apply button', () => { + render(); + + const fromDate = screen.getByText('1'); + const toDate = screen.getByText('2'); + const applyButton = screen.getByText(footer); + userEvent.click(fromDate); + userEvent.click(toDate); + userEvent.click(applyButton); + + expect(mockProps.setCustomTimeRangeFilter).toBeCalled(); + expect(mockProps.handleDateChange).toBeCalled(); + expect(mockProps.onClose).toBeCalled(); + }); + + it('should call setCustomTimeRangeFilter, handleDateChange and onClose if user choses a single date and clicks on Apply button', () => { + render(); + + const fromDate = screen.getByText('1'); + const applyButton = screen.getByText(footer); + userEvent.click(fromDate); + userEvent.click(applyButton); + + expect(mockProps.setCustomTimeRangeFilter).toBeCalled(); + expect(mockProps.handleDateChange).toBeCalled(); + expect(mockProps.onClose).toBeCalled(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/DatePicker/date-picker.scss b/packages/trader/src/AppV2/Components/DatePicker/date-picker.scss new file mode 100644 index 000000000000..a463d3216040 --- /dev/null +++ b/packages/trader/src/AppV2/Components/DatePicker/date-picker.scss @@ -0,0 +1,33 @@ +.date-picker__action-sheet { + padding: 0; + + .react-calendar { + &__tile { + font-size: var(--core-fontSize-100); + + &--now:after { + display: none; + } + + &--range { + background-color: var(--semantic-color-monochrome-surface-normal-low); + color: var(--component-dropdownItem-label-color-default); + } + + &--rangeStart, + &--rangeEnd { + background-color: var(--component-dropdownItem-bg-selected); + color: var(--component-dropdownItem-label-color-selectedWhite); + } + + &:disabled { + background-color: unset; + color: var(--component-textIcon-normal-disabled); + } + } + } +} + +.quill-date-picker__wrapper--fixed-width { + margin: auto; +} diff --git a/packages/trader/src/AppV2/Components/DatePicker/date-picker.tsx b/packages/trader/src/AppV2/Components/DatePicker/date-picker.tsx new file mode 100644 index 000000000000..85ba226c1eff --- /dev/null +++ b/packages/trader/src/AppV2/Components/DatePicker/date-picker.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { ActionSheet, DatePicker } from '@deriv-com/quill-ui'; +import moment from 'moment'; +import { toMoment } from '@deriv/shared'; +import { Localize } from '@deriv/translations'; +import { DEFAULT_DATE_FORMATTING_CONFIG } from 'AppV2/Utils/positions-utils'; + +type TDateRangePicker = { + handleDateChange: (values: { to?: moment.Moment; from?: moment.Moment; is_batch?: boolean }) => void; + isOpen?: boolean; + onClose: () => void; + setCustomTimeRangeFilter: (newCustomTimeFilter?: string | undefined) => void; +}; +const DateRangePicker = ({ handleDateChange, isOpen, onClose, setCustomTimeRangeFilter }: TDateRangePicker) => { + const [chosenRangeString, setChosenRangeString] = React.useState(); + const [chosenRange, setChosenRange] = React.useState<(string | null | Date)[] | null | Date>([]); + + const onApply = () => { + setCustomTimeRangeFilter(chosenRangeString); + if (Array.isArray(chosenRange) && chosenRange.length) { + handleDateChange({ + from: toMoment(chosenRange[0]), + to: chosenRange[1] ? toMoment(chosenRange[1]) : moment(chosenRange[0]).endOf('day'), + }); + } + onClose(); + }; + + const onFormattedDate = (value: string) => { + const trimmedValue = value.trim(); + const partialRange = trimmedValue.endsWith('-'); + setChosenRangeString(partialRange ? trimmedValue.substring(0, trimmedValue.length - 1) : trimmedValue); + }; + + return ( + + + } /> + + Date.parse(date.toDateString()) > Date.parse(toMoment().toString())} + /> + + , + onAction: onApply, + }} + /> + + + ); +}; + +export default DateRangePicker; diff --git a/packages/trader/src/AppV2/Components/DatePicker/index.ts b/packages/trader/src/AppV2/Components/DatePicker/index.ts new file mode 100644 index 000000000000..e1fd3c52ef90 --- /dev/null +++ b/packages/trader/src/AppV2/Components/DatePicker/index.ts @@ -0,0 +1,4 @@ +import DatePicker from './date-picker'; +import './date-picker.scss'; + +export default DatePicker; diff --git a/packages/trader/src/AppV2/Components/EmptyPositions/__tests__/empty-positions.spec.tsx b/packages/trader/src/AppV2/Components/EmptyPositions/__tests__/empty-positions.spec.tsx new file mode 100644 index 000000000000..d8828d058a57 --- /dev/null +++ b/packages/trader/src/AppV2/Components/EmptyPositions/__tests__/empty-positions.spec.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import EmptyPositions from '../empty-positions'; + +describe('EmptyPositions', () => { + const iconId = 'dt_empty_state_icon'; + + it('should render Loader before timer ends', () => { + render(); + + expect(screen.getByTestId('dt_initial_loader')).toBeInTheDocument(); + }); + + it('should render "No open trades" content when isClosedTab prop is false', () => { + jest.useFakeTimers(); + render(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('No open trades')).toBeInTheDocument(); + expect(screen.getByText('Your open trades will appear here.')).toBeInTheDocument(); + }); + + it('should render "No closed trades" content when isClosedTab prop is true', () => { + jest.useFakeTimers(); + render(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('No closed trades')).toBeInTheDocument(); + expect(screen.getByText('Your closed trades will be shown here.')).toBeInTheDocument(); + }); + + it('should render "No matches found" content when noMatchesFound prop is true', () => { + jest.useFakeTimers(); + render(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('No matches found')).toBeInTheDocument(); + expect(screen.getByText(/Try changing or removing filters/i)).toBeInTheDocument(); + }); + + it('should render an empty state icon regardless of props', () => { + jest.useFakeTimers(); + const { rerender } = render(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByTestId(iconId)).toBeInTheDocument(); + rerender(); + expect(screen.getByTestId(iconId)).toBeInTheDocument(); + rerender(); + expect(screen.getByTestId(iconId)).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.scss b/packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.scss new file mode 100644 index 000000000000..2b264d9cf198 --- /dev/null +++ b/packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.scss @@ -0,0 +1,22 @@ +.empty-positions { + &__open, + &__closed { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-inline: var(--core-spacing-2400); + flex-grow: 1; + + .message { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--semantic-spacing-gap-sm); + } + + .icon { + fill: var(--core-color-solid-slate-200); + } + } +} diff --git a/packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.tsx b/packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.tsx new file mode 100644 index 000000000000..3c5016fa14ef --- /dev/null +++ b/packages/trader/src/AppV2/Components/EmptyPositions/empty-positions.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Text } from '@deriv-com/quill-ui'; +import { Loading } from '@deriv/components'; +import { StandaloneBriefcaseFillIcon, StandaloneSearchFillIcon } from '@deriv/quill-icons'; +import { Localize } from '@deriv/translations'; + +export type TEmptyPositionsProps = { + isClosedTab?: boolean; + noMatchesFound?: boolean; +}; + +const EmptyPositions = ({ isClosedTab, noMatchesFound }: TEmptyPositionsProps) => { + const [showLoader, setShowLoader] = React.useState(true); + + React.useEffect(() => { + const timeout = setTimeout(() => setShowLoader(false), 500); + return () => { + clearTimeout(timeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const Icon = noMatchesFound ? StandaloneSearchFillIcon : StandaloneBriefcaseFillIcon; + + if (showLoader) return ; + return ( +
+
+ +
+
+ {/* There is an issue with tokens: the 'lg' size should give 18px but it's giving 20px, it's being discussed. */} + + {noMatchesFound && } + {!noMatchesFound && + (isClosedTab ? ( + + ) : ( + + ))} + + + {noMatchesFound && ( + + )} + {!noMatchesFound && + (isClosedTab ? ( + + ) : ( + + ))} + +
+
+ ); +}; + +export default EmptyPositions; diff --git a/packages/trader/src/AppV2/Components/EmptyPositions/index.ts b/packages/trader/src/AppV2/Components/EmptyPositions/index.ts new file mode 100644 index 000000000000..f3ead86e9adf --- /dev/null +++ b/packages/trader/src/AppV2/Components/EmptyPositions/index.ts @@ -0,0 +1,4 @@ +import './empty-positions.scss'; + +export { default as EmptyPositions } from './empty-positions'; +export type { TEmptyPositionsProps } from './empty-positions'; diff --git a/packages/trader/src/AppV2/Components/Filter/__tests__/contract-type-filter.spec.tsx b/packages/trader/src/AppV2/Components/Filter/__tests__/contract-type-filter.spec.tsx new file mode 100644 index 000000000000..aafb2c82db9b --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/__tests__/contract-type-filter.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ContractTypeFilter from '../contract-type-filter'; + +const defaultFilterName = 'All trade types'; +const mockProps = { + setContractTypeFilter: jest.fn(), + contractTypeFilter: [], +}; +const mediaQueryList = { + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +window.matchMedia = jest.fn().mockImplementation(() => mediaQueryList); + +describe('ContractTypeFilter', () => { + it('should change data-state of the dropdown if user clicks on the filter', () => { + render(); + + const dropdownChevron = screen.getByTestId('dt_chevron'); + expect(dropdownChevron).toHaveClass('rotate--close'); + + userEvent.click(screen.getByText(defaultFilterName)); + expect(dropdownChevron).toHaveClass('rotate--open'); + }); + + it('should render correct chip name if contractTypeFilter is with single item', () => { + const mockContractTypeFilter = ['Multipliers']; + + render(); + + expect(screen.queryByText(defaultFilterName)).not.toBeInTheDocument(); + expect(screen.getAllByText(mockContractTypeFilter[0])).toHaveLength(2); + }); + + it('should render correct chip name is contractTypeFilter is with multiple items', () => { + const mockContractTypeFilter = ['Vanillas', 'Turbos']; + render(); + + expect(screen.queryByText(defaultFilterName)).not.toBeInTheDocument(); + expect(screen.getByText(`${mockContractTypeFilter.length} trade types`)).toBeInTheDocument(); + }); + + it('should call setContractTypeFilter and setter (spied on) with array with chosen option after user clicks on contract type and clicks on "Apply" button', async () => { + const mockSetChangedOptions = jest.fn(); + jest.spyOn(React, 'useState') + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [[], mockSetChangedOptions]); + + render(); + + userEvent.click(screen.getByText('Accumulators')); + userEvent.click(screen.getByText('Apply')); + + expect(mockSetChangedOptions).toHaveBeenCalledWith(['Accumulators']); + expect(mockProps.setContractTypeFilter).toBeCalled(); + }); + + it('should call setter (spied on) with array without chosen option if user clicks on it, but it was already in contractTypeFilter', async () => { + const mockContractTypeFilter = ['Rise/Fall', 'Higher/Lower']; + const mockSetChangedOptions = jest.fn(); + jest.spyOn(React, 'useState') + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [mockContractTypeFilter, mockSetChangedOptions]); + + render(); + + userEvent.click(screen.getByText('Rise/Fall')); + + expect(mockSetChangedOptions).toHaveBeenCalledWith(['Higher/Lower']); + }); + + it('should call setter (spied on) with empty array if user clicks on "Clear All" button', async () => { + const mockContractTypeFilter = ['Touch/No Touch']; + const mockSetChangedOptions = jest.fn(); + jest.spyOn(React, 'useState') + .mockImplementationOnce(() => [false, jest.fn()]) + .mockImplementationOnce(() => [mockContractTypeFilter, mockSetChangedOptions]); + + render(); + + userEvent.click(screen.getByText('Clear All')); + + expect(mockSetChangedOptions).toHaveBeenCalledWith([]); + }); +}); diff --git a/packages/trader/src/AppV2/Components/Filter/__tests__/custom-time-filter-button.spec.tsx b/packages/trader/src/AppV2/Components/Filter/__tests__/custom-time-filter-button.spec.tsx new file mode 100644 index 000000000000..2ecc49d5a3df --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/__tests__/custom-time-filter-button.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CustomDateFilterButton from '../custom-time-filter-button'; + +const customTimeRangeFilter = '25 May 2024'; +const mockProps = { + setShowDatePicker: jest.fn(), +}; + +describe('CustomDateFilterButton', () => { + it('should render component with default props', () => { + render(); + + expect(screen.getByText('Custom')).toBeInTheDocument(); + }); + + it('should render component with customTimeRangeFilter if it was passed', () => { + render(); + + expect(screen.getByText(customTimeRangeFilter)).toBeInTheDocument(); + }); + + it('should call setShowDatePicker if user clicks on the component', () => { + render(); + + userEvent.click(screen.getByText('Custom')); + expect(mockProps.setShowDatePicker).toBeCalled(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/Filter/__tests__/time-filter.spec.tsx b/packages/trader/src/AppV2/Components/Filter/__tests__/time-filter.spec.tsx new file mode 100644 index 000000000000..7945dea1594b --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/__tests__/time-filter.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TimeFilter from '../time-filter'; + +const defaultFilterName = 'All time'; +const datePickerComponentText = 'Choose a date range'; +const mockProps = { + handleDateChange: jest.fn(), + setTimeFilter: jest.fn(), + setCustomTimeRangeFilter: jest.fn(), + setNoMatchesFound: jest.fn(), +}; +const mediaQueryList = { + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +window.matchMedia = jest.fn().mockImplementation(() => mediaQueryList); + +describe('TimeFilter', () => { + it('should change data-state of the dropdown if user clicks on the filter', () => { + render(); + + const dropdownChevron = screen.getByTestId('dt_chevron'); + expect(dropdownChevron).toHaveClass('rotate--close'); + + userEvent.click(dropdownChevron); + expect(dropdownChevron).toHaveClass('rotate--open'); + }); + + it('should render correct chip name if user have not chosen anything else', () => { + render(); + + expect(screen.getAllByText(defaultFilterName)).toHaveLength(2); + }); + + it('should call setTimeFilter with corresponding value if user clicks on "Today"', () => { + render(); + + userEvent.click(screen.getByText('Today')); + + expect(mockProps.setTimeFilter).toHaveBeenCalledWith('Today'); + }); + + it('should call setTimeFilter with corresponding value if user clicks on "Yesterday"', () => { + render(); + + userEvent.click(screen.getByText('Yesterday')); + + expect(mockProps.setTimeFilter).toHaveBeenCalledWith('Yesterday'); + }); + + it('should call setTimeFilter with corresponding value if user clicks on "Last 60 days"', () => { + render(); + + userEvent.click(screen.getByText('Last 60 days')); + + expect(mockProps.setTimeFilter).toHaveBeenCalledWith('60'); + }); + + it('should call setTimeFilter and setCustomTimeRangeFilter with empty string if user clicks on "All time"', () => { + render(); + + userEvent.click(screen.getByText('All time')); + + expect(mockProps.setTimeFilter).toHaveBeenCalledWith(''); + }); + + it('should show Date Picker if user clicks on "Custom" button', () => { + render(); + + expect(screen.queryByText(datePickerComponentText)).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Custom')); + + expect(screen.getByText(datePickerComponentText)).toBeInTheDocument(); + }); + + it('should close Date Picker if it was shown after user clicks on overlay', () => { + render(); + + userEvent.click(screen.getByText('Custom')); + expect(screen.getByText(datePickerComponentText)).toBeInTheDocument(); + + userEvent.click(screen.getAllByTestId('dt-actionsheet-overlay')[1]); + expect(screen.queryByText(datePickerComponentText)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/Filter/contract-type-filter.tsx b/packages/trader/src/AppV2/Components/Filter/contract-type-filter.tsx new file mode 100644 index 000000000000..c3153d55f852 --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/contract-type-filter.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import Chip from 'AppV2/Components/Chip'; +import { ActionSheet, Checkbox } from '@deriv-com/quill-ui'; +import { Localize } from '@deriv/translations'; + +type TContractTypeFilter = { + contractTypeFilter: string[] | []; + setContractTypeFilter: (filterValues: string[]) => void; +}; + +const availableContracts = [ + { tradeType: , id: 'Accumulators' }, + { tradeType: , id: 'Vanillas' }, + { tradeType: , id: 'Turbos' }, + { tradeType: , id: 'Multipliers' }, + { tradeType: , id: 'Rise/Fall' }, + { tradeType: , id: 'Higher/Lower' }, + { tradeType: , id: 'Touch/No touch' }, + { tradeType: , id: 'Matches/Differs' }, + { tradeType: , id: 'Even/Odd' }, + { tradeType: , id: 'Over/Under' }, +]; + +const ContractTypeFilter = ({ contractTypeFilter, setContractTypeFilter }: TContractTypeFilter) => { + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + const [changedOptions, setChangedOptions] = React.useState(contractTypeFilter); + + const onActionSheetClose = () => { + setIsDropdownOpen(false); + setChangedOptions(contractTypeFilter); + }; + + const onChange = (e: React.ChangeEvent | React.KeyboardEvent) => { + const newSelectedOption = (e.target as EventTarget & HTMLInputElement).id; + + if (changedOptions.includes(newSelectedOption)) { + setChangedOptions([...changedOptions.filter(item => item !== newSelectedOption)]); + } else { + setChangedOptions([...changedOptions, newSelectedOption]); + } + }; + + const getChipLabel = () => { + const arrayLength = contractTypeFilter.length; + if (!arrayLength) return ; + if (arrayLength === 1) return availableContracts.find(type => type.id === contractTypeFilter[0])?.tradeType; + return ; + }; + + return ( + + setIsDropdownOpen(!isDropdownOpen)} + selected={!!changedOptions.length} + size='sm' + /> + + + } /> + + {availableContracts.map(({ tradeType, id }) => ( + + ))} + + , + onAction: () => setContractTypeFilter(changedOptions), + }} + secondaryAction={{ + content: , + onAction: () => setChangedOptions([]), + }} + shouldCloseOnSecondaryButtonClick={false} + /> + + + + ); +}; + +export default ContractTypeFilter; diff --git a/packages/trader/src/AppV2/Components/Filter/custom-time-filter-button.tsx b/packages/trader/src/AppV2/Components/Filter/custom-time-filter-button.tsx new file mode 100644 index 000000000000..b69116ced4ef --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/custom-time-filter-button.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Text } from '@deriv-com/quill-ui'; +import { LabelPairedChevronRightSmBoldIcon } from '@deriv/quill-icons'; +import { Localize } from '@deriv/translations'; + +type TCustomDateFilterButton = { + customTimeRangeFilter?: string; + setShowDatePicker: React.Dispatch>; +}; + +const CustomDateFilterButton = ({ customTimeRangeFilter, setShowDatePicker }: TCustomDateFilterButton) => ( + +); + +export default CustomDateFilterButton; diff --git a/packages/trader/src/AppV2/Components/Filter/filter.scss b/packages/trader/src/AppV2/Components/Filter/filter.scss new file mode 100644 index 000000000000..50487a926c16 --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/filter.scss @@ -0,0 +1,45 @@ +.filter { + &__item { + justify-content: space-between; + width: 100%; + height: var(--core-size-2000); + padding-block: var(--core-spacing-400); + + label { + flex-grow: 1; + } + + &__wrapper { + padding-block: var(--core-spacing-800); + + .quill-radio-button { + padding-block: var(--core-spacing-400); + width: 100%; + + &__label { + font-size: var(--core-fontSize-100); + flex-grow: 1; + margin: 0; + } + } + } + } +} + +.custom-time-filter { + &__wrapper { + display: flex; + gap: var(--core-spacing-400); + margin-block: var(--core-spacing-400); + width: 100%; + } + + &__label { + flex-grow: 1; + } + + &__icon { + width: var(--core-size-1200); + height: var(--core-size-1200); + } +} diff --git a/packages/trader/src/AppV2/Components/Filter/index.ts b/packages/trader/src/AppV2/Components/Filter/index.ts new file mode 100644 index 000000000000..00228fcf8ea8 --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/index.ts @@ -0,0 +1,4 @@ +import './filter.scss'; + +export { default as ContractTypeFilter } from './contract-type-filter'; +export { default as TimeFilter } from './time-filter'; diff --git a/packages/trader/src/AppV2/Components/Filter/time-filter.tsx b/packages/trader/src/AppV2/Components/Filter/time-filter.tsx new file mode 100644 index 000000000000..ad8b55571cb6 --- /dev/null +++ b/packages/trader/src/AppV2/Components/Filter/time-filter.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import Chip from 'AppV2/Components/Chip'; +import { toMoment } from '@deriv/shared'; +import { ActionSheet, RadioGroup } from '@deriv-com/quill-ui'; +import { Localize } from '@deriv/translations'; +import CustomDateFilterButton from './custom-time-filter-button'; +import DateRangePicker from 'AppV2/Components/DatePicker'; + +type TTimeFilter = { + customTimeRangeFilter?: string; + handleDateChange: (values: { to?: moment.Moment; from?: moment.Moment; is_batch?: boolean }) => void; + setTimeFilter: (newTimeFilter?: string | undefined) => void; + setCustomTimeRangeFilter: (newCustomTimeFilter?: string | undefined) => void; + setNoMatchesFound: React.Dispatch>; + timeFilter?: string; +}; + +const timeFilterList = [ + { + value: '0', + label: , + }, + { + value: 'Today', + label: , + }, + { + value: 'Yesterday', + label: , + }, + { + value: '7', + label: , + }, + { + value: '30', + label: , + }, + { + value: '60', + label: , + }, + { + value: '90', + label: , + }, +]; + +const TimeFilter = ({ + customTimeRangeFilter, + handleDateChange, + setTimeFilter, + setCustomTimeRangeFilter, + setNoMatchesFound, + timeFilter, +}: TTimeFilter) => { + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + const [showDatePicker, setShowDatePicker] = React.useState(false); + + const defaultCheckedTime = '0'; + const selectedRadioButtonValue = customTimeRangeFilter || timeFilter || defaultCheckedTime; + const isChipSelected = !!(customTimeRangeFilter || timeFilter); + + const onRadioButtonChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === defaultCheckedTime) { + onReset(); + return; + } + setTimeFilter(value); + setIsDropdownOpen(false); + + if (value === 'Today') { + handleDateChange({ + from: toMoment().startOf('day'), + to: toMoment().endOf('day'), + is_batch: true, + }); + } else if (value === 'Yesterday') { + handleDateChange({ + from: toMoment().subtract(1, 'days').startOf('day'), + to: toMoment().subtract(1, 'days').endOf('day'), + is_batch: true, + }); + } else { + handleDateChange({ + from: toMoment().startOf('day').subtract(Number(value), 'day').add(1, 's'), + to: toMoment().endOf('day'), + is_batch: true, + }); + } + }; + + const onReset = () => { + setTimeFilter(''); + setCustomTimeRangeFilter(''); + setIsDropdownOpen(false); + handleDateChange({ + to: toMoment().endOf('day'), + is_batch: true, + }); + setNoMatchesFound(false); + }; + + const getChipLabel = () => + customTimeRangeFilter || timeFilterList.find(item => item.value === (timeFilter || defaultCheckedTime))?.label; + + return ( + + setIsDropdownOpen(!isDropdownOpen)} + selected={isChipSelected} + size='sm' + /> + setIsDropdownOpen(false)} position='left'> + + } /> + + + {timeFilterList.map(({ value, label }) => ( + + ))} + + + + , + onAction: onReset, + }} + shouldCloseOnSecondaryButtonClick={false} + /> + + + {showDatePicker && ( + { + setShowDatePicker(false); + setIsDropdownOpen(false); + }} + setCustomTimeRangeFilter={setCustomTimeRangeFilter} + /> + )} + + ); +}; + +export default TimeFilter; diff --git a/packages/trader/src/AppV2/Components/TotalProfitLoss/__tests__/total-profit-loss.spec.tsx b/packages/trader/src/AppV2/Components/TotalProfitLoss/__tests__/total-profit-loss.spec.tsx new file mode 100644 index 000000000000..2f504d995136 --- /dev/null +++ b/packages/trader/src/AppV2/Components/TotalProfitLoss/__tests__/total-profit-loss.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TotalProfitLoss from '../total-profit-loss'; + +const totalProfitLoss = 'Total profit/loss:'; +const mockProps = { + totalProfitLoss: 230.56, +}; + +describe('TotalProfitLoss', () => { + it('should render component with default props', () => { + render(); + + expect(screen.getByText(totalProfitLoss)).toBeInTheDocument(); + expect(screen.getByText(/230.56/)).toBeInTheDocument(); + expect(screen.getByText(/USD/i)).toBeInTheDocument(); + }); + + it('should render component with passed currency', () => { + render(); + + expect(screen.getByText(totalProfitLoss)).toBeInTheDocument(); + expect(screen.getByText(/BYN/i)).toBeInTheDocument(); + }); + + it('should render component with another text and with specific className if hasBottomAlignment is true', () => { + render(); + + expect(screen.getByTestId('dt_total_profit_loss')).toHaveClass('total-profit-loss bottom'); + expect(screen.getByText(/Last contracts:/)).toBeInTheDocument(); + }); + + it('should reflect correct amount of contracts in text if hasBottomAlignment is true and positionsCount was passed', () => { + render(); + + expect(screen.getByText(/Last 50 contracts:/)).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/TotalProfitLoss/index.ts b/packages/trader/src/AppV2/Components/TotalProfitLoss/index.ts new file mode 100644 index 000000000000..d58576aaae45 --- /dev/null +++ b/packages/trader/src/AppV2/Components/TotalProfitLoss/index.ts @@ -0,0 +1,4 @@ +import TotalProfitLoss from './total-profit-loss'; +import './total-profit-loss.scss'; + +export default TotalProfitLoss; diff --git a/packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.scss b/packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.scss new file mode 100644 index 000000000000..526918985a3f --- /dev/null +++ b/packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.scss @@ -0,0 +1,28 @@ +.total-profit-loss { + position: sticky; + inset-block-start: 0; + z-index: 1; + display: flex; + justify-content: space-between; + width: 100%; + padding: var(--core-spacing-400) var(--core-spacing-800); + background-color: var(--core-color-solid-slate-75); + + &.bottom { + position: absolute; + inset-inline-start: 0; + inset-block-start: unset; + inset-block-end: 0; + z-index: 2; + background-color: var(--component-modal-bg); + box-shadow: var(--core-elevation-shadow-210); + } + &__amount { + &.positive { + color: var(--core-color-solid-green-600); + } + &.negative { + color: var(--core-color-solid-red-600); + } + } +} diff --git a/packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.tsx b/packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.tsx new file mode 100644 index 000000000000..a19dd1a26463 --- /dev/null +++ b/packages/trader/src/AppV2/Components/TotalProfitLoss/total-profit-loss.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Text } from '@deriv-com/quill-ui'; +import { getCardLabels } from '@deriv/shared'; +import { Money } from '@deriv/components'; +import { Localize } from '@deriv/translations'; + +type TTotalProfitLossProps = { + currency?: string; + hasBottomAlignment?: boolean; + positionsCount?: number | string; + totalProfitLoss: number; +}; + +const TotalProfitLoss = ({ + currency, + hasBottomAlignment, + positionsCount = '', + totalProfitLoss, +}: TTotalProfitLossProps) => ( +
+ + {hasBottomAlignment ? ( + + ) : ( + getCardLabels().TOTAL_PROFIT_LOSS + )} + + 0, + negative: totalProfitLoss < 0, + })} + bold + size='sm' + > + + +
+); + +export default TotalProfitLoss; diff --git a/packages/trader/src/AppV2/Containers/Positions/__tests__/positions-content.spec.tsx b/packages/trader/src/AppV2/Containers/Positions/__tests__/positions-content.spec.tsx new file mode 100644 index 000000000000..42694ea6117e --- /dev/null +++ b/packages/trader/src/AppV2/Containers/Positions/__tests__/positions-content.spec.tsx @@ -0,0 +1,368 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { mockStore } from '@deriv/stores'; +import { ReportsStoreProvider } from '../../../../../../reports/src/Stores/useReportsStores'; +import TraderProviders from '../../../../trader-providers'; +import ModulesProvider from 'Stores/Providers/modules-providers'; +import PositionsContent, { TClosedPosition } from '../positions-content'; +import { TPortfolioPosition } from '@deriv/stores/types'; + +const contractTypeFilter = 'Filter by trade types'; +const contractCardList = 'ContractCardList'; +const emptyPositions = 'EmptyPositions'; +const totalProfitLoss = 'Total profit/loss:'; + +const mediaQueryList = { + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +window.matchMedia = jest.fn().mockImplementation(() => mediaQueryList); + +jest.mock('@deriv/shared', () => ({ + ...jest.requireActual('@deriv/shared'), + WS: { + activeSymbols: jest.fn(), + authorized: { + activeSymbols: jest.fn(), + subscribeProposalOpenContract: jest.fn(), + send: jest.fn(), + }, + buy: jest.fn(), + storage: { + contractsFor: jest.fn(), + send: jest.fn(), + }, + contractUpdate: jest.fn(), + contractUpdateHistory: jest.fn(), + subscribeTicksHistory: jest.fn(), + forgetStream: jest.fn(), + forget: jest.fn(), + forgetAll: jest.fn(), + send: jest.fn(), + subscribeProposal: jest.fn(), + subscribeTicks: jest.fn(), + time: jest.fn(), + tradingTimes: jest.fn(), + wait: jest.fn(), + profitTable: jest.fn().mockReturnValue({ + profit_table: { + transactions: [ + { + contract_info: { + app_id: 16929, + buy_price: 10, + contract_id: 243705193508, + contract_type: 'TURBOSLONG', + duration_type: 'minutes', + longcode: + 'You will receive a payout at expiry if the spot price never breaches the barrier. The payout is equal to the payout per point multiplied by the distance between the final price and the barrier.', + payout: 0, + purchase_time: '28 May 2024 10:18:24', + sell_price: 0, + sell_time: '28 May 2024 10:21:08', + shortcode: 'TURBOSLONG_1HZ100V_10.00_1716891504_1716891900_S-255P_3.692058_1716891504', + transaction_id: 486048790368, + underlying_symbol: '1HZ100V', + profit_loss: '-10.00', + display_name: '', + purchase_time_unix: 1716891504, + }, + }, + ], + }, + }), + }, +})); + +jest.mock('AppV2/Components/ContractCard', () => ({ + ...jest.requireActual('AppV2/Components/ContractCard'), + ContractCardList: jest.fn(({ positions }: { positions: (TPortfolioPosition | TClosedPosition)[] }) => ( +
+

{contractCardList}

+
    + {positions.map(({ contract_info }) => ( +
  • {contract_info.contract_type}
  • + ))} +
+
+ )), + ContractCardsSections: jest.fn(() =>
ContractCardsSections
), +})); + +jest.mock('AppV2/Components/EmptyPositions', () => ({ + ...jest.requireActual('AppV2/Components/EmptyPositions'), + EmptyPositions: jest.fn(() =>
{emptyPositions}
), +})); + +jest.mock('AppV2/Components/Filter', () => ({ + ...jest.requireActual('AppV2/Components/Filter'), + ContractTypeFilter: jest.fn(() =>
{contractTypeFilter}
), + TimeFilter: jest.fn(() =>
TimeFilter
), +})); + +describe('PositionsContent', () => { + let defaultMockStore: ReturnType; + + const mockProps = { + hasButtonsDemo: false, + setHasButtonsDemo: jest.fn(), + }; + + beforeEach(() => { + defaultMockStore = mockStore({ + portfolio: { + active_positions: [ + { + contract_info: { + account_id: 147849428, + barrier_count: 1, + bid_price: 41.4, + buy_price: 10, + commission: 0.36, + contract_id: 243687440268, + contract_type: 'MULTUP', + currency: 'USD', + current_spot: 807.2, + current_spot_display_value: '807.20', + current_spot_time: 1716882618, + date_expiry: 4870540799, + date_settlement: 4870540800, + date_start: 1716877413, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 782.35, + entry_spot_display_value: '782.35', + entry_tick: 782.35, + entry_tick_display_value: '782.35', + entry_tick_time: 1716877414, + expiry_time: 4870540799, + id: '3f168dfb-c3c3-5cb2-e636-e7b6e25a7c56', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 0, + is_path_dependent: 1, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 0, + is_valid_to_sell: 1, + limit_order: { + stop_out: { + display_name: 'Stop out', + order_amount: -10, + order_date: 1716877413, + value: '774.81', + }, + }, + longcode: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 1000, minus commissions.", + multiplier: 100, + profit: 31.4, + profit_percentage: 314, + purchase_time: 1716877413, + shortcode: 'MULTUP_1HZ100V_10.00_100_1716877413_4870540799_0_0.00_N1', + status: 'open', + transaction_ids: { + buy: 486015531488, + }, + underlying: '1HZ100V', + }, + details: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 1000, minus commissions.", + display_name: '', + id: 243687440268, + indicative: 41.4, + purchase: 10, + reference: 486015531488, + type: 'MULTUP', + contract_update: { + stop_out: { + display_name: 'Stop out', + order_amount: -10, + order_date: 1716877413, + value: '774.81', + }, + }, + entry_spot: 782.35, + profit_loss: 31.4, + is_valid_to_sell: true, + status: 'profit', + }, + { + contract_info: { + account_id: 147849428, + barrier: '821.69', + barrier_count: 1, + bid_price: 4.4, + buy_price: 10, + contract_id: 243705193508, + contract_type: 'TURBOSLONG', + currency: 'USD', + current_spot: 823.04, + current_spot_display_value: '823.04', + current_spot_time: 1716891600, + date_expiry: 1716891900, + date_settlement: 1716891900, + date_start: 1716891504, + display_name: 'Volatility 100 (1s) Index', + display_number_of_contracts: '3.692058', + entry_spot: 824.24, + entry_spot_display_value: '824.24', + entry_tick: 824.24, + entry_tick_display_value: '824.24', + entry_tick_time: 1716891504, + expiry_time: 1716891900, + id: '631c07ee-ff93-a6e0-3e14-7917581b8b1b', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 1, + is_path_dependent: 1, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 0, + is_valid_to_sell: 1, + longcode: + 'You will receive a payout at expiry if the spot price never breaches the barrier. The payout is equal to the payout per point multiplied by the distance between the final price and the barrier.', + profit: -5.6, + profit_percentage: -56, + purchase_time: 1716891504, + shortcode: 'TURBOSLONG_1HZ100V_10.00_1716891504_1716891900_S-255P_3.692058_1716891504', + status: 'open', + transaction_ids: { + buy: 486048790368, + }, + underlying: '1HZ100V', + }, + details: + 'You will receive a payout at expiry if the spot price never breaches the barrier. The payout is equal to the payout per point multiplied by the distance between the final price and the barrier.', + display_name: '', + id: 243705193508, + indicative: 4.4, + purchase: 10, + reference: 486048790368, + type: 'TURBOSLONG', + barrier: 821.69, + entry_spot: 824.24, + profit_loss: -5.6, + is_valid_to_sell: true, + status: 'profit', + }, + ], + }, + modules: { + profit_table: { + data: [], + handleScroll: jest.fn(), + is_empty: true, + is_loading: false, + onMount: jest.fn(), + onUnmount: jest.fn(), + handleDateChange: jest.fn(), + }, + positions: { + openContractTypeFilter: [], + closedContractTypeFilter: [], + timeFilter: '', + customTimeRangeFilter: '', + setClosedContractTypeFilter: jest.fn(), + setOpenContractTypeFilter: jest.fn(), + setTimeFilter: jest.fn(), + setCustomTimeRangeFilter: jest.fn(), + }, + }, + }); + }); + + const mockPositionsContent = (isClosedTab = false) => { + return ( + + + + + + + + ); + }; + + it('should render loader if there is no data yet', () => { + defaultMockStore = mockStore({}); + render(mockPositionsContent()); + + expect(screen.getByTestId('dt_initial_loader')).toBeInTheDocument(); + }); + + it('should render EmptyPositions if data has loaded but user has no open positions', () => { + defaultMockStore = mockStore({ portfolio: { active_positions: [], is_active_empty: true } }); + render(mockPositionsContent()); + + expect(screen.getByText(emptyPositions)).toBeInTheDocument(); + }); + + it('should render EmptyPositions if data has loaded but user has no closed positions', () => { + render(mockPositionsContent(true)); + + expect(screen.getByText(emptyPositions)).toBeInTheDocument(); + }); + + it('should render contract type filter, total profit/loss and contract card list for open positions if they exist', () => { + render(mockPositionsContent()); + + expect(screen.queryByText(emptyPositions)).not.toBeInTheDocument(); + expect(screen.getByText(contractTypeFilter)).toBeInTheDocument(); + expect(screen.getByText(totalProfitLoss)).toBeInTheDocument(); + expect(screen.getByText(contractCardList)).toBeInTheDocument(); + }); + + it('should show EmptyPositions component if user chose contract type filter and such contracts are absent from positions', () => { + defaultMockStore = mockStore({ + modules: { + ...defaultMockStore.modules, + positions: { + ...defaultMockStore.modules.positions, + openContractTypeFilter: ['Accumulators'], + }, + }, + portfolio: defaultMockStore.portfolio, + }); + render(mockPositionsContent()); + + expect(screen.getByText(emptyPositions)).toBeInTheDocument(); + }); + + it('should show filtered cards if user chose contract type filter and such contracts are present in positions', () => { + defaultMockStore = mockStore({ + modules: { + ...defaultMockStore.modules, + positions: { + ...defaultMockStore.modules.positions, + openContractTypeFilter: ['Multipliers'], + }, + }, + portfolio: defaultMockStore.portfolio, + }); + render(mockPositionsContent()); + + expect(screen.queryByText(emptyPositions)).not.toBeInTheDocument(); + expect(screen.queryByText('TURBOSLONG')).not.toBeInTheDocument(); + expect(screen.getByText('MULTUP')).toBeInTheDocument(); + }); + + it('should show all cards if a user has reset the filter', () => { + defaultMockStore = mockStore({ + modules: { + ...defaultMockStore.modules, + positions: { + ...defaultMockStore.modules.positions, + openContractTypeFilter: [], + }, + }, + portfolio: defaultMockStore.portfolio, + }); + render(mockPositionsContent()); + + expect(screen.queryByText(emptyPositions)).not.toBeInTheDocument(); + expect(screen.getByText('MULTUP')).toBeInTheDocument(); + expect(screen.getByText('TURBOSLONG')).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Containers/Positions/__tests__/positions.spec.tsx b/packages/trader/src/AppV2/Containers/Positions/__tests__/positions.spec.tsx new file mode 100644 index 000000000000..3289518e43ee --- /dev/null +++ b/packages/trader/src/AppV2/Containers/Positions/__tests__/positions.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { mockStore } from '@deriv/stores'; +import { ReportsStoreProvider } from '../../../../../../reports/src/Stores/useReportsStores'; +import TraderProviders from '../../../../trader-providers'; +import ModulesProvider from 'Stores/Providers/modules-providers'; +import Positions from '../positions'; + +const defaultMockStore = mockStore({}); + +describe('Positions', () => { + it('should render component', () => { + render( + + + + + + + + ); + + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(2); + + const openTab = tabs[0]; + const closedTab = tabs[1]; + expect(openTab).toHaveAttribute('aria-selected', 'true'); + expect(closedTab).toHaveAttribute('aria-selected', 'false'); + + expect(screen.getByText('Open')).toBeInTheDocument(); + expect(screen.getByText('Closed')).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/AppV2/Containers/Positions/positions-content.tsx b/packages/trader/src/AppV2/Containers/Positions/positions-content.tsx new file mode 100644 index 000000000000..fad873131ff8 --- /dev/null +++ b/packages/trader/src/AppV2/Containers/Positions/positions-content.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { TContractInfo } from '@deriv/shared'; +import { Loading } from '@deriv/components'; +import { observer, useStore } from '@deriv/stores'; +import { EmptyPositions, TEmptyPositionsProps } from 'AppV2/Components/EmptyPositions'; +import { TPortfolioPosition } from '@deriv/stores/types'; +import { ContractCardList, ContractCardsSections } from 'AppV2/Components/ContractCard'; +import { ContractTypeFilter, TimeFilter } from 'AppV2/Components/Filter'; +import TotalProfitLoss from 'AppV2/Components/TotalProfitLoss'; +import { filterPositions, getTotalPositionsProfit } from '../../Utils/positions-utils'; +import { TReportsStore, useReportsStore } from '../../../../../reports/src/Stores/useReportsStores'; +import useTradeTypeFilter from 'AppV2/Hooks/useTradeTypeFilter'; +import useTimeFilter from 'AppV2/Hooks/useTimeFilter'; + +type TPositionsContentProps = Omit & { + hasButtonsDemo?: boolean; + setHasButtonsDemo?: React.Dispatch>; +}; + +export type TClosedPosition = { + contract_info: TReportsStore['profit_table']['data'][number]; +}; + +const PositionsContent = observer(({ hasButtonsDemo, isClosedTab, setHasButtonsDemo }: TPositionsContentProps) => { + const { contractTypeFilter, setContractTypeFilter } = useTradeTypeFilter({ isClosedTab }); + const { timeFilter, setTimeFilter, customTimeRangeFilter, setCustomTimeRangeFilter } = useTimeFilter(); + const [filteredPositions, setFilteredPositions] = React.useState<(TPortfolioPosition | TClosedPosition)[]>([]); + const [noMatchesFound, setNoMatchesFound] = React.useState(false); + + const { common, client, portfolio } = useStore(); + const { server_time = undefined } = isClosedTab ? {} : common; // Server time is required only to update cards timers in Open positions + const { currency } = client; + const { active_positions, is_active_empty, onClickCancel, onClickSell, onMount: onOpenTabMount } = portfolio; + const { + data, + fetchNextBatch: fetchMoreClosedPositions, + handleScroll, + is_empty, + is_loading: isFetchingClosedPositions, + onMount: onClosedTabMount, + onUnmount: onClosedTabUnmount, + handleDateChange, + } = useReportsStore().profit_table; + const closedPositions = React.useMemo(() => data.map(d => ({ contract_info: d })), [data]); + const positions = React.useMemo( + () => (isClosedTab ? closedPositions : active_positions), + [active_positions, isClosedTab, closedPositions] + ); + const hasNoActiveFilters = isClosedTab + ? !timeFilter && !customTimeRangeFilter && !contractTypeFilter.length + : !contractTypeFilter.length; + const hasNoPositions = hasNoActiveFilters && (isClosedTab ? is_empty : is_active_empty); + const shouldShowEmptyMessage = hasNoPositions || noMatchesFound; + const shouldShowContractCards = + !!filteredPositions.length && (isClosedTab || (filteredPositions[0]?.contract_info as TContractInfo)?.status); + const shouldShowTakeProfit = !isClosedTab || !!(timeFilter || customTimeRangeFilter); + + const onScroll = React.useCallback( + (e: React.UIEvent) => { + if (isClosedTab) { + handleScroll(e); + } + }, + [handleScroll, isClosedTab] + ); + + const contractCards = isClosedTab ? ( + + ) : ( + + ); + + React.useEffect(() => { + if (contractTypeFilter.length) { + const result = filterPositions(positions, contractTypeFilter); + setNoMatchesFound(!result.length); + setFilteredPositions(result); + if (result.length < 5 && isClosedTab) { + fetchMoreClosedPositions(); + } + } else { + setNoMatchesFound(false); + setFilteredPositions(positions); + } + if (isClosedTab) setNoMatchesFound(!positions.length && !!(timeFilter || customTimeRangeFilter)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isClosedTab, positions, contractTypeFilter, timeFilter, customTimeRangeFilter]); + + React.useEffect(() => { + isClosedTab ? onClosedTabMount() : onOpenTabMount(); + + return () => { + isClosedTab && onClosedTabUnmount(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!shouldShowContractCards && !shouldShowEmptyMessage) return ; + return ( +
+ {!hasNoPositions && ( +
+ {isClosedTab && ( + + )} + +
+ )} + {shouldShowEmptyMessage ? ( + + ) : ( + shouldShowContractCards && ( + + {shouldShowTakeProfit && ( + + )} + {contractCards} + + ) + )} +
+ ); +}); + +export default PositionsContent; diff --git a/packages/trader/src/AppV2/Containers/Positions/positions.scss b/packages/trader/src/AppV2/Containers/Positions/positions.scss index e69de29bb2d1..43f460db88bf 100644 --- a/packages/trader/src/AppV2/Containers/Positions/positions.scss +++ b/packages/trader/src/AppV2/Containers/Positions/positions.scss @@ -0,0 +1,63 @@ +$TABS_HEIGHT: var(--core-size-2400); + +.positions-page { + height: 100%; + background-color: var(--core-color-solid-slate-75); + position: relative; + + .tab-list--container { + display: block; + justify-content: unset; + background-color: var(--core-color-solid-slate-50); + + button { + width: 50%; + } + } + &__tabs { + height: 100%; + + &-content { + height: calc(100% - $TABS_HEIGHT); + overflow-y: hidden; + + & > div { + height: 100%; + + .positions-page { + &__open, + &__closed { + height: 100%; + display: flex; + flex-direction: column; + overflow-y: scroll; + } + } + } + } + } + &__filter { + &__wrapper { + padding: var(--core-spacing-400); + display: flex; + gap: var(--core-spacing-400); + overflow-x: auto; + min-height: var(--core-spacing-2400); + white-space: nowrap; + + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox */ + + &::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } + + .quill-chip { + display: inline-flex; + } + } + } + .initial-loader { + height: 100%; + } +} diff --git a/packages/trader/src/AppV2/Containers/Positions/positions.tsx b/packages/trader/src/AppV2/Containers/Positions/positions.tsx index 815cf0156296..3fa9b81c5ff9 100644 --- a/packages/trader/src/AppV2/Containers/Positions/positions.tsx +++ b/packages/trader/src/AppV2/Containers/Positions/positions.tsx @@ -1,8 +1,40 @@ import React from 'react'; -import { Text } from '@deriv-com/quill-ui'; +import { Localize } from '@deriv/translations'; +import { Tab } from '@deriv-com/quill-ui'; +import PositionsContent from './positions-content'; const Positions = () => { - return Positions; + const [hasButtonsDemo, setHasButtonsDemo] = React.useState(true); + + const tabs = [ + { + id: 'open', + title: , + content: , + }, + { + id: 'closed', + title: , + content: , + }, + ]; + + return ( +
+ + + {tabs.map(({ id, title }) => ( + {title} + ))} + + + {tabs.map(({ id, content }) => ( + {content} + ))} + + +
+ ); }; export default Positions; diff --git a/packages/trader/src/AppV2/Hooks/__tests__/useTimeFilter.spec.tsx b/packages/trader/src/AppV2/Hooks/__tests__/useTimeFilter.spec.tsx new file mode 100644 index 000000000000..e6a75b534cd4 --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/__tests__/useTimeFilter.spec.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { mockStore } from '@deriv/stores'; +import ModulesProvider from 'Stores/Providers/modules-providers'; +import useTimeFilter from '../useTimeFilter'; + +describe('useTimeFilter', () => { + const wrapper = ({ children }: { children: JSX.Element }) => { + return {children}; + }; + + it('should return the correct initial values', () => { + const { result } = renderHook(() => useTimeFilter(), { wrapper }); + expect(result.current.customTimeRangeFilter).toEqual(''); + expect(result.current.timeFilter).toEqual(''); + expect(result.current.setCustomTimeRangeFilter).toBeDefined(); + expect(result.current.setTimeFilter).toBeDefined(); + }); +}); diff --git a/packages/trader/src/AppV2/Hooks/__tests__/useTradeTypeFilter.spec.tsx b/packages/trader/src/AppV2/Hooks/__tests__/useTradeTypeFilter.spec.tsx new file mode 100644 index 000000000000..8447a816a291 --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/__tests__/useTradeTypeFilter.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { mockStore } from '@deriv/stores'; +import ModulesProvider from 'Stores/Providers/modules-providers'; +import useTradeTypeFilter from '../useTradeTypeFilter'; + +describe('useTradeTypeFilter', () => { + const wrapper = ({ children }: { children: JSX.Element }) => { + return {children}; + }; + + it('should return the correct initial values if isClosedTab === true', () => { + const { result } = renderHook(() => useTradeTypeFilter({ isClosedTab: true }), { wrapper }); + const { contractTypeFilter, setContractTypeFilter } = result.current; + + expect(contractTypeFilter).toEqual([]); + expect(setContractTypeFilter).toBeDefined(); + }); + + it('should return the correct initial values if isClosedTab === false', () => { + const { result } = renderHook(() => useTradeTypeFilter({}), { wrapper }); + const { contractTypeFilter, setContractTypeFilter } = result.current; + + expect(contractTypeFilter).toEqual([]); + expect(setContractTypeFilter).toBeDefined(); + }); +}); diff --git a/packages/trader/src/AppV2/Hooks/useTimeFilter.ts b/packages/trader/src/AppV2/Hooks/useTimeFilter.ts new file mode 100644 index 000000000000..e4874065ad77 --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/useTimeFilter.ts @@ -0,0 +1,10 @@ +import { useModulesStore } from 'Stores/useModulesStores'; + +const useTimeFilter = () => { + const { positions } = useModulesStore(); + const { timeFilter, setTimeFilter, customTimeRangeFilter, setCustomTimeRangeFilter } = positions; + + return { timeFilter, setTimeFilter, customTimeRangeFilter, setCustomTimeRangeFilter }; +}; + +export default useTimeFilter; diff --git a/packages/trader/src/AppV2/Hooks/useTradeTypeFilter.ts b/packages/trader/src/AppV2/Hooks/useTradeTypeFilter.ts new file mode 100644 index 000000000000..627172e87ff8 --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/useTradeTypeFilter.ts @@ -0,0 +1,16 @@ +import { useModulesStore } from 'Stores/useModulesStores'; + +type TUseTradeTypeFilter = { isClosedTab?: boolean }; + +const useTradeTypeFilter = ({ isClosedTab }: TUseTradeTypeFilter) => { + const { positions } = useModulesStore(); + const { setClosedContractTypeFilter, setOpenContractTypeFilter, openContractTypeFilter, closedContractTypeFilter } = + positions; + + const contractTypeFilter = isClosedTab ? closedContractTypeFilter : openContractTypeFilter; + const setContractTypeFilter = isClosedTab ? setClosedContractTypeFilter : setOpenContractTypeFilter; + + return { contractTypeFilter, setContractTypeFilter }; +}; + +export default useTradeTypeFilter; diff --git a/packages/trader/src/AppV2/Utils/__tests__/positions-utils.spec.ts b/packages/trader/src/AppV2/Utils/__tests__/positions-utils.spec.ts new file mode 100644 index 000000000000..2f72b466d88f --- /dev/null +++ b/packages/trader/src/AppV2/Utils/__tests__/positions-utils.spec.ts @@ -0,0 +1,315 @@ +import { TPortfolioPosition } from '@deriv/stores/types'; +import { filterPositions, getProfit, getTotalPositionsProfit } from '../positions-utils'; + +const mockedActivePositions = [ + { + contract_info: { + account_id: 112905368, + barrier: '682.60', + barrier_count: 1, + bid_price: 6.38, + buy_price: 9, + contract_id: 242807007748, + contract_type: 'CALL', + currency: 'USD', + current_spot: 681.76, + current_spot_display_value: '681.76', + current_spot_time: 1716220628, + date_expiry: 1716221100, + date_settlement: 1716221100, + date_start: 1716220562, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 682.6, + entry_spot_display_value: '682.60', + entry_tick: 682.6, + entry_tick_display_value: '682.60', + entry_tick_time: 1716220563, + expiry_time: 1716221100, + id: '917d1b48-305b-a2f4-5b9c-7fb1f2c6c145', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 1, + is_path_dependent: 0, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 0, + is_valid_to_sell: 1, + longcode: + 'Win payout if Volatility 100 (1s) Index is strictly higher than entry spot at 2024-05-20 16:05:00 GMT.', + payout: 17.61, + profit: -2.62, + profit_percentage: -29.11, + purchase_time: 1716220562, + shortcode: 'CALL_1HZ100V_17.61_1716220562_1716221100F_S0P_0', + status: 'open', + transaction_ids: { + buy: 484286139408, + }, + underlying: '1HZ100V', + }, + details: + 'Win payout if Volatility 100 (1s) Index is strictly higher than entry spot at 2024-05-20 16:05:00 GMT.', + display_name: '', + id: 242807007748, + indicative: 6.38, + payout: 17.61, + purchase: 9, + reference: 484286139408, + type: 'CALL', + profit_loss: -2.62, + is_valid_to_sell: true, + status: 'profit', + barrier: 682.6, + entry_spot: 682.6, + }, + { + contract_info: { + account_id: 112905368, + barrier_count: 1, + bid_price: 8.9, + buy_price: 9.39, + cancellation: { + ask_price: 0.39, + date_expiry: 1716224183, + }, + commission: 0.03, + contract_id: 242807045608, + contract_type: 'MULTUP', + currency: 'USD', + current_spot: 681.71, + current_spot_display_value: '681.71', + current_spot_time: 1716220672, + date_expiry: 4869849599, + date_settlement: 4869849600, + date_start: 1716220583, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 682.23, + entry_spot_display_value: '682.23', + entry_tick: 682.23, + entry_tick_display_value: '682.23', + entry_tick_time: 1716220584, + expiry_time: 4869849599, + id: '917d1b48-305b-a2f4-5b9c-7fb1f2c6c145', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 0, + is_path_dependent: 1, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 1, + is_valid_to_sell: 0, + limit_order: { + stop_out: { + display_name: 'Stop out', + order_amount: -9, + order_date: 1716220583, + value: '614.26', + }, + }, + longcode: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 90, minus commissions.", + multiplier: 10, + profit: -0.1, + profit_percentage: -1.11, + purchase_time: 1716220583, + shortcode: 'MULTUP_1HZ100V_9.00_10_1716220583_4869849599_60m_0.00_N1', + status: 'open', + transaction_ids: { + buy: 484286215128, + }, + underlying: '1HZ100V', + validation_error: + 'The spot price has moved. We have not closed this contract because your profit is negative and deal cancellation is active. Cancel your contract to get your full stake back.', + validation_error_code: 'General', + }, + details: + "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 90, minus commissions.", + display_name: '', + id: 242807045608, + indicative: 8.9, + purchase: 9.39, + reference: 484286215128, + type: 'MULTUP', + contract_update: { + stop_out: { + display_name: 'Stop out', + order_amount: -9, + order_date: 1716220583, + value: '614.26', + }, + }, + profit_loss: -0.1, + is_valid_to_sell: false, + status: 'profit', + entry_spot: 682.23, + }, + { + contract_info: { + account_id: 112905368, + barrier_count: 2, + barrier_spot_distance: '0.296', + bid_price: 9.84, + buy_price: 9, + contract_id: 242807268688, + contract_type: 'ACCU', + currency: 'USD', + current_spot: 682.72, + current_spot_display_value: '682.72', + current_spot_high_barrier: '683.016', + current_spot_low_barrier: '682.424', + current_spot_time: 1716220720, + date_expiry: 1747785599, + date_settlement: 1747785600, + date_start: 1716220710, + display_name: 'Volatility 100 (1s) Index', + entry_spot: 682.58, + entry_spot_display_value: '682.58', + entry_tick: 682.58, + entry_tick_display_value: '682.58', + entry_tick_time: 1716220711, + expiry_time: 1747785599, + growth_rate: 0.01, + high_barrier: '683.046', + id: '917d1b48-305b-a2f4-5b9c-7fb1f2c6c145', + is_expired: 0, + is_forward_starting: 0, + is_intraday: 0, + is_path_dependent: 1, + is_settleable: 0, + is_sold: 0, + is_valid_to_cancel: 0, + is_valid_to_sell: 1, + longcode: + 'After the entry spot tick, your stake will grow continuously by 1% for every tick that the spot price remains within the ± 0.04331% from the previous spot price.', + low_barrier: '682.454', + profit: 0.84, + profit_percentage: 9.33, + purchase_time: 1716220710, + shortcode: 'ACCU_1HZ100V_9.00_0_0.01_1_0.000433139675_1716220710', + status: 'open', + tick_count: 230, + tick_passed: 9, + tick_stream: [ + { + epoch: 1716220711, + tick: 682.58, + tick_display_value: '682.58', + }, + { + epoch: 1716220712, + tick: 682.71, + tick_display_value: '682.71', + }, + { + epoch: 1716220713, + tick: 682.5, + tick_display_value: '682.50', + }, + { + epoch: 1716220714, + tick: 682.57, + tick_display_value: '682.57', + }, + { + epoch: 1716220715, + tick: 682.57, + tick_display_value: '682.57', + }, + { + epoch: 1716220716, + tick: 682.75, + tick_display_value: '682.75', + }, + { + epoch: 1716220717, + tick: 682.87, + tick_display_value: '682.87', + }, + { + epoch: 1716220718, + tick: 682.74, + tick_display_value: '682.74', + }, + { + epoch: 1716220719, + tick: 682.75, + tick_display_value: '682.75', + }, + { + epoch: 1716220720, + tick: 682.72, + tick_display_value: '682.72', + }, + ], + transaction_ids: { + buy: 484286658868, + }, + underlying: '1HZ100V', + }, + details: + 'After the entry spot tick, your stake will grow continuously by 1% for every tick that the spot price remains within the ± 0.04331% from the previous spot price.', + display_name: '', + id: 242807268688, + indicative: 9.84, + purchase: 9, + reference: 484286658868, + type: 'ACCU', + profit_loss: 0.84, + is_valid_to_sell: true, + current_tick: 9, + status: 'profit', + entry_spot: 682.58, + high_barrier: 683.046, + low_barrier: 682.454, + }, + { + contract_info: { + app_id: 16929, + buy_price: 10, + contract_id: 243585717228, + contract_type: 'TURBOSLONG', + duration_type: 'minutes', + longcode: + 'You will receive a payout at expiry if the spot price never breaches the barrier. The payout is equal to the payout per point multiplied by the distance between the final price and the barrier.', + payout: 0, + purchase_time: '27 May 2024 09:41:00', + sell_price: 0, + sell_time: '27 May 2024 09:43:36', + shortcode: 'TURBOSLONG_1HZ100V_10.00_1716802860_1716804660_S-237P_3.971435_1716802860', + transaction_id: 485824148848, + underlying_symbol: '1HZ100V', + profit_loss: '-10.00', + display_name: '', + purchase_time_unix: 1716802860, + }, + }, +] as TPortfolioPosition[]; + +describe('filterPositions', () => { + it('should filter positions based on passed filter array', () => { + expect(filterPositions(mockedActivePositions, ['Multipliers'])).toEqual([mockedActivePositions[1]]); + + expect(filterPositions(mockedActivePositions, ['Multipliers', 'Rise/Fall'])).toEqual([ + mockedActivePositions[0], + mockedActivePositions[1], + ]); + + expect(filterPositions(mockedActivePositions, ['Turbos'])).toEqual([mockedActivePositions[3]]); + expect(filterPositions(mockedActivePositions, ['Even/Odd'])).toEqual([]); + }); +}); + +describe('getProfit', () => { + it('should return correct profit, based on contract_info', () => { + expect(getProfit(mockedActivePositions[0].contract_info)).toEqual(-2.62); + expect(getProfit(mockedActivePositions[1].contract_info)).toEqual(-0.4900000000000002); + expect(getProfit(mockedActivePositions[2].contract_info)).toEqual(0.84); + expect(getProfit(mockedActivePositions[3].contract_info)).toEqual('-10.00'); + }); +}); + +describe('getTotalPositionsProfit', () => { + it('should return correct total profit, based on all positions', () => { + expect(getTotalPositionsProfit(mockedActivePositions)).toEqual(-12.27); + }); +}); diff --git a/packages/trader/src/AppV2/Utils/positions-utils.ts b/packages/trader/src/AppV2/Utils/positions-utils.ts new file mode 100644 index 000000000000..2140fa5f1e8c --- /dev/null +++ b/packages/trader/src/AppV2/Utils/positions-utils.ts @@ -0,0 +1,37 @@ +import { getSupportedContracts, getTotalProfit, isHighLow, isMultiplierContract } from '@deriv/shared'; +import { TPortfolioPosition } from '@deriv/stores/types'; +import { TClosedPosition } from 'AppV2/Containers/Positions/positions-content'; + +export const DEFAULT_DATE_FORMATTING_CONFIG = { + day: '2-digit', + month: 'short', + year: 'numeric', +} as Record; + +export const filterPositions = (positions: (TPortfolioPosition | TClosedPosition)[], filter: string[]) => { + // Split contract type names with '/' (e.g. Rise/Fall) + const splittedFilter = filter.map(option => (option.includes('/') ? option.split('/') : option)).flat(); + + return positions.filter(({ contract_info }) => { + const config = getSupportedContracts(isHighLow({ shortcode: contract_info.shortcode }))[ + contract_info.contract_type as keyof ReturnType + ]; + if (!config) return false; + return splittedFilter.includes('main_title' in config ? config.main_title : config.name); + }); +}; + +export const getProfit = ( + contract_info: TPortfolioPosition['contract_info'] | TClosedPosition['contract_info'] +): string | number => { + return ( + (contract_info as TClosedPosition['contract_info']).profit_loss ?? + (isMultiplierContract(contract_info.contract_type) + ? getTotalProfit(contract_info as TPortfolioPosition['contract_info']) + : (contract_info as TPortfolioPosition['contract_info']).profit) + ); +}; + +export const getTotalPositionsProfit = (positions: (TPortfolioPosition | TClosedPosition)[]) => { + return positions.reduce((sum, { contract_info }) => sum + Number(getProfit(contract_info)), 0); +}; diff --git a/packages/trader/src/AppV2/app.tsx b/packages/trader/src/AppV2/app.tsx index 16fb0be1e881..48ea53f533c7 100644 --- a/packages/trader/src/AppV2/app.tsx +++ b/packages/trader/src/AppV2/app.tsx @@ -2,12 +2,14 @@ import React from 'react'; import type { TWebSocket } from 'Types'; import initStore from 'App/init-store'; import type { TCoreStores } from '@deriv/stores/types'; +import ModulesProvider from 'Stores/Providers/modules-providers'; import TraderProviders from '../trader-providers'; import BottomNav from './Components/BottomNav'; import Trade from './Containers/Trade'; import Markets from './Containers/Markets'; import Positions from './Containers/Positions'; import Menu from './Containers/Menu'; +import { ReportsStoreProvider } from '../../../reports/src/Stores/useReportsStores'; import { NotificationsProvider } from '@deriv-com/quill-ui'; import 'Sass/app.scss'; import '@deriv-com/quill-tokens/dist/quill.css'; @@ -22,21 +24,27 @@ type Apptypes = { const App = ({ passthrough }: Apptypes) => { const root_store = initStore(passthrough.root_store, passthrough.WS); + const [currentPageIdx, setCurrentPageIdx] = React.useState(0); + React.useEffect(() => { return () => root_store.ui.setPromptHandler(false); }, [root_store]); return ( - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/packages/trader/src/Stores/Modules/Positions/__tests__/positions-store.spec.ts b/packages/trader/src/Stores/Modules/Positions/__tests__/positions-store.spec.ts new file mode 100644 index 000000000000..1724513ec14e --- /dev/null +++ b/packages/trader/src/Stores/Modules/Positions/__tests__/positions-store.spec.ts @@ -0,0 +1,49 @@ +import { mockStore } from '@deriv/stores'; +import PositionsStore from '../positions-store'; +import { configure } from 'mobx'; +import { TRootStore } from 'Types'; + +configure({ safeDescriptors: false }); + +let mockedPositionsStore: PositionsStore; + +beforeAll(async () => { + mockedPositionsStore = new PositionsStore({ + root_store: mockStore({}) as unknown as TRootStore, + }); +}); + +describe('PositionsStore', () => { + describe('setClosedContractTypeFilter', () => { + it('should set closedContractTypeFilter', () => { + mockedPositionsStore.setClosedContractTypeFilter(['Accumulators']); + expect(mockedPositionsStore.closedContractTypeFilter).toEqual(['Accumulators']); + mockedPositionsStore.setClosedContractTypeFilter([]); + expect(mockedPositionsStore.closedContractTypeFilter).toEqual([]); + }); + }); + describe('setOpenContractTypeFilter', () => { + it('should set openContractTypeFilter', () => { + mockedPositionsStore.setOpenContractTypeFilter(['Rise/Fall']); + expect(mockedPositionsStore.openContractTypeFilter).toEqual(['Rise/Fall']); + mockedPositionsStore.setOpenContractTypeFilter([]); + expect(mockedPositionsStore.openContractTypeFilter).toEqual([]); + }); + }); + describe('setTimeFilter', () => { + it('should set timeFilter', () => { + mockedPositionsStore.setTimeFilter('All time'); + expect(mockedPositionsStore.timeFilter).toEqual('All time'); + mockedPositionsStore.setTimeFilter(); + expect(mockedPositionsStore.timeFilter).toEqual(''); + }); + }); + describe('setCustomTimeRangeFilter', () => { + it('should set customTimeRangeFilter', () => { + mockedPositionsStore.setCustomTimeRangeFilter('25 May 2024'); + expect(mockedPositionsStore.customTimeRangeFilter).toEqual('25 May 2024'); + mockedPositionsStore.setCustomTimeRangeFilter(); + expect(mockedPositionsStore.customTimeRangeFilter).toEqual(''); + }); + }); +}); diff --git a/packages/trader/src/Stores/Modules/Positions/positions-store.ts b/packages/trader/src/Stores/Modules/Positions/positions-store.ts new file mode 100644 index 000000000000..7b32391c11ed --- /dev/null +++ b/packages/trader/src/Stores/Modules/Positions/positions-store.ts @@ -0,0 +1,41 @@ +import { makeObservable, observable, action } from 'mobx'; +import { TRootStore } from 'Types'; +import BaseStore from 'Stores/base-store'; + +export default class PositionsStore extends BaseStore { + openContractTypeFilter: string[] = []; + closedContractTypeFilter: string[] = []; + timeFilter = ''; + customTimeRangeFilter = ''; + + constructor({ root_store }: { root_store: TRootStore }) { + super({ root_store }); + + makeObservable(this, { + openContractTypeFilter: observable, + closedContractTypeFilter: observable, + timeFilter: observable, + customTimeRangeFilter: observable, + setClosedContractTypeFilter: action.bound, + setOpenContractTypeFilter: action.bound, + setTimeFilter: action.bound, + setCustomTimeRangeFilter: action.bound, + }); + } + + setClosedContractTypeFilter(contractTypes: string[]) { + this.closedContractTypeFilter = [...contractTypes]; + } + + setOpenContractTypeFilter(contractTypes: string[]) { + this.openContractTypeFilter = [...contractTypes]; + } + + setTimeFilter(newTimeFilter?: string) { + this.timeFilter = newTimeFilter || ''; + } + + setCustomTimeRangeFilter(newCustomTimeFilter?: string) { + this.customTimeRangeFilter = newCustomTimeFilter || ''; + } +} diff --git a/packages/trader/src/Stores/Modules/Trading/__tests__/trade-store.spec.ts b/packages/trader/src/Stores/Modules/Trading/__tests__/trade-store.spec.ts index cabfca201c7d..9b0b04621e59 100644 --- a/packages/trader/src/Stores/Modules/Trading/__tests__/trade-store.spec.ts +++ b/packages/trader/src/Stores/Modules/Trading/__tests__/trade-store.spec.ts @@ -5,6 +5,7 @@ import { mockStore } from '@deriv/stores'; import TradeStore from '../trade-store'; import { configure } from 'mobx'; import { ContractType } from '../Helpers/contract-type'; +import { TRootStore } from 'Types'; configure({ safeDescriptors: false }); @@ -248,7 +249,7 @@ beforeAll(async () => { common: { server_time: moment('2024-02-26T11:59:59.488Z'), }, - }), + }) as unknown as TRootStore, }); await ContractType.buildContractTypesConfig(symbol); mockedTradeStore.onMount(); diff --git a/packages/trader/src/Stores/Modules/Trading/trade-store.ts b/packages/trader/src/Stores/Modules/Trading/trade-store.ts index 68b2158ecb83..7ce2e045b223 100644 --- a/packages/trader/src/Stores/Modules/Trading/trade-store.ts +++ b/packages/trader/src/Stores/Modules/Trading/trade-store.ts @@ -50,11 +50,10 @@ import { action, computed, makeObservable, observable, override, reaction, runIn import { createProposalRequests, getProposalErrorField, getProposalInfo } from './Helpers/proposal'; import { getHoveredColor } from './Helpers/barrier-utils'; import BaseStore from '../../base-store'; -import { TTextValueNumber, TTextValueStrings } from 'Types'; +import { TRootStore, TTextValueNumber, TTextValueStrings } from 'Types'; import { ChartBarrierStore } from '../SmartChart/chart-barrier-store'; import debounce from 'lodash.debounce'; import { setLimitOrderBarriers } from './Helpers/limit-orders'; -import type { TCoreStores } from '@deriv/stores/types'; import { ActiveSymbols, ActiveSymbolsRequest, @@ -319,7 +318,7 @@ export default class TradeStore extends BaseStore { is_initial_barrier_applied = false; is_digits_widget_active = false; should_skip_prepost_lifecycle = false; - constructor({ root_store }: { root_store: TCoreStores }) { + constructor({ root_store }: { root_store: TRootStore }) { const local_storage_properties = [ 'amount', 'currency', diff --git a/packages/trader/src/Stores/Modules/index.js b/packages/trader/src/Stores/Modules/index.js deleted file mode 100644 index 27463b9f10eb..000000000000 --- a/packages/trader/src/Stores/Modules/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import TradeStore from './Trading/trade-store'; - -export default class ModulesStore { - constructor(root_store, core_store) { - this.cashier = core_store.modules.cashier; - this.trade = new TradeStore({ root_store }); - } -} diff --git a/packages/trader/src/Stores/Modules/index.ts b/packages/trader/src/Stores/Modules/index.ts new file mode 100644 index 000000000000..2504e498067c --- /dev/null +++ b/packages/trader/src/Stores/Modules/index.ts @@ -0,0 +1,16 @@ +import TradeStore from './Trading/trade-store'; +import PositionsStore from './Positions/positions-store'; +import { TCoreStores } from '@deriv/stores/types'; +import { TRootStore } from 'Types'; + +export default class ModulesStore { + positions: PositionsStore; + trade: TradeStore; + cashier: any; + + constructor(root_store: TRootStore, core_store: TCoreStores) { + this.cashier = core_store.modules.cashier; + this.trade = new TradeStore({ root_store }); + this.positions = new PositionsStore({ root_store }); + } +} diff --git a/packages/trader/src/Stores/Providers/modules-providers.tsx b/packages/trader/src/Stores/Providers/modules-providers.tsx new file mode 100644 index 000000000000..fee7631e0fff --- /dev/null +++ b/packages/trader/src/Stores/Providers/modules-providers.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { StoreProvider } from '@deriv/stores'; +import { ModulesStoreProvider } from 'Stores/useModulesStores'; +import type { TCoreStores } from '@deriv/stores/types'; + +export const ModulesProvider = ({ children, store }: React.PropsWithChildren<{ store: TCoreStores }>) => { + return ( + + {children} + + ); +}; + +export default ModulesProvider; diff --git a/packages/trader/src/Stores/base-store.ts b/packages/trader/src/Stores/base-store.ts index 3d666736ba27..1037acd53796 100644 --- a/packages/trader/src/Stores/base-store.ts +++ b/packages/trader/src/Stores/base-store.ts @@ -1,12 +1,12 @@ import { action, intercept, observable, reaction, toJS, when, makeObservable } from 'mobx'; import { isProduction, isEmptyObject, Validator } from '@deriv/shared'; -import { TCoreStores } from '@deriv/stores/types'; import { getValidationRules } from './Modules/Trading/Constants/validation-rules'; +import { TRootStore } from 'Types'; type TValidationRules = ReturnType | Record; type TBaseStoreOptions = { - root_store: TCoreStores; + root_store: TRootStore; local_storage_properties?: string[]; session_storage_properties?: string[]; validation_rules?: TValidationRules; @@ -38,7 +38,7 @@ export default class BaseStore { pre_switch_account_listener: null | (() => Promise) = null; realAccountSignupEndedDisposer: null | (() => void) = null; real_account_signup_ended_listener: null | (() => Promise) = null; - root_store: TCoreStores; + root_store: TRootStore; session_storage_properties: string[]; store_name = ''; switchAccountDisposer: null | (() => void) = null; diff --git a/packages/trader/src/Stores/useModulesStores.tsx b/packages/trader/src/Stores/useModulesStores.tsx new file mode 100644 index 000000000000..3ac894968fa0 --- /dev/null +++ b/packages/trader/src/Stores/useModulesStores.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useStore } from '@deriv/stores'; +import ModulesStore from './Modules'; + +const ModulesStoreContext = React.createContext(null); + +export const ModulesStoreProvider = ({ children }: React.PropsWithChildren) => { + const { modules } = useStore(); + return {children}; +}; + +export const useModulesStore = () => { + const store = React.useContext(ModulesStoreContext); + + if (!store) { + throw new Error('useModulesStore must be used within ModulesStoreProvider'); + } + + return store; +}; diff --git a/packages/trader/src/Types/common-prop.type.ts b/packages/trader/src/Types/common-prop.type.ts index 1ef702385b3d..0ff83bdbd3cc 100644 --- a/packages/trader/src/Types/common-prop.type.ts +++ b/packages/trader/src/Types/common-prop.type.ts @@ -19,10 +19,25 @@ import { UpdateContractRequest, } from '@deriv/api-types'; import { TCoreStores } from '@deriv/stores/types'; +import ModulesStore from 'Stores/Modules'; import { useTraderStore } from 'Stores/useTraderStores'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { TSocketEndpointNames, TSocketResponse } from '../../../api/types'; +export type TRootStore = { + client: TCoreStores['client']; + common: TCoreStores['common']; + modules: ModulesStore; + ui: TCoreStores['ui']; + gtm: TCoreStores['gtm']; + notifications: TCoreStores['notifications']; + contract_replay: TCoreStores['contract_replay']; + contract_trade: TCoreStores['contract_trade']; + portfolio: TCoreStores['portfolio']; + chart_barrier_store: TCoreStores['chart_barrier_store']; + active_symbols: TCoreStores['active_symbols']; +}; + export type TBinaryRoutesProps = { is_logged_in: boolean; is_logging_in: boolean;