Browse Source

Reordering in InputAddressMulti (#2337)

* Reorderable InputAddressMulti

* Styling

Co-authored-by: Jaco Greeff <jacogr@gmail.com>
Keith Ingram 5 years ago
parent
commit
b43c7dc593

+ 1 - 0
package.json

@@ -43,6 +43,7 @@
     "@types/chart.js": "^2.9.15",
     "@types/file-saver": "^2.0.1",
     "@types/i18next": "^13.0.0",
+    "@types/react-beautiful-dnd": "^12.1.1",
     "@types/react-copy-to-clipboard": "^4.3.0",
     "@types/react-dom": "^16.9.5",
     "@types/react-router-dom": "^5.1.3",

+ 3 - 0
packages/apps/src/Apps.tsx

@@ -25,6 +25,8 @@ interface SidebarState {
   transition: SideBarTransition;
 }
 
+export const PORTAL_ID = 'portals';
+
 function sanitize (value?: string): string {
   return value?.toLowerCase().replace('-', ' ') || '';
 }
@@ -98,6 +100,7 @@ function Apps ({ className }: Props): React.ReactElement<Props> {
         </Signer>
         <ConnectingOverlay />
         <AccountsOverlay />
+        <div id={PORTAL_ID} />
       </div>
       <WarmUp />
     </>

+ 2 - 1
packages/page-accounts/src/modals/RecoverSetup.tsx

@@ -51,11 +51,12 @@ export default function RecoverSetup ({ address, className, onClose }: Props): R
         />
         <InputAddressMulti
           available={availableHelpers}
+          availableLabel={t('available social recovery helpers')}
           help={t('The addresses that are able to help in recovery. You can select up to {{maxHelpers}} trusted helpers.', { replace: { maxHelpers: MAX_HELPERS } })}
-          label={t('trusted social recovery helpers')}
           onChange={setHelpers}
           maxCount={MAX_HELPERS}
           value={helpers}
+          valueLabel={t('trusted social recovery helpers')}
         />
         <InputNumber
           help={t('The threshold of vouches that is to be reached for the account to be recovered.')}

+ 5 - 4
packages/page-council/src/Overview/Vote.tsx

@@ -58,9 +58,9 @@ class Vote extends TxModal<Props, State> {
   }
 
   protected isDisabled = (): boolean => {
-    const { accountId, votes } = this.state;
+    const { accountId, votes, voteValue } = this.state;
 
-    return !accountId || votes.length === 0;
+    return !accountId || votes.length === 0 || voteValue.lten(0);
   }
 
   protected renderTrigger = (): React.ReactNode => {
@@ -97,11 +97,12 @@ class Vote extends TxModal<Props, State> {
         />
         <InputAddressMulti
           available={available}
-          help={t('Filter available candidates based on name, address or short account index.')}
-          label={t('filter candidates')}
+          availableLabel={t('council candidates')}
+          help={t('Select and order council candidates you wish to vote for.')}
           maxCount={MAX_VOTES}
           onChange={this.onChangeVotes}
           value={votes}
+          valueLabel={t('my ordered votes')}
         />
       </>
     );

+ 0 - 2
packages/page-parachains/src/Parachain/index.tsx

@@ -39,8 +39,6 @@ function Parachain ({ className, basePath, isMine, paraInfoRef, sudoKey }: Props
     );
   }
 
-  console.log(parachain);
-
   if (!id || isNull(parachain)) {
     return (
       <>

+ 2 - 1
packages/page-staking/src/Actions/Account/Nominate.tsx

@@ -78,12 +78,13 @@ function Nominate ({ className, controllerId, nominees, onClose, next, stakingOv
         />
         <InputAddressMulti
           available={available}
+          availableLabel={t('candidate accounts')}
           className='medium'
           help={t('Filter available candidates based on name, address or short account index.')}
-          label={t('filter candidates')}
           maxCount={MAX_NOMINEES}
           onChange={setSelection}
           value={selection || []}
+          valueLabel={t('nominated accounts')}
         />
       </Modal.Content>
       <Modal.Actions onCancel={onClose}>

+ 1 - 0
packages/react-components/package.json

@@ -23,6 +23,7 @@
     "i18next-browser-languagedetector": "^4.0.2",
     "i18next-xhr-backend": "^3.2.2",
     "react": "^16.13.0",
+    "react-beautiful-dnd": "^13.0.0",
     "react-chartjs-2": "^2.9.0",
     "react-copy-to-clipboard": "^5.0.2",
     "react-dom": "^16.13.0",

+ 48 - 35
packages/react-components/src/AddressToggle.tsx

@@ -15,61 +15,64 @@ import Toggle from './Toggle';
 interface Props {
   address: string;
   className?: string;
+  isHidden?: boolean;
   filter?: string;
+  noToggle?: boolean;
   onChange?: (isChecked: boolean) => void;
-  value: boolean;
+  value?: boolean;
 }
 
-function AddressToggle ({ address, className, filter, onChange, value }: Props): React.ReactElement<Props> | null {
+function getIsFiltered (address: string, filter?: string, info?: DeriveAccountInfo): boolean {
+  if (!filter || address.includes(filter)) {
+    return false;
+  }
+
+  const [,, extracted] = getAddressName(address);
+  const filterLower = filter.toLowerCase();
+
+  if (extracted.toLowerCase().includes(filterLower)) {
+    return false;
+  }
+
+  if (info) {
+    const { accountId, accountIndex, identity, nickname } = info;
+
+    if (identity.display?.toLowerCase().includes(filterLower) || accountId?.toString().includes(filter) || accountIndex?.toString().includes(filter) || nickname?.toLowerCase().includes(filterLower)) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+function AddressToggle ({ address, className, filter, isHidden, noToggle, onChange, value }: Props): React.ReactElement<Props> | null {
   const { api } = useApi();
   const info = useCall<DeriveAccountInfo>(api.derive.accounts.info as any, [address]);
   const [isFiltered, setIsFiltered] = useState(false);
 
   useEffect((): void => {
-    let isFiltered = true;
-
-    if (!filter || address.includes(filter)) {
-      isFiltered = false;
-    } else if (info) {
-      const [,, extracted] = getAddressName(address);
-      const filterLower = filter.toLowerCase();
-
-      if (extracted.toLowerCase().includes(filterLower)) {
-        isFiltered = false;
-      } else if (info) {
-        const { accountId, accountIndex, identity, nickname } = info;
-
-        if (identity.display?.toLowerCase().includes(filterLower) || accountId?.toString().includes(filter) || accountIndex?.toString().includes(filter) || nickname?.toLowerCase().includes(filterLower)) {
-          isFiltered = false;
-        }
-      }
-    }
-
-    setIsFiltered(isFiltered);
-  }, [filter, info, value]);
-
-  if (isFiltered) {
-    return null;
-  }
+    setIsFiltered(getIsFiltered(address, filter, info));
+  }, [address, filter, info]);
 
   const _onClick = (): void => onChange && onChange(!value);
 
   return (
     <div
-      className={`ui--AddressToggle ${className} ${value ? 'isAye' : 'isNay'}`}
+      className={`ui--AddressToggle ${className} ${(value || noToggle) ? 'isAye' : 'isNay'} ${isHidden || isFiltered ? 'isHidden' : ''}`}
       onClick={_onClick}
     >
       <AddressMini
         className='ui--AddressToggle-address'
         value={address}
       />
-      <div className='ui--AddressToggle-toggle'>
-        <Toggle
-          label=''
-          onChange={onChange}
-          value={value}
-        />
-      </div>
+      {!noToggle && (
+        <div className='ui--AddressToggle-toggle'>
+          <Toggle
+            label=''
+            value={value}
+          />
+        </div>
+      )}
     </div>
   );
 }
@@ -96,7 +99,17 @@ export default styled(AddressToggle)`
     border-color: #ccc;
   }
 
+  &.isHidden {
+    display: none;
+  }
+
+  &.isDragging {
+    background: white;
+    box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
+  }
+
   &.isAye {
+    cursor: move;
     .ui--AddressToggle-address {
       filter: none;
       opacity: 1;

+ 8 - 4
packages/react-components/src/Input.tsx

@@ -18,6 +18,7 @@ interface Props extends BareProps {
   defaultValue?: any;
   help?: React.ReactNode;
   icon?: React.ReactNode;
+  inputClassName?: string;
   isAction?: boolean;
   isDisabled?: boolean;
   isDisabledError?: boolean;
@@ -89,7 +90,7 @@ const isSelectAll = (key: string, isPreKeyDown: boolean): boolean =>
 
 let counter = 0;
 
-export default function Input ({ autoFocus = false, children, className, defaultValue, help, icon, isEditable = false, isAction = false, isDisabled = false, isDisabledError = false, isError = false, isFull = false, isHidden = false, isReadOnly = false, label, labelExtra, max, maxLength, min, name, onBlur, onChange, onEnter, onEscape, onKeyDown, onKeyUp, onPaste, placeholder, style, tabIndex, type = 'text', value, withEllipsis, withLabel }: Props): React.ReactElement<Props> {
+export default function Input ({ autoFocus = false, children, className, defaultValue, help, icon, inputClassName, isEditable = false, isAction = false, isDisabled = false, isDisabledError = false, isError = false, isFull = false, isHidden = false, isReadOnly = false, label, labelExtra, max, maxLength, min, name, onBlur, onChange, onEnter, onEscape, onKeyDown, onKeyUp, onPaste, placeholder, style, tabIndex, type = 'text', value, withEllipsis, withLabel }: Props): React.ReactElement<Props> {
   const [stateName] = useState(`in_${counter++}_at_${Date.now()}`);
 
   const _onBlur = (): void => {
@@ -133,9 +134,12 @@ export default function Input ({ autoFocus = false, children, className, default
         action={isAction}
         autoFocus={autoFocus}
         className={
-          isEditable
-            ? 'ui--Input edit icon'
-            : 'ui--Input'
+          [
+            isEditable
+              ? 'ui--Input edit icon'
+              : 'ui--Input',
+            inputClassName || ''
+          ].join(' ')
         }
         defaultValue={
           isUndefined(value)

+ 165 - 39
packages/react-components/src/InputAddressMulti.tsx

@@ -2,70 +2,194 @@
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
+import ReactDOM from 'react-dom';
+import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
 import styled from 'styled-components';
 import { useDebounce } from '@polkadot/react-hooks';
 
+// FIXME :()
+import { PORTAL_ID } from '../../apps/src/Apps';
 import { useTranslation } from './translate';
 import AddressToggle from './AddressToggle';
 import Input from './Input';
 
 interface Props {
   available: string[];
+  availableLabel: React.ReactNode;
   className?: string;
   help: React.ReactNode;
-  label: React.ReactNode;
   maxCount: number;
   onChange: (values: string[]) => void;
+  valueLabel: React.ReactNode;
   value: string[];
 }
 
-function InputAddressMulti ({ available, className, help, label, maxCount, onChange, value }: Props): React.ReactElement<Props> {
+function uniquesOf (list: string[]): string[] {
+  return [...new Set(list)];
+}
+
+function InputAddressMulti ({ available: propsAvailable = [], className, help, maxCount, onChange, availableLabel, valueLabel, value }: Props): React.ReactElement<Props> {
   const { t } = useTranslation();
   const [_filter, setFilter] = useState<string>('');
   const filter = useDebounce(_filter);
 
-  const _onClick = (key: string): (isChecked: boolean) => void =>
-    (isChecked: boolean): void => {
-      let newValues = value.filter((address): boolean => address !== key);
+  const available = useMemo(
+    (): string[] => uniquesOf(propsAvailable),
+    [propsAvailable]
+  );
+
+  const isSelected = useMemo(
+    (): Record<string, boolean> => {
+      return available.reduce(
+        (result: Record<string, boolean>, address) => {
+          return {
+            ...result,
+            [address]: value.includes(address)
+          };
+        },
+        {}
+      );
+    },
+    [value, available]
+  );
 
-      if (isChecked) {
-        newValues = [key].concat(newValues).slice(0, maxCount);
+  const onReorder = (source: any, destination: any): void => {
+    const result = Array.from(value);
+    const [removed] = result.splice(source.index, 1);
+    result.splice(destination.index, 0, removed);
+
+    onChange(uniquesOf(result));
+  };
+
+  const onSelect = (address: string): () => void => {
+    return (): void => {
+      if (isSelected[address] || (maxCount && value.length >= maxCount)) {
+        return;
       }
 
-      onChange(newValues);
+      onChange(
+        uniquesOf(
+          [
+            ...value,
+            address
+          ]
+        )
+      );
+    };
+  };
+
+  const onDeselect = (index: number): () => void => {
+    return (): void => {
+      onChange(
+        uniquesOf([
+          ...value.slice(0, index),
+          ...value.slice(index + 1)
+        ])
+      );
     };
+  };
+
+  const onDragEnd = (result: any): void => {
+    const { source, destination } = result;
+
+    onReorder(source, destination);
+  };
 
   return (
     <div className={`ui--InputAddressMulti ${className}`}>
-      <Input
-        autoFocus
-        className='ui--InputAddressMulti-Input'
-        help={help}
-        label={label}
-        onChange={setFilter}
-        placeholder={t('partial name, address or account index')}
-        value={_filter}
-      />
-      <div className='ui--InputAddressMulti-container'>
-        {available.map((key): React.ReactNode => (
-          <AddressToggle
-            address={key}
-            filter={filter}
-            key={key}
-            onChange={_onClick(key)}
-            value={value.includes(key)}
-          />
-        ))}
+      <div className='ui--InputAddressMulti-column'>
+        <Input
+          autoFocus
+          className='ui--InputAddressMulti-Input label-small'
+          label={availableLabel}
+          onChange={setFilter}
+          placeholder={t('filter by name, address, or account index')}
+          value={_filter}
+        />
+        <div className='ui--InputAddressMulti-items'>
+          {available.map((address): React.ReactNode => (
+            <AddressToggle
+              address={address}
+              filter={filter}
+              isHidden={isSelected[address]}
+              key={address}
+              noToggle
+              onChange={onSelect(address)}
+            />
+          ))}
+        </div>
+      </div>
+      <div className='ui--InputAddressMulti-column'>
+        <Input
+          autoFocus
+          className='ui--InputAddressMulti-Input label-small'
+          help={help}
+          inputClassName='retain-appearance'
+          isDisabled
+          label={valueLabel}
+          onChange={setFilter}
+          placeholder={t('drag and drop to reorder')}
+          value={''}
+        />
+        <DragDropContext onDragEnd={onDragEnd}>
+          <Droppable droppableId='available'>
+            {(provided: any): React.ReactNode => (
+              <div
+                className='ui--InputAddressMulti-items'
+                ref={provided.innerRef}
+              >
+                {value.map((address, index): React.ReactNode => (
+                  <Draggable
+                    key={address}
+                    draggableId={address}
+                    index={index}
+                  >
+                    {(provided: any, snapshot: any): React.ReactNode => {
+                      const element = (
+                        <div
+                          ref={provided.innerRef}
+                          {...provided.draggableProps}
+                          {...provided.dragHandleProps}
+                        >
+                          <AddressToggle
+                            address={address}
+                            className={snapshot.isDragging ? 'isDragging' : ''}
+                            noToggle
+                            onChange={onDeselect(index)}
+                          />
+                        </div>
+                      );
+
+                      if (snapshot.isDragging) {
+                        return ReactDOM.createPortal(element, document.getElementById(PORTAL_ID) as Element);
+                      }
+
+                      return element;
+                    }}
+                  </Draggable>
+                ))}
+                {provided.placeholder}
+              </div>
+            )}
+          </Droppable>
+        </DragDropContext>
       </div>
     </div>
   );
 }
 
 export default styled(InputAddressMulti)`
+  border-top-width: 0px;
+  margin-left: 2rem;
+  width: calc(100% - 2rem);
+  display: inline-flex;
+  justify-content: space-between;
+
   .ui--InputAddressMulti-Input {
     .ui.input {
       margin-bottom: 0rem;
+      opacity: 1 !important;
 
       input {
         border-bottom-width: 0px;
@@ -75,19 +199,21 @@ export default styled(InputAddressMulti)`
     }
   }
 
-  .ui--InputAddressMulti-container {
-    background: white;
-    border: 1px solid rgba(34,36,38,.15);
-    border-top-width: 0px;
-    border-radius: 0 0 0.25rem 0.25rem;
-    margin-left: 2rem;
+  .ui--InputAddressMulti-column {
+    display: flex;
+    flex-direction: column;
+    min-height: 15rem;
     max-height: 15rem;
-    overflow-y: scroll;
+    width: 50%;
     padding: 0.25rem 0.5rem;
-    display: flex;
-    flex-wrap: wrap;
-    justify-content: center;
 
-    .ui--AddressToggle {}
+    .ui--InputAddressMulti-items {
+      background: white;
+      border: 1px solid rgba(34,36,38,0.15);
+      border-top-width: 0;
+      border-radius: 0 0 0.286rem 0.286rem;
+      flex: 1;
+      overflow-y: auto;
+    }
   }
 `;

+ 5 - 0
packages/react-components/src/react-beautiful-dnd.d.ts

@@ -0,0 +1,5 @@
+// Copyright 2017-2020 @polkadot/react-components authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+declare module 'react-beautiful-dnd';

+ 1 - 1
packages/react-components/src/styles/semantic.ts

@@ -55,7 +55,7 @@ export default css`
   .ui.input {
     width: 100%;
 
-    &.disabled {
+    &.disabled:not(.retain-appearance) {
       opacity: 1;
 
       input {

+ 106 - 5
yarn.lock

@@ -3239,6 +3239,7 @@ __metadata:
     i18next-browser-languagedetector: ^4.0.2
     i18next-xhr-backend: ^3.2.2
     react: ^16.13.0
+    react-beautiful-dnd: ^13.0.0
     react-chartjs-2: ^2.9.0
     react-copy-to-clipboard: ^5.0.2
     react-dom: ^16.13.0
@@ -3788,6 +3789,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/react-beautiful-dnd@npm:^12.1.1":
+  version: 12.1.1
+  resolution: "@types/react-beautiful-dnd@npm:12.1.1"
+  dependencies:
+    "@types/react": "*"
+  checksum: 2/976a6b6642f5ff19a9f427b014974be36f4c2a4d67d8e92d17b803245401e08c603180a91f17f736f8019a3560139029290f5757cbae9ff1509767e5cb9ce11b
+  languageName: node
+  linkType: hard
+
 "@types/react-copy-to-clipboard@npm:^4.3.0":
   version: 4.3.0
   resolution: "@types/react-copy-to-clipboard@npm:4.3.0"
@@ -7246,6 +7256,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"css-box-model@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "css-box-model@npm:1.2.0"
+  dependencies:
+    tiny-invariant: ^1.0.6
+  checksum: 2/5ad584c36128969604a150e5447de64979d48f7dd1c8e113c069895c859f20746103786509d282ca74126252e7ba54baa04f1b2484c8d8641591c13bf3f977e9
+  languageName: node
+  linkType: hard
+
 "css-color-keywords@npm:^1.0.0":
   version: 1.0.0
   resolution: "css-color-keywords@npm:1.0.0"
@@ -14075,6 +14094,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"memoize-one@npm:^5.1.1":
+  version: 5.1.1
+  resolution: "memoize-one@npm:5.1.1"
+  checksum: 2/e7c4a9cceca71334bf09c38818c375a280dc6e8ec03a92701c16199763269ce756b7b0b370f4b464c071045349fb7b3e4a3cc7460a3f46a277f394d8634a6eb5
+  languageName: node
+  linkType: hard
+
 "memoizee@npm:^0.4.14":
   version: 0.4.14
   resolution: "memoizee@npm:0.4.14"
@@ -17099,6 +17125,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"raf-schd@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "raf-schd@npm:4.0.2"
+  checksum: 2/28ad12b28814dc1b145a518e5ec6011e12aaf4b490cc7548ee0f8f2e26a7ac2168b62281588320a61743f7c4f70966c2db947aae60021f17546cb721a8d45821
+  languageName: node
+  linkType: hard
+
 "ramda@npm:^0.26":
   version: 0.26.1
   resolution: "ramda@npm:0.26.1"
@@ -17169,6 +17202,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-beautiful-dnd@npm:^13.0.0":
+  version: 13.0.0
+  resolution: "react-beautiful-dnd@npm:13.0.0"
+  dependencies:
+    "@babel/runtime": ^7.8.4
+    css-box-model: ^1.2.0
+    memoize-one: ^5.1.1
+    raf-schd: ^4.0.2
+    react-redux: ^7.1.1
+    redux: ^4.0.4
+    use-memo-one: ^1.1.1
+  peerDependencies:
+    react: ^16.8.5
+    react-dom: ^16.8.5
+  checksum: 2/0ce5027375308139c04f6fd1c429591fdd17b175903a63bf238d2e0b2c3db1260272870b24ee50262fc7718db914416274740e50f19eaf0e8bbd03a9cf0cd556
+  languageName: node
+  linkType: hard
+
 "react-chartjs-2@npm:^2.9.0":
   version: 2.9.0
   resolution: "react-chartjs-2@npm:2.9.0"
@@ -17235,7 +17286,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-is@npm:^16.12.0, react-is@npm:^16.13.0, react-is@npm:^16.6.0, react-is@npm:^16.6.3, react-is@npm:^16.7.0, react-is@npm:^16.8.1, react-is@npm:^16.8.6":
+"react-is@npm:^16.12.0, react-is@npm:^16.13.0, react-is@npm:^16.6.0, react-is@npm:^16.6.3, react-is@npm:^16.7.0, react-is@npm:^16.8.1, react-is@npm:^16.8.6, react-is@npm:^16.9.0":
   version: 16.13.0
   resolution: "react-is@npm:16.13.0"
   checksum: 2/122f38efe00aca3f8c391a90513cb5e2ef2710db164730c9c6125f2f089b5cd413f22669e06606c5d84df08086c9a6b50fae90efa532baa82bc3a7c5c3b8b5da
@@ -17291,6 +17342,29 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-redux@npm:^7.1.1":
+  version: 7.2.0
+  resolution: "react-redux@npm:7.2.0"
+  dependencies:
+    "@babel/runtime": ^7.5.5
+    hoist-non-react-statics: ^3.3.0
+    loose-envify: ^1.4.0
+    prop-types: ^15.7.2
+    react-is: ^16.9.0
+  peerDependencies:
+    react: ^16.8.3
+    react-dom: "*"
+    react-native: "*"
+    redux: ^2.0.0 || ^3.0.0 || ^4.0.0-0
+  peerDependenciesMeta:
+    react-dom:
+      optional: true
+    react-native:
+      optional: true
+  checksum: 2/e4beaa6064a5354f65bdeaf22c90fb8bf3df1ec0bcecf7c568321c6eba0cdf4b432236a828cb53801c5815a83d5090f38738f67ed14fb8cfddd21abe5fba317c
+  languageName: node
+  linkType: hard
+
 "react-router-dom@npm:^5.1.2":
   version: 5.1.2
   resolution: "react-router-dom@npm:5.1.2"
@@ -17591,6 +17665,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"redux@npm:^4.0.4":
+  version: 4.0.5
+  resolution: "redux@npm:4.0.5"
+  dependencies:
+    loose-envify: ^1.4.0
+    symbol-observable: ^1.2.0
+  checksum: 2/112739c2fb83ae2e18335d942a883b3ee14ebada31ff3a7924511a1f38a4278a762f5823491f3eb5083492de601ed76cb2687295c7d5db9a6e098c2542cdc05b
+  languageName: node
+  linkType: hard
+
 "regenerate-unicode-properties@npm:^8.1.0":
   version: 8.1.0
   resolution: "regenerate-unicode-properties@npm:8.1.0"
@@ -18165,6 +18249,7 @@ __metadata:
     "@types/chart.js": ^2.9.15
     "@types/file-saver": ^2.0.1
     "@types/i18next": ^13.0.0
+    "@types/react-beautiful-dnd": ^12.1.1
     "@types/react-copy-to-clipboard": ^4.3.0
     "@types/react-dom": ^16.9.5
     "@types/react-router-dom": ^5.1.3
@@ -19672,6 +19757,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"symbol-observable@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "symbol-observable@npm:1.2.0"
+  checksum: 2/268834a1d4cba19d40f367e5c2755f612969c8418e43a3be17408e392802a667f8bb542893440d58a080a8ea8da05ea98e27e472b9f4ff6fbda78a21a1a41c53
+  languageName: node
+  linkType: hard
+
 "symbol-tree@npm:^3.2.2":
   version: 3.2.4
   resolution: "symbol-tree@npm:3.2.4"
@@ -19963,7 +20055,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tiny-invariant@npm:^1.0.2":
+"tiny-invariant@npm:^1.0.2, tiny-invariant@npm:^1.0.6":
   version: 1.1.0
   resolution: "tiny-invariant@npm:1.1.0"
   checksum: 2/64318fbd77c451cfff23b57b9f3aef56594d9cea051a87dc538c9b371f97e8d474eaa2a7cbd60b8aa23f852393152495e8651b197607465fdf9c8ff134043b1b
@@ -20815,6 +20907,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"use-memo-one@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "use-memo-one@npm:1.1.1"
+  peerDependencies:
+    react: ^16.8.0
+  checksum: 2/691a41156a966ec83246b0c684f9ce84dcf9fe1d6441d136d3a2cf681d84f5be510265e1e3a5951b1f2f45a760a3492e3c389392c1e66240d5430150148383df
+  languageName: node
+  linkType: hard
+
 "use@npm:^3.1.0":
   version: 3.1.1
   resolution: "use@npm:3.1.1"
@@ -21811,8 +21912,8 @@ __metadata:
   linkType: hard
 
 "ws@npm:^7.0.0, ws@npm:^7.1.0":
-  version: 7.2.1
-  resolution: "ws@npm:7.2.1"
+  version: 7.2.2
+  resolution: "ws@npm:7.2.2"
   peerDependencies:
     bufferutil: ^4.0.1
     utf-8-validate: ^5.0.2
@@ -21821,7 +21922,7 @@ __metadata:
       optional: true
     utf-8-validate:
       optional: true
-  checksum: 2/097beba4b2722f097b8bcf92b1da6e81838c389b5e7f0246eb622a1949083fc6868aed0f51bc4c520d2d37f983e45134f903ee98b0881b0d7d51e25a1446753d
+  checksum: 2/fe66f98e07cdb832d6161edd147e6bdec13cf7e811cd34b11d85fdc7b7c17eaf9498c1656dc4786c9d2207dc1cd86aef0060706af8c756ef86e9b5e9421ef566
   languageName: node
   linkType: hard