Body.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import React from 'react';
  2. import { Link } from 'react-router-dom';
  3. import { Card, Header, Button, Icon, Message } from 'semantic-ui-react';
  4. import { ProposalType } from '@polkadot/joy-utils/types/proposals';
  5. import { bytesToString } from '@polkadot/joy-utils/functions/misc';
  6. import styled from 'styled-components';
  7. import AddressMini from '@polkadot/react-components/AddressMiniJoy';
  8. import TxButton from '@polkadot/joy-utils/TxButton';
  9. import { ProposalId, TerminateRoleParameters } from '@joystream/types/proposals';
  10. import { MemberId, Membership } from '@joystream/types/members';
  11. import ProfilePreview from '@polkadot/joy-utils/MemberProfilePreview';
  12. import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
  13. import { Option, Bytes } from '@polkadot/types/';
  14. import { BlockNumber } from '@polkadot/types/interfaces';
  15. import { formatBalance } from '@polkadot/util';
  16. import { PromiseComponent } from '@polkadot/joy-utils/react/components';
  17. import ReactMarkdown from 'react-markdown';
  18. import { WorkingGroupOpeningPolicyCommitment, RewardPolicy } from '@joystream/types/working-group';
  19. import {
  20. ActivateOpeningAt,
  21. ActivateOpeningAtKeys,
  22. StakingPolicy
  23. } from '@joystream/types/hiring';
  24. import { WorkingGroup, WorkingGroupKey } from '@joystream/types/common';
  25. import { ApplicationsDetailsByOpening } from '@polkadot/joy-utils/react/components/working-groups/ApplicationDetails';
  26. import { LeadInfoFromId } from '@polkadot/joy-utils/react/components/working-groups/LeadInfo';
  27. import { formatReward } from '@polkadot/joy-utils/functions/format';
  28. type BodyProps = {
  29. title: string;
  30. description: string;
  31. params: any[];
  32. type: ProposalType;
  33. iAmProposer: boolean;
  34. proposalId: number | ProposalId;
  35. proposerId: number | MemberId;
  36. isCancellable: boolean;
  37. cancellationFee: number;
  38. };
  39. function ProposedAddress (props: { address?: string | null }) {
  40. if (props.address === null || props.address === undefined) {
  41. return <>NONE</>;
  42. }
  43. return (
  44. <AddressMini value={props.address} isShort={false} isPadded={false} withAddress={true} style={{ padding: 0 }} />
  45. );
  46. }
  47. function ProposedMember (props: { memberId?: MemberId | number | null }) {
  48. if (props.memberId === null || props.memberId === undefined) {
  49. return <>NONE</>;
  50. }
  51. const memberId: MemberId | number = props.memberId;
  52. const transport = useTransport();
  53. const [member, error, loading] = usePromise<Membership | null>(
  54. () => transport.members.membershipById(memberId),
  55. null
  56. );
  57. return (
  58. <PromiseComponent error={error} loading={loading} message="Fetching profile...">
  59. { member ? (
  60. <ProfilePreview
  61. avatar_uri={ member.avatar_uri.toString() }
  62. root_account={ member.root_account.toString() }
  63. handle={ member.handle.toString() }
  64. link={ true }
  65. />
  66. ) : 'Profile not found' }
  67. </PromiseComponent>
  68. );
  69. }
  70. const ParsedHRT = styled.pre`
  71. font-size: 14px;
  72. font-weight: normal;
  73. background: #eee;
  74. border-radius: 0.5rem;
  75. padding: 1rem;
  76. margin: 0;
  77. white-space: pre-wrap;
  78. `;
  79. type ParsedParamValue = string | number | JSX.Element;
  80. class ParsedParam {
  81. name: string;
  82. value: ParsedParamValue;
  83. fullWidth: boolean;
  84. constructor (name: string, value: ParsedParamValue, fullWidth = false) {
  85. this.name = name;
  86. this.value = value;
  87. this.fullWidth = fullWidth;
  88. }
  89. }
  90. // The methods for parsing params by Proposal type.
  91. const paramParsers: { [x in ProposalType]: (params: any[]) => ParsedParam[]} = {
  92. Text: ([content]) => [
  93. new ParsedParam(
  94. 'Content',
  95. <ReactMarkdown className='TextProposalContent' source={content} linkTarget='_blank' />,
  96. true
  97. )
  98. ],
  99. RuntimeUpgrade: ([hash, filesize]) => [
  100. new ParsedParam('Blake2b256 hash of WASM code', hash, true),
  101. new ParsedParam('File size', filesize + ' bytes')
  102. ],
  103. SetElectionParameters: ([params]) => [
  104. new ParsedParam('Announcing period', params.announcing_period + ' blocks'),
  105. new ParsedParam('Voting period', params.voting_period + ' blocks'),
  106. new ParsedParam('Revealing period', params.revealing_period + ' blocks'),
  107. new ParsedParam('Council size', params.council_size + ' members'),
  108. new ParsedParam('Candidacy limit', params.candidacy_limit + ' members'),
  109. new ParsedParam('New term duration', params.new_term_duration + ' blocks'),
  110. new ParsedParam('Min. council stake', formatBalance(params.min_council_stake)),
  111. new ParsedParam('Min. voting stake', formatBalance(params.min_voting_stake))
  112. ],
  113. Spending: ([amount, account]) => [
  114. new ParsedParam('Amount', formatBalance(amount)),
  115. new ParsedParam('Account', <ProposedAddress address={account} />)
  116. ],
  117. SetLead: ([memberId, accountId]) => [
  118. new ParsedParam('Member', <ProposedMember memberId={ memberId } />),
  119. new ParsedParam('Account id', <ProposedAddress address={accountId} />)
  120. ],
  121. SetContentWorkingGroupMintCapacity: ([capacity]) => [
  122. new ParsedParam('Mint capacity', formatBalance(capacity))
  123. ],
  124. EvictStorageProvider: ([accountId]) => [
  125. new ParsedParam('Storage provider account', <ProposedAddress address={accountId} />)
  126. ],
  127. SetValidatorCount: ([count]) => [
  128. new ParsedParam('Validator count', count)
  129. ],
  130. SetStorageRoleParameters: ([params]) => [
  131. new ParsedParam('Min. stake', formatBalance(params.min_stake)),
  132. // "Min. actors": params.min_actors,
  133. new ParsedParam('Max. actors', params.max_actors),
  134. new ParsedParam('Reward', formatBalance(params.reward)),
  135. new ParsedParam('Reward period', params.reward_period + ' blocks'),
  136. // "Bonding period": params.bonding_period + " blocks",
  137. new ParsedParam('Unbonding period', params.unbonding_period + ' blocks'),
  138. // "Min. service period": params.min_service_period + " blocks",
  139. // "Startup grace period": params.startup_grace_period + " blocks",
  140. new ParsedParam('Entry request fee', formatBalance(params.entry_request_fee))
  141. ],
  142. AddWorkingGroupLeaderOpening: ([{ activate_at, commitment, human_readable_text, working_group }]) => {
  143. const workingGroup = new WorkingGroup(working_group);
  144. const activateAt = new ActivateOpeningAt(activate_at);
  145. const activateAtBlock = activateAt.type === ActivateOpeningAtKeys.ExactBlock ? activateAt.value : null;
  146. const OPCommitment = new WorkingGroupOpeningPolicyCommitment(commitment);
  147. const {
  148. application_staking_policy: applicationSP,
  149. role_staking_policy: roleSP,
  150. application_rationing_policy: rationingPolicy
  151. } = OPCommitment;
  152. let HRT = bytesToString(new Bytes(human_readable_text));
  153. try { HRT = JSON.stringify(JSON.parse(HRT), undefined, 4); } catch (e) { /* Do nothing */ }
  154. const formatStake = (stake: Option<StakingPolicy>) => (
  155. stake.isSome ? stake.unwrap().amount_mode.type + `(${stake.unwrap().amount})` : 'NONE'
  156. );
  157. const formatPeriod = (unstakingPeriod: Option<BlockNumber>) => (
  158. unstakingPeriod.unwrapOr(0) + ' blocks'
  159. );
  160. return [
  161. new ParsedParam('Working group', workingGroup.type),
  162. new ParsedParam('Activate at', `${activateAt.type}${activateAtBlock ? `(${activateAtBlock.toString()})` : ''}`),
  163. new ParsedParam('Application stake', formatStake(applicationSP)),
  164. new ParsedParam('Role stake', formatStake(roleSP)),
  165. new ParsedParam(
  166. 'Max. applications',
  167. rationingPolicy.isSome ? rationingPolicy.unwrap().max_active_applicants.toNumber() : 'UNLIMITED'
  168. ),
  169. new ParsedParam(
  170. 'Terminate unstaking period (role stake)',
  171. formatPeriod(OPCommitment.terminate_role_stake_unstaking_period)
  172. ),
  173. new ParsedParam(
  174. 'Exit unstaking period (role stake)',
  175. formatPeriod(OPCommitment.exit_role_stake_unstaking_period)
  176. ),
  177. // <required_to_prevent_sneaking>
  178. new ParsedParam(
  179. 'Terminate unstaking period (appl. stake)',
  180. formatPeriod(OPCommitment.terminate_application_stake_unstaking_period)
  181. ),
  182. new ParsedParam(
  183. 'Exit unstaking period (appl. stake)',
  184. formatPeriod(OPCommitment.exit_role_application_stake_unstaking_period)
  185. ),
  186. new ParsedParam(
  187. 'Appl. accepted unstaking period (appl. stake)',
  188. formatPeriod(OPCommitment.fill_opening_successful_applicant_application_stake_unstaking_period)
  189. ),
  190. new ParsedParam(
  191. 'Appl. failed unstaking period (role stake)',
  192. formatPeriod(OPCommitment.fill_opening_failed_applicant_role_stake_unstaking_period)
  193. ),
  194. new ParsedParam(
  195. 'Appl. failed unstaking period (appl. stake)',
  196. formatPeriod(OPCommitment.fill_opening_failed_applicant_application_stake_unstaking_period)
  197. ),
  198. new ParsedParam(
  199. 'Crowded out unstaking period (role stake)',
  200. roleSP.isSome ? formatPeriod(roleSP.unwrap().crowded_out_unstaking_period_length) : '0 blocks'
  201. ),
  202. new ParsedParam(
  203. 'Review period expierd unstaking period (role stake)',
  204. roleSP.isSome ? formatPeriod(roleSP.unwrap().review_period_expired_unstaking_period_length) : '0 blocks'
  205. ),
  206. new ParsedParam(
  207. 'Crowded out unstaking period (appl. stake)',
  208. applicationSP.isSome ? formatPeriod(applicationSP.unwrap().crowded_out_unstaking_period_length) : '0 blocks'
  209. ),
  210. new ParsedParam(
  211. 'Review period expierd unstaking period (appl. stake)',
  212. applicationSP.isSome ? formatPeriod(applicationSP.unwrap().review_period_expired_unstaking_period_length) : '0 blocks'
  213. ),
  214. // </required_to_prevent_sneaking>
  215. new ParsedParam('Human readable text', <ParsedHRT>{ HRT }</ParsedHRT>, true)
  216. ];
  217. },
  218. SetWorkingGroupMintCapacity: ([capacity, group]) => [
  219. new ParsedParam('Working group', (new WorkingGroup(group)).type),
  220. new ParsedParam('Mint capacity', formatBalance(capacity))
  221. ],
  222. BeginReviewWorkingGroupLeaderApplication: ([id, group]) => [
  223. new ParsedParam('Working group', (new WorkingGroup(group)).type),
  224. // TODO: Adjust the link to work with multiple groups after working-groups are normalized!
  225. new ParsedParam('Opening id', <Link to={`/working-groups/opportunities/storageProviders/${id}`}>#{id}</Link>)
  226. ],
  227. FillWorkingGroupLeaderOpening: ([params]) => {
  228. const { opening_id, successful_application_id, reward_policy, working_group } = params;
  229. const rewardPolicy = reward_policy && new RewardPolicy(reward_policy);
  230. return [
  231. new ParsedParam('Working group', (new WorkingGroup(working_group)).type),
  232. // TODO: Adjust the link to work with multiple groups after working-groups are normalized!
  233. new ParsedParam('Opening id', <Link to={`/working-groups/opportunities/storageProviders/${opening_id}`}>#{opening_id}</Link>),
  234. new ParsedParam('Reward policy', rewardPolicy ? formatReward(rewardPolicy, true) : 'NONE'),
  235. new ParsedParam(
  236. 'Result',
  237. <ApplicationsDetailsByOpening
  238. openingId={opening_id}
  239. acceptedIds={[successful_application_id]}
  240. group={(new WorkingGroup(working_group)).type as WorkingGroupKey}/>,
  241. true
  242. )
  243. ];
  244. },
  245. SlashWorkingGroupLeaderStake: ([leadId, amount, group]) => [
  246. new ParsedParam('Working group', (new WorkingGroup(group)).type),
  247. new ParsedParam('Slash amount', formatBalance(amount)),
  248. new ParsedParam('Lead', <LeadInfoFromId group={(new WorkingGroup(group).type as WorkingGroupKey)} leadId={leadId}/>, true)
  249. ],
  250. DecreaseWorkingGroupLeaderStake: ([leadId, amount, group]) => [
  251. new ParsedParam('Working group', (new WorkingGroup(group)).type),
  252. new ParsedParam('Decrease amount', formatBalance(amount)),
  253. new ParsedParam('Lead', <LeadInfoFromId group={(new WorkingGroup(group).type as WorkingGroupKey)} leadId={leadId}/>, true)
  254. ],
  255. SetWorkingGroupLeaderReward: ([leadId, amount, group]) => [
  256. new ParsedParam('Working group', (new WorkingGroup(group)).type),
  257. new ParsedParam('New reward amount', formatBalance(amount)),
  258. new ParsedParam('Lead', <LeadInfoFromId group={(new WorkingGroup(group).type as WorkingGroupKey)} leadId={leadId}/>, true)
  259. ],
  260. TerminateWorkingGroupLeaderRole: ([params]) => {
  261. const paramsObj = new TerminateRoleParameters(params);
  262. const { working_group: workingGroup, rationale, worker_id: leadId, slash } = paramsObj;
  263. return [
  264. new ParsedParam('Working group', workingGroup.type),
  265. new ParsedParam('Rationale', bytesToString(rationale), true),
  266. new ParsedParam('Slash stake', slash.isTrue ? 'YES' : 'NO'),
  267. new ParsedParam('Lead', <LeadInfoFromId group={workingGroup.type as WorkingGroupKey} leadId={leadId.toNumber()}/>, true)
  268. ];
  269. }
  270. };
  271. const StyledProposalDescription = styled(Card.Description)`
  272. font-size: 1.15rem;
  273. `;
  274. const ProposalParams = styled.div`
  275. border: 1px solid rgba(0,0,0,.2);
  276. padding: 1.5rem 2rem 1rem 2rem;
  277. position: relative;
  278. margin-top: 1.7rem;
  279. display: grid;
  280. grid-template-columns: 1fr 1fr;
  281. grid-column-gap: 1rem;
  282. grid-row-gap: 0.5rem;
  283. @media screen and (max-width: 767px) {
  284. grid-template-columns: 1fr;
  285. }
  286. `;
  287. const ParamsHeader = styled.h4`
  288. position: absolute;
  289. top: 0;
  290. transform: translateY(-50%);
  291. background: #fff;
  292. font-weight: normal;
  293. padding: 0.3rem;
  294. left: 0.5rem;
  295. `;
  296. type ProposalParamProps = { fullWidth?: boolean };
  297. const ProposalParam = ({ fullWidth, children }: React.PropsWithChildren<ProposalParamProps>) => (
  298. <div style={{ gridColumn: (fullWidth || undefined) && '1/3' }}>
  299. { children }
  300. </div>
  301. );
  302. const ProposalParamName = styled.div`
  303. font-size: 0.9rem;
  304. font-weight: normal;
  305. `;
  306. const ProposalParamValue = styled.div`
  307. color: black;
  308. word-wrap: break-word;
  309. word-break: break-word;
  310. font-size: 1.15rem;
  311. font-weight: bold;
  312. & .TextProposalContent {
  313. font-weight: normal;
  314. }
  315. `;
  316. export default function Body ({
  317. type,
  318. title,
  319. description,
  320. params = [],
  321. iAmProposer,
  322. proposalId,
  323. proposerId,
  324. isCancellable,
  325. cancellationFee
  326. }: BodyProps) {
  327. const parseParams = paramParsers[type];
  328. const parsedParams = parseParams(params);
  329. return (
  330. <Card fluid>
  331. <Card.Content>
  332. <Card.Header>
  333. <Header as="h1">{title}</Header>
  334. </Card.Header>
  335. <StyledProposalDescription>
  336. <ReactMarkdown source={description} linkTarget='_blank' />
  337. </StyledProposalDescription>
  338. <ProposalParams>
  339. <ParamsHeader>Parameters:</ParamsHeader>
  340. { parsedParams.map(({ name, value, fullWidth }) => (
  341. <ProposalParam key={name} fullWidth={fullWidth}>
  342. <ProposalParamName>{name}:</ProposalParamName>
  343. <ProposalParamValue>{value}</ProposalParamValue>
  344. </ProposalParam>
  345. ))}
  346. </ProposalParams>
  347. { iAmProposer && isCancellable && (<>
  348. <Message warning visible>
  349. <Message.Content>
  350. <Message.Header>Proposal cancellation</Message.Header>
  351. <p style={{ margin: '0.5em 0', padding: '0' }}>
  352. {'You can only cancel your proposal while it\'s still in the Voting Period.'}
  353. </p>
  354. <p style={{ margin: '0.5em 0', padding: '0' }}>
  355. The cancellation fee for this type of proposal is:&nbsp;
  356. <b>{ cancellationFee ? formatBalance(cancellationFee) : 'NONE' }</b>
  357. </p>
  358. <Button.Group color="red">
  359. <TxButton
  360. params={ [proposerId, proposalId] }
  361. tx={ 'proposalsEngine.cancelProposal' }
  362. onClick={ sendTx => { sendTx(); } }
  363. className={'icon left labeled'}
  364. >
  365. <Icon name="cancel" inverted />
  366. Withdraw proposal
  367. </TxButton>
  368. </Button.Group>
  369. </Message.Content>
  370. </Message>
  371. </>) }
  372. </Card.Content>
  373. </Card>
  374. );
  375. }