Преглед изворни кода

Merge pull request #453 from Joystream/joystream-constantinople

Constantinople Deployment
Martin пре 4 година
родитељ
комит
eb9d2aad76
100 измењених фајлова са 4493 додато и 1348 уклоњено
  1. 1 1
      README.md
  2. 2 0
      package.json
  3. 39 36
      packages/app-staking/src/Actions/Accounts.tsx
  4. 43 36
      packages/app-staking/src/Overview/CurrentList.tsx
  5. 15 9
      packages/app-staking/src/Overview/index.tsx
  6. 28 17
      packages/app-staking/src/index.tsx
  7. 38 52
      packages/apps-routing/src/index.ts
  8. 3 7
      packages/apps-routing/src/joy-election.ts
  9. 3 6
      packages/apps-routing/src/joy-members.ts
  10. 4 7
      packages/apps-routing/src/joy-proposals.ts
  11. 3 6
      packages/apps-routing/src/joy-storage.ts
  12. 2 1
      packages/apps/public/locales/en/ui.json
  13. 50 48
      packages/apps/src/SideBar/Item.tsx
  14. 53 149
      packages/apps/src/SideBar/index.tsx
  15. 30 24
      packages/apps/src/TopBar.tsx
  16. 19 6
      packages/apps/src/index.tsx
  17. 2 2
      packages/joy-election/src/Applicant.tsx
  18. 35 0
      packages/joy-election/src/CandidatePreview.tsx
  19. 42 45
      packages/joy-election/src/Council.tsx
  20. 2 1
      packages/joy-election/src/SealedVote.tsx
  21. 12 12
      packages/joy-election/src/VoteForm.tsx
  22. 1 1
      packages/joy-forum/src/CategoryList.tsx
  23. 1 1
      packages/joy-forum/src/ViewThread.tsx
  24. 1 1
      packages/joy-media/src/channels/ChannelAvatar.tsx
  25. 1 1
      packages/joy-media/src/channels/ChannelPreview.tsx
  26. 2 2
      packages/joy-media/src/channels/EditChannel.tsx
  27. 1 1
      packages/joy-media/src/music/EditMusicAlbum.tsx
  28. 2 1
      packages/joy-media/src/schemas/channel/Channel.ts
  29. 2 1
      packages/joy-media/src/transport.substrate.ts
  30. 1 5
      packages/joy-media/src/transport.ts
  31. 1 1
      packages/joy-media/src/upload/UploadAudio.tsx
  32. 2 2
      packages/joy-media/src/upload/UploadVideo.tsx
  33. 0 15
      packages/joy-media/src/utils.ts
  34. 16 7
      packages/joy-members/src/Details.tsx
  35. 135 108
      packages/joy-members/src/EditForm.tsx
  36. 61 14
      packages/joy-members/src/List.tsx
  37. 9 8
      packages/joy-members/src/index.tsx
  38. 1 3
      packages/joy-pages/src/md/Privacy.md
  39. 2 1
      packages/joy-proposals/package.json
  40. 0 104
      packages/joy-proposals/src/Dashboard.tsx
  41. 0 254
      packages/joy-proposals/src/Details.tsx
  42. 0 14
      packages/joy-proposals/src/FilterProps.ts
  43. 0 139
      packages/joy-proposals/src/NewForm.tsx
  44. 11 0
      packages/joy-proposals/src/NotDone.tsx
  45. 201 0
      packages/joy-proposals/src/Proposal/Body.tsx
  46. 6 0
      packages/joy-proposals/src/Proposal/ChooseProposalType.css
  47. 56 0
      packages/joy-proposals/src/Proposal/ChooseProposalType.tsx
  48. 71 0
      packages/joy-proposals/src/Proposal/Details.tsx
  49. 17 0
      packages/joy-proposals/src/Proposal/Error.tsx
  50. 14 0
      packages/joy-proposals/src/Proposal/Loading.tsx
  51. 20 0
      packages/joy-proposals/src/Proposal/PromiseComponent.tsx
  52. 56 0
      packages/joy-proposals/src/Proposal/Proposal.css
  53. 147 0
      packages/joy-proposals/src/Proposal/ProposalDetails.tsx
  54. 25 0
      packages/joy-proposals/src/Proposal/ProposalFromId.tsx
  55. 41 0
      packages/joy-proposals/src/Proposal/ProposalPreview.tsx
  56. 90 0
      packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx
  57. 48 0
      packages/joy-proposals/src/Proposal/ProposalType.css
  58. 152 0
      packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx
  59. 53 0
      packages/joy-proposals/src/Proposal/Votes.tsx
  60. 100 0
      packages/joy-proposals/src/Proposal/VotingSection.tsx
  61. 5 0
      packages/joy-proposals/src/Proposal/index.tsx
  62. 38 0
      packages/joy-proposals/src/Proposal/useVoteStyles.tsx
  63. 0 28
      packages/joy-proposals/src/ProposalById.tsx
  64. 0 78
      packages/joy-proposals/src/Proposals.tsx
  65. 89 0
      packages/joy-proposals/src/forms/EvictStorageProviderForm.tsx
  66. 138 0
      packages/joy-proposals/src/forms/FileDropdown.tsx
  67. 23 0
      packages/joy-proposals/src/forms/FormContainer.tsx
  68. 61 0
      packages/joy-proposals/src/forms/FormFields.tsx
  69. 195 0
      packages/joy-proposals/src/forms/GenericProposalForm.tsx
  70. 26 0
      packages/joy-proposals/src/forms/LabelWithHelp.tsx
  71. 79 0
      packages/joy-proposals/src/forms/MintCapacityForm.tsx
  72. 68 0
      packages/joy-proposals/src/forms/RuntimeUpgradeForm.tsx
  73. 188 0
      packages/joy-proposals/src/forms/SetContentWorkingGroupLeadForm.tsx
  74. 24 0
      packages/joy-proposals/src/forms/SetContentWorkingGroupMintCapForm.tsx
  75. 14 0
      packages/joy-proposals/src/forms/SetCouncilMintCapForm.tsx
  76. 219 0
      packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx
  77. 79 0
      packages/joy-proposals/src/forms/SetMaxValidatorCountForm.tsx
  78. 256 0
      packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx
  79. 70 0
      packages/joy-proposals/src/forms/SignalForm.tsx
  80. 96 0
      packages/joy-proposals/src/forms/SpendingProposalForm.tsx
  81. 37 0
      packages/joy-proposals/src/forms/errorHandling.ts
  82. 23 0
      packages/joy-proposals/src/forms/forms.css
  83. 11 0
      packages/joy-proposals/src/forms/index.ts
  84. 0 20
      packages/joy-proposals/src/index.css
  85. 58 73
      packages/joy-proposals/src/index.tsx
  86. 23 0
      packages/joy-proposals/src/runtime/TransportContext.tsx
  87. 66 0
      packages/joy-proposals/src/runtime/cache.ts
  88. 4 0
      packages/joy-proposals/src/runtime/index.ts
  89. 16 0
      packages/joy-proposals/src/runtime/transport.mock.ts
  90. 328 0
      packages/joy-proposals/src/runtime/transport.substrate.ts
  91. 79 0
      packages/joy-proposals/src/runtime/transport.ts
  92. 25 0
      packages/joy-proposals/src/stories/ProposalDetails.stories.tsx
  93. 38 0
      packages/joy-proposals/src/stories/ProposalForms.stories.tsx
  94. 11 0
      packages/joy-proposals/src/stories/ProposalPreview.stories.tsx
  95. 9 0
      packages/joy-proposals/src/stories/ProposalPreviewList.stories.tsx
  96. 9 0
      packages/joy-proposals/src/stories/ProposalTypes.stories.tsx
  97. 52 0
      packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts
  98. 8 0
      packages/joy-proposals/src/stories/data/ProposalPreview.mock.ts
  99. 95 0
      packages/joy-proposals/src/stories/data/ProposalPreviewList.mock.ts
  100. 160 0
      packages/joy-proposals/src/stories/data/ProposalTypesInfo.mock.ts

+ 1 - 1
README.md

@@ -4,7 +4,7 @@
 
 A Portal into the Joystream network. Provides a view and interaction layer from a browser.
 
