Преглед на файлове

Treasury tipping (#2551)

Jaco Greeff преди 4 години
родител
ревизия
936cc70d00

+ 1 - 1
packages/page-treasury/src/Overview/Approve.tsx

@@ -1,4 +1,4 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 

+ 1 - 1
packages/page-treasury/src/Overview/Proposal.tsx

@@ -1,4 +1,4 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 

+ 6 - 6
packages/page-treasury/src/Overview/Propose.tsx → packages/page-treasury/src/Overview/ProposalCreate.tsx

@@ -17,13 +17,13 @@ function Propose ({ className }: Props): React.ReactElement<Props> | null {
   const { t } = useTranslation();
   const [accountId, setAccountId] = useState<string | null>(null);
   const [beneficiary, setBeneficiary] = useState<string | null>(null);
-  const [isProposeOpen, togglePropose] = useToggle();
+  const [isOpen, toggleOpen] = useToggle();
   const [value, setValue] = useState<BN | undefined>();
   const hasValue = value?.gtn(0);
 
   return (
     <>
-      {isProposeOpen && (
+      {isOpen && (
         <Modal
           className={className}
           header={t('Submit treasury proposal')}
@@ -52,14 +52,14 @@ function Propose ({ className }: Props): React.ReactElement<Props> | null {
               onChange={setValue}
             />
           </Modal.Content>
-          <Modal.Actions onCancel={togglePropose}>
+          <Modal.Actions onCancel={toggleOpen}>
             <TxButton
               accountId={accountId}
               icon='add'
               isDisabled={!accountId || !hasValue}
               isPrimary
               label={t('Submit proposal')}
-              onStart={togglePropose}
+              onStart={toggleOpen}
               params={[value, beneficiary]}
               tx='treasury.proposeSpend'
             />
@@ -67,9 +67,9 @@ function Propose ({ className }: Props): React.ReactElement<Props> | null {
         </Modal>
       )}
       <Button
-        icon='check'
+        icon='plus'
         label={t('Submit proposal')}
-        onClick={togglePropose}
+        onClick={toggleOpen}
       />
     </>
   );

+ 4 - 17
packages/page-treasury/src/Overview/Proposals.tsx

@@ -1,14 +1,12 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 
 import { DeriveTreasuryProposal } from '@polkadot/api-derive/types';
-import { AccountId, Balance } from '@polkadot/types/interfaces';
 
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback } from 'react';
 import { useHistory } from 'react-router-dom';
 import { Table } from '@polkadot/react-components';
-import { useApi, useAccounts, useCall } from '@polkadot/react-hooks';
 
 import Proposal from './Proposal';
 import { useTranslation } from '../translate';
@@ -16,25 +14,14 @@ import { useTranslation } from '../translate';
 interface Props {
   className?: string;
   isApprovals?: boolean;
+  isMember: boolean;
   proposals?: DeriveTreasuryProposal[];
 }
 
-function ProposalsBase ({ className, isApprovals, proposals }: Props): React.ReactElement<Props> {
+function ProposalsBase ({ className, isApprovals, isMember, proposals }: Props): React.ReactElement<Props> {
   const { t } = useTranslation();
-  const { api } = useApi();
-  const { allAccounts } = useAccounts();
-  const members = useCall<[AccountId, Balance][]>((api.query.electionsPhragmen || api.query.elections).members, []);
-  const [isMember, setIsMember] = useState(false);
   const history = useHistory();
 
-  useEffect((): void => {
-    allAccounts && members && setIsMember(
-      members
-        .map(([accountId]): string => accountId.toString())
-        .some((accountId): boolean => allAccounts.includes(accountId))
-    );
-  }, [allAccounts, members]);
-
   const _onRespond = useCallback(
     (): void => {
       history.push('/council/motions');

+ 1 - 1
packages/page-treasury/src/Overview/Submission.tsx

@@ -50,7 +50,7 @@ function Submission ({ councilProposals, id, isDisabled }: Props): React.ReactEl
     <>
       {isOpen && (
         <Modal
-          header={t('Submit to council')}
+          header={t('To council')}
           size='small'
         >
           <Modal.Content>

+ 1 - 1
packages/page-treasury/src/Overview/Summary.tsx

@@ -1,4 +1,4 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 

+ 79 - 0
packages/page-treasury/src/Overview/Tip.tsx

@@ -0,0 +1,79 @@
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import { OpenTip } from '@polkadot/types/interfaces';
+
+import React from 'react';
+import { AddressSmall, AddressMini, Expander } from '@polkadot/react-components';
+import { useApi, useCall } from '@polkadot/react-hooks';
+import { FormatBalance } from '@polkadot/react-query';
+import { Option } from '@polkadot/types';
+
+import { useTranslation } from '../translate';
+import TipEndorse from './TipEndorse';
+import TipReason from './TipReason';
+
+interface Props {
+  className?: string;
+  hash: string;
+  isMember: boolean;
+  members: string[];
+}
+
+function Tip ({ className, hash, isMember, members }: Props): React.ReactElement<Props> | null {
+  const { t } = useTranslation();
+  const { api } = useApi();
+  const tip = useCall<OpenTip | null>(api.query.treasury.tips, [hash], {
+    transform: (optTip: Option<OpenTip>) => optTip.unwrapOr(null)
+  });
+
+  if (!tip) {
+    return null;
+  }
+
+  const { finder, reason, tips, who } = tip;
+  const finderInfo = finder.unwrapOr(null);
+
+  return (
+    <tr className={className}>
+      <td className='address'>
+        <AddressSmall value={who} />
+      </td>
+      <td className='address'>
+        {finderInfo && (
+          <AddressMini value={finderInfo[0]} />
+        )}
+      </td>
+      <td className='number'>
+        {finderInfo && (
+          <FormatBalance value={finderInfo[1]} />
+        )}
+      </td>
+      <TipReason hash={reason} />
+      <td className='start all'>
+        {tips.length !== 0 && (
+          <Expander summary={t('Endorsements ({{count}})', { replace: { count: tips.length } })}>
+            {tips.map(([tipper, balance]) => (
+              <AddressMini
+                balance={balance}
+                key={tipper.toString()}
+                value={tipper}
+                withBalance
+              />
+            ))}
+          </Expander>
+        )}
+      </td>
+      <td className='button'>
+        <TipEndorse
+          hash={hash}
+          isMember={isMember}
+          members={members}
+        />
+      </td>
+    </tr>
+  );
+}
+
+export default React.memo(Tip);

+ 108 - 0
packages/page-treasury/src/Overview/TipCreate.tsx

@@ -0,0 +1,108 @@
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import BN from 'bn.js';
+import React, { useEffect, useState } from 'react';
+import { Button, Input, InputAddress, InputBalance, Modal, TxButton } from '@polkadot/react-components';
+import { useToggle } from '@polkadot/react-hooks';
+
+import { useTranslation } from '../translate';
+
+interface Props {
+  members: string[];
+  refresh: () => void;
+}
+
+const MAX_REASON_LEN = 128;
+const MIN_REASON_LEN = 5;
+
+function TipCreate ({ members, refresh }: Props): React.ReactElement<Props> {
+  const { t } = useTranslation();
+  const [isOpen, toggleOpen] = useToggle();
+  const [accountId, setAccountId] = useState<string | null>(null);
+  const [beneficiary, setBeneficiary] = useState<string | null>(null);
+  const [isMember, setIsMember] = useState(false);
+  const [reason, setReason] = useState('');
+  const [value, setValue] = useState<BN | undefined>();
+  const hasValue = value?.gtn(0);
+  const hasReason = reason?.length >= MIN_REASON_LEN && reason?.length <= MAX_REASON_LEN;
+
+  useEffect((): void => {
+    setIsMember(
+      accountId
+        ? members.includes(accountId)
+        : false
+    );
+  }, [accountId, members]);
+
+  return (
+    <>
+      <Button
+        icon='plus'
+        label={t('Tip')}
+        onClick={toggleOpen}
+      />
+      {isOpen && (
+        <Modal
+          header={t('Submit tip request')}
+          size='small'
+        >
+          <Modal.Content>
+            <InputAddress
+              help={t('Select the account you wish to submit the tip from.')}
+              label={t('submit with account')}
+              onChange={setAccountId}
+              type='account'
+              withLabel
+            />
+            <InputAddress
+              help={t('The account to which the tip will be transferred if approved')}
+              label={t('beneficiary')}
+              onChange={setBeneficiary}
+              type='allPlus'
+            />
+            <Input
+              autoFocus
+              help={t('The reason why this tip should be paid.')}
+              isError={!hasReason}
+              label={t('tip reason')}
+              onChange={setReason}
+            />
+            {isMember && (
+              <InputBalance
+                help={t('The suggested value for this tip')}
+                isError={!hasValue}
+                label={t('tip value')}
+                onChange={setValue}
+              />
+            )}
+          </Modal.Content>
+          <Modal.Actions onCancel={toggleOpen}>
+            <TxButton
+              accountId={accountId}
+              icon='add'
+              isDisabled={!accountId || (isMember ? !hasValue : false) || !hasReason}
+              isPrimary
+              label={t('Propose tip')}
+              onStart={toggleOpen}
+              onSuccess={refresh}
+              params={
+                isMember
+                  ? [reason, beneficiary, value]
+                  : [reason, beneficiary]
+              }
+              tx={
+                isMember
+                  ? 'treasury.tipNew'
+                  : 'treasury.reportAwesome'
+              }
+            />
+          </Modal.Actions>
+        </Modal>
+      )}
+    </>
+  );
+}
+
+export default React.memo(TipCreate);

+ 72 - 0
packages/page-treasury/src/Overview/TipEndorse.tsx

@@ -0,0 +1,72 @@
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import BN from 'bn.js';
+import React, { useState } from 'react';
+import { Button, InputAddress, InputBalance, Modal, TxButton } from '@polkadot/react-components';
+import { useToggle } from '@polkadot/react-hooks';
+
+import { useTranslation } from '../translate';
+
+interface Props {
+  hash: string;
+  isMember: boolean;
+  members: string[];
+}
+
+function TipEndorse ({ hash, isMember, members }: Props): React.ReactElement<Props> {
+  const { t } = useTranslation();
+  const [isOpen, toggleOpen] = useToggle();
+  const [accountId, setAccountId] = useState<string | null>(null);
+  const [value, setValue] = useState<BN | undefined>();
+  const hasValue = value?.gtn(0);
+
+  return (
+    <>
+      <Button
+        icon='check'
+        isDisabled={!isMember}
+        label={t('Endorse')}
+        onClick={toggleOpen}
+      />
+      {isOpen && (
+        <Modal
+          header={t('Submit tip endorsement')}
+          size='small'
+        >
+          <Modal.Content>
+            <InputAddress
+              filter={members}
+              help={t('Select the account you wish to submit the tip from.')}
+              label={t('submit with account')}
+              onChange={setAccountId}
+              type='account'
+              withLabel
+            />
+            <InputBalance
+              help={t('The tip amount that should be allocated')}
+              isError={!hasValue}
+              label={t('value')}
+              onChange={setValue}
+            />
+          </Modal.Content>
+          <Modal.Actions onCancel={toggleOpen}>
+            <TxButton
+              accountId={accountId}
+              icon='add'
+              isDisabled={!accountId || !hasValue }
+              isPrimary
+              label={t('Submit tip')}
+              onStart={toggleOpen}
+              params={[hash, value]}
+              tx='treasury.tip'
+            />
+          </Modal.Actions>
+        </Modal>
+      )}
+    </>
+  );
+}
+
+export default React.memo(TipEndorse);

+ 30 - 0
packages/page-treasury/src/Overview/TipReason.tsx

@@ -0,0 +1,30 @@
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import { Hash } from '@polkadot/types/interfaces';
+
+import React from 'react';
+import { useApi, useCall } from '@polkadot/react-hooks';
+import { Bytes, Option } from '@polkadot/types';
+import { hexToString } from '@polkadot/util';
+
+interface Props {
+  hash: Hash;
+}
+
+function TipReason ({ hash }: Props): React.ReactElement<Props> {
+  const { api } = useApi();
+  const reasonText = useCall<string | null>(api.query.treasury.reasons, [hash], {
+    transform: (optBytes: Option<Bytes>) =>
+      optBytes.isSome
+        ? hexToString(optBytes.unwrap().toHex())
+        : null
+  });
+
+  return (
+    <td className='start all'>{reasonText || hash.toHex()}</td>
+  );
+}
+
+export default React.memo(TipReason);

+ 46 - 0
packages/page-treasury/src/Overview/Tips.tsx

@@ -0,0 +1,46 @@
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import React from 'react';
+import { Table } from '@polkadot/react-components';
+
+import { useTranslation } from '../translate';
+import Tip from './Tip';
+
+interface Props {
+  className?: string;
+  hashes?: string[] | null;
+  isMember: boolean;
+  members: string[];
+}
+
+function Tips ({ className, hashes, isMember, members }: Props): React.ReactElement<Props> {
+  const { t } = useTranslation();
+
+  return (
+    <Table
+      className={className}
+      empty={hashes && t('No open tips')}
+      header={[
+        [t('tips'), 'start'],
+        [t('finder'), 'address'],
+        [t('fee')],
+        [t('reason'), 'start'],
+        [],
+        []
+      ]}
+    >
+      {hashes?.map((hash): React.ReactNode => (
+        <Tip
+          hash={hash}
+          isMember={isMember}
+          key={hash}
+          members={members}
+        />
+      ))}
+    </Table>
+  );
+}
+
+export default React.memo(Tips);

+ 56 - 9
packages/page-treasury/src/Overview/index.tsx

@@ -1,23 +1,56 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 
 import { DeriveTreasuryProposals } from '@polkadot/api-derive/types';
-import { AppProps, BareProps, I18nProps } from '@polkadot/react-components/types';
+import { BareProps as Props } from '@polkadot/react-components/types';
+import { AccountId, Balance } from '@polkadot/types/interfaces';
 
-import React from 'react';
+import React, { useEffect, useState } from 'react';
 import { Button } from '@polkadot/react-components';
-import { useApi, useCall } from '@polkadot/react-hooks';
+import { useAccounts, useApi, useCall, useIncrement, useIsMountedRef } from '@polkadot/react-hooks';
 
-import Summary from './Summary';
+import ProposalCreate from './ProposalCreate';
 import Proposals from './Proposals';
-import Propose from './Propose';
+import Summary from './Summary';
+import TipCreate from './TipCreate';
+import Tips from './Tips';
 
-interface Props extends AppProps, BareProps, I18nProps {}
+interface Members {
+  isMember: boolean;
+  members: string[];
+}
 
 function Overview ({ className }: Props): React.ReactElement<Props> {
   const { api } = useApi();
   const info = useCall<DeriveTreasuryProposals>(api.derive.treasury.proposals, []);
+  const { allAccounts } = useAccounts();
+  const queryMembers = useCall<[AccountId, Balance][]>((api.query.electionsPhragmen || api.query.elections).members, []);
+  const [{ isMember, members }, setMembers] = useState<Members>({ isMember: false, members: [] });
+  const mountedRef = useIsMountedRef();
+  const [hashTrigger, triggerHashes] = useIncrement();
+  const [hashes, setHashes] = useState<string[] | null>(null);
+
+  useEffect((): void => {
+    if (hashTrigger && mountedRef.current) {
+      api.query.treasury.tips.keys().then((keys) =>
+        mountedRef.current && setHashes(
+          keys.map((key) => key.args[0].toHex())
+        )
+      );
+    }
+  }, [api, hashTrigger, mountedRef]);
+
+  useEffect((): void => {
+    if (allAccounts && queryMembers) {
+      const members = queryMembers.map(([accountId]) => accountId.toString());
+
+      setMembers({
+        isMember: members.some((accountId) => allAccounts.includes(accountId)),
+        members
+      });
+    }
+  }, [allAccounts, queryMembers]);
 
   return (
     <div className={className}>
@@ -26,13 +59,27 @@ function Overview ({ className }: Props): React.ReactElement<Props> {
         proposalCount={info?.approvals.length}
       />
       <Button.Group>
-        <Propose />
+        <ProposalCreate />
+        <Button.Or />
+        <TipCreate
+          members={members}
+          refresh={triggerHashes}
+        />
       </Button.Group>
-      <Proposals proposals={info?.proposals} />
+      <Proposals
+        isMember={isMember}
+        proposals={info?.proposals}
+      />
       <Proposals
         isApprovals
+        isMember={isMember}
         proposals={info?.approvals}
       />
+      <Tips
+        hashes={hashes}
+        isMember={isMember}
+        members={members}
+      />
     </div>
   );
 }

+ 1 - 1
packages/page-treasury/src/index.tsx

@@ -1,4 +1,4 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 

+ 1 - 1
packages/page-treasury/src/useCounter.ts

@@ -1,4 +1,4 @@
-// Copyright 2017-2020 @polkadot/app-democracy authors & contributors
+// Copyright 2017-2020 @polkadot/app-treasury authors & contributors
 // This software may be modified and distributed under the terms
 // of the Apache-2.0 license. See the LICENSE file for details.
 

+ 1 - 0
packages/react-hooks/src/index.ts

@@ -11,6 +11,7 @@ export { default as useCall } from './useCall';
 export { default as useFormField } from './useFormField';
 export { default as useDebounce } from './useDebounce';
 export { default as useFavorites } from './useFavorites';
+export { default as useIncrement } from './useIncrement';
 export { default as useIsMountedRef } from './useIsMountedRef';
 export { default as useMembers } from './useMembers';
 export { default as useModal } from './useModal';

+ 15 - 0
packages/react-hooks/src/useIncrement.ts

@@ -0,0 +1,15 @@
+// Copyright 2017-2020 @polkadot/react-hooks authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import { useCallback, useState } from 'react';
+
+export default function useIncrement (defaultValue = 1): [number, () => void, (value: number) => void] {
+  const [value, setValue] = useState(defaultValue);
+  const increment = useCallback(
+    (): void => setValue((value: number) => ++value),
+    []
+  );
+
+  return [value, increment, setValue];
+}