-This can be accessed as a hosted application via [https://testnet.joystream.org/acropolis/pioneer](https://testnet.joystream.org/acropolis/pioneer).
+This can be accessed as a hosted application via [https://testnet.joystream.org](https://testnet.joystream.org).
 
 ## overview
 

+ 2 - 0
package.json

@@ -44,6 +44,8 @@
     "@storybook/addon-knobs": "^5.2.5",
     "@storybook/addon-storysource": "^5.2.5",
     "@types/jest": "^24.0.22",
+    "@types/react-router-dom": "^5.1.4",
+    "@types/yup": "^0.26.36",
     "autoprefixer": "^9.7.1",
     "empty": "^0.10.1",
     "html-loader": "^0.5.5",

+ 39 - 36
packages/app-staking/src/Actions/Accounts.tsx

@@ -8,6 +8,7 @@ import { I18nProps } from '@polkadot/react-components/types';
 import { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
 import { KeyringSectionOption } from '@polkadot/ui-keyring/options/types';
 import { withCalls, withMulti } from '@polkadot/react-api/with';
+import { withOnlyAccounts } from '@polkadot/joy-utils/MyAccount';
 
 import React, { useState } from 'react';
 import styled from 'styled-components';
@@ -23,7 +24,7 @@ interface Props extends I18nProps, ComponentProps, ApiProps {
   myControllers?: string[];
 }
 
-function getMyStashes (myControllers?: string[], allAccounts?: SubjectInfo): string[] | null {
+function getMyStashes(myControllers?: string[], allAccounts?: SubjectInfo): string[] | null {
   const result: string[] = [];
 
   if (!myControllers) {
@@ -39,11 +40,18 @@ function getMyStashes (myControllers?: string[], allAccounts?: SubjectInfo): str
   return result;
 }
 
-function Accounts ({ allAccounts, allStashes, className, myControllers, recentlyOnline, t }: Props): React.ReactElement<Props> {
+function Accounts({
+  allAccounts,
+  allStashes,
+  className,
+  myControllers,
+  recentlyOnline,
+  t
+}: Props): React.ReactElement<Props> {
   const [isNewStakeOpen, setIsNewStateOpen] = useState(false);
   const myStashes = getMyStashes(myControllers, allAccounts);
-  const stashOptions = allStashes.map((stashId): KeyringSectionOption =>
-    createOption(stashId, (<AccountName params={stashId} />) as any)
+  const stashOptions = allStashes.map(
+    (stashId): KeyringSectionOption => createOption(stashId, (<AccountName params={stashId} />) as any)
   );
   const isEmpty = !isNewStakeOpen && (!myStashes || myStashes.length === 0);
 
@@ -51,51 +59,46 @@ function Accounts ({ allAccounts, allStashes, className, myControllers, recently
 
   return (
     <CardGrid
-      buttons={
-        <Button
-          isPrimary
-          key='new-stake'
-          label={t('New stake')}
-          icon='add'
-          onClick={_toggleNewStake}
-        />
-      }
+      buttons={<Button isPrimary key="new-stake" label={t('New stake')} icon="add" onClick={_toggleNewStake} />}
       className={className}
       emptyText={t('No funds staked yet.')}
       isEmpty={isEmpty}
     >
-      {isNewStakeOpen && (
-        <StartStaking onClose={_toggleNewStake} />
-      )}
-      {myStashes && myStashes.map((address, index): React.ReactNode => (
-        address && (
-          <Account
-            allStashes={allStashes}
-            accountId={address}
-            key={index}
-            recentlyOnline={recentlyOnline}
-            stashOptions={stashOptions}
-          />
-        )
-      ))}
+      {isNewStakeOpen && <StartStaking onClose={_toggleNewStake} />}
+      {myStashes &&
+        myStashes.map(
+          (address, index): React.ReactNode =>
+            address && (
+              <Account
+                allStashes={allStashes}
+                accountId={address}
+                key={index}
+                recentlyOnline={recentlyOnline}
+                stashOptions={stashOptions}
+              />
+            )
+        )}
     </CardGrid>
   );
 }
 
 export default withMulti(
-  styled(Accounts)`
-    .ui--CardGrid-buttons {
-      text-align: right;
-    }
-  `,
+  withOnlyAccounts(
+    styled(Accounts)`
+      .ui--CardGrid-buttons {
+        text-align: right;
+      }
+    `
+  ),
   translate,
-  withCalls<Props>(
-    ['query.staking.bonded', {
+  withCalls<Props>([
+    'query.staking.bonded',
+    {
       isMulti: true,
       paramPick: ({ allAccounts }: Props): undefined | string[] => {
         return allAccounts && Object.keys(allAccounts);
       },
       propName: 'myControllers'
-    }]
-  )
+    }
+  ])
 );

+ 43 - 36
packages/app-staking/src/Overview/CurrentList.tsx

@@ -23,40 +23,52 @@ interface Props extends I18nProps {
   stakingOverview?: DerivedStakingOverview;
 }
 
-function renderColumn (myAccounts: string[], addresses: AccountId[] | string[], defaultName: string, withOnline: boolean, filter: string, { authorsMap, lastAuthors, recentlyOnline, stakingOverview }: Props, pointIndexes?: number[]): React.ReactNode {
-  return (addresses as AccountId[]).map((address, index): React.ReactNode => (
-    <Address
-      address={address}
-      authorsMap={authorsMap}
-      defaultName={defaultName}
-      filter={filter}
-      isElected={stakingOverview && stakingOverview.currentElected.some((accountId): boolean => accountId.eq(address))}
-      lastAuthors={lastAuthors}
-      key={address.toString()}
-      myAccounts={myAccounts}
-      points={
-        stakingOverview && pointIndexes && pointIndexes[index] !== -1
-          ? stakingOverview.eraPoints.individual[pointIndexes[index]]
-          : undefined
-      }
-      recentlyOnline={
-        withOnline
-          ? recentlyOnline
-          : undefined
-      }
-    />
-  ));
+function renderColumn(
+  myAccounts: string[],
+  addresses: AccountId[] | string[],
+  defaultName: string,
+  withOnline: boolean,
+  filter: string,
+  { authorsMap, lastAuthors, recentlyOnline, stakingOverview }: Props,
+  pointIndexes?: number[]
+): React.ReactNode {
+  return (addresses as AccountId[]).map(
+    (address, index): React.ReactNode => (
+      <Address
+        address={address}
+        authorsMap={authorsMap}
+        defaultName={defaultName}
+        filter={filter}
+        isElected={
+          stakingOverview && stakingOverview.currentElected.some((accountId): boolean => accountId.eq(address))
+        }
+        lastAuthors={lastAuthors}
+        key={address.toString()}
+        myAccounts={myAccounts}
+        points={
+          stakingOverview && pointIndexes && pointIndexes[index] !== -1
+            ? stakingOverview.eraPoints.individual[pointIndexes[index]]
+            : undefined
+        }
+        recentlyOnline={withOnline ? recentlyOnline : undefined}
+      />
+    )
+  );
 }
 
-function filterAccounts (list: string[] = [], without: AccountId[] | string[]): string[] {
+function filterAccounts(list: string[] = [], without: AccountId[] | string[]): string[] {
   return list.filter((accountId): boolean => !without.includes(accountId as any));
 }
 
-function CurrentList (props: Props): React.ReactElement<Props> {
+function CurrentList(props: Props): React.ReactElement<Props> {
   const { isSubstrateV2 } = useContext(ApiContext);
   const [filter, setFilter] = useState<ValidatorFilter>('all');
   const [myAccounts] = useState(keyring.getAccounts().map(({ address }): string => address));
-  const [{ electedFiltered, nextFiltered, pointIndexes }, setFiltered] = useState<{ electedFiltered: string[]; nextFiltered: string[]; pointIndexes: number[] }>({ electedFiltered: [], nextFiltered: [], pointIndexes: [] });
+  const [{ electedFiltered, nextFiltered, pointIndexes }, setFiltered] = useState<{
+    electedFiltered: string[];
+    nextFiltered: string[];
+    pointIndexes: number[];
+  }>({ electedFiltered: [], nextFiltered: [], pointIndexes: [] });
   const { next, stakingOverview, t } = props;
 
   useEffect((): void => {
@@ -89,17 +101,12 @@ function CurrentList (props: Props): React.ReactElement<Props> {
           withLabel={false}
         />
       </FilterOverlay>
-      <Columar className='validator--ValidatorsList'>
-        <Column
-          emptyText={t('No addresses found')}
-          headerText={t('validators')}
-        >
-          {stakingOverview && renderColumn(myAccounts, stakingOverview.validators, t('validator'), true, filter, props, pointIndexes)}
+      <Columar className="validator--ValidatorsList">
+        <Column emptyText={t('No addresses found')} headerText={t('validators')}>
+          {stakingOverview &&
+            renderColumn(myAccounts, stakingOverview.validators, t('validator'), true, filter, props, pointIndexes)}
         </Column>
-        <Column
-          emptyText={t('No addresses found')}
-          headerText={t('next up')}
-        >
+        <Column emptyText={t('No addresses found')} headerText={t('next up')}>
           {(electedFiltered.length !== 0 || nextFiltered.length !== 0) && (
             <>
               {renderColumn(myAccounts, electedFiltered, t('intention'), false, filter, props)}

+ 15 - 9
packages/app-staking/src/Overview/index.tsx

@@ -14,24 +14,30 @@ import Summary from './Summary';
 
 interface Props extends BareProps, ComponentProps {}
 
-export default function Overview ({ allControllers, allStashes, recentlyOnline, stakingOverview }: Props): React.ReactElement<Props> {
+export default function Overview({
+  allControllers,
+  allStashes,
+  recentlyOnline,
+  stakingOverview
+}: Props): React.ReactElement<Props> {
   const { isSubstrateV2 } = useContext(ApiContext);
   const { byAuthor, lastBlockAuthors, lastBlockNumber } = useContext(BlockAuthorsContext);
   const [next, setNext] = useState<string[]>([]);
   const validators = stakingOverview && stakingOverview.validators;
 
   useEffect((): void => {
-    validators && setNext(
-      isSubstrateV2
-        // this is a V2 node currentValidators is a list of stashes
-        ? allStashes.filter((address): boolean => !validators.includes(address as any))
-        // this is a V1 node currentValidators is a list of controllers
-        : allControllers.filter((address): boolean => !validators.includes(address as any))
-    );
+    validators &&
+      setNext(
+        isSubstrateV2
+          ? // this is a V2 node currentValidators is a list of stashes
+            allStashes.filter((address): boolean => !validators.includes(address as any))
+          : // this is a V1 node currentValidators is a list of controllers
+            allControllers.filter((address): boolean => !validators.includes(address as any))
+      );
   }, [allControllers, allStashes, validators]);
 
   return (
-    <div className='staking--Overview'>
+    <div className="staking--Overview">
       <Summary
         allControllers={allControllers}
         lastBlock={lastBlockNumber}

+ 28 - 17
packages/app-staking/src/index.tsx

@@ -34,8 +34,16 @@ interface Props extends AppProps, ApiProps, I18nProps {
 const EMPY_ACCOUNTS: string[] = [];
 const EMPTY_ALL: [string[], string[]] = [EMPY_ACCOUNTS, EMPY_ACCOUNTS];
 
-function App ({ allAccounts, allStashesAndControllers: [allStashes, allControllers] = EMPTY_ALL, basePath, className, recentlyOnline, stakingOverview, t }: Props): React.ReactElement<Props> {
-  const _renderComponent = (Component: React.ComponentType<ComponentProps>): () => React.ReactNode => {
+function App({
+  allAccounts,
+  allStashesAndControllers: [allStashes, allControllers] = EMPTY_ALL,
+  basePath,
+  className,
+  recentlyOnline,
+  stakingOverview,
+  t
+}: Props): React.ReactElement<Props> {
+  const _renderComponent = (Component: React.ComponentType<ComponentProps>): (() => React.ReactNode) => {
     // eslint-disable-next-line react/display-name
     return (): React.ReactNode => {
       if (!allAccounts) {
@@ -54,17 +62,19 @@ function App ({ allAccounts, allStashesAndControllers: [allStashes, allControlle
     };
   };
 
+  const hasAccount = allAccounts && Object.keys(allAccounts).length;
+
   return (
     <main className={`staking--App ${className}`}>
-      <HelpOverlay md={basicMd} />
+      <HelpOverlay
+        md={basicMd}
+        style={{
+          top: hasAccount ? '5.25rem' : 0
+        }}
+      />
       <header>
         <Tabs
           basePath={basePath}
-          hidden={
-            !allAccounts || Object.keys(allAccounts).length === 0
-              ? ['actions']
-              : []
-          }
           items={[
             {
               isRoot: true,
@@ -95,15 +105,16 @@ export default withMulti(
   translate,
   withCalls<Props>(
     ['derive.imOnline.receivedHeartbeats', { propName: 'recentlyOnline' }],
-    ['derive.staking.controllers', {
-      propName: 'allStashesAndControllers',
-      transform: ([stashes, controllers]: [AccountId[], Option<AccountId>[]]): [string[], string[]] => [
-        stashes.map((accountId): string => accountId.toString()),
-        controllers
-          .filter((optId): boolean => optId.isSome)
-          .map((accountId): string => accountId.unwrap().toString())
-      ]
-    }],
+    [
+      'derive.staking.controllers',
+      {
+        propName: 'allStashesAndControllers',
+        transform: ([stashes, controllers]: [AccountId[], Option<AccountId>[]]): [string[], string[]] => [
+          stashes.map((accountId): string => accountId.toString()),
+          controllers.filter((optId): boolean => optId.isSome).map((accountId): string => accountId.unwrap().toString())
+        ]
+      }
+    ],
     ['derive.staking.overview', { propName: 'stakingOverview' }]
   ),
   withObservable(accountObservable.subject, { propName: 'allAccounts' })

+ 38 - 52
packages/apps-routing/src/index.ts

@@ -8,15 +8,15 @@ import appSettings from '@polkadot/joy-settings/';
 
 import election from './joy-election';
 import forum from './joy-forum';
-import help from './joy-help';
+// import help from './joy-help';
 import media from './joy-media';
 import members from './joy-members';
 import proposals from './joy-proposals';
 import roles from './joy-roles';
 import storageRoles from './joy-storage';
-import pages from './joy-pages';
+// import pages from './joy-pages';
 
-import template from './123code';
+// import template from './123code';
 import accounts from './accounts';
 import addressbook from './addressbook';
 // import claims from './claims';
@@ -37,61 +37,47 @@ import toolbox from './toolbox';
 import transfer from './transfer';
 // import treasury from './treasury';
 
-const routes: Routes = appSettings.isBasicMode
-  ? ([] as Routes).concat(
-    explorer,
-    staking,
-    roles,
-    storageRoles,
-    transfer,
-    null,
-    media,
-    forum,
-    members,
-    accounts,
-    addressbook,
-    null,
-    election,
-    proposals,
-    null,
-    help,
-    settings,
-    template,
-    null,
-    pages
-  )
-  : ([] as Routes).concat(
-    // dashboard,
-    explorer,
-    staking,
-    roles,
-    storageRoles,
-    transfer,
-    null,
-    media,
-    forum,
-    members,
-    accounts,
-    addressbook,
-    null,
-    election,
-    proposals,
-    null,
+let routes: Routes = ([] as Routes);
+
+if (appSettings.isFullMode) {
+  routes = routes.concat(explorer);
+}
+
+// Basic routes
+routes = routes.concat(
+  staking,
+  roles,
+  storageRoles,
+  transfer,
+  null,
+  media,
+  forum,
+  members,
+  accounts,
+  addressbook,
+  null,
+  election,
+  proposals,
+  null
+);
+
+if (appSettings.isFullMode) {
+  routes = routes.concat(
     storage,
     extrinsics,
     sudo,
-    null,
-    help,
-    settings,
-    toolbox,
     js,
-    template,
-    null,
-    pages
-  );
+    toolbox,
+    null
+  )
+}
+
+routes = routes.concat(
+  settings
+);
 
 const setup: Routing = {
-  default: 'explorer',
+  default: 'staking',
   routes
 };
 

+ 3 - 7
packages/apps-routing/src/joy-election.ts

@@ -4,15 +4,11 @@ import Election from '@polkadot/joy-election/index';
 
 export const councilSidebarName = 'council';
 
-export default ([
+export default [
   {
     Component: Election,
     display: {
-      needsAccounts: true,
-      needsApi: [
-        'query.council.activeCouncil',
-        'query.councilElection.stage',
-      ]
+      needsApi: ['query.council.activeCouncil', 'query.councilElection.stage']
     },
     i18n: {
       defaultValue: 'Council'
@@ -20,4 +16,4 @@ export default ([
     icon: 'university',
     name: councilSidebarName
   }
-] as Routes);
+] as Routes;

+ 3 - 6
packages/apps-routing/src/joy-members.ts

@@ -2,14 +2,11 @@ import { Routes } from './types';
 
 import Members from '@polkadot/joy-members/index';
 
-export default ([
+export default [
   {
     Component: Members,
     display: {
-      needsAccounts: true,
-      needsApi: [
-        'query.members.membersCreated'
-      ]
+      needsApi: ['query.members.membersCreated']
     },
     i18n: {
       defaultValue: 'Membership'
@@ -17,4 +14,4 @@ export default ([
     icon: 'users',
     name: 'members'
   }
-] as Routes);
+] as Routes;

+ 4 - 7
packages/apps-routing/src/joy-proposals.ts

@@ -1,15 +1,12 @@
 import { Routes } from './types';
 
-import Proposals from '@polkadot/joy-proposals/index';
+import Proposals from '@polkadot/joy-proposals/';
 
-export default ([
+export default [
   {
     Component: Proposals,
     display: {
-      needsAccounts: true,
-      needsApi: [
-        'query.proposals.proposalCount',
-      ]
+      needsApi: ['query.proposalsEngine.proposalCount']
     },
     i18n: {
       defaultValue: 'Proposals'
@@ -17,4 +14,4 @@ export default ([
     icon: 'tasks',
     name: 'proposals'
   }
-] as Routes);
+] as Routes;

+ 3 - 6
packages/apps-routing/src/joy-storage.ts

@@ -2,14 +2,11 @@ import { Routes } from './types';
 
 import Storage from '@polkadot/joy-storage/index';
 
-export default ([
+export default [
   {
     Component: Storage,
     display: {
-      needsAccounts: true,
-      needsApi: [
-        'query.actors.actorAccountIds'
-      ]
+      needsApi: ['query.actors.actorAccountIds']
     },
     i18n: {
       defaultValue: 'Storage'
@@ -17,4 +14,4 @@ export default ([
     icon: 'database',
     name: 'storage'
   }
-] as Routes);
+] as Routes;

+ 2 - 1
packages/apps/public/locales/en/ui.json

@@ -640,5 +640,6 @@
   "OLD: Upload": "",
   "Staked Providers": "",
   "Role Details": "",
-  "My Staking Requests": ""
+  "My Staking Requests": "",
+  "New Proposal": ""
 }

+ 50 - 48
packages/apps/src/SideBar/Item.tsx

@@ -23,7 +23,6 @@ import { queryToProp } from '@polkadot/joy-utils/index';
 import { ElectionStage } from '@joystream/types/';
 import { councilSidebarName } from '@polkadot/apps-routing/joy-election';
 
-
 interface Props extends I18nProps {
   isCollapsed: boolean;
   onClick: () => void;
@@ -34,15 +33,14 @@ interface Props extends I18nProps {
 }
 
 type Subtitle = {
-  text: string,
-  classes: string[]
+  text: string;
+  classes: string[];
 };
 
-
 const disabledLog: Map<string, string> = new Map();
 const TOOLTIP_OFFSET = { right: -4 };
 
-function logDisabled (route: string, message: string): void {
+function logDisabled(route: string, message: string): void {
   if (!disabledLog.get(route)) {
     disabledLog.set(route, message);
 
@@ -50,7 +48,7 @@ function logDisabled (route: string, message: string): void {
   }
 }
 
-function hasEndpoint (api: ApiPromise, endpoint: string): boolean {
+function hasEndpoint(api: ApiPromise, endpoint: string): boolean {
   const [area, section, method] = endpoint.split('.');
 
   try {
@@ -60,7 +58,13 @@ function hasEndpoint (api: ApiPromise, endpoint: string): boolean {
   }
 }
 
-function checkVisible (name: string, { api, isApiReady, isApiConnected }: ApiProps, hasAccounts: boolean, hasSudo: boolean, { isHidden, needsAccounts, needsApi, needsSudo }: Route['display']): boolean {
+function checkVisible(
+  name: string,
+  { api, isApiReady, isApiConnected }: ApiProps,
+  hasAccounts: boolean,
+  hasSudo: boolean,
+  { isHidden, needsAccounts, needsApi, needsSudo }: Route['display']
+): boolean {
   if (isHidden) {
     return false;
   } else if (needsAccounts && !hasAccounts) {
@@ -89,7 +93,15 @@ function checkVisible (name: string, { api, isApiReady, isApiConnected }: ApiPro
   return notFound.length === 0;
 }
 
-function Item ({ allAccounts, route: { Modal, display, i18n, icon, name }, t, isCollapsed, onClick, sudoKey, electionStage }: Props): React.ReactElement<Props> | null {
+function Item({
+  allAccounts,
+  route: { Modal, display, i18n, icon, name },
+  t,
+  isCollapsed,
+  onClick,
+  sudoKey,
+  electionStage
+}: Props): React.ReactElement<Props> | null {
   const apiProps = useContext(ApiContext);
   const [hasAccounts, setHasAccounts] = useState(false);
   const [hasSudo, setHasSudo] = useState(false);
@@ -126,54 +138,46 @@ function Item ({ allAccounts, route: { Modal, display, i18n, icon, name }, t, is
       }
     }
     return undefined;
-  }
+  };
 
   const subtitle = _getSubtitle(name);
 
   const body = (
     <>
       <Icon name={icon} />
-      <span className='text SidebarItem'>
-            <div>{t(`sidebar.${name}`, i18n)}</div>
-            {subtitle && <div className={`SidebarSubtitle ${subtitle.classes.join(' ')}`}>{subtitle.text}</div>}
+      <span className="text SidebarItem">
+        <div>{t(`sidebar.${name}`, i18n)}</div>
+        {subtitle && <div className={`SidebarSubtitle ${subtitle.classes.join(' ')}`}>{subtitle.text}</div>}
       </span>
-      <Tooltip
-        offset={TOOLTIP_OFFSET}
-        place='right'
-        text={t(`sidebar.${name}`, i18n)}
-        trigger={`nav-${name}`}
-      />
+      <Tooltip offset={TOOLTIP_OFFSET} place="right" text={t(`sidebar.${name}`, i18n)} trigger={`nav-${name}`} />
     </>
   );
 
   return (
-    <Menu.Item className='apps--SideBar-Item'>
-      {Modal
-        ? (
-          <a
-            className='apps--SideBar-Item-NavLink'
-            data-for={`nav-${name}`}
-            data-tip
-            data-tip-disable={!isCollapsed}
-            onClick={onClick}
-          >
-            {body}
-          </a>
-        )
-        : (
-          <NavLink
-            activeClassName='apps--SideBar-Item-NavLink-active'
-            className='apps--SideBar-Item-NavLink'
-            data-for={`nav-${name}`}
-            data-tip
-            data-tip-disable={!isCollapsed}
-            onClick={onClick}
-            to={`/${name}`}
-          >
-            {body}
-          </NavLink>
-        )
-      }
+    <Menu.Item className="apps--SideBar-Item">
+      {Modal ? (
+        <a
+          className="apps--SideBar-Item-NavLink"
+          data-for={`nav-${name}`}
+          data-tip
+          data-tip-disable={!isCollapsed}
+          onClick={onClick}
+        >
+          {body}
+        </a>
+      ) : (
+        <NavLink
+          activeClassName="apps--SideBar-Item-NavLink-active"
+          className="apps--SideBar-Item-NavLink"
+          data-for={`nav-${name}`}
+          data-tip
+          data-tip-disable={!isCollapsed}
+          onClick={onClick}
+          to={`/${name}`}
+        >
+          {body}
+        </NavLink>
+      )}
     </Menu.Item>
   );
 }
@@ -182,8 +186,6 @@ export default withMulti(
   Item,
   translate,
   withCalls(queryToProp('query.councilElection.stage', { propName: 'electionStage' })),
-  withCalls<Props>(
-    ['query.sudo.key', { propName: 'sudoKey' }]
-  ),
+  withCalls<Props>(['query.sudo.key', { propName: 'sudoKey' }]),
   withObservable(accountObservable.subject, { propName: 'allAccounts' })
 );

+ 53 - 149
packages/apps/src/SideBar/index.tsx

@@ -9,18 +9,15 @@ import './SideBar.css';
 
 import React, { useState } from 'react';
 import styled from 'styled-components';
-import { Responsive } from 'semantic-ui-react';
+import { Responsive, Icon, SemanticICONS } from 'semantic-ui-react';
 import routing from '@polkadot/apps-routing';
-import { Button, ChainImg, Icon, Menu, media } from '@polkadot/react-components';
+import { Button, ChainImg, Menu, media } from '@polkadot/react-components';
 import { classes } from '@polkadot/react-components/util';
 
 import translate from '../translate';
 import Item from './Item';
-import NodeInfo from './NodeInfo';
 import NetworkModal from '../modals/Network';
 
-import { SemanticICONS } from 'semantic-ui-react';
-
 interface Props extends I18nProps /*ApiProps,*/ {
   className?: string;
   collapse: () => void;
@@ -31,177 +28,93 @@ interface Props extends I18nProps /*ApiProps,*/ {
 }
 
 type OuterLinkProps = {
-  url: string,
-  title: string,
-  icon?: SemanticICONS
+  url: string;
+  title: string;
+  icon?: SemanticICONS;
 };
 
-function OuterLink ({ url, title, icon = 'external alternate' }: OuterLinkProps) {
+export function OuterLink({ url, title, icon = 'external alternate' }: OuterLinkProps) {
   return (
-    <Menu.Item className='apps--SideBar-Item'>
-      <a className='apps--SideBar-Item-NavLink' href={url} target='_blank'>
+    <Menu.Item className="apps--SideBar-Item">
+      <a className="apps--SideBar-Item-NavLink" href={url} target="_blank">
         <Icon name={icon} />
-        <span className='text'>{title}</span>
+        <span className="text">{title}</span>
       </a>
     </Menu.Item>
   );
 }
 
-/*
-interface State {
-  modals: Record<string, boolean>;
-}
-
-class SideBar extends React.PureComponent<Props, State> {
-  public state: State;
-
-  public constructor (props: Props) {
-    super(props);
-
-    // setup modals for each of the actual modal routes
-    this.state = {
-      modals: routing.routes.reduce((result, route): Record<string, boolean> => {
+function SideBar({
+  className,
+  collapse,
+  handleResize,
+  isCollapsed,
+  toggleMenu,
+  menuOpen
+}: Props): React.ReactElement<Props> {
+  const [modals, setModals] = useState<Record<string, boolean>>(
+    routing.routes.reduce(
+      (result: Record<string, boolean>, route): Record<string, boolean> => {
         if (route && route.Modal) {
           result[route.name] = false;
         }
 
         return result;
-      }, { network: false } as unknown as Record<string, boolean>)
-    };
-  }
-
-  public render (): React.ReactNode {
-    const { className, handleResize, isCollapsed, toggleMenu, menuOpen } = this.props;
-
-    return (
-      <Responsive
-        onUpdate={handleResize}
-        className={classes(className, 'apps-SideBar-Wrapper', isCollapsed ? 'collapsed' : 'expanded')}
-      >
-        <ChainImg
-          className={`toggleImg ${menuOpen ? 'closed' : 'open delayed'}`}
-          onClick={toggleMenu}
-        />
-        {this.renderModals()}
-        {this.state.modals.network && (
-          <NetworkModal onClose={this.toggleNetworkModal}/>
-        )}
-        <div className='apps--SideBar'>
-          <Menu
-            secondary
-            vertical
-          >
-            <div className='apps-SideBar-Scroll'>
-              {this.renderJoystreamLogo()}
-              {this.renderRoutes()}
-              <Menu.Divider hidden />
-
-              <OuterLink url='https://testnet.joystream.org/faucet' title='Free Tokens' />
-              <OuterLink url='https://blog.joystream.org/acropolis-incentives/' title='Earn Monero' />
-              <Menu.Divider hidden />
-              {
-                isCollapsed
-                  ? undefined
-                  : <NodeInfo />
-              }
-            </div>
-            {this.renderCollapse()}
-          </Menu>
-          <Responsive minWidth={SIDEBAR_MENU_THRESHOLD}>
-*/
-function SideBar ({ className, collapse, handleResize, isCollapsed, toggleMenu, menuOpen }: Props): React.ReactElement<Props> {
-  const [modals, setModals] = useState<Record<string, boolean>>(
-    routing.routes.reduce((result: Record<string, boolean>, route): Record<string, boolean> => {
-      if (route && route.Modal) {
-        result[route.name] = false;
-      }
-
-      return result;
-    }, { network: false })
+      },
+      { network: false }
+    )
   );
 
-  const _toggleModal = (name: string): () => void =>
-    (): void => setModals({ ...modals, [name]: !modals[name] });
+  const _toggleModal = (name: string): (() => void) => (): void => setModals({ ...modals, [name]: !modals[name] });
 
   return (
     <Responsive
       onUpdate={handleResize}
       className={classes(className, 'apps-SideBar-Wrapper', isCollapsed ? 'collapsed' : 'expanded')}
     >
-      <ChainImg
-        className={`toggleImg ${menuOpen ? 'closed' : 'open delayed'}`}
-        onClick={toggleMenu}
-      />
-      {routing.routes.map((route): React.ReactNode => (
-        route && route.Modal
-          ? route.Modal && modals[route.name]
-            ? (
-              <route.Modal
-                key={route.name}
-                onClose={_toggleModal(route.name)}
-              />
+      <ChainImg className={`toggleImg ${menuOpen ? 'closed' : 'open delayed'}`} onClick={toggleMenu} />
+      {routing.routes.map(
+        (route): React.ReactNode =>
+          route && route.Modal ? (
+            route.Modal && modals[route.name] ? (
+              <route.Modal key={route.name} onClose={_toggleModal(route.name)} />
+            ) : (
+              <div key={route.name} />
             )
-            : <div key={route.name} />
-          : null
-      ))}
-      {modals.network && (
-        <NetworkModal onClose={_toggleModal('network')}/>
+          ) : null
       )}
-      <div className='apps--SideBar'>
-        <Menu
-          secondary
-          vertical
-        >
-          <div className='apps-SideBar-Scroll'>
+      {modals.network && <NetworkModal onClose={_toggleModal('network')} />}
+      <div className="apps--SideBar">
+        <Menu secondary vertical>
+          <div className="apps-SideBar-Scroll">
             {JoystreamLogo(isCollapsed)}
-            {routing.routes.map((route, index): React.ReactNode => (
-              route
-                ? (
+            {routing.routes.map(
+              (route, index): React.ReactNode =>
+                route ? (
                   <Item
                     isCollapsed={isCollapsed}
                     key={route.name}
                     route={route}
-                    onClick={
-                      route.Modal
-                        ? _toggleModal(route.name)
-                        : handleResize
-                    }
-                  />
-                )
-                : (
-                  <Menu.Divider
-                    hidden
-                    key={index}
+                    onClick={route.Modal ? _toggleModal(route.name) : handleResize}
                   />
+                ) : (
+                  <Menu.Divider hidden key={index} />
                 )
-            ))}
-            <Menu.Divider hidden />
-            <OuterLink url='https://faucet.joystream.org/' title='Free Tokens' />
-            <OuterLink url='https://blog.joystream.org/acropolis-incentives/' title='Earn Monero' />
-            <Menu.Divider hidden />
-            {
-              isCollapsed
-                ? undefined
-                : <NodeInfo />
-            }
+            )}
+            {/* <Menu.Divider hidden />
+            <OuterLink url='https://joystream.org/testnet' title='Tokenomics' />
+            <OuterLink url='https://blog.joystream.org/constantinople-incentives/' title='Earn Monero' />
+            <Menu.Divider hidden /> */}
           </div>
           <Responsive
             minWidth={SIDEBAR_MENU_THRESHOLD}
             className={`apps--SideBar-collapse ${isCollapsed ? 'collapsed' : 'expanded'}`}
           >
-            <Button
-              icon={`angle double ${isCollapsed ? 'right' : 'left'}`}
-              isBasic
-              isCircular
-              onClick={collapse}
-            />
+            <Button icon={`angle double ${isCollapsed ? 'right' : 'left'}`} isBasic isCircular onClick={collapse} />
           </Responsive>
         </Menu>
         <Responsive minWidth={SIDEBAR_MENU_THRESHOLD}>
-          <div
-            className='apps--SideBar-toggle'
-            onClick={collapse}
-          />
+          <div className="apps--SideBar-toggle" onClick={collapse} />
         </Responsive>
       </div>
     </Responsive>
@@ -237,16 +150,7 @@ export default translate(
   `
 );
 
-
-function JoystreamLogo (isCollapsed: boolean) {
-  const logo = isCollapsed
-  ? 'images/logo-j.svg'
-  : 'images/logo-joytream.svg';
-  return (
-  <img
-    alt='Joystream'
-    className='apps--SideBar-logo'
-    src={logo}
-  />
-  );
+function JoystreamLogo(isCollapsed: boolean) {
+  const logo = isCollapsed ? 'images/logo-j.svg' : 'images/logo-joytream.svg';
+  return <img alt="Joystream" className="apps--SideBar-logo" src={logo} />;
 }

+ 30 - 24
packages/apps/src/TopBar.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { Link } from 'react-router-dom';
+// import { Link } from 'react-router-dom';
 import { I18nProps } from '@polkadot/react-components/types';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { InputAddress } from '@polkadot/react-components';
@@ -9,33 +9,39 @@ import './TopBar.css';
 
 type Props = I18nProps & {};
 
-function renderAddress (address: string) {
-  const balance = <span className='label'>Balance: </span>;
+function renderAddress(address: string) {
+  const balance = <span className="label">Balance: </span>;
 
-  return <div className='JoyTopBar'>
-    <InputAddress
-      defaultValue={address}
-      help='My current key that signs transactions'
-      label='My key'
-      labelExtra={<Available label={balance} params={address} />}
-      type='account'
-    />
-  </div>;
+  return (
+    <div className="JoyTopBar">
+      <InputAddress
+        defaultValue={address}
+        help="My current key that signs transactions"
+        label="My key"
+        labelExtra={<Available label={balance} params={address} />}
+        type="account"
+      />
+    </div>
+  );
 }
 
-function renderNoAddress () {
-  return <div className='JoyTopBar NoMyAddress'>
-    <i className='warning sign icon'></i>
-    <span style={{ marginRight: '1rem' }}>You need to create a key if you want to use all features.</span>
-    <Link className='ui small button orange' to='/accounts'>Create key</Link>
-  </div>;
-}
+// function renderNoAddress() {
+//   return (
+//     <div className="JoyTopBar NoMyAddress">
+//       <i className="warning sign icon"></i>
+//       <span style={{ marginRight: '1rem' }}>You need to create a key if you want to use all features.</span>
+//       <Link className="ui small button orange" to="/accounts">
+//         Create key
+//       </Link>
+//     </div>
+//   );
+// }
 
-function Component (_props: Props) {
-  const { state: { address } } = useMyAccount();
-  return address
-    ? renderAddress(address)
-    : renderNoAddress();
+function Component(_props: Props) {
+  const {
+    state: { address }
+  } = useMyAccount();
+  return address ? renderAddress(address) : null;
 }
 
 export default translate(Component);

+ 19 - 6
packages/apps/src/index.tsx

@@ -27,6 +27,23 @@ import Apps from './Apps';
 const rootId = 'root';
 const rootElement = document.getElementById(rootId);
 
+(window as any).Joystream = {
+  setRemoteEndpoint: (apiUrl: string) => {
+    settings.set({
+      apiUrl
+    });
+
+    window.location.reload();
+  },
+  setAdvancedUI: (flag: boolean) => {
+    settings.set({
+      uiMode: flag ? 'full' : 'light'
+    });
+
+    window.location.reload();
+  }
+};
+
 // we split here so that both these forms are allowed
 //  - http://localhost:3000/?rpc=wss://substrate-rpc.parity.io/#/explorer
 //  - http://localhost:3000/#/explorer?rpc=wss://substrate-rpc.parity.io
@@ -66,16 +83,12 @@ if (!rootElement) {
 }
 
 ReactDOM.render(
-  <Suspense fallback='...'>
+  <Suspense fallback="...">
     <MyAccountProvider>
       <Queue>
         <QueueConsumer>
           {({ queuePayload, queueSetTxStatus }): React.ReactNode => (
-            <Api
-              queuePayload={queuePayload}
-              queueSetTxStatus={queueSetTxStatus}
-              url={wsEndpoint}
-            >
+            <Api queuePayload={queuePayload} queueSetTxStatus={queueSetTxStatus} url={wsEndpoint}>
               <MyMembershipProvider>
                 <BlockAuthors>
                   <Events>

+ 2 - 2
packages/joy-election/src/Applicant.tsx

@@ -7,7 +7,7 @@ import { ApiProps } from '@polkadot/react-api/types';
 import { withCalls } from '@polkadot/react-api/with';
 import { AccountId } from '@polkadot/types/interfaces';
 import { formatBalance } from '@polkadot/util';
-import AddressMini from '@polkadot/react-components/AddressMiniJoy';
+import CandidatePreview from "./CandidatePreview";
 
 import translate from './translate';
 import { calcTotalStake } from '@polkadot/joy-utils/index';
@@ -29,7 +29,7 @@ class Applicant extends React.PureComponent<Props> {
       <Table.Row>
         <Table.Cell>{index + 1}</Table.Cell>
         <Table.Cell>
-          <AddressMini value={accountId} isShort={false} isPadded={false} withBalance={true} />
+          <CandidatePreview accountId={accountId}/>
         </Table.Cell>
         <Table.Cell style={{ textAlign: 'right' }}>
           {formatBalance(calcTotalStake(stake))}

+ 35 - 0
packages/joy-election/src/CandidatePreview.tsx

@@ -0,0 +1,35 @@
+import React from "react";
+import AddressMini from "@polkadot/react-components/AddressMiniJoy";
+import MemberByAccount from "@polkadot/joy-utils/MemberByAccountPreview";
+import { AccountId } from "@polkadot/types/interfaces";
+
+import styled from 'styled-components';
+
+const StyledCouncilCandidate = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  padding: 1rem;
+`;
+
+const CandidateMembership = styled.div``;
+
+const CandidateAddress = styled.div`
+  margin-left: auto;
+`;
+
+type CouncilCandidateProps = {
+  accountId: AccountId | string;
+};
+
+const CouncilCandidate: React.FunctionComponent<CouncilCandidateProps> = ({ accountId }) => (
+  <StyledCouncilCandidate>
+    <CandidateMembership>
+      <MemberByAccount accountId={accountId} />
+    </CandidateMembership>
+    <CandidateAddress>
+      <AddressMini value={accountId} isShort={false} isPadded={false} withBalance={true} />
+    </CandidateAddress>
+  </StyledCouncilCandidate>
+);
+
+export default CouncilCandidate;

+ 42 - 45
packages/joy-election/src/Council.tsx

@@ -1,63 +1,62 @@
-import React from 'react';
+import React from "react";
 
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { withCalls } from '@polkadot/react-api/with';
-import { Table } from 'semantic-ui-react';
-import { formatBalance } from '@polkadot/util';
-import AddressMini from '@polkadot/react-components/AddressMiniJoy';
+import { ApiProps } from "@polkadot/react-api/types";
+import { I18nProps } from "@polkadot/react-components/types";
+import { withCalls } from "@polkadot/react-api/with";
+import { Table } from "semantic-ui-react";
+import { formatBalance } from "@polkadot/util";
+import CouncilCandidate from './CandidatePreview';
 
-import { calcBackersStake } from '@polkadot/joy-utils/index';
-import { Seat } from '@joystream/types/';
-import translate from './translate';
-import Section from '@polkadot/joy-utils/Section';
+import { calcBackersStake } from "@polkadot/joy-utils/index";
+import { Seat } from "@joystream/types/";
+import translate from "./translate";
+import Section from "@polkadot/joy-utils/Section";
 
-type Props = ApiProps & I18nProps & {
-  council?: Seat[]
-};
+type Props = ApiProps &
+  I18nProps & {
+    council?: Seat[];
+  };
 
 type State = {};
 
 class Council extends React.PureComponent<Props, State> {
-
   state: State = {};
 
-  private renderTable (council: Seat[]) {
+  private renderTable(council: Seat[]) {
     return (
       <Table celled selectable compact>
-      <Table.Header>
-        <Table.Row>
-          <Table.HeaderCell>#</Table.HeaderCell>
-          <Table.HeaderCell>Council member</Table.HeaderCell>
-          <Table.HeaderCell>Own stake</Table.HeaderCell>
-          <Table.HeaderCell>Backers' stake</Table.HeaderCell>
-          <Table.HeaderCell>Backers count</Table.HeaderCell>
-        </Table.Row>
-      </Table.Header>
-      <Table.Body>{council.map((seat, index) => (
-        <Table.Row key={index}>
-          <Table.Cell>{index + 1}</Table.Cell>
-          <Table.Cell>
-            <AddressMini value={seat.member} isShort={false} isPadded={false} withBalance={true} />
-          </Table.Cell>
-          <Table.Cell>{formatBalance(seat.stake)}</Table.Cell>
-          <Table.Cell>{formatBalance(calcBackersStake(seat.backers))}</Table.Cell>
-          <Table.Cell>{seat.backers.length}</Table.Cell>
-        </Table.Row>
-      ))}</Table.Body>
+        <Table.Header>
+          <Table.Row>
+            <Table.HeaderCell>#</Table.HeaderCell>
+            <Table.HeaderCell>Council member</Table.HeaderCell>
+            <Table.HeaderCell>Own stake</Table.HeaderCell>
+            <Table.HeaderCell>Backers' stake</Table.HeaderCell>
+            <Table.HeaderCell>Backers count</Table.HeaderCell>
+          </Table.Row>
+        </Table.Header>
+        <Table.Body>
+          {council.map((seat, index) => (
+            <Table.Row key={index}>
+              <Table.Cell>{index + 1}</Table.Cell>
+              <Table.Cell>
+                <CouncilCandidate accountId={seat.member} />
+              </Table.Cell>
+              <Table.Cell>{formatBalance(seat.stake)}</Table.Cell>
+              <Table.Cell>{formatBalance(calcBackersStake(seat.backers))}</Table.Cell>
+              <Table.Cell>{seat.backers.length}</Table.Cell>
+            </Table.Row>
+          ))}
+        </Table.Body>
       </Table>
     );
   }
 
-  render () {
+  render() {
     const { council = [] } = this.props;
     // console.log({ council });
     return (
-      <Section title='Active council members'>
-      {!council.length
-          ? <em>Council is not elected yet</em>
-          : this.renderTable(council)
-      }
+      <Section title="Active council members">
+        {!council.length ? <em>Council is not elected yet</em> : this.renderTable(council)}
       </Section>
     );
   }
@@ -65,7 +64,5 @@ class Council extends React.PureComponent<Props, State> {
 
 // inject the actual API calls automatically into props
 export default translate(
-  withCalls<Props>(
-    ['query.council.activeCouncil', { propName: 'council' }]
-  )(Council)
+  withCalls<Props>(["query.council.activeCouncil", { propName: "council" }])(Council)
 );

+ 2 - 1
packages/joy-election/src/SealedVote.tsx

@@ -12,6 +12,7 @@ import translate from './translate';
 import { calcTotalStake } from '@polkadot/joy-utils/index';
 import { SealedVote } from '@joystream/types/';
 import AddressMini from '@polkadot/react-components/AddressMiniJoy';
+import CandidatePreview from "./CandidatePreview";
 import { findVoteByHash } from './myVotesStore';
 
 type Props = ApiProps & I18nProps & {
@@ -29,7 +30,7 @@ class Comp extends React.PureComponent<Props> {
 
     if (sealedVote.vote.isSome) {
       const candidateId = sealedVote.vote.unwrap();
-      return <AddressMini value={candidateId} isShort={false} isPadded={false} withBalance={true} />;
+      return <CandidatePreview accountId={candidateId} />;
     } else {
       const revealUrl = `/council/reveals?hashedVote=${hash.toHex()}`;
       return <Link to={revealUrl} className='ui button primary inverted'>Reveal this vote</Link>;

+ 12 - 12
packages/joy-election/src/VoteForm.tsx

@@ -8,17 +8,18 @@ import { AppProps, I18nProps } from '@polkadot/react-components/types';
 import { ApiProps } from '@polkadot/react-api/types';
 import { withCalls, withMulti } from '@polkadot/react-api/with';
 import { AccountId, Balance } from '@polkadot/types/interfaces';
-import { Button, Input, Labelled, InputAddress } from '@polkadot/react-components/index';
+import { Button, Input, Labelled } from '@polkadot/react-components/index';
 import { SubmittableResult } from '@polkadot/api';
 import { formatBalance } from '@polkadot/util';
 
 import translate from './translate';
-import { accountIdsToOptions, hashVote } from './utils';
+import { hashVote } from './utils';
 import { queryToProp, ZERO, getUrlParam, nonEmptyStr } from '@polkadot/joy-utils/index';
 import TxButton from '@polkadot/joy-utils/TxButton';
 import InputStake from '@polkadot/joy-utils/InputStake';
-import AddressMini from '@polkadot/react-components/AddressMiniJoy';
+import CandidatePreview from "./CandidatePreview";
 import { MyAccountProps, withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
+import MembersDropdown from "@polkadot/joy-utils/MembersDropdown";
 import { saveVote, NewVote } from './myVotesStore';
 import { TxFailedCallback } from '@polkadot/react-components/Status/types';
 
@@ -63,7 +64,6 @@ class Component extends React.PureComponent<Props, State> {
     const { myAddress } = this.props;
     const { applicantId, stake, salt, isStakeValid, isFormSubmitted } = this.state;
     const isFormValid = nonEmptyStr(applicantId) && isStakeValid;
-    const applicantOpts = accountIdsToOptions(this.props.applicants || []);
     const hashedVote = hashVote(applicantId, salt);
 
     const buildNewVote = (): Partial<NewVote> => ({
@@ -86,7 +86,9 @@ class Component extends React.PureComponent<Props, State> {
         <Table.Body>
           <Table.Row>
             <Table.Cell>Applicant</Table.Cell>
-            <Table.Cell><AddressMini value={applicantId} isShort={false} isPadded={false} withBalance={true}  /></Table.Cell>
+            <Table.Cell>
+              { applicantId && <CandidatePreview accountId={applicantId}/> }
+            </Table.Cell>
           </Table.Row>
           <Table.Row>
             <Table.Cell>Stake</Table.Cell>
@@ -115,13 +117,11 @@ class Component extends React.PureComponent<Props, State> {
       // New vote form:
       : <div>
         <div className='ui--row'>
-          <InputAddress
-            label='Applicant to vote for:'
-            onChange={this.onChangeApplicant}
-            type='address'
-            options={applicantOpts}
-            value={applicantId}
-            placeholder='Select an applicant you support'
+          <MembersDropdown
+            onChange={ (event, data) => this.onChangeApplicant(data.value as string) }
+            accounts={this.props.applicants || []}
+            value={applicantId || ''}
+            placeholder="Select an applicant you support"
           />
         </div>
         <InputStake

+ 1 - 1
packages/joy-forum/src/CategoryList.tsx

@@ -16,7 +16,7 @@ import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { withForumCalls } from './calls';
 import { withMulti, withApi } from '@polkadot/react-api';
 import { ApiProps } from '@polkadot/react-api/types';
-import { bnToStr, isEmptyArr } from '@polkadot/joy-utils/';
+import { bnToStr, isEmptyArr } from '@polkadot/joy-utils/index';
 import TxButton from '@polkadot/joy-utils/TxButton';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';

+ 1 - 1
packages/joy-forum/src/ViewThread.tsx

@@ -15,7 +15,7 @@ import { withForumCalls } from './calls';
 import { withApi, withMulti } from '@polkadot/react-api';
 import { ApiProps } from '@polkadot/react-api/types';
 import { orderBy } from 'lodash';
-import { bnToStr } from '@polkadot/joy-utils/';
+import { bnToStr } from '@polkadot/joy-utils/index';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
 

+ 1 - 1
packages/joy-media/src/channels/ChannelAvatar.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 import { Link } from 'react-router-dom';
 import { ChannelEntity } from '../entities/ChannelEntity';
 import { BgImg } from '../common/BgImg';
-import { DEFAULT_THUMBNAIL_URL } from '../utils';
+import { DEFAULT_THUMBNAIL_URL } from '@polkadot/joy-utils/images';
 
 const defaultSizePx = 75;
 

+ 1 - 1
packages/joy-media/src/channels/ChannelPreview.tsx

@@ -7,7 +7,7 @@ import { ChannelAvatar, ChannelAvatarSize } from './ChannelAvatar';
 import { isPublicChannel } from './ChannelHelpers';
 import { isMusicChannel, isVideoChannel, isAccountAChannelOwner, isVerifiedChannel } from './ChannelHelpers';
 import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext';
-import { nonEmptyStr } from '@polkadot/joy-utils/';
+import { nonEmptyStr } from '@polkadot/joy-utils/index';
 import { CurationPanel } from './CurationPanel';
 import { ChannelNameAsLink } from './ChannelNameAsLink';
 

+ 2 - 2
packages/joy-media/src/channels/EditChannel.tsx

@@ -5,12 +5,12 @@ import { History } from 'history';
 
 import { Text, Option } from '@polkadot/types';
 import TxButton from '@polkadot/joy-utils/TxButton';
-import { onImageError } from '../utils';
+import { onImageError } from '@polkadot/joy-utils/images';
 import { withMediaForm, MediaFormProps } from '../common/MediaForms';
 import { ChannelType, ChannelClass as Fields, buildChannelValidationSchema, ChannelFormValues, ChannelToFormValues, ChannelGenericProp } from '../schemas/channel/Channel';
 import { MediaDropdownOptions } from '../common/MediaDropdownOptions';
 import { ChannelId, ChannelContentType, ChannelPublicationStatus, OptionalText } from '@joystream/types/content-working-group';
-import { newOptionalText, findFirstParamOfSubstrateEvent } from '@polkadot/joy-utils/';
+import { newOptionalText, findFirstParamOfSubstrateEvent } from '@polkadot/joy-utils/index';
 import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext';
 import { ChannelPublicationStatusDropdownOptions, isAccountAChannelOwner } from './ChannelHelpers';
 import { TxCallback } from '@polkadot/react-components/Status/types';

+ 1 - 1
packages/joy-media/src/music/EditMusicAlbum.tsx

@@ -4,7 +4,7 @@ import { Form, withFormik } from 'formik';
 import { History } from 'history';
 
 import TxButton from '@polkadot/joy-utils/TxButton';
-import { onImageError } from '../utils';
+import { onImageError } from '@polkadot/joy-utils/images';
 import { ReorderableTracks } from './ReorderableTracks';
 import { MusicAlbumValidationSchema, MusicAlbumType, MusicAlbumClass as Fields, MusicAlbumFormValues, MusicAlbumToFormValues } from '../schemas/music/MusicAlbum';
 import { withMediaForm, MediaFormProps, datePlaceholder } from '../common/MediaForms';

+ 2 - 1
packages/joy-media/src/schemas/channel/Channel.ts

@@ -3,7 +3,8 @@ import * as Yup from 'yup';
 import { BlockNumber, AccountId } from '@polkadot/types/interfaces';
 import { ChannelContentTypeValue, PrincipalId, Channel, ChannelId, ChannelPublicationStatusValue, ChannelCurationStatusValue } from '@joystream/types/content-working-group';
 import { MemberId } from '@joystream/types/members';
-import { ChannelValidationConstraints, ValidationConstraint } from '@polkadot/joy-media/transport';
+import { ChannelValidationConstraints } from '@polkadot/joy-media/transport';
+import { ValidationConstraint } from '@polkadot/joy-utils/ValidationConstraint';
 
 function textValidation (constraint?: ValidationConstraint) {
   if (!constraint) {

+ 2 - 1
packages/joy-media/src/transport.substrate.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js';
-import { MediaTransport, ChannelValidationConstraints, ValidationConstraint } from './transport';
+import { MediaTransport, ChannelValidationConstraints } from './transport';
 import { ClassId, Class, EntityId, Entity, ClassName } from '@joystream/types/versioned-store';
 import { InputValidationLengthConstraint } from '@joystream/types/forum';
 import { PlainEntity, EntityCodecResolver } from '@joystream/types/versioned-store/EntityCodec';
@@ -25,6 +25,7 @@ import { ChannelCodec } from './schemas/channel/Channel';
 import { FeaturedContentType } from './schemas/general/FeaturedContent';
 import { AnyChannelId, asChannelId, AnyClassId, AnyEntityId } from './common/TypeHelpers';
 import { SimpleCache } from '@polkadot/joy-utils/SimpleCache';
+import { ValidationConstraint } from '@polkadot/joy-utils/ValidationConstraint';
 
 const FIRST_CHANNEL_ID = 1;
 const FIRST_CLASS_ID = 1;

+ 1 - 5
packages/joy-media/src/transport.ts

@@ -18,11 +18,7 @@ import { MediaDropdownOptions } from './common/MediaDropdownOptions';
 import { ChannelEntity } from './entities/ChannelEntity';
 import { ChannelId } from '@joystream/types/content-working-group';
 import { isVideoChannel, isPublicChannel } from './channels/ChannelHelpers';
-
-export interface ValidationConstraint {
-  min: number
-  max: number
-}
+import { ValidationConstraint } from '@polkadot/joy-utils/ValidationConstraint';
 
 export interface ChannelValidationConstraints {
   handle: ValidationConstraint

+ 1 - 1
packages/joy-media/src/upload/UploadAudio.tsx

@@ -5,7 +5,7 @@ import { History } from 'history';
 
 import TxButton from '@polkadot/joy-utils/TxButton';
 import { ContentId } from '@joystream/types/media';
-import { onImageError } from '../utils';
+import { onImageError } from '@polkadot/joy-utils/images';
 import { MusicTrackValidationSchema, MusicTrackType, MusicTrackClass as Fields, MusicTrackFormValues, MusicTrackToFormValues } from '../schemas/music/MusicTrack';
 import { withMediaForm, MediaFormProps, datePlaceholder } from '../common/MediaForms';
 import EntityId from '@joystream/types/versioned-store/EntityId';

+ 2 - 2
packages/joy-media/src/upload/UploadVideo.tsx

@@ -6,7 +6,7 @@ import moment from 'moment';
 
 import TxButton, { OnTxButtonClick } from '@polkadot/joy-utils/TxButton';
 import { ContentId } from '@joystream/types/media';
-import { onImageError } from '../utils';
+import { onImageError } from '@polkadot/joy-utils/images';
 import { VideoValidationSchema, VideoType, VideoClass as Fields, VideoFormValues, VideoToFormValues, VideoCodec, VideoPropId } from '../schemas/video/Video';
 import { MediaFormProps, withMediaForm, datePlaceholder } from '../common/MediaForms';
 import EntityId from '@joystream/types/versioned-store/EntityId';
@@ -18,7 +18,7 @@ import { Credential } from '@joystream/types/versioned-store/permissions/credent
 import { Class, VecClassPropertyValue } from '@joystream/types/versioned-store';
 import { TxCallback } from '@polkadot/react-components/Status/types';
 import { SubmittableResult } from '@polkadot/api';
-import { nonEmptyStr, filterSubstrateEventsAndExtractData } from '@polkadot/joy-utils/';
+import { nonEmptyStr, filterSubstrateEventsAndExtractData } from '@polkadot/joy-utils/index';
 import { u16, u32, bool, Option, Vec } from '@polkadot/types';
 import { isInternalProp } from '@joystream/types/versioned-store/EntityCodec';
 import { MediaObjectCodec } from '../schemas/general/MediaObject';

+ 0 - 15
packages/joy-media/src/utils.ts

@@ -1,18 +1,3 @@
-export const DEFAULT_THUMBNAIL_URL = 'images/default-thumbnail.png';
-
-// This is a hack to just satisfy TypeScript compiler.
-type ImageOnErrorEvent = EventTarget & {
-  src: string,
-  onerror?: (e: any) => void
-};
-
-export function onImageError (event: React.SyntheticEvent<HTMLImageElement, Event>) {
-  const target = event.target as ImageOnErrorEvent;
-  // Set onerror callback to undefined to prevent infinite callbacks when image src path fails:
-  target.onerror = undefined;
-  target.src = DEFAULT_THUMBNAIL_URL;
-}
-
 export function fileNameWoExt (fileName: string): string {
   const lastDotIdx = fileName.lastIndexOf('.');
   return fileName.substring(0, lastDotIdx);

+ 16 - 7
packages/joy-members/src/Details.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { Link } from 'react-router-dom';
-import { Table } from 'semantic-ui-react';
+import { Table, Loader } from 'semantic-ui-react';
 import ReactMarkdown from 'react-markdown';
 import { IdentityIcon } from '@polkadot/react-components';
 import { ApiProps } from '@polkadot/react-api/types';
@@ -21,17 +21,23 @@ import { MyAccountProps, withMyAccount } from '@polkadot/joy-utils/MyAccount';
 type Props = ApiProps & I18nProps & MyAccountProps & {
   preview?: boolean,
   memberId: MemberId,
-  memberProfile?: Option<any>, // TODO refactor to Option<Profile>
+  // This cannot be named just "memberProfile", since it will conflict with "withAccount's" memberProfile
+  // (which holds  member profile associated with currently selected account)
+  detailsMemberProfile?: Option<any>, // TODO refactor to Option<Profile>
   activeCouncil?: Seat[]
 };
 
 class Component extends React.PureComponent<Props> {
 
   render () {
-    const { memberProfile } = this.props;
-    return memberProfile
-      ? this.renderProfile(memberProfile.unwrap() as Profile)
-      : null;
+    const { detailsMemberProfile } = this.props;
+    return detailsMemberProfile
+      ? this.renderProfile(detailsMemberProfile.unwrap() as Profile)
+      : (
+        <div className={`item ProfileDetails`}>
+          <Loader active inline/>
+        </div>
+      );
   }
 
   private renderProfile (memberProfile: Profile) {
@@ -167,6 +173,9 @@ class Component extends React.PureComponent<Props> {
 export default translate(withMyAccount(
   withCalls<Props>(
     queryToProp('query.council.activeCouncil'),
-    queryMembershipToProp('memberProfile', 'memberId'),
+    queryMembershipToProp(
+      'memberProfile',
+      { paramName: 'memberId', propName: 'detailsMemberProfile' }
+    ),
   )(Component)
 ));

+ 135 - 108
packages/joy-members/src/EditForm.tsx

@@ -17,42 +17,42 @@ import { withCalls } from '@polkadot/react-api/index';
 import { Button, Message } from 'semantic-ui-react';
 import { formatBalance } from '@polkadot/util';
 import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
-import isEqual from 'lodash/isEqual'
+import isEqual from 'lodash/isEqual';
 
 // TODO get next settings from Substrate:
 const HANDLE_REGEX = /^[a-z0-9_]+$/;
 
-const buildSchema = (p: ValidationProps) => Yup.object().shape({
-  handle: Yup.string()
-    .matches(HANDLE_REGEX, 'Handle can have only lowercase letters (a-z), numbers (0-9) and underscores (_).')
-    .min(p.minHandleLength, `Handle is too short. Minimum length is ${p.minHandleLength} chars.`)
-    .max(p.maxHandleLength, `Handle is too long. Maximum length is ${p.maxHandleLength} chars.`)
-    .required('Handle is required'),
-  avatar: Yup.string()
-    .url('Avatar must be a valid URL of an image.')
-    .max(p.maxAvatarUriLength, `Avatar URL is too long. Maximum length is ${p.maxAvatarUriLength} chars.`),
-  about: Yup.string()
-    .max(p.maxAboutTextLength, `Text is too long. Maximum length is ${p.maxAboutTextLength} chars.`)
-});
+const buildSchema = (p: ValidationProps) =>
+  Yup.object().shape({
+    handle: Yup.string()
+      .matches(HANDLE_REGEX, 'Handle can have only lowercase letters (a-z), numbers (0-9) and underscores (_).')
+      .min(p.minHandleLength, `Handle is too short. Minimum length is ${p.minHandleLength} chars.`)
+      .max(p.maxHandleLength, `Handle is too long. Maximum length is ${p.maxHandleLength} chars.`)
+      .required('Handle is required'),
+    avatar: Yup.string()
+      .url('Avatar must be a valid URL of an image.')
+      .max(p.maxAvatarUriLength, `Avatar URL is too long. Maximum length is ${p.maxAvatarUriLength} chars.`),
+    about: Yup.string().max(p.maxAboutTextLength, `Text is too long. Maximum length is ${p.maxAboutTextLength} chars.`)
+  });
 
 type ValidationProps = {
-  minHandleLength: number,
-  maxHandleLength: number,
-  maxAvatarUriLength: number,
-  maxAboutTextLength: number
+  minHandleLength: number;
+  maxHandleLength: number;
+  maxAvatarUriLength: number;
+  maxAboutTextLength: number;
 };
 
 type OuterProps = ValidationProps & {
-  profile?: Profile,
-  paidTerms: PaidMembershipTerms,
-  paidTermId: PaidTermId,
-  memberId? : MemberId,
+  profile?: Profile;
+  paidTerms: PaidMembershipTerms;
+  paidTermId: PaidTermId;
+  memberId?: MemberId;
 };
 
 type FormValues = {
-  handle: string,
-  avatar: string,
-  about: string
+  handle: string;
+  avatar: string;
+  about: string;
 };
 
 type FieldName = keyof FormValues;
@@ -76,7 +76,7 @@ const InnerForm = (props: FormProps) => {
     isSubmitting,
     setSubmitting,
     resetForm,
-    memberId,
+    memberId
   } = props;
 
   const onSubmit = (sendTx: () => void) => {
@@ -102,91 +102,101 @@ const InnerForm = (props: FormProps) => {
 
   // TODO extract to forms.tsx
   const fieldToTextOption = (field: FieldName): OptionText => {
-    return isFieldChanged(field)
-      ? OptionText.some(values[field])
-      : OptionText.none();
+    return isFieldChanged(field) ? OptionText.some(values[field]) : OptionText.none();
   };
 
   const buildTxParams = () => {
     if (!isValid) return [];
 
     const userInfo = new UserInfo({
-      handle:     fieldToTextOption('handle'),
+      handle: fieldToTextOption('handle'),
       avatar_uri: fieldToTextOption('avatar'),
-      about:      fieldToTextOption('about')
+      about: fieldToTextOption('about')
     });
 
     if (profile) {
       // update profile
-      return [ memberId, userInfo ];
+      return [memberId, userInfo];
     } else {
       // register as new member
-      return [ paidTermId, userInfo ];
+      return [paidTermId, userInfo];
     }
   };
 
   // TODO show warning that you don't have enough balance to buy a membership
 
   return (
-    <Section title='My Membership Profile'>
-    <Form className='ui form JoyForm'>
-      <LabelledText name='handle' label='Handle/nickname' placeholder={`You can use a-z, 0-9 and underscores.`} style={{ maxWidth: '30rem' }} {...props}/>
-      <LabelledText name='avatar' label='Avatar URL' placeholder='Paste here an URL of your avatar image.' {...props}/>
-      <LabelledField name='about' label='About' {...props}>
-        <Field component='textarea' id='about' name='about' disabled={isSubmitting} rows={3} placeholder='Write here anything you would like to share about yourself with Joystream community.' />
-      </LabelledField>
-      {!profile && paidTerms &&
-        <Message warning style={{ display: 'block', marginBottom: '0' }}>
-          <p>Membership costs <b>{formatBalance(paidTerms.fee)}</b> tokens.</p>
-          <p>
-            <span>By clicking the "Register" button you agree to our </span>
-            <Link to={`/pages/tos`}>Terms of Service</Link>
-            <span> and </span>
-            <Link to={`/pages/privacy`}>Privacy Policy</Link>
-            .
-          </p>
-        </Message>
-      }
-      <LabelledField invisibleLabel {...props}>
-        <TxButton
-          type='submit'
-          size='large'
-          label={profile
-            ? 'Update my profile'
-            : 'Register'
-          }
-          isDisabled={!dirty || isSubmitting}
-          params={buildTxParams()}
-          tx={profile
-            ? 'members.updateProfile'
-            : 'members.buyMembership'
-          }
-          onClick={onSubmit}
-          txFailedCb={onTxFailed}
-          txSuccessCb={onTxSuccess}
+    <Section title="My Membership Profile">
+      <Form className="ui form JoyForm">
+        <LabelledText
+          name="handle"
+          label="Handle/nickname"
+          placeholder={`You can use a-z, 0-9 and underscores.`}
+          style={{ maxWidth: '30rem' }}
+          {...props}
         />
-        <Button
-          type='button'
-          size='large'
-          disabled={!dirty || isSubmitting}
-          onClick={() => resetForm()}
-          content='Reset form'
+        <LabelledText
+          name="avatar"
+          label="Avatar URL"
+          placeholder="Paste here an URL of your avatar image."
+          {...props}
         />
-      </LabelledField>
-    </Form>
+        <LabelledField name="about" label="About" {...props}>
+          <Field
+            component="textarea"
+            id="about"
+            name="about"
+            disabled={isSubmitting}
+            rows={3}
+            placeholder="Write here anything you would like to share about yourself with Joystream community."
+          />
+        </LabelledField>
+        {!profile && paidTerms && (
+          <Message warning style={{ display: 'block', marginBottom: '0' }}>
+            <p>
+              Membership costs <b>{formatBalance(paidTerms.fee)}</b> tokens.
+            </p>
+            <p>
+              <span>By clicking the "Register" button you agree to our </span>
+              <Link to={`/pages/tos`}>Terms of Service</Link>
+              <span> and </span>
+              <Link to={`/pages/privacy`}>Privacy Policy</Link>.
+            </p>
+          </Message>
+        )}
+        <LabelledField invisibleLabel {...props}>
+          <TxButton
+            type="submit"
+            size="large"
+            label={profile ? 'Update my profile' : 'Register'}
+            isDisabled={!dirty || isSubmitting}
+            params={buildTxParams()}
+            tx={profile ? 'members.updateProfile' : 'members.buyMembership'}
+            onClick={onSubmit}
+            txFailedCb={onTxFailed}
+            txSuccessCb={onTxSuccess}
+          />
+          <Button
+            type="button"
+            size="large"
+            disabled={!dirty || isSubmitting}
+            onClick={() => resetForm()}
+            content="Reset form"
+          />
+        </LabelledField>
+      </Form>
     </Section>
   );
 };
 
 const EditForm = withFormik<OuterProps, FormValues>({
-
   // Transform outer props into form values
   mapPropsToValues: props => {
     const { profile: p } = props;
     return {
       handle: p ? p.handle.toString() : '',
       avatar: p ? p.avatar_uri.toString() : '',
-      about:  p ? p.about.toString() : ''
+      about: p ? p.about.toString() : ''
     };
   },
 
@@ -198,17 +208,17 @@ const EditForm = withFormik<OuterProps, FormValues>({
 })(InnerForm);
 
 type WithMyProfileProps = {
-  memberId?: MemberId,
-  memberProfile?: Option<any>, // TODO refactor to Option<Profile>
-  paidTermsId: PaidTermId,
-  paidTerms?: Option<PaidMembershipTerms>,
-  minHandleLength?: BN,
-  maxHandleLength?: BN,
-  maxAvatarUriLength?: BN,
-  maxAboutTextLength?: BN
+  memberId?: MemberId;
+  memberProfile?: Option<any>; // TODO refactor to Option<Profile>
+  paidTermsId: PaidTermId;
+  paidTerms?: Option<PaidMembershipTerms>;
+  minHandleLength?: BN;
+  maxHandleLength?: BN;
+  maxAvatarUriLength?: BN;
+  maxAboutTextLength?: BN;
 };
 
-function WithMyProfileInner (p: WithMyProfileProps) {
+function WithMyProfileInner(p: WithMyProfileProps) {
   const triedToFindProfile = !p.memberId || p.memberProfile;
   if (
     triedToFindProfile &&
@@ -224,16 +234,18 @@ function WithMyProfileInner (p: WithMyProfileProps) {
       console.error('Could not find active paid membership terms');
     }
 
-    return <EditForm
-      minHandleLength={p.minHandleLength.toNumber()}
-      maxHandleLength={p.maxHandleLength.toNumber()}
-      maxAvatarUriLength={p.maxAvatarUriLength.toNumber()}
-      maxAboutTextLength={p.maxAboutTextLength.toNumber()}
-      profile={profile as Profile}
-      paidTerms={p.paidTerms.unwrap()}
-      paidTermId={p.paidTermsId}
-      memberId={p.memberId}
-    />;
+    return (
+      <EditForm
+        minHandleLength={p.minHandleLength.toNumber()}
+        maxHandleLength={p.maxHandleLength.toNumber()}
+        maxAvatarUriLength={p.maxAvatarUriLength.toNumber()}
+        maxAboutTextLength={p.maxAboutTextLength.toNumber()}
+        profile={profile as Profile}
+        paidTerms={p.paidTerms.unwrap()}
+        paidTermId={p.paidTermsId}
+        memberId={p.memberId}
+      />
+    );
   } else return <em>Loading...</em>;
 }
 
@@ -243,17 +255,29 @@ const WithMyProfile = withCalls<WithMyProfileProps>(
   queryMembershipToProp('maxAvatarUriLength'),
   queryMembershipToProp('maxAboutTextLength'),
   queryMembershipToProp('memberProfile', 'memberId'),
-  queryMembershipToProp('paidMembershipTermsById',
-    { paramName: 'paidTermsId', propName: 'paidTerms' })
+  queryMembershipToProp('paidMembershipTermsById', { paramName: 'paidTermsId', propName: 'paidTerms' })
 )(WithMyProfileInner);
 
 type WithMyMemberIdProps = MyAccountProps & {
-  memberIdsByRootAccountId?: Vec<MemberId>,
-  memberIdsByControllerAccountId?: Vec<MemberId>,
-  paidTermsIds?: Vec<PaidTermId>
+  memberIdsByRootAccountId?: Vec<MemberId>;
+  memberIdsByControllerAccountId?: Vec<MemberId>;
+  paidTermsIds?: Vec<PaidTermId>;
 };
 
-function WithMyMemberIdInner (p: WithMyMemberIdProps) {
+function WithMyMemberIdInner(p: WithMyMemberIdProps) {
+  if (p.allAccounts && !Object.keys(p.allAccounts).length) {
+    return (
+      <Message warning className="JoyMainStatus">
+        <Message.Header>Please create a key to get started.</Message.Header>
+        <div style={{ marginTop: '1rem' }}>
+          <Link to={`/accounts`} className="ui button orange">
+            Create key
+          </Link>
+        </div>
+      </Message>
+    );
+  }
+
   if (p.memberIdsByRootAccountId && p.memberIdsByControllerAccountId && p.paidTermsIds) {
     if (p.paidTermsIds.length) {
       // let member_ids = p.memberIdsByRootAccountId.slice(); // u8a.subarray is not a function!!
@@ -265,13 +289,16 @@ function WithMyMemberIdInner (p: WithMyMemberIdProps) {
       console.error('Active paid membership terms is empty');
     }
   }
+
   return <em>Loading...</em>;
 }
 
-const WithMyMemberId = withMyAccount(withCalls<WithMyMemberIdProps>(
-  queryMembershipToProp('memberIdsByRootAccountId', 'myAddress'),
-  queryMembershipToProp('memberIdsByControllerAccountId', 'myAddress'),
-  queryMembershipToProp('activePaidMembershipTerms', { propName: 'paidTermsIds' })
-)(WithMyMemberIdInner));
+const WithMyMemberId = withMyAccount(
+  withCalls<WithMyMemberIdProps>(
+    queryMembershipToProp('memberIdsByRootAccountId', 'myAddress'),
+    queryMembershipToProp('memberIdsByControllerAccountId', 'myAddress'),
+    queryMembershipToProp('activePaidMembershipTerms', { propName: 'paidTermsIds' })
+  )(WithMyMemberIdInner)
+);
 
 export default WithMyMemberId;

+ 61 - 14
packages/joy-members/src/List.tsx

@@ -8,43 +8,90 @@ import Section from '@polkadot/joy-utils/Section';
 import translate from './translate';
 import Details from './Details';
 import { MemberId } from '@joystream/types/members';
+import { RouteComponentProps, Redirect } from 'react-router-dom';
+import { Pagination, Icon, PaginationProps } from 'semantic-ui-react';
+import styled from 'styled-components';
 
-type Props = ApiProps & I18nProps & {
+const StyledPagination = styled(Pagination)`
+  border-bottom: 1px solid #ddd !important;
+`;
+
+type Props = ApiProps & I18nProps & RouteComponentProps & {
   firstMemberId: BN,
-  membersCreated: BN
+  membersCreated: BN,
+  match: { params: { page?: string } }
 };
 
 type State = {};
 
+const MEMBERS_PER_PAGE = 20;
+
 class Component extends React.PureComponent<Props, State> {
 
   state: State = {};
 
+  onPageChange = (e: React.MouseEvent, data: PaginationProps) => {
+    const { history } = this.props;
+    history.push(`/members/list/${ data.activePage }`);
+  }
+
+  renderPagination(currentPage:number, pagesCount: number) {
+    return (
+      <StyledPagination
+        pointing
+        secondary
+        activePage={ currentPage }
+        ellipsisItem={{ content: <Icon name='ellipsis horizontal' />, icon: true }}
+        firstItem={{ content: <Icon name='angle double left' />, icon: true }}
+        lastItem={{ content: <Icon name='angle double right' />, icon: true }}
+        prevItem={{ content: <Icon name='angle left' />, icon: true }}
+        nextItem={{ content: <Icon name='angle right' />, icon: true }}
+        totalPages={ pagesCount }
+        onPageChange={ this.onPageChange }
+      />
+    )
+  }
+
   render () {
     const {
       firstMemberId,
-      membersCreated
+      membersCreated,
+      match: { params: { page } }
     } = this.props;
 
     const membersCount = membersCreated.toNumber();
+    const pagesCount = Math.ceil(membersCount / MEMBERS_PER_PAGE) || 1;
+    const currentPage = Math.min(parseInt(page || '1'), pagesCount);
+
+    if (currentPage.toString() !== page) {
+      return <Redirect to={ `/members/list/${ currentPage }` } />;
+    }
+
     const ids: MemberId[] = [];
     if (membersCount > 0) {
-      const firstId = firstMemberId.toNumber();
-      for (let i = firstId; i < membersCount; i++) {
+      const firstId = firstMemberId.toNumber() + (currentPage - 1) * MEMBERS_PER_PAGE;
+      const lastId = Math.min(firstId + MEMBERS_PER_PAGE, membersCount) - 1;
+      for (let i = firstId; i <= lastId; i++) {
         ids.push(new MemberId(i));
       }
     }
 
     return (
-      <Section title={`Members (${membersCount})`}>{
-        ids.length === 0
-          ? <em>No registered members yet.</em>
-          : <div className='ui huge relaxed middle aligned divided list ProfilePreviews'>
-              {ids.map((id, i) =>
-                <Details {...this.props} key={i} memberId={id} preview />
-              )}
-            </div>
-      }</Section>
+      <Section
+        title={`Members (${membersCount})`}
+        pagination={ (pagesCount > 1 && this.renderPagination(currentPage, pagesCount)) || undefined }>
+        {
+          membersCount === 0
+            ? <em>No registered members yet.</em>
+            : (
+              <div className='ui huge relaxed middle aligned divided list ProfilePreviews'>
+                {ids.map((id, i) =>
+                  <Details {...this.props} key={i} memberId={id} preview />
+                )}
+              </div>
+            )
+        }
+      </Section>
     );
   }
 }

+ 9 - 8
packages/joy-members/src/index.tsx

@@ -18,6 +18,7 @@ import DetailsByHandle from './DetailsByHandle';
 import EditForm from './EditForm';
 import { withMyAccount, MyAccountProps } from '@polkadot/joy-utils/MyAccount';
 import {FIRST_MEMBER_ID} from './constants';
+import { RouteComponentProps } from 'react-router-dom';
 
 // define out internal types
 type Props = AppProps & ApiProps & I18nProps & MyAccountProps & {
@@ -31,9 +32,9 @@ class App extends React.PureComponent<Props> {
 
     return [
       {
-        isRoot: true,
-        name: 'members',
-        text: t('All members') + ` (${memberCount})`
+        name: 'list',
+        text: t('All members') + ` (${memberCount})`,
+        forcedExact: false
       },
       {
         name: 'edit',
@@ -46,17 +47,16 @@ class App extends React.PureComponent<Props> {
     ];
   }
 
-  private renderList () {
+  private renderList (routeProps: RouteComponentProps) {
     const { membersCreated, ...otherProps } = this.props;
     return membersCreated ?
-      <List firstMemberId={FIRST_MEMBER_ID} membersCreated={membersCreated} {...otherProps} />
+      <List firstMemberId={FIRST_MEMBER_ID} membersCreated={membersCreated} {...otherProps} {...routeProps}/>
       : <em>Loading...</em>;
   }
 
   render () {
     const { basePath } = this.props;
     const tabs = this.buildTabs();
-    const list = () => this.renderList();
 
     return (
       <main className='members--App'>
@@ -66,8 +66,9 @@ class App extends React.PureComponent<Props> {
         <Switch>
           <Route path={`${basePath}/edit`} component={EditForm} />
           <Route path={`${basePath}/dashboard`} component={Dashboard} />
-          <Route path={`${basePath}/:handle`} component={DetailsByHandle} />
-          <Route render={list} />
+          <Route path={`${basePath}/list/:page([0-9]+)?`} render={ props => this.renderList(props) } />
+          <Route exact={true} path={`${basePath}/:handle`} component={DetailsByHandle} />
+          <Route render={ props => this.renderList(props) } />
         </Switch>
       </main>
     );

+ 1 - 3
packages/joy-pages/src/md/Privacy.md

@@ -14,7 +14,7 @@ Relevant to the Privacy Policy and Cookie Policy are the following terms:
 
 
 # Privacy Policy
-**Last updated on the 17th of March 2020**
+**Last updated on the 19th of May 2020**
 
 ## 1. Agreement to the Policy
 By using any of Our Software, the User are accepting this Privacy Policy. If you are acting on behalf of another company or an employer, you must have the rights to act on their behalf. The Privacy Policy is not extended to any of our newsletters, where Users are bound by the [privacy policy](https://mailchimp.com/legal/privacy/) of [Mailchimp](https://mailchimp.com/).
@@ -28,8 +28,6 @@ This Privacy Policy may be changed at the sole discretion of Company. If any mat
 All data written to the Blockchain, is implicitly collected not only by Company, but also anyone else in the world that is running the Full Node locally, or accessed via the App or a third party.
 This includes, but is not limited to, Content hashes, Membership profile, Memo field, and any other way a User can record data on the Blockchain.
 
-When using the [faucet](https://faucet.joystream.org/) ("Faucet") subpage of the Website, Company will record the IP address behind every new request for tokens. This data will be deleted every 14 days.
-
 Company uses [Google Analytics](https://marketingplatform.google.com/about/analytics/), with IP anonymization, to collect statistics on Website and the version of App hosted by us. All customizable data sharing settings are turned off to improve the privacy of Users.
 
 Company will not sell your data for advertising, or other purposes.

+ 2 - 1
packages/joy-proposals/package.json

@@ -8,8 +8,9 @@
   "maintainers": [],
   "dependencies": {
     "@babel/runtime": "^7.7.1",
+    "@polkadot/joy-utils": "^0.1.1",
     "@polkadot/react-components": "0.37.0-beta.63",
     "@polkadot/react-query": "0.37.0-beta.63",
-    "@polkadot/joy-utils": "^0.1.1"
+    "react-dropzone": "^10.2.2"
   }
 }

+ 0 - 104
packages/joy-proposals/src/Dashboard.tsx

@@ -1,104 +0,0 @@
-import BN from 'bn.js';
-import React from 'react';
-
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { withCalls } from '@polkadot/react-api/with';
-import { BlockNumber, Balance } from '@polkadot/types/interfaces';
-import { Bubble } from '@polkadot/react-components/index';
-import { formatNumber, formatBalance } from '@polkadot/util';
-
-import { queryToProp, ZERO } from '@polkadot/joy-utils/index';
-import Section from '@polkadot/joy-utils/Section';
-import translate from './translate';
-
-type Props = ApiProps & I18nProps & {
-  bestNumber?: BN,
-  approvalQuorum: BN,
-  minStake: Balance,
-  cancellationFee: Balance,
-  rejectionFee: Balance,
-  votingPeriod: BlockNumber,
-  nameMaxLen: BN,
-  descriptionMaxLen: BN,
-  wasmCodeMaxLen: BN,
-  proposalCount: BN,
-  activeProposalIds: BN[]
-};
-
-type State = {};
-
-class Dashboard extends React.PureComponent<Props, State> {
-
-  state: State = {};
-
-  renderProposals () {
-    const p = this.props;
-    const { proposalCount = ZERO, activeProposalIds = [] } = p;
-
-    return <Section title='Proposals'>
-      <Bubble label='Active proposals'>
-        {activeProposalIds.length}
-      </Bubble>
-      <Bubble label='All proposals'>
-        {formatNumber(proposalCount)}
-      </Bubble>
-    </Section>;
-  }
-
-  renderConfig () {
-    const p = this.props;
-    return <Section title='Configuration'>
-      <Bubble label='Approval quorum'>
-        {formatNumber(p.approvalQuorum)}%
-      </Bubble>
-      <Bubble label='Min. stake for proposal'>
-        {formatBalance(p.minStake)}
-      </Bubble>
-      <Bubble label='Min. cancellation fee'>
-        {formatBalance(p.cancellationFee)}
-      </Bubble>
-      <Bubble label='Min. rejection fee'>
-        {formatBalance(p.rejectionFee)}
-      </Bubble>
-      <Bubble label='Voting period'>
-        {formatNumber(p.votingPeriod)} blocks
-      </Bubble>
-      <Bubble label='Max. length of name'>
-        {formatNumber(p.nameMaxLen)} chars
-      </Bubble>
-      <Bubble label='Max. length of description'>
-        {formatNumber(p.descriptionMaxLen)} chars
-      </Bubble>
-      <Bubble label='Max. length of WASM code'>
-        {formatNumber(p.wasmCodeMaxLen)} chars
-      </Bubble>
-    </Section>;
-  }
-
-  render () {
-    return (
-      <div className='JoySections'>
-        {this.renderProposals()}
-        {this.renderConfig()}
-      </div>
-    );
-  }
-}
-
-// inject the actual API calls automatically into props
-export default translate(
-  withCalls<Props>(
-    queryToProp('derive.chain.bestNumber'),
-    queryToProp('query.proposals.approvalQuorum'), // TODO rename to 'quorumPercent' ?
-    queryToProp('query.proposals.minStake'),
-    queryToProp('query.proposals.cancellationFee'),
-    queryToProp('query.proposals.rejectionFee'),
-    queryToProp('query.proposals.votingPeriod'),
-    queryToProp('query.proposals.nameMaxLen'),
-    queryToProp('query.proposals.descriptionMaxLen'),
-    queryToProp('query.proposals.wasmCodeMaxLen'),
-    queryToProp('query.proposals.proposalCount'),
-    queryToProp('query.proposals.activeProposalIds')
-  )(Dashboard)
-);

+ 0 - 254
packages/joy-proposals/src/Details.tsx

@@ -1,254 +0,0 @@
-import BN from 'bn.js';
-import React from 'react';
-import { Table, Message } from 'semantic-ui-react';
-import ReactMarkdown from 'react-markdown';
-
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { withCalls } from '@polkadot/react-api/with';
-import { Labelled } from '@polkadot/react-components/index';
-import { formatNumber, formatBalance } from '@polkadot/util';
-
-import { queryToProp, ZERO } from '@polkadot/joy-utils/index';
-import AddressMini from '@polkadot/react-components/AddressMiniJoy';
-import Section from '@polkadot/joy-utils/Section';
-import translate from './translate';
-import FilterProps from './FilterProps';
-import { Seat, VoteKind, VoteKinds, Proposal, ProposalVotes, ProposalStatuses as Status } from '@joystream/types/';
-import TxButton from '@polkadot/joy-utils/TxButton';
-import { MyAccountProps, withMyAccount } from '@polkadot/joy-utils/MyAccount';
-import { Link } from 'react-router-dom';
-
-const allVoteKinds = [
-  VoteKinds.Approve,
-  VoteKinds.Reject,
-  VoteKinds.Slash,
-  VoteKinds.Abstain
-];
-
-type Props = ApiProps & I18nProps & FilterProps & MyAccountProps & {
-  preview?: boolean,
-  activeCouncil?: Seat[],
-  id: BN,
-  proposal?: Proposal,
-  votes?: ProposalVotes,
-  votingPeriod?: BN,
-  bestNumber?: BN
-};
-
-type State = {
-  preview: boolean
-};
-
-export class Component extends React.PureComponent<Props, State> {
-
-  constructor (props: Props) {
-    super(props);
-    const { preview = false } = this.props;
-    this.state = {
-      preview
-    };
-  }
-
-  render () {
-    const { proposal } = this.props;
-    // console.log({ proposal });
-    return !proposal ? null : this.renderProposal(proposal);
-  }
-
-  private renderProposal = (proposal: Proposal) => {
-    const p = this.props;
-    const { myAddress, activeCouncil = [], votes = [], votingPeriod = ZERO, bestNumber = ZERO } = p;
-
-    const { preview } = this.state;
-    const accountAlreadyVoted = votes.length > 0
-      && votes.find(([voter]) => voter.eq(myAddress)) !== undefined;
-
-    const expiresAt = proposal.proposed_at.add(votingPeriod);
-    const blocksLeftForVoting = expiresAt.gt(bestNumber)
-      ? expiresAt.sub(bestNumber)
-      : ZERO;
-
-    const proposalUrl = `/proposals/${proposal.id}`;
-
-    // TODO Improve UX: use status to filter proposals:
-
-    const status = proposal.status.toString();
-    const isActive = status === Status.Active;
-    // const isCancelled = status === Status.Cancelled;
-    // const isExpired = status === Status.Expired;
-    // const isApproved = status === Status.Approved;
-    // const isRejected = status === Status.Rejected;
-    // const isSlashed = status === Status.Slashed;
-    // const isFinalized = !isActive;
-
-    const amICouncilor = activeCouncil.find(x => myAddress === x.member.toString()) !== undefined;
-    const canVote = amICouncilor && isActive;
-
-    return <>
-      <h2 className='header'>
-        <span className='Proposal-name' style={{ marginRight: '.25rem' }}>
-          {preview
-            ? <Link to={proposalUrl}>{proposal.name}</Link>
-            : proposal.name
-          }
-        </span>
-        <span style={{ color: '#bbb' }}>
-          {' #'}{formatNumber(proposal.id)}
-        </span>
-      </h2>
-
-      <div style={{ margin: '1rem 0' }}>
-        {this.renderStatus(proposal.status.toString())}
-        <div className='ui basic label'>Votes:
-          <div className='detail'>{votes.length} / {activeCouncil.length}</div>
-        </div>
-        {isActive && <div className='ui basic label'>Voting ends in:
-          <div className='detail'>{formatNumber(blocksLeftForVoting)}</div>
-        </div>}
-        <div className='ui basic label'>Proposed at block #
-          <div className='detail'>{formatNumber(proposal.proposed_at)}</div>
-        </div>
-        <div className='ui basic label'>Stake:
-          <div className='detail'>{formatBalance(proposal.stake)}</div>
-        </div>
-      </div>
-
-      <div style={{ marginTop: '.5rem' }}>
-        <span className='Preview-label'>Hash of runtime upgrade: </span>
-        <code>{proposal.wasm_hash.toString()}</code>
-      </div>
-
-      <div style={{ marginTop: '.5rem' }}>
-        <span className='Preview-label' style={{ marginRight: '.25rem' }}>Proposer: </span>
-        <AddressMini value={proposal.proposer} isShort={false} isPadded={true} withBalance={true} />
-      </div>
-
-      {!preview && <div>
-        <Section level={3} title='Description' className='Proposal-description'>
-          <ReactMarkdown className='JoyViewMD' source={proposal.description.toString()} linkTarget='_blank' />
-        </Section>
-
-        {/* <div style={{ marginTop: '1rem', color: 'skyblue' }}>
-          <div><em>TODO Show how many approval votes out of quorum recived so far.</em></div>
-          <div><em>TODO Voting ends in N blocks (if active)</em></div>
-        </div> */}
-
-        {canVote && <Section level={3} title='Vote on this proposal'>
-          <Labelled style={{ marginTop: '.5rem' }}>
-            {accountAlreadyVoted
-              ? <Message compact info size='tiny' content='Selected account already voted on this proposal.' />
-              : allVoteKinds.map(voteKind => {
-                const { color, icon } = this.styleOfVoteKind(voteKind);
-
-                return <TxButton
-                  isPrimary={false}
-                  className={color}
-                  label={<><i className={`${icon} icon`}></i>{voteKind}</>}
-                  params={[proposal.id, new VoteKind(voteKind)]}
-                  tx='proposals.voteOnProposal'
-                />;
-              })}
-          </Labelled>
-        </Section>}
-
-        <Section level={3} title={`Casted votes (${votes.length})`}>
-          {votes.length === 0 ? <em>No votes yet.</em> : this.renderVotes(votes)}
-        </Section>
-      </div>} {/* End of "if !preview" */}
-    </>;
-  }
-
-  private renderVotes = (votes: ProposalVotes) => {
-    return (
-      <Table celled selectable compact>
-      <Table.Header>
-        <Table.Row>
-          <Table.HeaderCell>#</Table.HeaderCell>
-          <Table.HeaderCell>Vote</Table.HeaderCell>
-          <Table.HeaderCell>Council member</Table.HeaderCell>
-        </Table.Row>
-      </Table.Header>
-      <Table.Body>{votes.map(([voter, voteKind], i) => {
-        return <Table.Row>
-          <Table.Cell>{i + 1}</Table.Cell>
-          <Table.Cell>{this.renderVoteKind(voteKind.toString())}</Table.Cell>
-          <Table.Cell>
-            <AddressMini value={voter} isShort={false} isPadded={false} withBalance={true} />
-          </Table.Cell>
-        </Table.Row>;
-      })}</Table.Body>
-      </Table>
-    );
-  }
-
-  private renderVoteKind = (voteKind: string) => {
-    const { color, icon } = this.styleOfVoteKind(voteKind);
-    return (
-      <span className={`ui basic ${color} label`} style={{ margin: '0 .5rem', textAlign: 'center', border: '0' }}>
-        <i className={`${icon} icon`}></i>{voteKind}
-      </span>
-    );
-  }
-
-  private styleOfVoteKind = (voteKind: string) => {
-    let color = '';
-    let icon = '';
-    if (voteKind === 'Approve') {
-      color = 'green';
-      icon = 'check';
-    } else if (voteKind === 'Reject') {
-      color = 'orange';
-      icon = 'times';
-    } else if (voteKind === 'Slash') {
-      color = 'red';
-      icon = 'times';
-    } else if (voteKind === 'Abstain') {
-      color = 'grey';
-      icon = 'exclamation';
-    }
-    return { color, icon };
-  }
-
-  private renderStatus = (voteKind: string) => {
-    let icon = '';
-    let color = '';
-    if (voteKind === 'Active') {
-      voteKind = 'Open for voting';
-      icon = 'bullhorn';
-      color = 'blue';
-    } else if (voteKind === 'Approved') {
-      icon = 'check';
-      color = 'green';
-    } else if (voteKind === 'Rejected') {
-      icon = 'times';
-      color = 'orange';
-    } else if (voteKind === 'Slashed') {
-      icon = 'times';
-      color = 'red';
-    } else if (voteKind === 'Cancelled') {
-      icon = 'trash alternate';
-      color = 'grey';
-    } else if (voteKind === 'Expired') {
-      icon = 'history';
-      color = 'grey';
-    }
-    return (
-      <span className={`ui basic ${color} label`}>
-        <i className={`${icon} icon`}></i>{voteKind}
-      </span>
-    );
-  }
-}
-
-export default translate(
-  withCalls<Props>(
-    queryToProp('derive.chain.bestNumber'),
-    queryToProp('query.council.activeCouncil'),
-    queryToProp('query.proposals.votingPeriod'),
-    ['query.proposals.proposals',
-      { paramName: 'id', propName: 'proposal' }],
-    ['query.proposals.votesByProposal',
-      { paramName: 'id', propName: 'votes' }]
-  )(withMyAccount(Component))
-);

+ 0 - 14
packages/joy-proposals/src/FilterProps.ts

@@ -1,14 +0,0 @@
-export type FilterProps = {
-  showActiveOnly?: boolean,
-  showFinalizedOnly?: boolean, // Includes anything but Active.
-
-  // Based on ProposalStatus enum:
-  showActive?: boolean,
-  showCancelled?: boolean,
-  showExpired?: boolean,
-  showApproved?: boolean,
-  showRejected?: boolean,
-  showSlashed?: boolean
-};
-
-export default FilterProps;

+ 0 - 139
packages/joy-proposals/src/NewForm.tsx

@@ -1,139 +0,0 @@
-import BN from 'bn.js';
-import React from 'react';
-
-import { I18nProps } from '@polkadot/react-components/types';
-import { ApiProps } from '@polkadot/react-api/types';
-import { withCalls, withMulti } from '@polkadot/react-api/with';
-import { u8aToHex, formatNumber } from '@polkadot/util';
-import { Input, InputFile, Labelled } from '@polkadot/react-components/index';
-import { Balance } from '@polkadot/types/interfaces';
-
-import translate from './translate';
-import { nonEmptyStr } from '@polkadot/joy-utils/index';
-import TxButton from '@polkadot/joy-utils/TxButton';
-import InputStake from '@polkadot/joy-utils/InputStake';
-import TextArea from '@polkadot/joy-utils/TextArea';
-import { MyAddressProps, withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
-
-type Props = ApiProps & I18nProps & MyAddressProps & {
-  minStake?: Balance
-};
-
-type State = {
-  stake?: BN,
-  name?: string,
-  description?: string,
-  wasmCode?: Uint8Array,
-  isStakeValid?: boolean,
-  isNameValid?: boolean,
-  isDescriptionValid?: boolean,
-  isWasmCodeValid?: boolean
-};
-
-class Component extends React.PureComponent<Props, State> {
-
-  state: State = {};
-
-  render () {
-    const { stake, name, description, wasmCode, isStakeValid, isWasmCodeValid } = this.state;
-    const isFormValid = this.isFormValid();
-
-    const wasmFilePlaceholder = wasmCode && isWasmCodeValid
-      ? formatNumber(wasmCode.length) + ' bytes'
-      : 'Drag and drop a WASM file here';
-
-    const wasmHex = isFormValid ? u8aToHex(wasmCode) : null;
-
-    return (
-      <div>
-        <InputStake
-          min={this.minStake()}
-          isValid={isStakeValid}
-          onChange={this.onChangeStake}
-        />
-        <div className='ui--row'>
-          <Input
-            label='Proposal name:'
-            value={name}
-            onChange={this.onChangeName}
-          />
-        </div>
-        <div className='ui--row'>
-          <TextArea
-            rows={3}
-            autoHeight={true}
-            label='Full description:'
-            placeholder='Provide full description of your proposal: new features, improvements, bug fixes etc.'
-            onChange={this.onChangeDescription}
-            value={description}
-          />
-        </div>
-        <div className='ui--row'>
-          <div className='full'>
-            <InputFile
-              // clearContent={!wasmCode && isWasmCodeValid}
-              // isError={!isWasmCodeValid}
-              label='WASM code of runtime upgrade:'
-              placeholder={wasmFilePlaceholder}
-              onChange={this.onChangeWasmCode}
-            />
-          </div>
-        </div>
-        <Labelled style={{ marginTop: '.5rem' }}>
-          <TxButton
-            isDisabled={!isFormValid}
-            label='Submit my proposal'
-            params={[stake, name, description, wasmHex]}
-            tx='proposals.createProposal'
-          />
-        </Labelled>
-      </div>
-    );
-  }
-
-  private onChangeName = (name?: string) => {
-    // TODO validate min / max len based on properties from Substrate:
-    const isNameValid = nonEmptyStr(name);
-    this.setState({ name, isNameValid });
-  }
-
-  private onChangeDescription = (description?: string) => {
-    // TODO validate min / max len based on properties from Substrate:
-    const isDescriptionValid = nonEmptyStr(description);
-    this.setState({ description, isDescriptionValid });
-  }
-
-  private onChangeWasmCode = (wasmCode: Uint8Array) => {
-    // TODO validate min / max len based on properties from Substrate:
-    let isWasmCodeValid = wasmCode && wasmCode.length > 0;
-    this.setState({ wasmCode, isWasmCodeValid });
-  }
-
-  private minStake = (): BN => {
-    return this.props.minStake || new BN(1);
-  }
-
-  private onChangeStake = (stake?: BN): void => {
-    const isStakeValid = stake && stake.gte(this.minStake());
-    this.setState({ stake, isStakeValid });
-  }
-
-  private isFormValid = (): boolean => {
-    const s = this.state;
-    return (
-      s.isStakeValid &&
-      s.isNameValid &&
-      s.isDescriptionValid &&
-      s.isWasmCodeValid
-    ) ? true : false;
-  }
-}
-
-export default withMulti(
-  Component,
-  translate,
-  withOnlyMembers,
-  withCalls<Props>(
-    ['query.proposals.minStake', { propName: 'minStake' }]
-  )
-);

+ 11 - 0
packages/joy-proposals/src/NotDone.tsx

@@ -0,0 +1,11 @@
+import React from "react";
+
+export default function NotDone(props: any) {
+  return (
+    <>
+      <h1>This is not implemented yet :( </h1>
+      <div>however, here is your props.</div>
+      <code>{JSON.stringify(props)}</code>
+    </>
+  );
+}

+ 201 - 0
packages/joy-proposals/src/Proposal/Body.tsx

@@ -0,0 +1,201 @@
+import React from "react";
+import { Card, Header, Button, Icon, Message } from "semantic-ui-react";
+import { ProposalType } from "../runtime/transport";
+import { blake2AsHex } from '@polkadot/util-crypto';
+import styled from 'styled-components';
+import AddressMini from '@polkadot/react-components/AddressMiniJoy';
+import TxButton from '@polkadot/joy-utils/TxButton';
+import { ProposalId } from "@joystream/types/proposals";
+import { MemberId } from "@joystream/types/members";
+import ProfilePreview from "@polkadot/joy-utils/MemberProfilePreview";
+import { useTransport } from "../runtime";
+import { usePromise } from "../utils";
+import { Profile } from "@joystream/types/members";
+import { Option } from "@polkadot/types/";
+import { formatBalance } from "@polkadot/util";
+import PromiseComponent from "./PromiseComponent";
+
+type BodyProps = {
+  title: string;
+  description: string;
+  params: any[];
+  type: ProposalType;
+  iAmProposer: boolean;
+  proposalId: number | ProposalId;
+  proposerId: number | MemberId;
+  isCancellable: boolean;
+  cancellationFee: number;
+};
+
+function ProposedAddress(props: { address?: string | null }) {
+  if (props.address === null || props.address === undefined) {
+    return <>NONE</>;
+  }
+
+  return (
+    <AddressMini value={props.address} isShort={false} isPadded={false} withAddress={true} style={{ padding: 0 }} />
+  );
+}
+
+function ProposedMember(props: { memberId?: MemberId | number | null }) {
+  if (props.memberId === null || props.memberId === undefined) {
+    return <>NONE</>;
+  }
+  const memberId: MemberId | number = props.memberId;
+
+  const transport = useTransport();
+  const [ member, error, loading ] = usePromise<Option<Profile> | null>(
+    () => transport.memberProfile(memberId),
+    null
+  );
+
+  const profile = member && member.unwrapOr(null);
+
+  return (
+    <PromiseComponent error={error} loading={loading} message="Fetching profile...">
+      { profile ? (
+        <ProfilePreview
+          avatar_uri={ profile.avatar_uri.toString() }
+          root_account={ profile.root_account.toString() }
+          handle={ profile.handle.toString() }
+          link={ true }
+        />
+      ) : 'Profile not found' }
+    </PromiseComponent>
+  );
+}
+
+// The methods for parsing params by Proposal type.
+// They take the params as array and return { LABEL: VALUE } object.
+const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: string | number | JSX.Element } } = {
+  Text: ([content]) => ({
+    Content: content
+  }),
+  RuntimeUpgrade: ([wasm]) => {
+    const buffer: Buffer = Buffer.from(wasm.replace("0x", ""), "hex");
+    return {
+      "Blake2b256 hash of WASM code": blake2AsHex(buffer, 256),
+      "File size": buffer.length + " bytes"
+    };
+  },
+  SetElectionParameters: ([params]) => ({
+      "Announcing period": params.announcing_period + " blocks",
+      "Voting period": params.voting_period + " blocks",
+      "Revealing period": params.revealing_period + " blocks",
+      "Council size": params.council_size + " members",
+      "Candidacy limit": params.candidacy_limit + " members",
+      "New term duration": params.new_term_duration + " blocks",
+      "Min. council stake": formatBalance(params.min_council_stake),
+      "Min. voting stake": formatBalance(params.min_voting_stake)
+  }),
+  Spending: ([amount, account]) => ({
+    Amount: formatBalance(amount),
+    Account: <ProposedAddress address={account} />
+  }),
+  SetLead: ([memberId, accountId]) => ({
+    "Member": <ProposedMember memberId={ memberId } />,
+    "Account id": <ProposedAddress address={accountId} />
+  }),
+  SetContentWorkingGroupMintCapacity: ([capacity]) => ({
+    "Mint capacity": formatBalance(capacity)
+  }),
+  EvictStorageProvider: ([accountId]) => ({
+    "Storage provider account": <ProposedAddress address={accountId} />
+  }),
+  SetValidatorCount: ([count]) => ({
+    "Validator count": count
+  }),
+  SetStorageRoleParameters: ([params]) => ({
+    "Min. stake": formatBalance(params.min_stake),
+    // "Min. actors": params.min_actors,
+    "Max. actors": params.max_actors,
+    Reward: formatBalance(params.reward),
+    "Reward period": params.reward_period + " blocks",
+    // "Bonding period": params.bonding_period + " blocks",
+    "Unbonding period": params.unbonding_period + " blocks",
+    // "Min. service period": params.min_service_period + " blocks",
+    // "Startup grace period": params.startup_grace_period + " blocks",
+    "Entry request fee": formatBalance(params.entry_request_fee)
+  })
+};
+
+const ProposalParams = styled.div`
+  display: grid;
+  font-weight: bold;
+  grid-template-columns: min-content 1fr;
+  grid-row-gap: 0.5rem;
+  @media screen and (max-width: 767px) {
+    grid-template-columns: 1fr;
+  }
+`;
+const ProposalParamName = styled.div`
+  margin-right: 1rem;
+  white-space: nowrap;
+`;
+const ProposalParamValue = styled.div`
+  color: black;
+  word-wrap: break-word;
+  word-break: break-all;
+  @media screen and (max-width: 767px) {
+    margin-top: -0.25rem;
+  }
+`;
+
+export default function Body({
+  type,
+  title,
+  description,
+  params = [],
+  iAmProposer,
+  proposalId,
+  proposerId,
+  isCancellable,
+  cancellationFee
+}: BodyProps) {
+  const parseParams = paramParsers[type];
+  const parsedParams = parseParams(params);
+  return (
+    <Card fluid>
+      <Card.Content>
+        <Card.Header>
+          <Header as="h1">{title}</Header>
+        </Card.Header>
+        <Card.Description>{description}</Card.Description>
+        <Header as="h4">Parameters:</Header>
+        <ProposalParams>
+          { Object.entries(parsedParams).map(([paramName, paramValue]) => (
+            <React.Fragment key={paramName}>
+              <ProposalParamName>{paramName}:</ProposalParamName>
+              <ProposalParamValue>{paramValue}</ProposalParamValue>
+            </React.Fragment>
+          ))}
+        </ProposalParams>
+        { iAmProposer && isCancellable && (<>
+          <Message warning active>
+            <Message.Content>
+              <Message.Header>Proposal cancellation</Message.Header>
+              <p style={{ margin: '0.5em 0', padding: '0' }}>
+                You can only cancel your proposal while it's still in the Voting Period.
+              </p>
+              <p style={{ margin: '0.5em 0', padding: '0' }}>
+                The cancellation fee for this type of proposal is:&nbsp;
+                <b>{ cancellationFee ? formatBalance(cancellationFee) : 'NONE' }</b>
+              </p>
+              <Button.Group color="red">
+                <TxButton
+                  params={ [ proposerId, proposalId ] }
+                  tx={ "proposalsEngine.cancelProposal" }
+                  onClick={ sendTx => { sendTx(); } }
+                  className={'icon left labeled'}
+                  >
+                  <Icon name="cancel" inverted />
+                  Withdraw proposal
+                </TxButton>
+              </Button.Group>
+            </Message.Content>
+          </Message>
+          </>) }
+      </Card.Content>
+    </Card>
+  );
+}

+ 6 - 0
packages/joy-proposals/src/Proposal/ChooseProposalType.css

@@ -0,0 +1,6 @@
+.ChooseProposalType .filters {
+  text-align: right;
+}
+.ChooseProposalType .filters .dropdown {
+  width: 200px;
+}

+ 56 - 0
packages/joy-proposals/src/Proposal/ChooseProposalType.tsx

@@ -0,0 +1,56 @@
+import React, { useState } from "react";
+import ProposalTypePreview from "./ProposalTypePreview";
+import { Item, Dropdown } from "semantic-ui-react";
+
+import { useTransport } from "../runtime";
+import { usePromise } from "../utils";
+import Error from "./Error";
+import Loading from "./Loading";
+import "./ChooseProposalType.css";
+import { RouteComponentProps } from "react-router-dom";
+
+export const Categories = {
+  storage: "Storage",
+  council: "Council",
+  validators: "Validators",
+  cwg: "Content Working Group",
+  other: "Other"
+} as const;
+
+export type Category = typeof Categories[keyof typeof Categories];
+
+export default function ChooseProposalType(props: RouteComponentProps) {
+  const transport = useTransport();
+
+  const [proposalTypes, error, loading] = usePromise(() => transport.proposalsTypesParameters(), []);
+  const [category, setCategory] = useState("");
+
+  if (loading && !error) {
+    return <Loading text="Fetching proposals..." />;
+  } else if (error || proposalTypes == null) {
+    return <Error error={error} />;
+  }
+
+  console.log({ proposalTypes, loading, error });
+  return (
+    <div className="ChooseProposalType">
+      <div className="filters">
+        <Dropdown
+          placeholder="Category"
+          options={Object.values(Categories).map(category => ({ value: category, text: category }))}
+          value={category}
+          onChange={(e, data) => setCategory((data.value || "").toString())}
+          clearable
+          selection
+        />
+      </div>
+      <Item.Group>
+        {proposalTypes
+          .filter(typeInfo => !category || typeInfo.category === category)
+          .map((typeInfo, idx) => (
+            <ProposalTypePreview key={`${typeInfo} - ${idx}`} typeInfo={typeInfo} history={props.history} />
+          ))}
+      </Item.Group>
+    </div>
+  );
+}

+ 71 - 0
packages/joy-proposals/src/Proposal/Details.tsx

@@ -0,0 +1,71 @@
+import React from "react";
+import { Item, Header } from "semantic-ui-react";
+import { ParsedProposal } from "../runtime/transport";
+import { ExtendedProposalStatus } from "./ProposalDetails";
+import styled from 'styled-components';
+
+import ProfilePreview from "@polkadot/joy-utils/MemberProfilePreview";
+
+const BlockInfo = styled.div`
+  font-size: 0.9em;
+`;
+
+type DetailProps = {
+  name: string,
+  value?: string
+};
+
+const Detail: React.FunctionComponent<DetailProps> = ({name, value, children}) => (
+  <Item>
+    <Item.Content>
+      <Item.Extra>{ name }:</Item.Extra>
+      { value && <Header as="h4">{value}</Header> }
+      { children }
+    </Item.Content>
+  </Item>
+);
+
+type DetailsProps = {
+  proposal: ParsedProposal;
+  extendedStatus: ExtendedProposalStatus;
+  proposerLink?: boolean;
+};
+
+export default function Details({ proposal, extendedStatus, proposerLink = false }: DetailsProps) {
+  const { type, createdAt, createdAtBlock, proposer } = proposal;
+  const { displayStatus, periodStatus, expiresIn, finalizedAtBlock, executedAtBlock, executionFailReason } = extendedStatus;
+  console.log(proposal);
+  return (
+    <Item.Group className="details-container">
+      <Detail name="Proposed By">
+        <ProfilePreview
+          avatar_uri={proposer.avatar_uri}
+          root_account={proposer.root_account}
+          handle={proposer.handle}
+          link={ proposerLink }
+        />
+        <Item.Extra>{ `${ createdAt.toLocaleString() }` }</Item.Extra>
+      </Detail>
+      <Detail name="Proposal type" value={type} />
+      <Detail name="Stage" value={displayStatus}>
+        <Item.Extra>
+          { createdAtBlock && <BlockInfo>Created at block <b>#{ createdAtBlock }</b></BlockInfo> }
+          { finalizedAtBlock && <BlockInfo>Finalized at block <b>#{ finalizedAtBlock }</b></BlockInfo> }
+          { executedAtBlock && (
+            <BlockInfo>
+              { displayStatus === "ExecutionFailed" ? 'Execution failed at' : 'Executed at' } block
+              <b> #{ executedAtBlock }</b>
+            </BlockInfo>
+          ) }
+        </Item.Extra>
+      </Detail>
+      { (periodStatus !== null) && <Detail name="Substage" value={periodStatus} /> }
+      {expiresIn !== null && (
+        <Detail
+          name={ periodStatus === 'Grace period' ? 'Executes in' : 'Expires in' }
+          value={`${expiresIn.toLocaleString("en-US")} blocks`} />
+      ) }
+      {executionFailReason && <Detail name="Execution error" value={ executionFailReason } /> }
+    </Item.Group>
+  );
+}

+ 17 - 0
packages/joy-proposals/src/Proposal/Error.tsx

@@ -0,0 +1,17 @@
+import React from "react";
+import { Container, Message } from "semantic-ui-react";
+
+type ErrorProps = {
+  error: any;
+};
+export default function Error({ error }: ErrorProps) {
+  console.error(error);
+  return (
+    <Container style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
+      <Message negative>
+        <Message.Header>Oops! We got an error!</Message.Header>
+        <p>{error.message}</p>
+      </Message>
+    </Container>
+  );
+}

+ 14 - 0
packages/joy-proposals/src/Proposal/Loading.tsx

@@ -0,0 +1,14 @@
+import React from "react";
+import { Loader, Container } from "semantic-ui-react";
+
+type LoadingProps = {
+  text: string;
+};
+
+export default function Loading({ text }: LoadingProps) {
+  return (
+    <Container style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
+      <Loader active>{text}</Loader>
+    </Container>
+  );
+}

+ 20 - 0
packages/joy-proposals/src/Proposal/PromiseComponent.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import Loading from "./Loading";
+import Error from "./Error";
+
+type PromiseComponentProps = {
+  loading: boolean,
+  error: any,
+  message: string,
+}
+const PromiseComponent: React.FunctionComponent<PromiseComponentProps> = ({ loading, error, message, children }) => {
+  if (loading && !error) {
+    return <Loading text={ message } />;
+  } else if (error) {
+    return <Error error={error} />;
+  }
+
+  return <>{ children }</>;
+}
+
+export default PromiseComponent;

+ 56 - 0
packages/joy-proposals/src/Proposal/Proposal.css

@@ -0,0 +1,56 @@
+.Proposal {
+  position: relative;
+
+  .description {
+    word-wrap: break-word;
+    word-break: break-all;
+  }
+
+  /* Ovverrides Semantic UI for the details page.*/
+  .ui.items > .item:first-child {
+    margin: 1em 0;
+  }
+
+  .details-container {
+    display: grid;
+    grid-template-columns: repeat(5, auto);
+  }
+
+  .details-container .item .extra {
+    margin-bottom: 0.5em !important;
+  }
+
+  .ui.items > .item .extra.proposed-by {
+    /* This is to ensure Proposed By: is above the name of the creator. The image is 50x50 and has 14pxs of margin right*/
+    padding-left: 64px;
+  }
+
+  .center-content {
+    justify-content: center;
+  }
+
+  .bold {
+    font-weight: 700;
+  }
+
+  .details-param {
+    display: flex;
+  }
+
+  .ui.tabular.list-menu {
+    margin-bottom: 2rem;
+  }
+
+  @media screen and (max-width: 767px) {
+    .details-container {
+      grid-template-columns: repeat(2, auto);
+      grid-template-rows: repeat(3, auto);
+    }
+    .details-container .item:first-child {
+      grid-column: 1/3;
+    }
+    .details-container .item {
+      margin: 0.5em 0 !important;
+    }
+  }
+}

+ 147 - 0
packages/joy-proposals/src/Proposal/ProposalDetails.tsx

@@ -0,0 +1,147 @@
+import React from "react";
+
+import { Container } from "semantic-ui-react";
+import Details from "./Details";
+import Body from "./Body";
+import VotingSection from "./VotingSection";
+import Votes from "./Votes";
+import { MyAccountProps, withMyAccount } from "@polkadot/joy-utils/MyAccount"
+import { ParsedProposal, ProposalVote } from "../runtime";
+import { withCalls } from '@polkadot/react-api';
+import { withMulti } from '@polkadot/react-api/with';
+
+import "./Proposal.css";
+import { ProposalId, ProposalDecisionStatuses, ApprovedProposalStatuses, ExecutionFailedStatus } from "@joystream/types/proposals";
+import { BlockNumber } from '@polkadot/types/interfaces'
+import { MemberId } from "@joystream/types/members";
+import { Seat } from "@joystream/types/";
+import PromiseComponent from './PromiseComponent';
+
+type BasicProposalStatus = 'Active' | 'Finalized';
+type ProposalPeriodStatus = 'Voting period' | 'Grace period';
+type ProposalDisplayStatus = BasicProposalStatus | ProposalDecisionStatuses | ApprovedProposalStatuses;
+
+export type ExtendedProposalStatus = {
+  displayStatus: ProposalDisplayStatus,
+  periodStatus: ProposalPeriodStatus | null,
+  expiresIn: number | null,
+  finalizedAtBlock: number | null,
+  executedAtBlock: number | null,
+  executionFailReason: string | null
+}
+
+export function getExtendedStatus(proposal: ParsedProposal, bestNumber: BlockNumber | undefined): ExtendedProposalStatus {
+  const basicStatus = Object.keys(proposal.status)[0] as BasicProposalStatus;
+  let expiresIn: number | null = null;
+
+  let displayStatus: ProposalDisplayStatus = basicStatus;
+  let periodStatus: ProposalPeriodStatus | null = null;
+  let finalizedAtBlock: number | null = null;
+  let executedAtBlock: number | null = null;
+  let executionFailReason: string | null = null;
+
+  let best = bestNumber ? bestNumber.toNumber() : 0;
+
+  const { votingPeriod, gracePeriod } = proposal.parameters;
+  const blockAge = best - proposal.createdAtBlock;
+
+  if (basicStatus === 'Active') {
+    periodStatus = 'Voting period';
+    expiresIn = Math.max(votingPeriod - blockAge, 0) || null;
+  }
+
+  if (basicStatus === 'Finalized') {
+    const { finalizedAt, proposalStatus } = proposal.status['Finalized'];
+    const decisionStatus: ProposalDecisionStatuses = Object.keys(proposalStatus)[0] as ProposalDecisionStatuses;
+    displayStatus = decisionStatus;
+    finalizedAtBlock = finalizedAt as number;
+    if (decisionStatus === 'Approved') {
+      const approvedStatus: ApprovedProposalStatuses = Object.keys(proposalStatus["Approved"])[0] as ApprovedProposalStatuses;
+      if (approvedStatus === 'PendingExecution') {
+        const finalizedAge = best - finalizedAt;
+        periodStatus = 'Grace period';
+        expiresIn = Math.max(gracePeriod - finalizedAge, 0) || null;
+      }
+      else {
+        // Executed / ExecutionFailed
+        displayStatus = approvedStatus;
+        executedAtBlock = finalizedAtBlock + gracePeriod;
+        if (approvedStatus === 'ExecutionFailed') {
+          const executionFailedStatus = proposalStatus.Approved.ExecutionFailed as ExecutionFailedStatus;
+          executionFailReason = new Buffer(executionFailedStatus.error.toString().replace('0x', ''), 'hex').toString();
+        }
+      }
+    }
+  }
+
+  return {
+    displayStatus,
+    periodStatus,
+    expiresIn: best ? expiresIn : null,
+    finalizedAtBlock,
+    executedAtBlock,
+    executionFailReason
+  }
+}
+
+
+type ProposalDetailsProps = MyAccountProps & {
+  proposal: ParsedProposal,
+  proposalId: ProposalId,
+  votesListState: { data: ProposalVote[], error: any, loading: boolean },
+  bestNumber?: BlockNumber,
+  council?: Seat[]
+};
+
+function ProposalDetails({
+  proposal,
+  proposalId,
+  myAddress,
+  myMemberId,
+  iAmMember,
+  council,
+  bestNumber,
+  votesListState
+}: ProposalDetailsProps) {
+  const iAmCouncilMember = Boolean(iAmMember && council && council.some(seat => seat.member.toString() === myAddress));
+  const iAmProposer = Boolean(iAmMember && myMemberId !== undefined && proposal.proposerId === myMemberId.toNumber());
+  const extendedStatus = getExtendedStatus(proposal, bestNumber);
+  const isVotingPeriod = extendedStatus.periodStatus === 'Voting period';
+  return (
+    <Container className="Proposal">
+      <Details proposal={proposal} extendedStatus={extendedStatus} proposerLink={ true }/>
+      <Body
+        type={ proposal.type }
+        title={ proposal.title }
+        description={ proposal.description }
+        params={ proposal.details }
+        iAmProposer={ iAmProposer }
+        proposalId={ proposalId }
+        proposerId={ proposal.proposerId }
+        isCancellable={ isVotingPeriod }
+        cancellationFee={ proposal.cancellationFee }
+        />
+      { iAmCouncilMember && (
+        <VotingSection
+          proposalId={proposalId}
+          memberId={ myMemberId as MemberId }
+          isVotingPeriod={ isVotingPeriod }/>
+      ) }
+      <PromiseComponent
+        error={votesListState.error}
+        loading={votesListState.loading}
+        message="Fetching the votes...">
+        <Votes votes={votesListState.data} />
+      </PromiseComponent>
+    </Container>
+  );
+}
+
+export default withMulti<ProposalDetailsProps>(
+  ProposalDetails,
+  withMyAccount,
+  withCalls(
+    ['derive.chain.bestNumber', { propName: 'bestNumber' }],
+    ['query.council.activeCouncil', { propName: 'council' }], // TODO: Handle via transport?
+  )
+);

+ 25 - 0
packages/joy-proposals/src/Proposal/ProposalFromId.tsx

@@ -0,0 +1,25 @@
+import React from "react";
+import { RouteComponentProps } from "react-router-dom";
+import ProposalDetails from "./ProposalDetails";
+import { useProposalSubscription } from "../utils";
+import Error from "./Error";
+import Loading from "./Loading";
+
+
+export default function ProposalFromId(props: RouteComponentProps<any>) {
+  const {
+    match: {
+      params: { id }
+    }
+  } = props;
+
+  const { proposal: proposalState, votes: votesState } = useProposalSubscription(id);
+
+  if (proposalState.loading && !proposalState.error) {
+    return <Loading text="Fetching Proposal..." />;
+  } else if (proposalState.error) {
+    return <Error error={proposalState.error} />;
+  }
+
+  return <ProposalDetails proposal={ proposalState.data } proposalId={ id } votesListState={ votesState }/>;
+}

+ 41 - 0
packages/joy-proposals/src/Proposal/ProposalPreview.tsx

@@ -0,0 +1,41 @@
+import React from "react";
+import { Header, Card } from "semantic-ui-react";
+import Details from "./Details";
+import { ParsedProposal } from "../runtime/transport";
+import { getExtendedStatus } from "./ProposalDetails";
+import { BlockNumber } from '@polkadot/types/interfaces';
+import styled from 'styled-components';
+
+import "./Proposal.css";
+
+const ProposalIdBox = styled.div`
+  position: absolute;
+  top: 0;
+  right: 0;
+  padding: 1rem;
+  color: rgba(0,0,0,0.4);
+  font-size: 1.1em;
+`;
+
+export type ProposalPreviewProps = {
+  proposal: ParsedProposal,
+  bestNumber?: BlockNumber
+};
+export default function ProposalPreview({ proposal, bestNumber }: ProposalPreviewProps) {
+  const extendedStatus = getExtendedStatus(proposal, bestNumber);
+  return (
+    <Card
+      fluid
+      className="Proposal"
+      href={`#/proposals/${proposal.id}`}>
+      <ProposalIdBox>{ `#${proposal.id.toString()}` }</ProposalIdBox>
+      <Card.Content>
+        <Card.Header>
+          <Header as="h1">{proposal.title}</Header>
+        </Card.Header>
+        <Card.Description>{proposal.description}</Card.Description>
+        <Details proposal={proposal} extendedStatus={extendedStatus} />
+      </Card.Content>
+    </Card>
+  );
+}

+ 90 - 0
packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx

@@ -0,0 +1,90 @@
+import React, { useState } from "react";
+import { Card, Container, Menu } from "semantic-ui-react";
+
+import ProposalPreview from "./ProposalPreview";
+import { useTransport, ParsedProposal } from "../runtime";
+import { usePromise } from "../utils";
+import PromiseComponent from './PromiseComponent';
+import { withCalls } from "@polkadot/react-api";
+import { BlockNumber } from "@polkadot/types/interfaces";
+
+const filters = ["All", "Active", "Canceled", "Approved", "Rejected", "Slashed", "Expired"] as const;
+
+type ProposalFilter = typeof filters[number];
+
+function filterProposals(filter: ProposalFilter, proposals: ParsedProposal[]) {
+  if (filter === "All") {
+    return proposals;
+  } else if (filter === "Active") {
+    return proposals.filter((prop: ParsedProposal) => {
+      const [activeOrFinalized] = Object.keys(prop.status);
+      return activeOrFinalized === "Active";
+    });
+  }
+
+  return proposals.filter((prop: ParsedProposal) => {
+    if (prop.status.Finalized == null || prop.status.Finalized.proposalStatus == null) {
+      return false;
+    }
+
+    const [finalStatus] = Object.keys(prop.status.Finalized.proposalStatus);
+    return finalStatus === filter;
+  });
+}
+
+function mapFromProposals(proposals: ParsedProposal[]) {
+  const proposalsMap = new Map<ProposalFilter, ParsedProposal[]>();
+
+  proposalsMap.set("All", proposals);
+  proposalsMap.set("Canceled", filterProposals("Canceled", proposals));
+  proposalsMap.set("Active", filterProposals("Active", proposals));
+  proposalsMap.set("Approved", filterProposals("Approved", proposals));
+  proposalsMap.set("Rejected", filterProposals("Rejected", proposals));
+  proposalsMap.set("Slashed", filterProposals("Slashed", proposals));
+  proposalsMap.set("Expired", filterProposals("Expired", proposals));
+
+  return proposalsMap;
+}
+
+type ProposalPreviewListProps = {
+  bestNumber?: BlockNumber;
+};
+
+function ProposalPreviewList({ bestNumber }: ProposalPreviewListProps) {
+  const transport = useTransport();
+  const [proposals, error, loading] = usePromise<ParsedProposal[]>(() => transport.proposals(), []);
+  const [activeFilter, setActiveFilter] = useState<ProposalFilter>("All");
+
+  const proposalsMap = mapFromProposals(proposals);
+  const filteredProposals = proposalsMap.get(activeFilter) as ParsedProposal[];
+
+  return (
+    <Container className="Proposal">
+      <PromiseComponent error={ error } loading={ loading } message="Fetching proposals...">
+        <Menu tabular className="list-menu">
+          {filters.map((filter, idx) => (
+            <Menu.Item
+              key={`${filter} - ${idx}`}
+              name={`${filter.toLowerCase()} - ${(proposalsMap.get(filter) as ParsedProposal[]).length}`}
+              active={activeFilter === filter}
+              onClick={() => setActiveFilter(filter)}
+            />
+          ))}
+        </Menu>
+        {
+          filteredProposals.length ? (
+            <Card.Group>
+              {filteredProposals.map((prop: ParsedProposal, idx: number) => (
+                <ProposalPreview key={`${prop.title}-${idx}`} proposal={prop} bestNumber={bestNumber} />
+              ))}
+            </Card.Group>
+          ) : `There are currently no ${ activeFilter !== 'All' ? activeFilter.toLocaleLowerCase() : 'submitted' } proposals.`
+        }
+      </PromiseComponent>
+    </Container>
+  );
+}
+
+export default withCalls<ProposalPreviewListProps>(["derive.chain.bestNumber", { propName: "bestNumber" }])(
+  ProposalPreviewList
+);

+ 48 - 0
packages/joy-proposals/src/Proposal/ProposalType.css

@@ -0,0 +1,48 @@
+.ProposalType {
+  background: #fff !important;
+  box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5 !important;
+  padding: 1em !important;
+  border-radius: 0.3em !important;
+}
+.ProposalType .header {
+  font-size: 1.5em !important;
+  line-height: 1;
+}
+.ProposalType .description-text {
+  flex-grow: 1;
+}
+.ProposalType .actions {
+  margin: 0 2em;
+  padding-top: 1em;
+}
+.ProposalType .proposal-details {
+  display: flex;
+  margin: 0 -2em;
+}
+.ProposalType .proposal-detail {
+  margin: 1em 2em;
+}
+.ProposalType .detail-value {
+  font-size: 1.2em;
+  font-weight: 700;
+}
+
+@media only screen and (max-width: 1199px) {
+  .ProposalType .proposal-details {
+    flex-direction: column;
+    margin: 1em 0;
+  }
+  .ProposalType .proposal-detail {
+    display: flex;
+    justify-content: space-between;
+    margin: .5em 0;
+  }
+}
+
+@media only screen and (max-width: 767px) {
+  .ProposalType .actions {
+    padding: 0;
+    margin: 0;
+    text-align: right;
+  }
+}

+ 152 - 0
packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx

@@ -0,0 +1,152 @@
+import React from "react";
+
+import { History } from "history";
+import { Item, Icon, Button, Label } from "semantic-ui-react";
+
+import { Category } from "./ChooseProposalType";
+import { ProposalType } from "../runtime";
+import { slugify, splitOnUpperCase } from "../utils";
+import styled from 'styled-components';
+import useVoteStyles from './useVoteStyles';
+import { formatBalance } from "@polkadot/util";
+
+import "./ProposalType.css";
+
+const QuorumsAndThresholds = styled.div`
+  display: grid;
+  grid-template-columns: min-content min-content;
+  grid-template-rows: auto auto;
+  grid-row-gap: 0.5rem;
+  grid-column-gap: 0.5rem;
+  margin-bottom: 1rem;
+  @media screen and (max-width: 480px) {
+    grid-template-columns: min-content;
+  }
+`;
+
+const QuorumThresholdLabel = styled(Label)`
+  opacity: 0.75;
+  white-space: nowrap;
+  margin: 0 !important;
+  display: flex !important;
+  align-items: center;
+  & b {
+    font-size: 1.2em;
+    margin-left: auto;
+    padding-left: 0.3rem;
+  }
+`;
+
+const CreateButton = styled(Button)`
+  font-size: 1.1em !important;
+  white-space: nowrap;
+  margin-right: 0;
+`;
+
+export type ProposalTypeInfo = {
+  type: ProposalType;
+  category: Category;
+  image: string;
+  description: string;
+  stake: number;
+  cancellationFee?: number;
+  gracePeriod: number;
+  votingPeriod: number;
+  approvalQuorum: number;
+  approvalThreshold: number;
+  slashingQuorum: number;
+  slashingThreshold: number;
+};
+
+type ProposalTypePreviewProps = {
+  typeInfo: ProposalTypeInfo;
+  history: History;
+};
+
+const ProposalTypeDetail = (props: { title: string, value: string }) => (
+  <div className="proposal-detail">
+    <div className="detail-title">{ `${props.title}:` }</div>
+    <div className="detail-value">{ props.value }</div>
+  </div>
+);
+
+export default function ProposalTypePreview(props: ProposalTypePreviewProps) {
+  const {
+    typeInfo: {
+      type,
+      description,
+      stake,
+      cancellationFee,
+      gracePeriod,
+      votingPeriod,
+      approvalQuorum,
+      approvalThreshold,
+      slashingQuorum,
+      slashingThreshold
+    }
+  } = props;
+
+  const handleClick = () => {
+    if (!props.history) return;
+    props.history.push(`/proposals/new/${slugify(type)}`);
+  };
+
+  return (
+    <Item className="ProposalType">
+      {/*
+        TODO: We can add it once we have the actual assets
+        <Item.Image size="tiny" src={image} />
+      */}
+      <Item.Content>
+        <Item.Header>{splitOnUpperCase(type).join(" ")}</Item.Header>
+        <Item.Description>{description}</Item.Description>
+        <div className="proposal-details">
+          <ProposalTypeDetail
+            title="Stake"
+            value={ formatBalance(stake) } />
+          <ProposalTypeDetail
+            title="Cancellation fee"
+            value={ cancellationFee ? formatBalance(cancellationFee) : "NONE" } />
+          <ProposalTypeDetail
+            title="Grace period"
+            value={ gracePeriod ? `${gracePeriod} block${gracePeriod > 1 ? "s" : ""}` : "NONE" } />
+          <ProposalTypeDetail
+            title="Voting period"
+            value={ votingPeriod ? `${votingPeriod} block${votingPeriod > 1 ? "s" : ""}` : "NONE" } />
+        </div>
+        <QuorumsAndThresholds>
+          { approvalQuorum && (
+            <QuorumThresholdLabel color={ useVoteStyles("Approve").color }>
+              <Icon name={ useVoteStyles("Approve").icon } />
+              Approval Quorum: <b>{ approvalQuorum }%</b>
+            </QuorumThresholdLabel>
+          ) }
+          { approvalThreshold && (
+            <QuorumThresholdLabel color={ useVoteStyles("Approve").color }>
+              <Icon name={ useVoteStyles("Approve").icon } />
+              Approval Threshold: <b>{ approvalThreshold }%</b>
+            </QuorumThresholdLabel>
+          ) }
+          { slashingQuorum && (
+            <QuorumThresholdLabel color={ useVoteStyles("Slash").color }>
+              <Icon name={ useVoteStyles("Slash").icon } />
+              Slashing Quorum: <b>{ slashingQuorum }%</b>
+            </QuorumThresholdLabel>
+          ) }
+          { slashingThreshold && (
+            <QuorumThresholdLabel color={ useVoteStyles("Slash").color }>
+              <Icon name={ useVoteStyles("Slash").icon } />
+              Slashing Threshold: <b>{ slashingThreshold }%</b>
+            </QuorumThresholdLabel>
+          ) }
+        </QuorumsAndThresholds>
+      </Item.Content>
+      <div className="actions">
+        <CreateButton primary size="medium" onClick={handleClick}>
+          Create
+          <Icon name="chevron right" />
+        </CreateButton>
+      </div>
+    </Item>
+  );
+}

+ 53 - 0
packages/joy-proposals/src/Proposal/Votes.tsx

@@ -0,0 +1,53 @@
+import React from "react";
+import { Header, Divider, Table, Icon } from "semantic-ui-react";
+import useVoteStyles from "./useVoteStyles";
+import { ProposalVote } from "../runtime";
+import { VoteKind } from "@joystream/types/proposals";
+import { VoteKindStr } from "./VotingSection";
+import ProfilePreview from "@polkadot/joy-utils/MemberProfilePreview";
+
+
+type VotesProps = {
+  votes: ProposalVote[]
+};
+
+export default function Votes({ votes }: VotesProps) {
+  const nonEmptyVotes = votes.filter(proposalVote => proposalVote.vote !== null);
+
+  if (!nonEmptyVotes.length) {
+    return <Header as="h3">No votes submitted yet!</Header>;
+  }
+
+  return (
+    <>
+      <Header as="h3">
+        All Votes: ({nonEmptyVotes.length} / {votes.length})
+      </Header>
+      <Divider />
+      <Table basic="very">
+        <Table.Body>
+          {nonEmptyVotes.map((proposalVote, idx) => {
+            const { vote, member } = proposalVote;
+            const voteStr = (vote as VoteKind).type.toString() as VoteKindStr;
+            const { icon, textColor } = useVoteStyles(voteStr);
+            return (
+              <Table.Row key={`${member.handle}-${idx}`}>
+                <Table.Cell className={textColor}>
+                  <Icon name={icon} />
+                  {voteStr}
+                </Table.Cell>
+                <Table.Cell>
+                  <ProfilePreview
+                    handle={member.handle}
+                    avatar_uri={member.avatar_uri}
+                    root_account={member.root_account}
+                  />
+                </Table.Cell>
+              </Table.Row>
+            );
+          })}
+        </Table.Body>
+      </Table>
+    </>
+  );
+}

+ 100 - 0
packages/joy-proposals/src/Proposal/VotingSection.tsx

@@ -0,0 +1,100 @@
+import React, { useState } from "react";
+
+import { Icon, Button, Message, Divider, Header } from "semantic-ui-react";
+import useVoteStyles from "./useVoteStyles";
+import TxButton from "@polkadot/joy-utils/TxButton";
+import { MemberId } from "@joystream/types/members";
+import { ProposalId } from "@joystream/types/proposals";
+import { useTransport } from "../runtime";
+import { VoteKind } from '@joystream/types/proposals';
+import { usePromise } from "../utils";
+import { VoteKinds } from "@joystream/types/proposals";
+
+export type VoteKindStr = typeof VoteKinds[number];
+
+type VoteButtonProps = {
+  memberId: MemberId,
+  voteKind: VoteKindStr,
+  proposalId: ProposalId,
+  onSuccess: () => void
+}
+function VoteButton({ voteKind, proposalId, memberId, onSuccess }: VoteButtonProps) {
+  const { icon, color } = useVoteStyles(voteKind);
+  return (
+    // Button.Group "cheat" to force TxButton color
+    <Button.Group color={color} style={{ marginRight: '5px' }}>
+      <TxButton
+        // isDisabled={ isSubmitting }
+        params={[
+          memberId,
+          proposalId,
+          voteKind
+        ]}
+        tx={ `proposalsEngine.vote` }
+        onClick={ sendTx => sendTx() }
+        txFailedCb={ () => null }
+        txSuccessCb={ onSuccess }
+        className={`icon left labeled`}>
+        <Icon name={icon} inverted />
+        { voteKind }
+      </TxButton>
+    </Button.Group>
+  )
+}
+
+type VotingSectionProps = {
+  memberId: MemberId,
+  proposalId: ProposalId,
+  isVotingPeriod: boolean,
+};
+
+export default function VotingSection({
+  memberId,
+  proposalId,
+  isVotingPeriod
+}: VotingSectionProps) {
+  const transport = useTransport();
+  const [voted, setVoted] = useState<VoteKindStr | null >(null);
+  const [vote] = usePromise<VoteKind | null | undefined>(
+    () => transport.voteByProposalAndMember(proposalId, memberId),
+    undefined
+  );
+
+  if (vote === undefined) {
+    // Loading / error
+    return null;
+  }
+
+  const voteStr: VoteKindStr | null = voted || (vote && vote.type.toString() as VoteKindStr);
+
+  if (voteStr) {
+    const { icon, color } = useVoteStyles(voteStr);
+
+    return (
+      <Message icon color={color}>
+        <Icon name={icon} />
+        <Message.Content>
+          You voted <span className="bold">{`"${voteStr}"`}</span>
+        </Message.Content>
+      </Message>
+    );
+  }
+  else if (!isVotingPeriod) {
+    return null;
+  }
+
+  return (
+    <>
+      <Header as="h3">Sumbit your vote</Header>
+      <Divider />
+      { VoteKinds.map((voteKind) =>
+        <VoteButton
+          voteKind={voteKind}
+          memberId={memberId}
+          proposalId={proposalId}
+          key={voteKind}
+          onSuccess={ () => setVoted(voteKind) }/>
+      ) }
+    </>
+  );
+}

+ 5 - 0
packages/joy-proposals/src/Proposal/index.tsx

@@ -0,0 +1,5 @@
+export { default as ProposalDetails } from "./ProposalDetails";
+export { default as ProposalPreview } from "./ProposalPreview";
+export { default as ProposalPreviewList } from "./ProposalPreviewList";
+export { default as ProposalFromId } from "./ProposalFromId";
+export { default as ChooseProposalType } from "./ChooseProposalType";

+ 38 - 0
packages/joy-proposals/src/Proposal/useVoteStyles.tsx

@@ -0,0 +1,38 @@
+import { SemanticCOLORS, SemanticICONS } from "semantic-ui-react";
+
+export default function useVoteStyles(
+  value: "Approve" | "Abstain" | "Reject" | "Slash"
+): { textColor: string; icon: SemanticICONS; color: SemanticCOLORS } {
+  let textColor;
+  let icon: SemanticICONS;
+  let color: SemanticCOLORS;
+
+  switch (value) {
+    case "Approve": {
+      icon = "smile";
+      color = "green";
+      textColor = "text-green";
+      break;
+    }
+    case "Abstain": {
+      icon = "meh";
+      color = "grey";
+      textColor = "text-grey";
+      break;
+    }
+    case "Reject": {
+      icon = "frown";
+      color = "orange";
+      textColor = "text-orange";
+      break;
+    }
+    case "Slash": {
+      icon = "times";
+      color = "red";
+      textColor = "text-red";
+      break;
+    }
+  }
+
+  return { textColor, color, icon };
+}

+ 0 - 28
packages/joy-proposals/src/ProposalById.tsx

@@ -1,28 +0,0 @@
-import BN from 'bn.js';
-import React from 'react';
-import { I18nProps } from '@polkadot/react-components/types';
-
-import translate from './translate';
-import Details from './Details';
-
-type Props = I18nProps & {
-  match: {
-    params: {
-      id: string
-    }
-  }
-};
-
-type State = {};
-
-export class Component extends React.PureComponent<Props, State> {
-
-  state: State = {};
-
-  render () {
-    const { match: { params: { id } } } = this.props;
-    return <Details id={new BN(id)} />;
-  }
-}
-
-export default translate(Component);

+ 0 - 78
packages/joy-proposals/src/Proposals.tsx

@@ -1,78 +0,0 @@
-import BN from 'bn.js';
-import React from 'react';
-import { Segment } from 'semantic-ui-react';
-
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { withCalls } from '@polkadot/react-api/with';
-
-import { queryToProp, ZERO } from '@polkadot/joy-utils/index';
-import { Seat } from '@joystream/types/';
-import Section from '@polkadot/joy-utils/Section';
-import translate from './translate';
-import Details from './Details';
-import FilterProps from './FilterProps';
-
-type Props = ApiProps & I18nProps & FilterProps & {
-  title: string,
-  proposalCount?: BN,
-  activeProposalIds?: BN[],
-  activeCouncil?: Seat[]
-};
-
-type State = {};
-
-export class Component extends React.PureComponent<Props, State> {
-
-  state: State = {};
-
-  render () {
-    const {
-      title = 'Proposals',
-      showActiveOnly,
-      showFinalizedOnly,
-      proposalCount = ZERO,
-      activeProposalIds = []
-    } = this.props;
-
-    const activeIdsSet = new Set(activeProposalIds.map(x => x.toString()));
-    const ids: BN[] = [];
-    let i: number = proposalCount.toNumber();
-    for (; i > 0; i--) {
-      const id = new BN(i);
-      const isActive = activeIdsSet.has(id.toString());
-      if (
-        isActive && showActiveOnly === true ||
-        !isActive && showFinalizedOnly === true ||
-        showActiveOnly !== true && showFinalizedOnly !== true
-      ) {
-        ids.push(id);
-      }
-    }
-
-    return (
-      <Section title={title}>{
-        ids.length === 0
-          ? <em>No proposals found.</em>
-          : ids.map((id, i) =>
-            <Segment className='ProposalPreviews'>
-              <div className='item'>
-                <div className='content Proposal'>
-                  <Details {...this.props} key={i} id={id} preview />
-                </div>
-              </div>
-            </Segment>
-          )
-      }</Section>
-    );
-  }
-}
-
-export default translate(
-  withCalls<Props>(
-    queryToProp('derive.chain.bestNumber'),
-    queryToProp('query.proposals.proposalCount'),
-    queryToProp('query.proposals.activeProposalIds'),
-    queryToProp('query.council.activeCouncil')
-  )(Component)
-);

+ 89 - 0
packages/joy-proposals/src/forms/EvictStorageProviderForm.tsx

@@ -0,0 +1,89 @@
+import React from "react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import * as Yup from "yup";
+import { Label, Loader } from "semantic-ui-react";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { FormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { InputAddress } from "@polkadot/react-components/index";
+import { accountIdsToOptions } from "@polkadot/joy-election/utils";
+import { AccountId } from "@polkadot/types/interfaces";
+import { useTransport } from "../runtime";
+import { usePromise } from "../utils";
+import "./forms.css";
+
+type FormValues = GenericFormValues & {
+  storageProvider: any;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  storageProvider: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+const EvictStorageProviderForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { errors, touched, values, setFieldValue } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  const transport = useTransport();
+  const [storageProviders /* error */, , loading] = usePromise<AccountId[]>(() => transport.storageProviders(), []);
+  const storageProvidersOptions = accountIdsToOptions(storageProviders);
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod="createEvictStorageProviderProposal"
+      proposalType="EvictStorageProvider"
+      submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", values.storageProvider]}
+    >
+      {loading ? (
+        <>
+          <Loader active inline style={{ marginRight: "5px" }} /> Fetching storage providers...
+        </>
+      ) : (
+        <FormField
+          error={errorLabelsProps.storageProvider}
+          label="Storage provider"
+          help="The storage provider you propose to evict"
+        >
+          <InputAddress
+            onChange={address => setFieldValue("storageProvider", address)}
+            type="address"
+            placeholder="Select storage provider"
+            value={values.storageProvider}
+            options={storageProvidersOptions}
+          />
+          {errorLabelsProps.storageProvider && <Label {...errorLabelsProps.storageProvider} prompt />}
+        </FormField>
+      )}
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    storageProvider: Validation.EvictStorageProvider.storageProvider
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "EvictStorageProvidersForm"
+})(EvictStorageProviderForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 138 - 0
packages/joy-proposals/src/forms/FileDropdown.tsx

@@ -0,0 +1,138 @@
+import React, { useState } from "react";
+import { FormikProps } from "formik";
+import { Icon, Loader } from "semantic-ui-react";
+import Dropzone from "react-dropzone";
+
+enum Status {
+  Accepted = "accepted",
+  Rejected = "rejected",
+  Active = "active",
+  Parsing = "parsing",
+  Default = "default"
+}
+
+const determineStatus = (
+  acceptedFiles: File[],
+  rejectedFiles: File[],
+  error: string | undefined,
+  isDragActive: boolean,
+  parsing: boolean
+): Status => {
+  if (parsing) return Status.Parsing;
+  if (error || rejectedFiles.length) return Status.Rejected;
+  if (acceptedFiles.length) return Status.Accepted;
+  if (isDragActive) return Status.Active;
+
+  return Status.Default;
+};
+
+// Get color by status (imporant to use #FFFFFF format, so we can easily swicth the opacity!)
+const getStatusColor = (status: Status): string => {
+  switch (status) {
+    case Status.Accepted:
+      return "#00DBB0";
+    case Status.Rejected:
+      return "#FF3861";
+    case Status.Active:
+    case Status.Parsing:
+      return "#000000";
+    default:
+      return "#333333";
+  }
+};
+
+const dropdownDivStyle = (status: Status): React.CSSProperties => {
+  const mainColor = getStatusColor(status);
+
+  return {
+    cursor: "pointer",
+    border: `1px solid ${mainColor + "30"}`,
+    borderRadius: "3px",
+    padding: "1.5em",
+    color: mainColor,
+    fontWeight: "bold",
+    transition: "color 0.5s, border-color 0.5s"
+  };
+};
+
+const dropdownIconStyle = (): React.CSSProperties => {
+  return {
+    marginRight: "0.5em",
+    opacity: 0.5
+  };
+};
+
+const innerSpanStyle = (): React.CSSProperties => {
+  return {
+    display: "flex",
+    alignItems: "center"
+  };
+};
+
+// Here we define a way of coverting the file into string for Formik purposes
+// This may change depnding on how we decide to actually send the data
+const parseFile = async (file: any): Promise<string> => {
+  const text = await file.text();
+  return text;
+};
+
+type FileDropdownProps<FormValuesT> = {
+  error: string | undefined;
+  name: keyof FormValuesT & string;
+  setFieldValue: FormikProps<FormValuesT>["setFieldValue"];
+  acceptedFormats: string | string[];
+  defaultText: string;
+};
+
+export default function FileDropdown<ValuesT = {}>(props: FileDropdownProps<ValuesT>) {
+  const [parsing, setParsing] = useState(false);
+  const { error, name, setFieldValue, acceptedFormats, defaultText } = props;
+  return (
+    <Dropzone
+      onDropAccepted={async acceptedFiles => {
+        setParsing(true);
+        const fileAsString: string = await parseFile(acceptedFiles[0]);
+        setFieldValue(name, fileAsString, true);
+        setParsing(false);
+      }}
+      multiple={false}
+      accept={acceptedFormats}
+    >
+      {({ getRootProps, getInputProps, acceptedFiles, rejectedFiles, isDragActive }) => {
+        const status = determineStatus(acceptedFiles, rejectedFiles, error, isDragActive, parsing);
+        return (
+          <section>
+            <div {...getRootProps({ style: dropdownDivStyle(status) })}>
+              <input {...getInputProps()} />
+              {
+                <span style={innerSpanStyle()}>
+                  <Icon name="cloud upload" size="huge" style={dropdownIconStyle()} />
+                  <p>
+                    {status === Status.Parsing && (
+                      <>
+                        <Loader style={{ marginRight: "0.5em" }} size="small" inline active /> Uploading...
+                      </>
+                    )}
+                    {status === Status.Rejected && (
+                      <>
+                        {error || "This is not a correct file!"}
+                        <br />
+                      </>
+                    )}
+                    {status === Status.Accepted && (
+                      <>
+                        {`Current file: ${acceptedFiles[0].name}`}
+                        <br />
+                      </>
+                    )}
+                    {status !== Status.Parsing && defaultText}
+                  </p>
+                </span>
+              }
+            </div>
+          </section>
+        );
+      }}
+    </Dropzone>
+  );
+}

+ 23 - 0
packages/joy-proposals/src/forms/FormContainer.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { withFormik } from "formik";
+
+export function withFormContainer<MyFormProps, FormValues>(formikProps: any) {
+  return function(InnerForm: React.ComponentType<any>) {
+    return withFormik<MyFormProps, FormValues>(formikProps)(function(props) {
+      const handleBlur = (e: React.FocusEvent<HTMLInputElement>, data: any): void => {
+        if (data && data.name) {
+          props.setFieldValue(data.name, data.value);
+          props.setFieldTouched(data.name);
+        }
+      };
+      const handleChange = (e: React.ChangeEvent<HTMLInputElement>, data: any): void => {
+        if (data && data.name) {
+          props.setFieldValue(data.name, data.value);
+          props.setFieldTouched(data.name);
+        }
+      };
+
+      return <InnerForm {...props} handleBlur={handleBlur} handleChange={handleChange} />;
+    });
+  };
+}

+ 61 - 0
packages/joy-proposals/src/forms/FormFields.tsx

@@ -0,0 +1,61 @@
+import React from "react";
+import { Form, FormInputProps, FormTextAreaProps } from "semantic-ui-react";
+import LabelWithHelp from './LabelWithHelp';
+
+/*
+ * Generic form field components
+ *
+ * The idea is to provide an easy way of introducing new logic,
+ * that will affect all of the exsiting form fields (or all fields of given type)
+ * and to easily switch the structure/display of a typical form field.
+*/
+
+type InputFormFieldProps = FormInputProps & {
+  help?: string,
+  unit?: string
+};
+
+export function InputFormField(props:InputFormFieldProps) {
+  const { unit } = props;
+  const fieldProps = { ...props, label: undefined };
+  return (
+    <FormField {...props}>
+      <Form.Input
+        {...fieldProps}
+        style={ unit ? { display: "flex", alignItems: "center" } : undefined }>
+          <input />
+          { unit && <div style={{ margin: "0 0 0 1rem" }}>{unit}</div> }
+      </Form.Input>
+    </FormField>
+  );
+}
+
+type TextareaFormFieldProps = FormTextAreaProps & {
+  help?: string,
+};
+
+export function TextareaFormField(props:TextareaFormFieldProps) {
+  const fieldProps = { ...props, label: undefined };
+  return (
+    <FormField {...props}>
+      <Form.TextArea {...fieldProps}/>
+    </FormField>
+  );
+}
+
+type FormFieldProps = InputFormFieldProps | TextareaFormFieldProps;
+
+export function FormField(props: React.PropsWithChildren<FormFieldProps>) {
+  const { error, label, help, children } = props;
+  return (
+    <Form.Field error={Boolean(error)}>
+      { (label && help) ?
+        <LabelWithHelp text={ label.toString() } help={ help }/>
+        : ( label ? <label>{ label.toString() }</label> : null )
+      }
+      { children }
+    </Form.Field>
+  );
+}
+
+export default FormField;

+ 195 - 0
packages/joy-proposals/src/forms/GenericProposalForm.tsx

@@ -0,0 +1,195 @@
+import React from "react";
+import { FormikProps, WithFormikConfig } from "formik";
+import { Form, Icon, Button, Message } from "semantic-ui-react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import Validation from "../validationSchema";
+import { InputFormField, TextareaFormField } from "./FormFields";
+import TxButton from "@polkadot/joy-utils/TxButton";
+import { SubmittableResult } from "@polkadot/api";
+import { TxFailedCallback, TxCallback } from "@polkadot/react-components/Status/types";
+import { MyAccountProps, withOnlyMembers } from "@polkadot/joy-utils/MyAccount";
+import { withMulti } from "@polkadot/react-api/with";
+import { withCalls } from "@polkadot/react-api";
+import { CallProps } from "@polkadot/react-api/types";
+import { Balance, Event } from "@polkadot/types/interfaces";
+import { RouteComponentProps } from "react-router";
+import { ProposalType } from "../runtime";
+import { calculateStake } from "../utils";
+import { formatBalance } from "@polkadot/util"
+import "./forms.css";
+import { ProposalId } from "@joystream/types/proposals";
+
+
+// Generic form values
+export type GenericFormValues = {
+  title: string;
+  rationale: string;
+};
+
+export const genericFormDefaultValues: GenericFormValues = {
+  title: "",
+  rationale: ""
+};
+
+// Helper generic types for defining form's Export, Container and Inner component prop types
+export type ProposalFormExportProps<AdditionalPropsT, FormValuesT> = RouteComponentProps &
+
+  AdditionalPropsT & {
+    initialData?: Partial<FormValuesT>;
+  };
+export type ProposalFormContainerProps<ExportPropsT> = ExportPropsT &
+  MyAccountProps &
+  CallProps & {
+    balances_totalIssuance?: Balance;
+  };
+
+export type ProposalFormInnerProps<ContainerPropsT, FormValuesT> = ContainerPropsT & FormikProps<FormValuesT>;
+
+// Types only used in this file
+type GenericProposalFormAdditionalProps = {
+  txMethod?: string;
+  submitParams?: any[];
+  proposalType?: ProposalType;
+};
+
+type GenericFormContainerProps = ProposalFormContainerProps<
+
+  ProposalFormExportProps<GenericProposalFormAdditionalProps, GenericFormValues>
+
+>;
+type GenericFormInnerProps = ProposalFormInnerProps<GenericFormContainerProps, GenericFormValues>;
+type GenericFormDefaultOptions = WithFormikConfig<GenericFormContainerProps, GenericFormValues>;
+
+// Default "withFormik" options that can be extended in specific forms
+export const genericFormDefaultOptions: GenericFormDefaultOptions = {
+  mapPropsToValues: (props: GenericFormContainerProps) => ({
+    ...genericFormDefaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: {
+
+    title: Validation.All.title,
+    rationale: Validation.All.rationale
+
+  },
+  handleSubmit: (values, { setSubmitting, resetForm }) => {
+    // This is handled via TxButton
+  }
+};
+
+// Generic proposal form with basic structure, "Title" and "Rationale" fields
+// Other fields can be passed as children
+export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps> = props => {
+  const {
+    handleChange,
+    errors,
+    isSubmitting,
+    touched,
+    handleSubmit,
+    children,
+    handleReset,
+    values,
+    txMethod,
+    submitParams,
+    isValid,
+    setSubmitting,
+    history,
+    balances_totalIssuance,
+    proposalType
+  } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<GenericFormValues>(errors, touched);
+
+  const onSubmit = (sendTx: () => void) => {
+    if (isValid) sendTx();
+  };
+
+  const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
+    setSubmitting(false);
+  };
+
+  const onTxSuccess: TxCallback = (txResult: SubmittableResult) => {
+    if (!history) return;
+    // Determine proposal id
+    let createdProposalId: number | null = null;
+    for (let e of txResult.events) {
+      const event = e.get('event') as Event | undefined;
+      if (event !== undefined && event.method === 'ProposalCreated') {
+        createdProposalId = (event.data[1] as ProposalId).toNumber();
+        break;
+      }
+    }
+    setSubmitting(false);
+    history.push(`/proposals/${ createdProposalId }`);
+  };
+
+  const requiredStake: number | undefined =
+    balances_totalIssuance &&
+    proposalType &&
+    calculateStake(proposalType, balances_totalIssuance.toNumber());
+
+  return (
+    <div className="Forms">
+      <Form className="proposal-form" onSubmit={handleSubmit}>
+        <InputFormField
+          label="Title"
+          help="The title of your proposal"
+          onChange={handleChange}
+          name="title"
+          placeholder="Title for your awesome proposal..."
+          error={errorLabelsProps.title}
+          value={values.title}
+        />
+        <TextareaFormField
+          label="Rationale"
+          help="The rationale behind your proposal"
+          onChange={handleChange}
+          name="rationale"
+          placeholder="This proposal is awesome because..."
+          error={errorLabelsProps.rationale}
+          value={values.rationale}
+        />
+        {children}
+        <Message warning visible>
+          <Message.Content>
+            <Icon name="warning circle" />
+            Required stake: <b>{ formatBalance(requiredStake) }</b>
+          </Message.Content>
+        </Message>
+        <div className="form-buttons">
+          {txMethod ? (
+            <TxButton
+              type="submit"
+              label="Submit proposal"
+              icon="paper plane"
+              isDisabled={isSubmitting || !isValid}
+              params={(submitParams || []).map(p => (p === "{STAKE}" ? requiredStake : p))}
+              tx={`proposalsCodex.${txMethod}`}
+              onClick={onSubmit}
+              txFailedCb={onTxFailed}
+              txSuccessCb={onTxSuccess}
+            />
+          ) : (
+            <Button type="submit" color="blue" loading={isSubmitting}>
+              <Icon name="paper plane" />
+              Submit
+            </Button>
+          )}
+
+          <Button type="button" color="grey" onClick={handleReset}>
+            <Icon name="times" />
+            Clear
+          </Button>
+        </div>
+      </Form>
+    </div>
+  );
+};
+
+// Helper that provides additional wrappers for proposal forms
+
+export function withProposalFormData<ContainerPropsT, ExportPropsT>(
+  FormContainerComponent: React.ComponentType<ContainerPropsT>
+): React.ComponentType<ExportPropsT> {
+  return withMulti(FormContainerComponent, withOnlyMembers, withCalls("query.balances.totalIssuance"));
+
+}

+ 26 - 0
packages/joy-proposals/src/forms/LabelWithHelp.tsx

@@ -0,0 +1,26 @@
+import React, { useState } from "react";
+import { Icon, Label, Transition } from "semantic-ui-react";
+
+type LabelWithHelpProps = { text:string, help: string };
+
+export default function LabelWithHelp(props: LabelWithHelpProps) {
+  const [open, setOpen] = useState(false);
+  return (
+    <label
+      style={{ position: 'relative', cursor: 'pointer', padding: '0.25em 0' }}
+      onMouseEnter={ () => setOpen(true) }
+      onMouseLeave={ () => setOpen(false) }
+      >
+      {props.text}
+      <span style={{ position: "absolute", display: "inline-flex", flexWrap: "wrap", marginTop: "-0.25em" }}>
+        <Icon
+          style={{ margin: '0.25em 0.1em 0.5em 0.25em' }}
+          name="help circle"
+          color="grey"/>
+        <Transition animation="fade" visible={open} duration={500}>
+          <Label basic style={{ minWidth: '150px' }} color="grey" content={props.help}/>
+        </Transition>
+      </span>
+    </label>
+  );
+}

+ 79 - 0
packages/joy-proposals/src/forms/MintCapacityForm.tsx

@@ -0,0 +1,79 @@
+import React from "react";
+import * as Yup from "yup";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { InputFormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { ProposalType } from "../runtime";
+import { formatBalance } from "@polkadot/util";
+import "./forms.css";
+
+type FormValues = GenericFormValues & {
+  capacity: string;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  capacity: ""
+};
+
+type MintCapacityGroup = "Council" | "Content Working Group";
+
+// Aditional props coming all the way from export comonent into the inner form.
+type FormAdditionalProps = {
+  mintCapacityGroup: MintCapacityGroup;
+  txMethod: string;
+  proposalType: ProposalType;
+};
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+const MintCapacityForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, mintCapacityGroup, values, txMethod, initialData, proposalType } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod={txMethod}
+      proposalType={proposalType}
+      submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", values.capacity]}
+    >
+      <InputFormField
+        error={errorLabelsProps.capacity}
+        onChange={handleChange}
+        name="capacity"
+        placeholder={ (initialData && initialData.capacity) }
+        label={`${mintCapacityGroup} Mint Capacity`}
+        help={`The new mint capacity you propse for ${mintCapacityGroup}`}
+        unit={ formatBalance.getDefaults().unit }
+        value={values.capacity}
+      />
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    capacity: Validation.SetContentWorkingGroupMintCapacity.mintCapacity
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "MintCapacityForm"
+})(MintCapacityForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 68 - 0
packages/joy-proposals/src/forms/RuntimeUpgradeForm.tsx

@@ -0,0 +1,68 @@
+import React from "react";
+import { Form } from "semantic-ui-react";
+import * as Yup from "yup";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { withFormContainer } from "./FormContainer";
+import "./forms.css";
+import FileDropdown from "./FileDropdown";
+
+type FormValues = GenericFormValues & {
+  WASM: string;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  WASM: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+const RuntimeUpgradeForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { errors, setFieldValue, values } = props;
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod="createRuntimeUpgradeProposal"
+      proposalType="RuntimeUpgrade"
+      submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", values.WASM]}
+    >
+      <Form.Field>
+        <FileDropdown<FormValues>
+          setFieldValue={setFieldValue}
+          defaultText="Drag-n-drop WASM bytecode of a runtime upgrade (*.wasm)"
+          acceptedFormats=".wasm"
+          name="WASM"
+          error={errors.WASM}
+        />
+      </Form.Field>
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    WASM: Validation.RuntimeUpgrade.WASM
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "RuntimeUpgradeForm"
+})(RuntimeUpgradeForm);
+
+export default withProposalFormData(FormContainer);

+ 188 - 0
packages/joy-proposals/src/forms/SetContentWorkingGroupLeadForm.tsx

@@ -0,0 +1,188 @@
+import React, { useEffect, useState } from "react";
+import { Dropdown, Label, Loader, Message, Icon, DropdownItemProps, DropdownOnSearchChangeData, DropdownProps } from "semantic-ui-react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import * as Yup from "yup";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { FormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { useTransport } from "../runtime";
+import { usePromise } from "../utils";
+import { Profile } from "@joystream/types/members";
+import PromiseComponent from "../Proposal/PromiseComponent";
+import _ from 'lodash';
+import "./forms.css";
+
+type FormValues = GenericFormValues & {
+  workingGroupLead: any;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  workingGroupLead: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+function memberOptionKey(id: number, profile: Profile) {
+  return `${id}:${profile.root_account.toString()}`;
+}
+
+const MEMBERS_QUERY_MIN_LENGTH = 4;
+const MEMBERS_NONE_OPTION: DropdownItemProps = {
+  key: '- NONE -',
+  text: '- NONE -',
+  value: 'none'
+}
+
+function membersToOptions(members: { id: number, profile: Profile }[]) {
+  return [MEMBERS_NONE_OPTION].concat(
+    members
+      .map(({ id, profile }) => ({
+        key: profile.handle,
+        text: `${ profile.handle } (id:${ id })`,
+        value: memberOptionKey(id, profile),
+        image: profile.avatar_uri.toString() ? { avatar: true, src: profile.avatar_uri } : null
+      }))
+  );
+}
+
+function filterMembers(options: DropdownItemProps[], query: string) {
+  if (query.length < MEMBERS_QUERY_MIN_LENGTH) {
+    return [MEMBERS_NONE_OPTION];
+  }
+  const regexp = new RegExp(_.escapeRegExp(query));
+  return options.filter((opt) => regexp.test((opt.text || '').toString()))
+}
+
+type MemberWithId = { id: number; profile: Profile };
+
+const SetContentWorkingGroupsLeadForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  // State
+  const [ membersOptions, setMembersOptions ] = useState([] as DropdownItemProps[]);
+  const [ filteredOptions, setFilteredOptions ] = useState([] as DropdownItemProps[]);
+  const [ membersSearchQuery, setMembersSearchQuery ] = useState("");
+  // Transport
+  const transport = useTransport();
+  const [members, /* error */, loading] = usePromise<MemberWithId[]>(
+    () => transport.membersExceptCouncil(),
+    []
+  );
+  const [currentLead, clError, clLoading] = usePromise<MemberWithId | null>(
+    () => transport.WGLead(),
+    null
+  );
+  // Generate members options array on load
+  useEffect(() => {
+    if (members.length) {
+      setMembersOptions(membersToOptions(members));
+    }
+  }, [members]);
+  // Filter options on search query change (we "pulled-out" this logic here to avoid lags)
+  useEffect(() => {
+    setFilteredOptions(filterMembers(membersOptions, membersSearchQuery));
+  }, [membersSearchQuery]);
+
+  return (
+    <PromiseComponent error={clError} loading={clLoading} message="Fetching current lead...">
+      <GenericProposalForm
+        {...props}
+        txMethod="createSetLeadProposal"
+        proposalType="SetLead"
+        submitParams={[
+          props.myMemberId,
+          values.title,
+          values.rationale,
+          "{STAKE}",
+          values.workingGroupLead !== MEMBERS_NONE_OPTION.value ? values.workingGroupLead.split(":") : undefined
+        ]}
+      >
+        {loading ? (
+          <>
+            <Loader active inline style={{ marginRight: "5px" }} /> Fetching members...
+          </>
+        ) : (<>
+          <FormField
+            error={errorLabelsProps.workingGroupLead}
+            label="New Content Working Group Lead"
+            help={
+              'The member you propose to set as a new Content Working Group Lead. ' +
+              'Start typing handle or use "id:[ID]" query.'
+            }
+          >
+            {
+              (!values.workingGroupLead || membersSearchQuery.length > 0) &&
+              (MEMBERS_QUERY_MIN_LENGTH - membersSearchQuery.length) > 0 && (
+                <Label>
+                  Type at least { MEMBERS_QUERY_MIN_LENGTH - membersSearchQuery.length } more characters
+                </Label>
+              )
+            }
+            <Dropdown
+              clearable
+              // Here we just ignore search query and return all options, since we pulled-out this logic
+              // to our component to avoid lags
+              search={ (options: DropdownItemProps[], query:string ) => options }
+              // On search change we update it in our state
+              onSearchChange={ (e: React.SyntheticEvent, data: DropdownOnSearchChangeData) => {
+                setMembersSearchQuery(data.searchQuery);
+              } }
+              name="workingGroupLead"
+              placeholder={ "Start typing member handle or \"id:[ID]\" query..." }
+              fluid
+              selection
+              options={filteredOptions}
+              onChange={
+                (e: React.ChangeEvent<any>, data: DropdownProps) => {
+                  // Fix TypeScript issue
+                  const originalHandler = handleChange as (e: React.ChangeEvent<any>, data: DropdownProps) => void;
+                  originalHandler(e, data);
+                  if (!data.value) {
+                    setMembersSearchQuery('');
+                  }
+                }
+              }
+              value={values.workingGroupLead}
+            />
+            {errorLabelsProps.workingGroupLead && <Label {...errorLabelsProps.workingGroupLead} prompt />}
+          </FormField>
+          <Message info active={1}>
+            <Message.Content>
+              <Icon name="info circle"/>
+              Current Content Working Group lead: <b>{ (currentLead && currentLead.profile.handle) || 'NONE' }</b>
+            </Message.Content>
+          </Message>
+        </>)}
+      </GenericProposalForm>
+    </PromiseComponent>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    workingGroupLead: Validation.SetLead.workingGroupLead
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "SetContentWorkingGroupLeadForm"
+})(SetContentWorkingGroupsLeadForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 24 - 0
packages/joy-proposals/src/forms/SetContentWorkingGroupMintCapForm.tsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import { default as MintCapacityForm } from './MintCapacityForm';
+import { RouteComponentProps } from 'react-router';
+import { useTransport } from "../runtime";
+import { usePromise } from "../utils";
+import PromiseComponent from '../Proposal/PromiseComponent';
+
+const ContentWorkingGroupMintCapForm = (props: RouteComponentProps) => {
+  const transport = useTransport();
+  const [ mintCapacity, error, loading ] = usePromise<number>(() => transport.WGMintCap(), 0);
+
+  return (
+    <PromiseComponent error={error} loading={loading} message="Fetching current mint capacity...">
+      <MintCapacityForm
+        mintCapacityGroup="Content Working Group"
+        txMethod="createSetContentWorkingGroupMintCapacityProposal"
+        proposalType="SetContentWorkingGroupMintCapacity"
+        initialData={{ capacity: mintCapacity.toString() }}
+        {...props} />
+    </PromiseComponent>
+  );
+};
+
+export default ContentWorkingGroupMintCapForm;

+ 14 - 0
packages/joy-proposals/src/forms/SetCouncilMintCapForm.tsx

@@ -0,0 +1,14 @@
+// import React from 'react';
+// import { default as MintCapacityForm } from './MintCapacityForm';
+import { RouteComponentProps } from 'react-router';
+
+const CouncilMintCapForm = (props: RouteComponentProps) => (
+  null
+    // <MintCapacityForm
+    //   mintCapacityGroup="Council"
+    //   txMethod="createSetContentWorkingGroupMintCapacityProposal"
+    //   proposalType="SetCouncilMintCapacity"
+    //   {...props} />
+);
+
+export default CouncilMintCapForm;

+ 219 - 0
packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx

@@ -0,0 +1,219 @@
+import React, { useEffect, useState } from "react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import { Divider, Form } from "semantic-ui-react";
+import * as Yup from "yup";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { InputFormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { createType } from "@polkadot/types";
+import "./forms.css";
+import { useTransport } from "../runtime";
+import { usePromise, snakeCaseToCamelCase } from "../utils";
+import { ElectionParameters } from "@joystream/types/proposals";
+import PromiseComponent from "../Proposal/PromiseComponent";
+
+type FormValues = GenericFormValues & {
+  announcingPeriod: string;
+  votingPeriod: string;
+  minVotingStake: string;
+  revealingPeriod: string;
+  minCouncilStake: string;
+  newTermDuration: string;
+  candidacyLimit: string;
+  councilSize: string;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  announcingPeriod: "",
+  votingPeriod: "",
+  minVotingStake: "",
+  revealingPeriod: "",
+  minCouncilStake: "",
+  newTermDuration: "",
+  candidacyLimit: "",
+  councilSize: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+function createElectionParameters(values: FormValues): ElectionParameters {
+  return new ElectionParameters({
+    announcing_period: createType("BlockNumber", parseInt(values.announcingPeriod)),
+    voting_period: createType("BlockNumber", parseInt(values.votingPeriod)),
+    revealing_period: createType("BlockNumber", parseInt(values.revealingPeriod)),
+    council_size: createType("u32", values.councilSize),
+    candidacy_limit: createType("u32", values.candidacyLimit),
+    new_term_duration: createType("BlockNumber", parseInt(values.newTermDuration)),
+    min_council_stake: createType("Balance", values.minCouncilStake),
+    min_voting_stake: createType("Balance", values.minVotingStake)
+  });
+}
+
+const SetCouncilParamsForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, setFieldValue, setFieldError } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  const [ placeholders, setPlaceholders ] = useState<{ [k in keyof FormValues]: string }>(defaultValues);
+
+  const transport = useTransport();
+  const [ councilParams, error, loading ] = usePromise<ElectionParameters | null>(() => transport.electionParameters(), null);
+  useEffect(() => {
+    if (councilParams) {
+      let fetchedPlaceholders = {...placeholders};
+      const fieldsToPopulate = [
+        "announcing_period",
+        "voting_period",
+        "min_voting_stake",
+        "revealing_period",
+        "min_council_stake",
+        "new_term_duration",
+        "candidacy_limit",
+        "council_size"
+      ] as const;
+      fieldsToPopulate.forEach(field => {
+        const camelCaseField = snakeCaseToCamelCase(field) as keyof FormValues;
+        setFieldValue(camelCaseField, councilParams[field].toString());
+        fetchedPlaceholders[camelCaseField] = councilParams[field].toString();
+      });
+      setPlaceholders(fetchedPlaceholders);
+    }
+  }, [councilParams]);
+
+  // This logic may be moved somewhere else in the future, but it's quite easy to enforce it here:
+  if (!errors.candidacyLimit && !errors.councilSize && parseInt(values.candidacyLimit) < parseInt(values.councilSize)) {
+    setFieldError('candidacyLimit', `Candidacy limit must be >= council size (${ values.councilSize })`);
+  }
+
+  return (
+    <PromiseComponent error={error} loading={loading} message="Fetching current parameters...">
+      <GenericProposalForm
+        {...props}
+        txMethod="createSetElectionParametersProposal"
+        proposalType="SetElectionParameters"
+        submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", createElectionParameters(values)]}
+      >
+        <Divider horizontal>Voting </Divider>
+        <Form.Group widths="equal" style={{ marginBottom: "8rem" }}>
+          <InputFormField
+            label="Announcing Period"
+            help="Announcing period in blocks"
+            onChange={handleChange}
+            name="announcingPeriod"
+            error={errorLabelsProps.announcingPeriod}
+            value={values.announcingPeriod}
+            placeholder={ placeholders.announcingPeriod }
+          />
+          <InputFormField
+            label="Voting Period"
+            help="Voting period in blocks"
+            onChange={handleChange}
+            name="votingPeriod"
+            error={errorLabelsProps.votingPeriod}
+            value={values.votingPeriod}
+            placeholder={ placeholders.votingPeriod }
+          />
+          <InputFormField
+            label="Revealing Period"
+            help="Revealing period in blocks"
+            fluid
+            onChange={handleChange}
+            name="revealingPeriod"
+            error={errorLabelsProps.revealingPeriod}
+            value={values.revealingPeriod}
+            placeholder={ placeholders.revealingPeriod }
+          />
+          <InputFormField
+            label="Minimum Voting Stake"
+            help="The minimum voting stake"
+            fluid
+            onChange={handleChange}
+            name="minVotingStake"
+            error={errorLabelsProps.minVotingStake}
+            value={values.minVotingStake}
+            placeholder={ placeholders.minVotingStake }
+            disabled
+          />
+        </Form.Group>
+        <Divider horizontal>Council</Divider>
+        <Form.Group widths="equal" style={{ marginBottom: "8rem" }}>
+          <InputFormField
+            label="Minimum Council Stake"
+            help="The minimum council stake"
+            fluid
+            onChange={handleChange}
+            name="minCouncilStake"
+            error={errorLabelsProps.minCouncilStake}
+            value={values.minCouncilStake}
+            placeholder={ placeholders.minCouncilStake }
+            disabled
+          />
+          <InputFormField
+            label="New Term Duration"
+            help="Duration of the new term in blocks"
+            fluid
+            onChange={handleChange}
+            name="newTermDuration"
+            error={errorLabelsProps.newTermDuration}
+            value={values.newTermDuration}
+            placeholder={ placeholders.newTermDuration }
+          />
+          <InputFormField
+            label="Council Size"
+            help="The size of the council (number of seats)"
+            fluid
+            onChange={handleChange}
+            name="councilSize"
+            error={errorLabelsProps.councilSize}
+            value={values.councilSize}
+            placeholder={ placeholders.councilSize }
+          />
+          <InputFormField
+            label="Candidacy Limit"
+            help="How many candidates that will be allowed in to the voting stage"
+            fluid
+            onChange={handleChange}
+            name="candidacyLimit"
+            error={errorLabelsProps.candidacyLimit}
+            value={values.candidacyLimit}
+            placeholder={ placeholders.candidacyLimit }
+          />
+        </Form.Group>
+      </GenericProposalForm>
+    </PromiseComponent>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    announcingPeriod: Validation.SetElectionParameters.announcingPeriod,
+    votingPeriod: Validation.SetElectionParameters.votingPeriod,
+    minVotingStake: Validation.SetElectionParameters.minVotingStake,
+    revealingPeriod: Validation.SetElectionParameters.revealingPeriod,
+    minCouncilStake: Validation.SetElectionParameters.minCouncilStake,
+    newTermDuration: Validation.SetElectionParameters.newTermDuration,
+    candidacyLimit: Validation.SetElectionParameters.candidacyLimit,
+    councilSize: Validation.SetElectionParameters.councilSize
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "SetCouncilParamsForm"
+})(SetCouncilParamsForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 79 - 0
packages/joy-proposals/src/forms/SetMaxValidatorCountForm.tsx

@@ -0,0 +1,79 @@
+import React, { useEffect } from "react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import * as Yup from "yup";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { InputFormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { useTransport } from "../runtime";
+import { usePromise } from "../utils";
+import "./forms.css";
+
+type FormValues = GenericFormValues & {
+  maxValidatorCount: string;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  maxValidatorCount: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+const SetMaxValidatorCountForm: React.FunctionComponent<FormInnerProps> = props => {
+  const transport = useTransport();
+  const [validatorCount] = usePromise<number>(() => transport.maxValidatorCount(), NaN);
+  const { handleChange, errors, touched, values, setFieldValue } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+
+  useEffect(() => {
+    if (validatorCount) {
+      setFieldValue("maxValidatorCount", validatorCount);
+    }
+  }, [validatorCount]);
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod="createSetValidatorCountProposal"
+      proposalType="SetValidatorCount"
+      submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", values.maxValidatorCount]}
+    >
+      <InputFormField
+        error={errorLabelsProps.maxValidatorCount}
+        label="Max Validator Count"
+        help="The new value for maximum number of Validators that you propose"
+        onChange={handleChange}
+        name="maxValidatorCount"
+        placeholder={validatorCount}
+        value={values.maxValidatorCount}
+      />
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    maxValidatorCount: Validation.SetValidatorCount.maxValidatorCount
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "SetMaxValidatorCountForm"
+})(SetMaxValidatorCountForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 256 - 0
packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx

@@ -0,0 +1,256 @@
+import React, { useState, useEffect } from "react";
+import { Form, Divider } from "semantic-ui-react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import * as Yup from "yup";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { InputFormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { BlockNumber, Balance } from "@polkadot/types/interfaces";
+import { u32 } from "@polkadot/types/primitive";
+import { createType } from "@polkadot/types";
+import { useTransport, StorageRoleParameters, IStorageRoleParameters } from "../runtime";
+import { usePromise } from "../utils";
+import { formatBalance } from "@polkadot/util";
+import "./forms.css";
+
+// Move to joy-types?
+type RoleParameters = {
+  min_stake: Balance;
+  min_actors: u32;
+  max_actors: u32;
+  reward: Balance;
+  reward_period: BlockNumber;
+  bonding_period: BlockNumber;
+  unbonding_period: BlockNumber;
+  min_service_period: BlockNumber;
+  startup_grace_period: BlockNumber;
+  entry_request_fee: Balance;
+};
+
+// All of those are strings, because that's how those values are beeing passed from inputs
+type FormValues = GenericFormValues &
+  {
+    [K in keyof RoleParameters]: string;
+  };
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  min_stake: "",
+  min_actors: "",
+  max_actors: "",
+  reward: "",
+  reward_period: "",
+  bonding_period: "",
+  unbonding_period: "",
+  min_service_period: "",
+  startup_grace_period: "",
+  entry_request_fee: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+function createRoleParameters(values: FormValues): RoleParameters {
+  return {
+    min_stake: createType("Balance", values.min_stake),
+    min_actors: createType("u32", values.min_actors),
+    max_actors: createType("u32", values.max_actors),
+    reward: createType("Balance", values.reward),
+    reward_period: createType("BlockNumber", values.reward_period),
+    bonding_period: createType("BlockNumber", values.bonding_period),
+    unbonding_period: createType("BlockNumber", values.unbonding_period),
+    min_service_period: createType("BlockNumber", values.min_service_period),
+    startup_grace_period: createType("BlockNumber", values.startup_grace_period),
+    entry_request_fee: createType("Balance", values.entry_request_fee)
+  };
+}
+
+const SetStorageRoleParamsForm: React.FunctionComponent<FormInnerProps> = props => {
+  const transport = useTransport();
+  const [params] = usePromise<IStorageRoleParameters | null>(() => transport.storageRoleParameters(), null);
+  const { handleChange, errors, touched, values, setFieldValue } = props;
+  const [placeholders, setPlaceholders] = useState<{ [k in keyof FormValues]: string }>(defaultValues);
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+
+  useEffect(() => {
+    if (params) {
+      const stringParams = Object.keys(params).reduce((obj, key) => {
+        return { ...obj, [`${key}`]: String(params[key as keyof IStorageRoleParameters]) };
+      }, {});
+      const fetchedPlaceholders = { ...placeholders, ...stringParams };
+
+      StorageRoleParameters.forEach(field => {
+        setFieldValue(field, params[field].toString());
+      });
+      setPlaceholders(fetchedPlaceholders);
+    }
+  }, [params]);
+
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod="createSetStorageRoleParametersProposal"
+      proposalType="SetStorageRoleParameters"
+      submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", createRoleParameters(values)]}
+    >
+      <Divider horizontal>Parameters</Divider>
+      <Form.Group widths="equal" style={{ marginBottom: "2em" }}>
+        <InputFormField
+          label="Min. actors"
+          help="Minimum number of actors in this role"
+          onChange={handleChange}
+          name="min_actors"
+          placeholder={placeholders.min_actors}
+          error={errorLabelsProps.min_actors}
+          value={values.min_actors}
+          disabled
+        />
+        <InputFormField
+          label="Max. actors"
+          help="Maximum number of actors in this role"
+          fluid
+          onChange={handleChange}
+          name="max_actors"
+          placeholder={placeholders.max_actors}
+          error={errorLabelsProps.max_actors}
+          value={values.max_actors}
+        />
+      </Form.Group>
+      <Form.Group widths="equal" style={{ marginBottom: "2em" }}>
+        <InputFormField
+          label="Reward"
+          help="Reward for performing this role (for each period)"
+          fluid
+          onChange={handleChange}
+          name="reward"
+          placeholder={placeholders.reward}
+          error={errorLabelsProps.reward}
+          value={values.reward}
+          unit={ formatBalance.getDefaults().unit }
+        />
+        <InputFormField
+          label="Reward period"
+          help="Reward period in blocks"
+          fluid
+          onChange={handleChange}
+          name="reward_period"
+          placeholder={placeholders.reward_period}
+          error={errorLabelsProps.reward_period}
+          value={values.reward_period}
+          unit="blocks"
+          disabled
+        />
+      </Form.Group>
+      <Form.Group widths="equal" style={{ marginBottom: "2em" }}>
+        <InputFormField
+          label="Min. stake"
+          help="Minimum stake for this role"
+          onChange={handleChange}
+          name="min_stake"
+          placeholder={placeholders.min_stake}
+          error={errorLabelsProps.min_stake}
+          value={values.min_stake}
+          unit={ formatBalance.getDefaults().unit }
+        />
+        <InputFormField
+          label="Min. service period"
+          help="Minimum period of service in blocks"
+          fluid
+          onChange={handleChange}
+          name="min_service_period"
+          placeholder={placeholders.min_service_period}
+          error={errorLabelsProps.min_service_period}
+          value={values.min_service_period}
+          unit="blocks"
+          disabled
+        />
+      </Form.Group>
+      <Form.Group widths="equal" style={{ marginBottom: "2em" }}>
+        <InputFormField
+          label="Bonding period"
+          help="Bonding period in blocks"
+          fluid
+          onChange={handleChange}
+          name="bonding_period"
+          placeholder={placeholders.bonding_period}
+          error={errorLabelsProps.bonding_period}
+          value={values.bonding_period}
+          unit="blocks"
+          disabled
+        />
+        <InputFormField
+          label="Unbounding period"
+          help="Unbounding period in blocks"
+          fluid
+          onChange={handleChange}
+          name="unbonding_period"
+          placeholder={placeholders.unbonding_period}
+          error={errorLabelsProps.unbonding_period}
+          value={values.unbonding_period}
+          unit="blocks"
+        />
+      </Form.Group>
+      <Form.Group widths="equal" style={{ marginBottom: "2em" }}>
+        <InputFormField
+          label="Startup grace period"
+          help="Startup grace period in blocks"
+          fluid
+          onChange={handleChange}
+          name="startup_grace_period"
+          placeholder={placeholders.startup_grace_period}
+          error={errorLabelsProps.startup_grace_period}
+          value={values.startup_grace_period}
+          unit="blocks"
+          disabled
+        />
+        <InputFormField
+          label="Entry request fee"
+          help="Entry request fee"
+          fluid
+          onChange={handleChange}
+          name="entry_request_fee"
+          placeholder={placeholders.entry_request_fee}
+          error={errorLabelsProps.entry_request_fee}
+          value={values.entry_request_fee}
+          unit={ formatBalance.getDefaults().unit }
+        />
+      </Form.Group>
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    min_stake: Validation.SetStorageRoleParameters.min_stake,
+    min_actors: Validation.SetStorageRoleParameters.min_actors,
+    max_actors: Validation.SetStorageRoleParameters.max_actors,
+    reward: Validation.SetStorageRoleParameters.reward,
+    reward_period: Validation.SetStorageRoleParameters.reward_period,
+    bonding_period: Validation.SetStorageRoleParameters.bonding_period,
+    unbonding_period: Validation.SetStorageRoleParameters.unbonding_period,
+    min_service_period: Validation.SetStorageRoleParameters.min_service_period,
+    startup_grace_period: Validation.SetStorageRoleParameters.startup_grace_period,
+    entry_request_fee: Validation.SetStorageRoleParameters.entry_request_fee
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "SetStorageRoleParamsForm"
+})(SetStorageRoleParamsForm);
+
+export default withProposalFormData(FormContainer);

+ 70 - 0
packages/joy-proposals/src/forms/SignalForm.tsx

@@ -0,0 +1,70 @@
+import React from "react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import * as Yup from "yup";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { TextareaFormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import "./forms.css";
+
+type FormValues = GenericFormValues & {
+  description: string;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  description: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+const SignalForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod="createTextProposal"
+      proposalType="Text"
+      submitParams={[props.myMemberId, values.title, values.rationale, "{STAKE}", values.description]}
+    >
+      <TextareaFormField
+        label="Description"
+        help="The extensive description of your proposal"
+        onChange={handleChange}
+        name="description"
+        placeholder="What I would like to propose is..."
+        error={errorLabelsProps.description}
+        value={values.description}
+      />
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    description: Validation.Text.description
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "SignalForm"
+})(SignalForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 96 - 0
packages/joy-proposals/src/forms/SpendingProposalForm.tsx

@@ -0,0 +1,96 @@
+import React from "react";
+import { getFormErrorLabelsProps } from "./errorHandling";
+import * as Yup from "yup";
+import { Label } from "semantic-ui-react";
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultOptions,
+  genericFormDefaultValues,
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from "./GenericProposalForm";
+import Validation from "../validationSchema";
+import { InputFormField, FormField } from "./FormFields";
+import { withFormContainer } from "./FormContainer";
+import { InputAddress } from "@polkadot/react-components/index";
+import { formatBalance } from "@polkadot/util";
+import "./forms.css";
+
+type FormValues = GenericFormValues & {
+  destinationAccount: any;
+  tokens: string;
+};
+
+const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  destinationAccount: "",
+  tokens: ""
+};
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+const SpendingProposalForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, setFieldValue } = props;
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  return (
+    <GenericProposalForm
+      {...props}
+      txMethod="createSpendingProposal"
+      proposalType="Spending"
+      submitParams={[
+        props.myMemberId,
+        values.title,
+        values.rationale,
+        "{STAKE}",
+        values.tokens,
+        values.destinationAccount
+      ]}
+    >
+      <InputFormField
+        label="Amount of tokens"
+        help="The amount of tokens you propose to spend"
+        onChange={handleChange}
+        name="tokens"
+        placeholder="100"
+        error={errorLabelsProps.tokens}
+        unit={ formatBalance.getDefaults().unit }
+        value={values.tokens}
+      />
+      <FormField
+        error={errorLabelsProps.destinationAccount}
+        label="Destination account"
+        help="The account you propose to send the tokens into"
+      >
+        <InputAddress
+          onChange={address => setFieldValue("destinationAccount", address)}
+          type="all"
+          placeholder="Select Destination Account"
+          value={values.destinationAccount}
+        />
+        {errorLabelsProps.destinationAccount && <Label {...errorLabelsProps.destinationAccount} prompt />}
+      </FormField>
+    </GenericProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    tokens: Validation.Spending.tokens,
+    destinationAccount: Validation.Spending.destinationAccount
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: "SpendingProposalsForm"
+})(SpendingProposalForm);
+
+export default withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer);

+ 37 - 0
packages/joy-proposals/src/forms/errorHandling.ts

@@ -0,0 +1,37 @@
+import { FormikErrors, FormikTouched } from "formik";
+import { LabelProps } from "semantic-ui-react";
+
+type FieldErrorLabelProps = LabelProps | null; // This is used for displaying semantic-ui errors
+type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
+
+// Single form field error state.
+// Takes formik "errors" and "touched" objects and the field name as arguments.
+// Returns value to use ie. in the semantic-ui Form.Input error prop.
+export function getErrorLabelProps<ValuesT>(
+  errors: FormikErrors<ValuesT>,
+  touched: FormikTouched<ValuesT>,
+  fieldName: keyof ValuesT,
+  pointing: LabelProps["pointing"] = undefined
+
+): FieldErrorLabelProps
+{
+  return (errors[fieldName] && touched[fieldName]) ?
+    { content: errors[fieldName], pointing }
+    : null;
+}
+
+// All form fields error states (uses default value for "pointing").
+// Takes formik "errors" and "touched" objects as arguments.
+// Returns object with field names as properties and values that can be used ie. for semantic-ui Form.Input error prop
+export function getFormErrorLabelsProps<ValuesT>(
+  errors: FormikErrors<ValuesT>,
+  touched: FormikTouched<ValuesT>
+): FormErrorLabelsProps<ValuesT>
+{
+  let errorStates: Partial<FormErrorLabelsProps<ValuesT>> = {};
+  for (let fieldName in errors) {
+    errorStates[fieldName] = getErrorLabelProps<ValuesT>(errors, touched, fieldName);
+  }
+
+  return <FormErrorLabelsProps<ValuesT>> errorStates;
+}

+ 23 - 0
packages/joy-proposals/src/forms/forms.css

@@ -0,0 +1,23 @@
+.Forms {
+  .proposal-form {
+    margin: 0 auto;
+  }
+
+  .ui.form.proposal-form {
+    & label {
+      font-size: 1rem;
+    }
+
+    & input[name="tokens"] {
+      max-width: 16rem;
+    }
+  }
+
+  .form-buttons {
+    display: flex;
+  }
+
+  .ui.dropdown .ui.avatar.image {
+    width: 2em !important;
+  }
+}

+ 11 - 0
packages/joy-proposals/src/forms/index.ts

@@ -0,0 +1,11 @@
+export { default as SignalForm } from "./SignalForm";
+export { default as SpendingProposalForm } from "./SpendingProposalForm";
+export { default as EvictStorageProviderForm } from "./EvictStorageProviderForm";
+export { default as MintCapacityForm } from "./MintCapacityForm";
+export { default as SetCouncilParamsForm } from "./SetCouncilParamsForm";
+export { default as SetContentWorkingGroupLeadForm } from "./SetContentWorkingGroupLeadForm";
+export { default as SetStorageRoleParamsForm } from "./SetStorageRoleParamsForm"
+export { default as RuntimeUpgradeForm } from "./RuntimeUpgradeForm";
+export { default as SetContentWorkingGroupMintCapForm } from './SetContentWorkingGroupMintCapForm';
+export { default as SetCouncilMintCapForm } from './SetCouncilMintCapForm';
+export { default as SetMaxValidatorCountForm } from './SetMaxValidatorCountForm';

+ 0 - 20
packages/joy-proposals/src/index.css

@@ -1,20 +0,0 @@
-.ProposalPreviews:hover {
-  border: 1px solid #4183c4;
-}
-.Proposal .ui.label {
-  background-color: #fcfcfc;
-  border: 1px solid #e0e0e0;
-}
-.Proposal-name {
-  font-weight: bold;
-  font-size: 1.5rem;
-}
-.Proposal-description {
-  margin-top: 1rem;
-  font-size: 1.1rem;
-}
-.Preview-label {
-  color: #999999;
-  font-size: .9rem;
-  font-weight: 300;
-}

+ 58 - 73
packages/joy-proposals/src/index.tsx

@@ -1,88 +1,73 @@
+import React from "react";
+import { Route, Switch } from "react-router";
 
-import BN from 'bn.js';
-import React from 'react';
-import { Route, Switch } from 'react-router';
+import { AppProps, I18nProps } from "@polkadot/react-components/types";
+import Tabs, { TabItem } from "@polkadot/react-components/Tabs";
+import { SubstrateProvider } from "./runtime";
+import { ProposalPreviewList, ProposalFromId, ChooseProposalType } from "./Proposal";
 
-import { AppProps, I18nProps } from '@polkadot/react-components/types';
-import { ApiProps } from '@polkadot/react-api/types';
-import { withCalls } from '@polkadot/react-api/with';
-import Tabs, { TabItem } from '@polkadot/react-components/Tabs';
+import "./index.css";
 
-// our app-specific styles
-import './index.css';
+import translate from "./translate";
+import NotDone from "./NotDone";
+import {
+  SignalForm,
+  EvictStorageProviderForm,
+  SpendingProposalForm,
+  SetContentWorkingGroupLeadForm,
+  SetContentWorkingGroupMintCapForm,
+  SetCouncilParamsForm,
+  SetStorageRoleParamsForm,
+  SetMaxValidatorCountForm,
+  RuntimeUpgradeForm
+} from "./forms";
 
-// local imports and components
-import translate from './translate';
-import Dashboard from './Dashboard';
-import Proposals from './Proposals';
-import ProposalById from './ProposalById';
-import NewForm from './NewForm';
-import { queryToProp, ZERO } from '@polkadot/joy-utils/index';
+interface Props extends AppProps, I18nProps {}
 
-// define out internal types
-type Props = AppProps & ApiProps & I18nProps & {
-  proposalCount?: BN,
-  activeProposalIds?: BN[]
-};
+function App(props: Props): React.ReactElement<Props> {
+  const { t, basePath } = props;
 
-class App extends React.PureComponent<Props> {
+  const tabs: TabItem[] = [
+    {
+      isRoot: true,
+      name: "proposals",
+      text: t("Proposals")
+    },
+    {
+      name: "new",
+      text: t("New Proposal")
+    }
+  ];
 
-  private buildTabs (): TabItem[] {
-    const { t, proposalCount = ZERO, activeProposalIds = [] } = this.props;
-    const activeCount = activeProposalIds.length;
-    const finalizedCount = proposalCount.sub(new BN(activeCount)).toNumber();
-    return [
-      {
-        isRoot: true,
-        name: 'proposals',
-        text: t('Dashboard')
-      },
-      {
-        name: 'active',
-        text: t('Active') + ` (${activeCount})`
-      },
-      {
-        name: 'finalized',
-        text: t('Finalized') + ` (${finalizedCount})`
-      },
-      {
-        name: 'new',
-        text: t('Create new')
-      }
-    ];
-  }
-
-  ActiveProposals = () => {
-    return <Proposals {...this.props} title='Active proposals' showActiveOnly={true} />;
-  }
-
-  FinalizedProposals = () => {
-    return <Proposals {...this.props} title='Finalized proposals' showFinalizedOnly={true} />;
-  }
-
-  render () {
-    const { basePath } = this.props;
-    const tabs = this.buildTabs();
-    return (
-      <main className='proposals--App'>
+  return (
+    <SubstrateProvider>
+      <main className="proposal--App">
         <header>
           <Tabs basePath={basePath} items={tabs} />
         </header>
         <Switch>
-          <Route path={`${basePath}/active`} component={this.ActiveProposals} />
-          <Route path={`${basePath}/finalized`} component={this.FinalizedProposals} />
-          <Route path={`${basePath}/new`} component={NewForm} />
-          <Route path={`${basePath}/:id`} component={ProposalById} />
-          <Route component={Dashboard} />
+          <Route exact path={`${basePath}/new`} component={ChooseProposalType} />
+          <Route exact path={`${basePath}/new/text`} component={SignalForm} />
+          <Route exact path={`${basePath}/new/runtime-upgrade`} component={RuntimeUpgradeForm} />
+          <Route exact path={`${basePath}/new/set-election-parameters`} component={SetCouncilParamsForm} />
+          <Route exact path={`${basePath}/new/spending`} component={SpendingProposalForm} />
+          <Route exact path={`${basePath}/new/set-lead`} component={SetContentWorkingGroupLeadForm} />
+          <Route
+            exact
+            path={`${basePath}/new/set-content-working-group-mint-capacity`}
+            component={SetContentWorkingGroupMintCapForm}
+          />
+          <Route exact path={`${basePath}/new/evict-storage-provider`} component={EvictStorageProviderForm} />
+          <Route exact path={`${basePath}/new/set-validator-count`} component={SetMaxValidatorCountForm} />
+          <Route exact path={`${basePath}/new/set-storage-role-parameters`} component={SetStorageRoleParamsForm} />
+          <Route exact path={`${basePath}/active`} component={NotDone} />
+          <Route exact path={`${basePath}/finalized`} component={NotDone} />
+          <Route exact path={`${basePath}/:id`} component={ProposalFromId} />
+          <Route component={ProposalPreviewList} />
         </Switch>
       </main>
-    );
-  }
+    </SubstrateProvider>
+  );
 }
 
-export default translate(
-  withCalls<Props>(
-    queryToProp('query.proposals.proposalCount'),
-    queryToProp('query.proposals.activeProposalIds')
-  )(App)
-);
+export default translate(App);

+ 23 - 0
packages/joy-proposals/src/runtime/TransportContext.tsx

@@ -0,0 +1,23 @@
+import React, { createContext, useContext } from "react";
+import { ApiContext } from "@polkadot/react-api";
+import { ApiProps } from "@polkadot/react-api/types";
+import { SubstrateTransport } from "./transport.substrate";
+import { MockTransport } from "./transport.mock";
+import { Transport } from "./transport";
+
+const TransportContext = createContext<Transport>((null as unknown) as Transport);
+
+export function MockProvider({ children }: { children: React.PropsWithChildren<{}> }) {
+  return <TransportContext.Provider value={new MockTransport()}>{children}</TransportContext.Provider>;
+}
+
+export function SubstrateProvider({ children }: { children: React.PropsWithChildren<{}> }) {
+  const api: ApiProps = useContext(ApiContext);
+  const transport = new SubstrateTransport(api);
+
+  return <TransportContext.Provider value={transport}>{children}</TransportContext.Provider>;
+}
+
+export function useTransport() {
+  return useContext(TransportContext) as SubstrateTransport;
+}

+ 66 - 0
packages/joy-proposals/src/runtime/cache.ts

@@ -0,0 +1,66 @@
+// Set does not do a deep equal when adding elements, so try to only use strings or another primitive for K
+
+export default class Cache<K, T extends { id: K }> extends Map<K, T> {
+  protected neverClear: Set<K>;
+
+  constructor(
+    objects: Iterable<readonly [K, T]>,
+    protected loaderFn: (ids: K[]) => Promise<T[]>,
+    neverClear: K[] | Set<K> = [],
+    public name?: string
+  ) {
+    super(objects);
+    this.name = name;
+    this.neverClear = new Set(neverClear);
+    this.loaderFn = loaderFn;
+  }
+
+  forceClear(): void {
+    const prevCacheSize = this.size;
+    this.clear();
+    console.info(`Removed all ${prevCacheSize} entries from ${this.name}, including ${this.neverClear}`);
+  }
+
+  clearExcept(keepIds: K[] | Set<K>, force: boolean = false): void {
+    const prevCacheSize = this.size;
+    const keepIdsSet = force ? new Set(keepIds) : new Set([...keepIds, ...this.neverClear]);
+
+    for (let key of this.keys()) {
+      if (!keepIdsSet.has(key)) {
+        this.delete(key);
+      }
+    }
+
+    console.info(`Removed ${prevCacheSize - this.size} entries out of ${prevCacheSize} from ${this.name}`);
+  }
+
+  clear(): void {
+    this.clearExcept([]);
+  }
+
+  async load(ids: K[], force: boolean = false): Promise<T[]> {
+    const idsNotInCache: K[] = [];
+    const cachedObjects: T[] = [];
+
+    ids.forEach(id => {
+      let objFromCache = this.get(id);
+      if (objFromCache && !force) {
+        cachedObjects.push(objFromCache);
+      } else {
+        idsNotInCache.push(id);
+      }
+    });
+
+    let loadedObjects: T[] = [];
+
+    if (idsNotInCache.length > 0) {
+      loadedObjects = await this.loaderFn(idsNotInCache);
+      loadedObjects.forEach(obj => {
+        const id = obj.id;
+        this.set(id, obj);
+      });
+    }
+
+    return [...cachedObjects, ...loadedObjects];
+  }
+}

+ 4 - 0
packages/joy-proposals/src/runtime/index.ts

@@ -0,0 +1,4 @@
+export { ParsedProposal, ProposalType, ProposalVote, IStorageRoleParameters, StorageRoleParameters } from "./transport";
+export { SubstrateTransport } from "./transport.substrate";
+export { MockTransport } from "./transport.mock";
+export { SubstrateProvider, useTransport } from "./TransportContext";

+ 16 - 0
packages/joy-proposals/src/runtime/transport.mock.ts

@@ -0,0 +1,16 @@
+import { Transport, ParsedProposal } from "./transport";
+
+function delay(ms: number) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export class MockTransport extends Transport {
+  constructor() {
+    super();
+  }
+
+  async proposals() {
+    await delay(Math.random() * 2000);
+    return Promise.all((Array.from({ length: 5 }, (_, i) => "Not implemented") as unknown) as ParsedProposal[]);
+  }
+}

+ 328 - 0
packages/joy-proposals/src/runtime/transport.substrate.ts

@@ -0,0 +1,328 @@
+import {
+  Transport,
+  ParsedProposal,
+  ProposalType,
+  ProposalTypes,
+  ParsedMember,
+  ProposalVote,
+  IStorageRoleParameters
+} from "./transport";
+import { Proposal, ProposalId, Seats, VoteKind, ElectionParameters } from "@joystream/types/proposals";
+import { MemberId, Profile, ActorInRole, RoleKeys, Role } from "@joystream/types/members";
+import { ApiProps } from "@polkadot/react-api/types";
+import { u32, u128, Vec, Option } from "@polkadot/types/";
+import { Balance, Moment, AccountId, BlockNumber, BalanceOf } from "@polkadot/types/interfaces";
+import { ApiPromise } from "@polkadot/api";
+
+import { FIRST_MEMBER_ID } from "@polkadot/joy-members/constants";
+
+import { includeKeys, calculateStake, calculateMetaFromType, splitOnUpperCase } from "../utils";
+import { MintId, Mint } from "@joystream/types/mint";
+import { LeadId } from "@joystream/types/content-working-group";
+
+export class SubstrateTransport extends Transport {
+  protected api: ApiPromise;
+
+  constructor(api: ApiProps) {
+    super();
+
+    if (!api) {
+      throw new Error("Cannot create SubstrateTransport: A Substrate API is required");
+    } else if (!api.isApiReady) {
+      throw new Error("Cannot create a SubstrateTransport: The Substrate API is not ready yet.");
+    }
+
+    this.api = api.api;
+  }
+
+  get proposalsEngine() {
+    return this.api.query.proposalsEngine;
+  }
+
+  get proposalsCodex() {
+    return this.api.query.proposalsCodex;
+  }
+
+  get members() {
+    return this.api.query.members;
+  }
+
+  get council() {
+    return this.api.query.council;
+  }
+
+  get councilElection() {
+    return this.api.query.councilElection;
+  }
+
+  get actors() {
+    return this.api.query.actors;
+  }
+
+  get contentWorkingGroup() {
+    return this.api.query.contentWorkingGroup;
+  }
+
+  get minting() {
+    return this.api.query.minting;
+  }
+
+  totalIssuance() {
+    return this.api.query.balances.totalIssuance<Balance>();
+  }
+
+  async blockHash(height: number): Promise<string> {
+    const blockHash = await this.api.rpc.chain.getBlockHash(height);
+
+    return blockHash.toString();
+  }
+
+  async blockTimestamp(height: number): Promise<Date> {
+    const blockTime = (await this.api.query.timestamp.now.at(await this.blockHash(height))) as Moment;
+
+    return new Date(blockTime.toNumber());
+  }
+
+  proposalCount() {
+    return this.proposalsEngine.proposalCount<u32>();
+  }
+
+  rawProposalById(id: ProposalId) {
+    return this.proposalsEngine.proposals<Proposal>(id);
+  }
+
+  proposalDetailsById(id: ProposalId) {
+    return this.proposalsCodex.proposalDetailsByProposalId(id);
+  }
+
+  memberProfile(id: MemberId | number): Promise<Option<Profile>> {
+    return this.members.memberProfile(id) as Promise<Option<Profile>>;
+  }
+
+  async cancellationFee(): Promise<number> {
+    return ((await this.api.consts.proposalsEngine.cancellationFee) as BalanceOf).toNumber();
+  }
+
+  async proposalById(id: ProposalId): Promise<ParsedProposal> {
+    const rawDetails = (await this.proposalDetailsById(id)).toJSON() as { [k: string]: any };
+    const type = Object.keys(rawDetails)[0] as ProposalType;
+    const details = Array.isArray(rawDetails[type]) ? rawDetails[type] : [rawDetails[type]];
+    const rawProposal = await this.rawProposalById(id);
+    const proposer = (await this.memberProfile(rawProposal.proposerId)).toJSON() as ParsedMember;
+    const proposal = rawProposal.toJSON() as {
+      title: string;
+      description: string;
+      parameters: any;
+      votingResults: any;
+      proposerId: number;
+      status: any;
+    };
+    const createdAtBlock = rawProposal.createdAt;
+    const createdAt = await this.blockTimestamp(createdAtBlock.toNumber());
+    const cancellationFee = await this.cancellationFee();
+
+    return {
+      id,
+      ...proposal,
+      details,
+      type,
+      proposer,
+      createdAtBlock: createdAtBlock.toJSON(),
+      createdAt,
+      cancellationFee
+    };
+  }
+
+  async proposalsIds() {
+    const total: number = (await this.proposalCount()).toNumber();
+    return Array.from({ length: total }, (_, i) => new ProposalId(i + 1));
+  }
+
+  async proposals() {
+    const ids = await this.proposalsIds();
+    return Promise.all(ids.map(id => this.proposalById(id)));
+  }
+
+  async activeProposals() {
+    const activeProposalIds = await this.proposalsEngine.activeProposalIds<ProposalId[]>();
+
+    return Promise.all(activeProposalIds.map(id => this.proposalById(id)));
+  }
+
+  async proposedBy(member: MemberId) {
+    const proposals = await this.proposals();
+    return proposals.filter(({ proposerId }) => member.eq(proposerId));
+  }
+
+  async proposalDetails(id: ProposalId) {
+    return this.proposalsCodex.proposalDetailsByProposalId(id);
+  }
+
+  async councilMembers(): Promise<(ParsedMember & { memberId: MemberId })[]> {
+    const council = (await this.council.activeCouncil()) as Seats;
+    return Promise.all(
+      council.map(async seat => {
+        const memberIds = (await this.members.memberIdsByControllerAccountId(seat.member)) as Vec<MemberId>;
+        const member = (await this.memberProfile(memberIds[0])).toJSON() as ParsedMember;
+        return {
+          ...member,
+          memberId: memberIds[0]
+        };
+      })
+    );
+  }
+
+  async voteByProposalAndMember(proposalId: ProposalId, voterId: MemberId): Promise<VoteKind | null> {
+    const vote = await this.proposalsEngine.voteExistsByProposalByVoter<VoteKind>(proposalId, voterId);
+    const hasVoted = (await this.proposalsEngine.voteExistsByProposalByVoter.size(proposalId, voterId)).toNumber();
+    return hasVoted ? vote : null;
+  }
+
+  async votes(proposalId: ProposalId): Promise<ProposalVote[]> {
+    const councilMembers = await this.councilMembers();
+    return Promise.all(
+      councilMembers.map(async member => {
+        const vote = await this.voteByProposalAndMember(proposalId, member.memberId);
+        return {
+          vote,
+          member
+        };
+      })
+    );
+  }
+
+  async fetchProposalMethodsFromCodex(includeKey: string) {
+    const methods = includeKeys(this.proposalsCodex, includeKey);
+    // methods = [proposalTypeVotingPeriod...]
+    return methods.reduce(async (prevProm, method) => {
+      const obj = await prevProm;
+      const period = (await this.proposalsCodex[method]()) as u32;
+      // setValidatorCountProposalVotingPeriod to SetValidatorCount
+      const key = splitOnUpperCase(method)
+        .slice(0, -3)
+        .map((w, i) => (i === 0 ? w.slice(0, 1).toUpperCase() + w.slice(1) : w))
+        .join("") as ProposalType;
+
+      return { ...obj, [`${key}`]: period.toNumber() };
+    }, Promise.resolve({}) as Promise<{ [k in ProposalType]: number }>);
+  }
+
+  async proposalTypesGracePeriod(): Promise<{ [k in ProposalType]: number }> {
+    return this.fetchProposalMethodsFromCodex("GracePeriod");
+  }
+
+  async proposalTypesVotingPeriod(): Promise<{ [k in ProposalType]: number }> {
+    return this.fetchProposalMethodsFromCodex("VotingPeriod");
+  }
+
+  async parametersFromProposalType(type: ProposalType) {
+    const votingPeriod = (await this.proposalTypesVotingPeriod())[type];
+    const gracePeriod = (await this.proposalTypesGracePeriod())[type];
+    const issuance = (await this.totalIssuance()).toNumber();
+    const stake = calculateStake(type, issuance);
+    const meta = calculateMetaFromType(type);
+    // Currently it's same for all types, but this will change soon
+    const cancellationFee = await this.cancellationFee();
+    return {
+      type,
+      votingPeriod,
+      gracePeriod,
+      stake,
+      cancellationFee,
+      ...meta
+    };
+  }
+
+  async proposalsTypesParameters() {
+    return Promise.all(ProposalTypes.map(type => this.parametersFromProposalType(type)));
+  }
+
+  async bestBlock() {
+    return await this.api.derive.chain.bestNumber();
+  }
+
+  async storageProviders(): Promise<AccountId[]> {
+    const providers = (await this.actors.accountIdsByRole(RoleKeys.StorageProvider)) as Vec<AccountId>;
+    return providers.toArray();
+  }
+
+  async membersExceptCouncil(): Promise<{ id: number; profile: Profile }[]> {
+    // Council members to filter out
+    const activeCouncil = (await this.council.activeCouncil()) as Seats;
+    const membersCount = ((await this.members.membersCreated()) as MemberId).toNumber();
+    const profiles: { id: number; profile: Profile }[] = [];
+    for (let id = FIRST_MEMBER_ID.toNumber(); id < membersCount; ++id) {
+      const profile = (await this.memberProfile(new MemberId(id))).unwrapOr(null);
+      if (
+        !profile ||
+        // Filter out council members
+        activeCouncil.some(
+          seat =>
+            seat.member.toString() === profile.controller_account.toString() ||
+            seat.member.toString() === profile.root_account.toString()
+        )
+      ) {
+        continue;
+      }
+      profiles.push({ id, profile });
+    }
+
+    return profiles;
+  }
+
+  async storageRoleParameters(): Promise<IStorageRoleParameters> {
+    const params = (
+      await this.api.query.actors.parameters(RoleKeys.StorageProvider)
+    ).toJSON() as IStorageRoleParameters;
+    return params;
+  }
+
+  async maxValidatorCount(): Promise<number> {
+    const count = ((await this.api.query.staking.validatorCount()) as u32).toNumber();
+    return count;
+  }
+
+  async electionParameters(): Promise<ElectionParameters> {
+    const announcing_period = (await this.councilElection.announcingPeriod()) as BlockNumber;
+    const voting_period = (await this.councilElection.votingPeriod()) as BlockNumber;
+    const revealing_period = (await this.councilElection.revealingPeriod()) as BlockNumber;
+    const new_term_duration = (await this.councilElection.newTermDuration()) as BlockNumber;
+    const min_council_stake = (await this.councilElection.minCouncilStake()) as Balance;
+    const min_voting_stake = (await this.councilElection.minVotingStake()) as Balance;
+    const candidacy_limit = (await this.councilElection.candidacyLimit()) as u32;
+    const council_size = (await this.councilElection.councilSize()) as u32;
+
+    return new ElectionParameters({
+      announcing_period,
+      voting_period,
+      revealing_period,
+      new_term_duration,
+      min_council_stake,
+      min_voting_stake,
+      candidacy_limit,
+      council_size
+    });
+  }
+
+  async WGMintCap(): Promise<number> {
+    const WGMintId = (await this.contentWorkingGroup.mint()) as MintId;
+    const WGMint = (await this.minting.mints(WGMintId)) as Vec<Mint>;
+    return (WGMint[0].get("capacity") as u128).toNumber();
+  }
+
+  async WGLead(): Promise<{ id: number; profile: Profile } | null> {
+    const optLeadId = (await this.contentWorkingGroup.currentLeadId()) as Option<LeadId>;
+    const leadId = optLeadId.unwrapOr(null);
+
+    if (!leadId) return null;
+
+    const actorInRole = new ActorInRole({
+      role: new Role(RoleKeys.CuratorLead),
+      actor_id: leadId
+    });
+    const memberId = (await this.members.membershipIdByActorInRole(actorInRole)) as MemberId;
+    const profile = (await this.memberProfile(memberId)).unwrapOr(null);
+
+    return profile && { id: memberId.toNumber(), profile };
+  }
+}

+ 79 - 0
packages/joy-proposals/src/runtime/transport.ts

@@ -0,0 +1,79 @@
+import { ProposalId, VoteKind } from "@joystream/types/proposals";
+import { MemberId } from "@joystream/types/members";
+export const ProposalTypes = [
+  "Text",
+  "RuntimeUpgrade",
+  "SetElectionParameters",
+  "Spending",
+  "SetLead",
+  "SetContentWorkingGroupMintCapacity",
+  "EvictStorageProvider",
+  "SetValidatorCount",
+  "SetStorageRoleParameters"
+] as const;
+
+export type ProposalType = typeof ProposalTypes[number];
+
+export type ParsedMember = {
+  about: string;
+  avatar_uri: string;
+  handle: string;
+  registered_at_block: number;
+  registered_at_time: number;
+  roles: any[];
+  entry: { [k: string]: any };
+  root_account: string;
+  controller_account: string;
+  subscription: any;
+  suspended: boolean;
+};
+
+export type ParsedProposal = {
+  id: ProposalId;
+  type: ProposalType;
+  title: string;
+  description: string;
+  status: any;
+  proposer: ParsedMember;
+  proposerId: number;
+  createdAtBlock: number;
+  createdAt: Date;
+  details: any[];
+  votingResults: any;
+  parameters: {
+    approvalQuorumPercentage: number;
+    approvalThresholdPercentage: number;
+    gracePeriod: number;
+    requiredStake: number;
+    slashingQuorumPercentage: number;
+    slashingThresholdPercentage: number;
+    votingPeriod: number;
+  };
+  cancellationFee: number;
+};
+
+export const StorageRoleParameters = [
+  "min_stake",
+  "min_actors",
+  "max_actors",
+  "reward",
+  "reward_period",
+  "bonding_period",
+  "unbonding_period",
+  "min_service_period",
+  "startup_grace_period",
+  "entry_request_fee"
+] as const;
+
+export type IStorageRoleParameters = {
+  [k in typeof StorageRoleParameters[number]]: number;
+};
+
+export type ProposalVote = {
+  vote: VoteKind | null;
+  member: ParsedMember & { memberId: MemberId };
+};
+
+export abstract class Transport {
+  abstract proposals(): Promise<ParsedProposal[]>;
+}

+ 25 - 0
packages/joy-proposals/src/stories/ProposalDetails.stories.tsx

@@ -0,0 +1,25 @@
+import React from "react";
+import "../index.css";
+
+import MockProposalDetails from "./data/ProposalDetails.mock";
+import { ProposalDetails } from "../Proposal";
+
+export default {
+  title: "Proposals | Details"
+};
+
+export const HasToVote = () => <ProposalDetails {...MockProposalDetails} />;
+
+export const VotedApproved = () => (
+  <ProposalDetails {...MockProposalDetails} vote={{ hasVoted: true, value: "Approve" }} />
+);
+
+export const VotedAbstain = () => (
+  <ProposalDetails {...MockProposalDetails} vote={{ hasVoted: true, value: "Abstain" }} />
+);
+
+export const VotedReject = () => (
+  <ProposalDetails {...MockProposalDetails} vote={{ hasVoted: true, value: "Reject" }} />
+);
+
+export const VotedSlash = () => <ProposalDetails {...MockProposalDetails} vote={{ hasVoted: true, value: "Slash" }} />;

+ 38 - 0
packages/joy-proposals/src/stories/ProposalForms.stories.tsx

@@ -0,0 +1,38 @@
+import "../index.css";
+import {
+  SignalForm,
+  EvictStorageProviderForm,
+  SpendingProposalForm,
+  SetCouncilParamsForm,
+  SetContentWorkingGroupLeadForm,
+  SetStorageRoleParamsForm,
+  RuntimeUpgradeForm,
+  SetContentWorkingGroupMintCapForm,
+  SetCouncilMintCapForm,
+  SetMaxValidatorCountForm
+} from "../forms";
+import withMock from './withMock';
+
+export default {
+  title: "Proposals | Forms"
+};
+
+export const Signal = () => withMock(SignalForm);
+
+export const StorageProviders = () => withMock(EvictStorageProviderForm);
+
+export const SpendingProposal = () => withMock(SpendingProposalForm);
+
+export const SetCouncilParams = () => withMock(SetCouncilParamsForm);
+
+export const SetContentWorkingGroupLead = () => withMock(SetContentWorkingGroupLeadForm);
+
+export const SetStorageRoleParams = () => withMock(SetStorageRoleParamsForm);
+
+export const RuntimeUpgrade = () => withMock(RuntimeUpgradeForm);
+
+export const ContentWorkingGroupMintCap = () => withMock(SetContentWorkingGroupMintCapForm);
+
+export const CouncilMintCap = () => withMock(SetCouncilMintCapForm);
+
+export const SetMaxValidatorCount = () => withMock(SetMaxValidatorCountForm);

+ 11 - 0
packages/joy-proposals/src/stories/ProposalPreview.stories.tsx

@@ -0,0 +1,11 @@
+import React from "react";
+import "../index.css";
+
+import MockProposalPreview from "./data/ProposalPreview.mock";
+import { ProposalPreview } from "../Proposal";
+
+export default {
+    title: "Proposals | Preview",
+};
+
+export const Default = () => <ProposalPreview {...MockProposalPreview} />;

+ 9 - 0
packages/joy-proposals/src/stories/ProposalPreviewList.stories.tsx

@@ -0,0 +1,9 @@
+import "../index.css";
+import { ProposalPreviewList } from "../Proposal";
+import withMock from './withMock';
+
+export default {
+    title: "Proposals | Preview List",
+};
+
+export const Default = () => withMock(ProposalPreviewList);

+ 9 - 0
packages/joy-proposals/src/stories/ProposalTypes.stories.tsx

@@ -0,0 +1,9 @@
+import "../index.css";
+import { ChooseProposalType } from "../Proposal";
+import withMock from './withMock';
+
+export default {
+    title: "Proposals | Proposal Types",
+};
+
+export const Default = () => withMock(ChooseProposalType);

+ 52 - 0
packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts

@@ -0,0 +1,52 @@
+import { ParsedProposal } from "../../runtime";
+import { ProposalId } from "@joystream/types/proposals"
+
+const mockedProposal: ParsedProposal = {
+  id: new ProposalId(100),
+  title: "Awesome Proposal",
+  description: "Please send me some tokens for coffee",
+  createdAtBlock: 36,
+  type: "Text",
+  details: ["Ciao"],
+  parameters: {
+    approvalQuorumPercentage: 66,
+    approvalThresholdPercentage: 80,
+    gracePeriod: 0,
+    requiredStake: 101520,
+    slashingQuorumPercentage: 60,
+    slashingThresholdPercentage: 80,
+    votingPeriod: 7200
+  },
+  proposerId: 303,
+  status: {
+    Active: {
+      stakeId: 0,
+      sourceAccountId: "5C4hrfkRjSLwQSFVtCvtbV6wctV1WFnkiexUZWLAh4Bc7jib"
+    }
+  },
+  proposer: {
+    about: "Bob",
+    avatar_uri: "https://react.semantic-ui.com/images/avatar/large/steve.jpg",
+    controller_account: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
+    handle: "bob55",
+    registered_at_block: 18,
+    registered_at_time: 1588087314000,
+    roles: [],
+    entry: {
+      Paid: 0
+    },
+    root_account: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
+    subscription: null,
+    suspended: false
+  },
+  votingResults: {
+    abstensions: 3,
+    approvals: 0,
+    rejections: 1,
+    slashes: 0
+  },
+  createdAt: new Date("Mar 25, 2020 at 14:20"),
+  cancellationFee: 5
+};
+
+export default mockedProposal;

+ 8 - 0
packages/joy-proposals/src/stories/data/ProposalPreview.mock.ts

@@ -0,0 +1,8 @@
+import { ProposalPreviewProps } from "../../Proposal/ProposalPreview";
+import mockedProposal from "../data/ProposalDetails.mock";
+
+const mockedProposalPreview: ProposalPreviewProps = {
+  proposal: mockedProposal
+};
+
+export default mockedProposalPreview;

+ 95 - 0
packages/joy-proposals/src/stories/data/ProposalPreviewList.mock.ts

@@ -0,0 +1,95 @@
+import mockedProposal from "./ProposalDetails.mock";
+
+// const MockProposalPreviewList: ParsedProposal[] = [
+//   {
+//     title: "Send me some tokens for coffee",
+//     description:
+//       "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.",
+//     finalized: "approved",
+//     details: {
+//       createdBy: {
+//         name: "Satoshi",
+//         avatar: "https://react.semantic-ui.com/images/avatar/large/steve.jpg"
+//       },
+//       stage: "Finalized",
+//       substage: "Grace Period",
+//       createdAt: "Mar 25, 2020 at 14:20",
+//       type: "Spending Proposal",
+//       expiresIn: 5678
+//     }
+//   },
+//   {
+//     title: "Send me some tokens for coffee",
+//     description:
+//       "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.",
+
+//     finalized: "slashed",
+//     details: {
+//       createdBy: {
+//         name: "David Douglas",
+//         avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg"
+//       },
+//       stage: "Active",
+//       substage: "Grace Period",
+//       createdAt: "Mar 25, 2020 at 14:20",
+//       type: "Spending Proposal",
+//       expiresIn: 5678
+//     }
+//   },
+//   {
+//     title: "Send me some tokens for coffee",
+//     description:
+//       "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.",
+
+//     finalized: "approved",
+//     details: {
+//       createdBy: {
+//         name: "David Douglas",
+//         avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg"
+//       },
+//       stage: "Active",
+//       substage: "Grace Period",
+//       createdAt: "Mar 25, 2020 at 14:20",
+//       type: "Spending Proposal",
+//       expiresIn: 5678
+//     }
+//   },
+//   {
+//     title: "Send me some tokens for coffee",
+//     description:
+//       "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.",
+
+//     finalized: "approved",
+//     details: {
+//       createdBy: {
+//         name: "David Douglas",
+//         avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg"
+//       },
+//       stage: "Active",
+//       substage: "Grace Period",
+//       createdAt: "Mar 25, 2020 at 14:20",
+//       type: "Spending Proposal",
+//       expiresIn: 5678
+//     }
+//   },
+//   {
+//     title: "Send me some tokens for coffee",
+//     description:
+//       "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.",
+
+//     finalized: "withdrawn",
+//     details: {
+//       createdBy: {
+//         name: "David Douglas",
+//         avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg"
+//       },
+//       stage: "Active",
+//       substage: "Grace Period",
+//       createdAt: "Mar 25, 2020 at 14:20",
+//       type: "Spending Proposal",
+//       expiresIn: 5678
+//     }
+//   }
+// ];
+const MockProposalPreviewList = Array.from({ length: 5 }, (_, i) => mockedProposal);
+export default MockProposalPreviewList;

+ 160 - 0
packages/joy-proposals/src/stories/data/ProposalTypesInfo.mock.ts

@@ -0,0 +1,160 @@
+import { ProposalTypeInfo } from "../../Proposal/ProposalTypePreview";
+import { Categories } from "../../Proposal/ChooseProposalType";
+
+const MockProposalTypesInfo: ProposalTypeInfo[] = [
+  {
+    type: "Text",
+    category: Categories.other,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 5,
+    cancellationFee: 0,
+    gracePeriod: 0,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "Spending",
+    category: Categories.other,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 10,
+    cancellationFee: 5,
+    gracePeriod: 3,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "RuntimeUpgrade",
+    category: Categories.other,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 100,
+    cancellationFee: 10,
+    gracePeriod: 14,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "EvictStorageProvider",
+    category: Categories.storage,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 100,
+    cancellationFee: 10,
+    gracePeriod: 1,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "SetStorageRoleParameters",
+    category: Categories.storage,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 500,
+    cancellationFee: 60,
+    gracePeriod: 14,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "SetValidatorCount",
+    category: Categories.validators,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 45,
+    cancellationFee: 10,
+    gracePeriod: 5,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "SetContentWorkingGroupMintCapacity",
+    category: Categories.cwg,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 90,
+    cancellationFee: 8,
+    gracePeriod: 5,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "SetLead",
+    category: Categories.cwg,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 500,
+    cancellationFee: 50,
+    gracePeriod: 7,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+  {
+    type: "SetElectionParameters",
+    category: Categories.council,
+    image: "https://react.semantic-ui.com/images/wireframe/image.png",
+    description:
+        "Change the total reward across all validators in a given block."+
+        "This is not the direct reward, but base reward for Pallet staking module."+
+        "The minimum value must be greater than 450 tJOY based on current runtime.",
+    stake: 1000,
+    cancellationFee: 100,
+    gracePeriod: 30,
+    votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
+  },
+];
+
+export default MockProposalTypesInfo;

Неке датотеке нису приказане због велике количине промена