Bläddra i källkod

Merge 'joystream-constantinople' and fix conflicts

Leszek Wiesner 4 år sedan
förälder
incheckning
d0ffeea90c
45 ändrade filer med 1220 tillägg och 878 borttagningar
  1. 39 36
      packages/app-staking/src/Actions/Accounts.tsx
  2. 48 37
      packages/app-staking/src/Overview/CurrentList.tsx
  3. 15 9
      packages/app-staking/src/Overview/index.tsx
  4. 28 17
      packages/app-staking/src/index.tsx
  5. 28 64
      packages/apps-routing/src/index.ts
  6. 3 7
      packages/apps-routing/src/joy-election.ts
  7. 3 6
      packages/apps-routing/src/joy-members.ts
  8. 6 7
      packages/apps-routing/src/joy-proposals.ts
  9. 3 6
      packages/apps-routing/src/joy-storage.ts
  10. 50 48
      packages/apps/src/SideBar/Item.tsx
  11. 52 86
      packages/apps/src/SideBar/index.tsx
  12. 30 24
      packages/apps/src/TopBar.tsx
  13. 12 6
      packages/apps/src/index.tsx
  14. 16 7
      packages/joy-members/src/Details.tsx
  15. 135 108
      packages/joy-members/src/EditForm.tsx
  16. 61 14
      packages/joy-members/src/List.tsx
  17. 9 8
      packages/joy-members/src/index.tsx
  18. 27 27
      packages/joy-proposals/src/Proposal/Body.tsx
  19. 51 41
      packages/joy-proposals/src/Proposal/Details.tsx
  20. 21 1
      packages/joy-proposals/src/Proposal/Proposal.css
  21. 24 9
      packages/joy-proposals/src/Proposal/ProposalDetails.tsx
  22. 11 0
      packages/joy-proposals/src/Proposal/ProposalPreview.tsx
  23. 25 29
      packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx
  24. 1 5
      packages/joy-proposals/src/Proposal/ProposalType.css
  25. 81 6
      packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx
  26. 3 4
      packages/joy-proposals/src/Proposal/VotingSection.tsx
  27. 15 4
      packages/joy-proposals/src/forms/GenericProposalForm.tsx
  28. 2 1
      packages/joy-proposals/src/forms/MintCapacityForm.tsx
  29. 2 2
      packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx
  30. 4 3
      packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx
  31. 2 2
      packages/joy-proposals/src/forms/SpendingProposalForm.tsx
  32. 0 6
      packages/joy-proposals/src/forms/forms.css
  33. 36 0
      packages/joy-proposals/src/stories/data/ProposalTypesInfo.mock.ts
  34. 92 31
      packages/joy-proposals/src/utils.ts
  35. 26 13
      packages/joy-proposals/src/validationSchema.ts
  36. 21 42
      packages/joy-storage/src/index.tsx
  37. 0 2
      packages/joy-types/joystream.json
  38. 4 3
      packages/joy-types/package.json
  39. 1 1
      packages/joy-types/src/forum.ts
  40. 33 1
      packages/joy-types/src/proposals.ts
  41. 141 128
      packages/joy-utils/src/MyAccount.tsx
  42. 46 5
      packages/joy-utils/src/Section.tsx
  43. 2 3
      packages/react-components/src/FilterOverlay.tsx
  44. 7 18
      packages/react-components/src/HelpOverlay.tsx
  45. 4 1
      packages/react-components/src/Tabs.tsx

+ 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 { withAccountRequired } 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;
-    }
-  `,
+  withAccountRequired(
+    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'
-    }]
-  )
+    }
+  ])
 );

+ 48 - 37
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 => {
@@ -73,7 +85,11 @@ function CurrentList (props: Props): React.ReactElement<Props> {
 
   return (
     <div>
-      <FilterOverlay>
+      <FilterOverlay
+        style={{
+          top: myAccounts.length ? '5.5rem' : '5px'
+        }}
+      >
         <Dropdown
           onChange={setFilter}
           options={[
@@ -89,17 +105,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' })

+ 28 - 64
packages/apps-routing/src/index.ts

@@ -4,11 +4,11 @@
 
 import { Routing, Routes } from './types';
 
-import appSettings from '@polkadot/joy-settings/';
+// 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';
@@ -16,7 +16,7 @@ import roles from './joy-roles';
 import storageRoles from './joy-storage';
 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';
@@ -24,74 +24,38 @@ import addressbook from './addressbook';
 // import council from './council';
 // import dashboard from './dashboard';
 // import democracy from './democracy';
-import explorer from './explorer';
-import extrinsics from './extrinsics';
+// import explorer from './explorer';
+// import extrinsics from './extrinsics';
 // import genericAsset from './generic-asset';
-import js from './js';
+// import js from './js';
 // import parachains from './parachains';
-import settings from './settings';
+// import settings from './settings';
 import staking from './staking';
-import storage from './storage';
-import sudo from './sudo';
-import toolbox from './toolbox';
+// import storage from './storage';
+// import sudo from './sudo';
+// 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,
-    storage,
-    extrinsics,
-    sudo,
-    null,
-    help,
-    settings,
-    toolbox,
-    js,
-    template,
-    null,
-    pages
-  );
-
+const routes: Routes = ([] as Routes).concat(
+  staking,
+  roles,
+  storageRoles,
+  transfer,
+  null,
+  media,
+  forum,
+  members,
+  accounts,
+  addressbook,
+  null,
+  election,
+  proposals,
+  null,
+  pages
+);
 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;

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

@@ -1,18 +1,17 @@
-import { Routes } from "./types";
+import { Routes } from './types';
 
-import Proposals from "@polkadot/joy-proposals/";
+import Proposals from '@polkadot/joy-proposals/';
 
 export default [
   {
     Component: Proposals,
     display: {
-      needsAccounts: true,
-      needsApi: ["query.proposalsEngine.proposalCount"]
+      needsApi: ['query.proposalsEngine.proposalCount']
     },
     i18n: {
-      defaultValue: "Proposals"
+      defaultValue: 'Proposals'
     },
-    icon: "tasks",
-    name: "proposals"
+    icon: 'tasks',
+    name: 'proposals'
   }
 ] 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;

+ 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' })
 );

+ 52 - 86
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,17 +28,17 @@ 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) {
+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>
   );
@@ -109,99 +106,77 @@ class SideBar extends React.PureComponent<Props, State> {
           </Menu>
           <Responsive minWidth={SIDEBAR_MENU_THRESHOLD}>
 */
-function SideBar ({ className, collapse, handleResize, isCollapsed, toggleMenu, menuOpen }: Props): React.ReactElement<Props> {
+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;
-      }
+    routing.routes.reduce(
+      (result: Record<string, boolean>, route): Record<string, boolean> => {
+        if (route && route.Modal) {
+          result[route.name] = false;
+        }
 
-      return result;
-    }, { network: false })
+        return result;
+      },
+      { 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
-                    }
+                    onClick={route.Modal ? _toggleModal(route.name) : handleResize}
                   />
+                ) : (
+                  <Menu.Divider hidden key={index} />
                 )
-                : (
-                  <Menu.Divider
-                    hidden
-                    key={index}
-                  />
-                )
-            ))}
+            )}
             <Menu.Divider hidden />
             <OuterLink url='https://tjoy.joystream.org/' title='Tokenomics' />
             <OuterLink url='https://blog.joystream.org/constantinople-incentives/' title='Earn Monero' />
             <Menu.Divider hidden />
-            {
-              isCollapsed
-                ? undefined
-                : <NodeInfo />
-            }
           </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 +212,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);

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

@@ -27,6 +27,16 @@ 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();
+  }
+};
+
 // 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 +76,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>

+ 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>
     );

+ 27 - 27
packages/joy-proposals/src/Proposal/Body.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import { Card, Header, Item, Button, Icon, Message } from "semantic-ui-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';
@@ -12,6 +12,7 @@ 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 = {
@@ -84,11 +85,11 @@ const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: s
       "Council size": params.council_size + " members",
       "Candidacy limit": params.candidacy_limit + " members",
       "New term duration": params.new_term_duration + " blocks",
-      "Min. council stake": params.min_council_stake + " tJOY",
-      "Min. voting stake": params.min_voting_stake + " tJOY"
+      "Min. council stake": formatBalance(params.min_council_stake),
+      "Min. voting stake": formatBalance(params.min_voting_stake)
   }),
   Spending: ([amount, account]) => ({
-    Amount: amount + " tJOY",
+    Amount: formatBalance(amount),
     Account: <ProposedAddress address={account} />
   }),
   SetLead: ([memberId, accountId]) => ({
@@ -96,7 +97,7 @@ const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: s
     "Account id": <ProposedAddress address={accountId} />
   }),
   SetContentWorkingGroupMintCapacity: ([capacity]) => ({
-    "Mint capacity": capacity + " tJOY"
+    "Mint capacity": formatBalance(capacity)
   }),
   EvictStorageProvider: ([accountId]) => ({
     "Storage provider account": <ProposedAddress address={accountId} />
@@ -105,37 +106,39 @@ const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: s
     "Validator count": count
   }),
   SetStorageRoleParameters: ([params]) => ({
-    "Min. stake": params.min_stake + " tJOY",
+    "Min. stake": formatBalance(params.min_stake),
     // "Min. actors": params.min_actors,
     "Max. actors": params.max_actors,
-    Reward: params.reward + " tJOY",
+    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": params.entry_request_fee + " tJOY"
+    "Entry request fee": formatBalance(params.entry_request_fee)
   })
 };
 
-const ProposalParam = styled.div`
-  display: flex;
+const ProposalParams = styled.div`
+  display: grid;
   font-weight: bold;
-  margin-bottom: 0.5em;
-  @media only screen and (max-width: 767px) {
-    flex-direction: column;
+  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`
-  min-width: ${(p: { longestParamName: number }) =>
-    p.longestParamName > 20 ? "240px" : p.longestParamName > 15 ? "200px" : ""};
+  margin-right: 1rem;
+  white-space: nowrap;
 `;
 const ProposalParamValue = styled.div`
   color: black;
-  font-weight: bold;
-  padding-left: 1rem;
   word-wrap: break-word;
   word-break: break-all;
+  @media screen and (max-width: 767px) {
+    margin-top: -0.25rem;
+  }
 `;
 
 export default function Body({
@@ -151,7 +154,6 @@ export default function Body({
 }: BodyProps) {
   const parseParams = paramParsers[type];
   const parsedParams = parseParams(params);
-  const longestParamName: number = Object.keys(parsedParams).reduce((a, b) => (b.length > a ? b.length : a), 0);
   return (
     <Card fluid>
       <Card.Content>
@@ -160,16 +162,14 @@ export default function Body({
         </Card.Header>
         <Card.Description>{description}</Card.Description>
         <Header as="h4">Parameters:</Header>
-        <Item.Group style={{ textAlign: "left" }} relaxed>
-
-          { Object.entries(parseParams(params)).map(([paramName, paramValue]) => (
-
-            <ProposalParam key={paramName}>
-              <ProposalParamName longestParamName={longestParamName}>{paramName}:</ProposalParamName>
+        <ProposalParams>
+          { Object.entries(parsedParams).map(([paramName, paramValue]) => (
+            <React.Fragment key={paramName}>
+              <ProposalParamName>{paramName}:</ProposalParamName>
               <ProposalParamValue>{paramValue}</ProposalParamValue>
-            </ProposalParam>
+            </React.Fragment>
           ))}
-        </Item.Group>
+        </ProposalParams>
         { iAmProposer && isCancellable && (<>
           <Message warning active>
             <Message.Content>
@@ -179,7 +179,7 @@ export default function Body({
               </p>
               <p style={{ margin: '0.5em 0', padding: '0' }}>
                 The cancellation fee for this type of proposal is:&nbsp;
-                <b>{ cancellationFee ? `${ cancellationFee } tJOY` : 'NONE' }</b>
+                <b>{ cancellationFee ? formatBalance(cancellationFee) : 'NONE' }</b>
               </p>
               <Button.Group color="red">
                 <TxButton

+ 51 - 41
packages/joy-proposals/src/Proposal/Details.tsx

@@ -2,9 +2,29 @@ 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 "./ProfilePreview";
 
+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;
@@ -12,50 +32,40 @@ type DetailsProps = {
 };
 
 export default function Details({ proposal, extendedStatus, proposerLink = false }: DetailsProps) {
-  const { type, createdAt, proposer } = proposal;
-  const { displayStatus, periodStatus, expiresIn } = extendedStatus;
+  const { type, createdAt, createdAtBlock, proposer } = proposal;
+  const { displayStatus, periodStatus, expiresIn, finalizedAtBlock, executedAtBlock, executionFailReason } = extendedStatus;
+  console.log(proposal);
   return (
     <Item.Group className="details-container">
-      <Item>
-        <Item.Content>
-          <Item.Extra>Proposed By:</Item.Extra>
-          <ProfilePreview
-            avatar_uri={proposer.avatar_uri}
-            root_account={proposer.root_account}
-            handle={proposer.handle}
-            link={ proposerLink }
-          />
-          <Item.Extra>{createdAt.toLocaleString()}</Item.Extra>
-        </Item.Content>
-      </Item>
-      <Item>
-        <Item.Content>
-          <Item.Extra>Proposal Type:</Item.Extra>
-          <Header as="h4">{type}</Header>
-        </Item.Content>
-      </Item>
-      <Item>
-        <Item.Content>
-          <Item.Extra>Stage:</Item.Extra>
-          <Header as="h4">{ displayStatus }</Header>
-        </Item.Content>
-      </Item>
-      { (periodStatus !== null) && (
-        <Item>
-          <Item.Content>
-            <Item.Extra>Substage:</Item.Extra>
-            <Header as="h4">{ periodStatus }</Header>
-          </Item.Content>
-        </Item>
-      )}
+      <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 && (
-        <Item>
-          <Item.Content>
-            <Item.Extra>{ periodStatus === 'Grace period' ? 'Executes in' : 'Expires in' }:</Item.Extra>
-            <Header as="h4">{`${expiresIn.toLocaleString("en-US")} blocks`}</Header>
-          </Item.Content>
-        </Item>
-      )}
+        <Detail
+          name={ periodStatus === 'Grace period' ? 'Executes in' : 'Expires in' }
+          value={`${expiresIn.toLocaleString("en-US")} blocks`} />
+      ) }
+      {executionFailReason && <Detail name="Execution error" value={ executionFailReason } /> }
     </Item.Group>
   );
 }

+ 21 - 1
packages/joy-proposals/src/Proposal/Proposal.css

@@ -1,4 +1,6 @@
 .Proposal {
+  position: relative;
+
   .description {
     word-wrap: break-word;
     word-break: break-all;
@@ -10,7 +12,12 @@
   }
 
   .details-container {
-    display: flex;
+    display: grid;
+    grid-template-columns: repeat(5, auto);
+  }
+
+  .details-container .item .extra {
+    margin-bottom: 0.5em !important;
   }
 
   h4.ui.header.details-handle {
@@ -42,4 +49,17 @@
   .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;
+    }
+  }
 }

+ 24 - 9
packages/joy-proposals/src/Proposal/ProposalDetails.tsx

@@ -11,7 +11,7 @@ import { withCalls } from '@polkadot/react-api';
 import { withMulti } from '@polkadot/react-api/with';
 
 import "./Proposal.css";
-import { ProposalId, ProposalDecisionStatuses, ApprovedProposalStatuses } from "@joystream/types/proposals";
+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/";
@@ -25,6 +25,9 @@ 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 {
@@ -33,31 +36,40 @@ export function getExtendedStatus(proposal: ParsedProposal, bestNumber: BlockNum
 
   let displayStatus: ProposalDisplayStatus = basicStatus;
   let periodStatus: ProposalPeriodStatus | null = null;
+  let finalizedAtBlock: number | null = null;
+  let executedAtBlock: number | null = null;
+  let executionFailReason: string | null = null;
 
-  if (!bestNumber) return { displayStatus, periodStatus, expiresIn };
+  let best = bestNumber ? bestNumber.toNumber() : 0;
 
   const { votingPeriod, gracePeriod } = proposal.parameters;
-  const blockAge = bestNumber.toNumber() - proposal.createdAtBlock;
+  const blockAge = best - proposal.createdAtBlock;
 
   if (basicStatus === 'Active') {
+    periodStatus = 'Voting period';
     expiresIn = Math.max(votingPeriod - blockAge, 0) || null;
-    if (expiresIn) periodStatus = 'Voting period';
   }
 
   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 = bestNumber.toNumber() - finalizedAt;
+        const finalizedAge = best - finalizedAt;
+        periodStatus = 'Grace period';
         expiresIn = Math.max(gracePeriod - finalizedAge, 0) || null;
-        if (expiresIn) periodStatus = 'Grace period';
       }
       else {
-        displayStatus = approvedStatus; // Executed / ExecutionFailed
+        // 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();
+        }
       }
     }
   }
@@ -65,7 +77,10 @@ export function getExtendedStatus(proposal: ParsedProposal, bestNumber: BlockNum
   return {
     displayStatus,
     periodStatus,
-    expiresIn
+    expiresIn: best ? expiresIn : null,
+    finalizedAtBlock,
+    executedAtBlock,
+    executionFailReason
   }
 }
 

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

@@ -4,9 +4,19 @@ 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
@@ -18,6 +28,7 @@ export default function ProposalPreview({ proposal, bestNumber }: ProposalPrevie
       fluid
       className="Proposal"
       href={`#/proposals/${proposal.id}`}>
+      <ProposalIdBox>{ `#${proposal.id.toString()}` }</ProposalIdBox>
       <Card.Content>
         <Card.Header>
           <Header as="h1">{proposal.title}</Header>

+ 25 - 29
packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx

@@ -4,12 +4,11 @@ import { Card, Container, Menu } from "semantic-ui-react";
 import ProposalPreview from "./ProposalPreview";
 import { useTransport, ParsedProposal } from "../runtime";
 import { usePromise } from "../utils";
-import Loading from "./Loading";
-import Error from "./Error";
+import PromiseComponent from './PromiseComponent';
 import { withCalls } from "@polkadot/react-api";
 import { BlockNumber } from "@polkadot/types/interfaces";
 
-const filters = ["All", "Active", "Canceled", "Approved", "Rejected", "Slashed"] as const;
+const filters = ["All", "Active", "Canceled", "Approved", "Rejected", "Slashed", "Expired"] as const;
 
 type ProposalFilter = typeof filters[number];
 
@@ -24,7 +23,6 @@ function filterProposals(filter: ProposalFilter, proposals: ParsedProposal[]) {
   }
 
   return proposals.filter((prop: ParsedProposal) => {
-    // Either Active or undefined for some reason
     if (prop.status.Finalized == null || prop.status.Finalized.proposalStatus == null) {
       return false;
     }
@@ -43,6 +41,7 @@ function mapFromProposals(proposals: ParsedProposal[]) {
   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;
 }
@@ -56,35 +55,32 @@ function ProposalPreviewList({ bestNumber }: ProposalPreviewListProps) {
   const [proposals, error, loading] = usePromise<ParsedProposal[]>(() => transport.proposals(), []);
   const [activeFilter, setActiveFilter] = useState<ProposalFilter>("All");
 
-  if (loading && !error) {
-    return <Loading text="Fetching proposals..." />;
-  } else if (error) {
-    return <Error error={error} />;
-  }
-
   const proposalsMap = mapFromProposals(proposals);
-
-  console.log({ proposals, error, loading });
-  console.log(proposalsMap);
+  const filteredProposals = proposalsMap.get(activeFilter) as ParsedProposal[];
 
   return (
     <Container className="Proposal">
-      <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>
-
-      <Card.Group>
-        {(proposalsMap.get(activeFilter) as ParsedProposal[]).map((prop: ParsedProposal, idx: number) => (
-          <ProposalPreview key={`${prop.title}-${idx}`} proposal={prop} bestNumber={bestNumber} />
-        ))}
-      </Card.Group>
+      <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>
   );
 }

+ 1 - 5
packages/joy-proposals/src/Proposal/ProposalType.css

@@ -13,11 +13,7 @@
 }
 .ProposalType .actions {
   margin: 0 2em;
-  padding-top: 2em;
-}
-.ProposalType .btn-create {
-  white-space: nowrap;
-  margin-right: 0;
+  padding-top: 1em;
 }
 .ProposalType .proposal-details {
   display: flex;

+ 81 - 6
packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx

@@ -1,14 +1,48 @@
 import React from "react";
 
 import { History } from "history";
-import { Item, Icon, Button } from "semantic-ui-react";
+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;
@@ -18,6 +52,10 @@ export type ProposalTypeInfo = {
   cancellationFee?: number;
   gracePeriod: number;
   votingPeriod: number;
+  approvalQuorum: number;
+  approvalThreshold: number;
+  slashingQuorum: number;
+  slashingThreshold: number;
 };
 
 type ProposalTypePreviewProps = {
@@ -34,7 +72,18 @@ const ProposalTypeDetail = (props: { title: string, value: string }) => (
 
 export default function ProposalTypePreview(props: ProposalTypePreviewProps) {
   const {
-    typeInfo: { type, description, stake, cancellationFee, gracePeriod, votingPeriod }
+    typeInfo: {
+      type,
+      description,
+      stake,
+      cancellationFee,
+      gracePeriod,
+      votingPeriod,
+      approvalQuorum,
+      approvalThreshold,
+      slashingQuorum,
+      slashingThreshold
+    }
   } = props;
 
   const handleClick = () => {
@@ -54,10 +103,10 @@ export default function ProposalTypePreview(props: ProposalTypePreviewProps) {
         <div className="proposal-details">
           <ProposalTypeDetail
             title="Stake"
-            value={ stake + "tJOY" } />
+            value={ formatBalance(stake) } />
           <ProposalTypeDetail
             title="Cancellation fee"
-            value={ cancellationFee ? `${cancellationFee} tJOY` : "NONE" } />
+            value={ cancellationFee ? formatBalance(cancellationFee) : "NONE" } />
           <ProposalTypeDetail
             title="Grace period"
             value={ gracePeriod ? `${gracePeriod} block${gracePeriod > 1 ? "s" : ""}` : "NONE" } />
@@ -65,12 +114,38 @@ export default function ProposalTypePreview(props: ProposalTypePreviewProps) {
             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">
-        <Button primary className="btn-create" size="medium" onClick={handleClick}>
+        <CreateButton primary size="medium" onClick={handleClick}>
           Create
           <Icon name="chevron right" />
-        </Button>
+        </CreateButton>
       </div>
     </Item>
   );

+ 3 - 4
packages/joy-proposals/src/Proposal/VotingSection.tsx

@@ -8,10 +8,9 @@ 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";
 
-// TODO: joy-types (there's something similar already I think)
-const voteKinds = ["Approve", "Slash", "Abstain", "Reject"] as const;
-export type VoteKindStr = "Approve" | "Slash" | "Abstain" | "Reject";
+export type VoteKindStr = typeof VoteKinds[number];
 
 type VoteButtonProps = {
   memberId: MemberId,
@@ -88,7 +87,7 @@ export default function VotingSection({
     <>
       <Header as="h3">Sumbit your vote</Header>
       <Divider />
-      { voteKinds.map((voteKind) =>
+      { VoteKinds.map((voteKind) =>
         <VoteButton
           voteKind={voteKind}
           memberId={memberId}

+ 15 - 4
packages/joy-proposals/src/forms/GenericProposalForm.tsx

@@ -11,11 +11,13 @@ 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 } from "@polkadot/types/interfaces";
+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
@@ -106,9 +108,18 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
   };
 
   const onTxSuccess: TxCallback = (txResult: SubmittableResult) => {
-    setSubmitting(false);
     if (!history) return;
-    history.push("/proposals");
+    // 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 =
@@ -141,7 +152,7 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
         <Message warning visible>
           <Message.Content>
             <Icon name="warning circle" />
-            Required stake: <b>{requiredStake} tJOY</b>
+            Required stake: <b>{ formatBalance(requiredStake) }</b>
           </Message.Content>
         </Message>
         <div className="form-buttons">

+ 2 - 1
packages/joy-proposals/src/forms/MintCapacityForm.tsx

@@ -15,6 +15,7 @@ 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 & {
@@ -55,7 +56,7 @@ const MintCapacityForm: React.FunctionComponent<FormInnerProps> = props => {
         placeholder={ (initialData && initialData.capacity) }
         label={`${mintCapacityGroup} Mint Capacity`}
         help={`The new mint capacity you propse for ${mintCapacityGroup}`}
-        unit="tJOY"
+        unit={ formatBalance.getDefaults().unit }
         value={values.capacity}
       />
     </GenericProposalForm>

+ 2 - 2
packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx

@@ -93,7 +93,7 @@ const SetCouncilParamsForm: React.FunctionComponent<FormInnerProps> = props => {
   }, [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 && values.candidacyLimit < values.councilSize) {
+  if (!errors.candidacyLimit && !errors.councilSize && parseInt(values.candidacyLimit) < parseInt(values.councilSize)) {
     setFieldError('candidacyLimit', `Candidacy limit must be >= council size (${ values.councilSize })`);
   }
 
@@ -182,7 +182,7 @@ const SetCouncilParamsForm: React.FunctionComponent<FormInnerProps> = props => {
           />
           <InputFormField
             label="Candidacy Limit"
-            help="How many members can candidate"
+            help="How many candidates that will be allowed in to the voting stage"
             fluid
             onChange={handleChange}
             name="candidacyLimit"

+ 4 - 3
packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx

@@ -20,6 +20,7 @@ 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?
@@ -137,7 +138,7 @@ const SetStorageRoleParamsForm: React.FunctionComponent<FormInnerProps> = props
           placeholder={placeholders.reward}
           error={errorLabelsProps.reward}
           value={values.reward}
-          unit="tJOY"
+          unit={ formatBalance.getDefaults().unit }
         />
         <InputFormField
           label="Reward period"
@@ -161,7 +162,7 @@ const SetStorageRoleParamsForm: React.FunctionComponent<FormInnerProps> = props
           placeholder={placeholders.min_stake}
           error={errorLabelsProps.min_stake}
           value={values.min_stake}
-          unit="tJOY"
+          unit={ formatBalance.getDefaults().unit }
         />
         <InputFormField
           label="Min. service period"
@@ -223,7 +224,7 @@ const SetStorageRoleParamsForm: React.FunctionComponent<FormInnerProps> = props
           placeholder={placeholders.entry_request_fee}
           error={errorLabelsProps.entry_request_fee}
           value={values.entry_request_fee}
-          unit="tJOY"
+          unit={ formatBalance.getDefaults().unit }
         />
       </Form.Group>
     </GenericProposalForm>

+ 2 - 2
packages/joy-proposals/src/forms/SpendingProposalForm.tsx

@@ -16,6 +16,7 @@ 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 & {
@@ -55,11 +56,10 @@ const SpendingProposalForm: React.FunctionComponent<FormInnerProps> = props => {
         label="Amount of tokens"
         help="The amount of tokens you propose to spend"
         onChange={handleChange}
-        className="tokens"
         name="tokens"
         placeholder="100"
         error={errorLabelsProps.tokens}
-        unit={"tJOY"}
+        unit={ formatBalance.getDefaults().unit }
         value={values.tokens}
       />
       <FormField

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

@@ -11,12 +11,6 @@
     & input[name="tokens"] {
       max-width: 16rem;
     }
-
-    & .ui.input.tokens::after {
-      content: "tJOY";
-      position: absolute;
-      left: 5px;
-    }
   }
 
   .form-buttons {

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

@@ -14,6 +14,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 0,
     gracePeriod: 0,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "Spending",
@@ -27,6 +31,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 5,
     gracePeriod: 3,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "RuntimeUpgrade",
@@ -40,6 +48,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 10,
     gracePeriod: 14,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "EvictStorageProvider",
@@ -53,6 +65,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 10,
     gracePeriod: 1,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "SetStorageRoleParameters",
@@ -66,6 +82,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 60,
     gracePeriod: 14,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "SetValidatorCount",
@@ -79,6 +99,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 10,
     gracePeriod: 5,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "SetContentWorkingGroupMintCapacity",
@@ -92,6 +116,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 8,
     gracePeriod: 5,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "SetLead",
@@ -105,6 +133,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 50,
     gracePeriod: 7,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
   {
     type: "SetElectionParameters",
@@ -118,6 +150,10 @@ const MockProposalTypesInfo: ProposalTypeInfo[] = [
     cancellationFee: 100,
     gracePeriod: 30,
     votingPeriod: 10000,
+    approvalQuorum: 80,
+    approvalThreshold: 80,
+    slashingQuorum: 80,
+    slashingThreshold: 80,
   },
 ];
 

+ 92 - 31
packages/joy-proposals/src/utils.ts

@@ -4,6 +4,16 @@ import { Category } from "./Proposal/ChooseProposalType";
 import { useTransport, ParsedProposal, ProposalVote } from "./runtime";
 import { ProposalId } from "@joystream/types/proposals";
 
+type ProposalMeta = {
+  description: string;
+  category: Category;
+  image: string;
+  approvalQuorum: number;
+  approvalThreshold: number;
+  slashingQuorum: number;
+  slashingThreshold: number;
+}
+
 export function includeKeys<T extends { [k: string]: any }>(obj: T, ...allowedKeys: string[]) {
   return Object.keys(obj).filter(objKey => {
     return allowedKeys.reduce(
@@ -154,59 +164,110 @@ export function calculateStake(type: ProposalType, issuance: number) {
   return stake;
 }
 
-export function calculateMetaFromType(type: ProposalType) {
-  let description = "";
+export function calculateMetaFromType(type: ProposalType): ProposalMeta {
   const image = "";
-  let category: Category = "Other";
   switch (type) {
     case "EvictStorageProvider": {
-      description = "Evicting Storage Provider Proposal";
-      category = "Storage";
-      break;
+      return {
+        description: "Evicting Storage Provider Proposal",
+        category: "Storage",
+        image,
+        approvalQuorum: 50,
+        approvalThreshold: 75,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "Text": {
-      description = "Signal Proposal";
-      category = "Other";
-      break;
+      return {
+        description: "Signal Proposal",
+        category: "Other",
+        image,
+        approvalQuorum: 60,
+        approvalThreshold: 80,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "SetStorageRoleParameters": {
-      description = "Set Storage Role Params Proposal";
-      category = "Storage";
-      break;
+      return {
+        description: "Set Storage Role Params Proposal",
+        category: "Storage",
+        image,
+        approvalQuorum: 66,
+        approvalThreshold: 80,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "SetValidatorCount": {
-      description = "Set Max Validator Count Proposal";
-      category = "Validators";
-      break;
+      return {
+        description: "Set Max Validator Count Proposal",
+        category: "Validators",
+        image,
+        approvalQuorum: 66,
+        approvalThreshold: 80,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "SetLead": {
-      description = "Set Lead Proposal";
-      category = "Content Working Group";
-      break;
+      return {
+        description: "Set Lead Proposal",
+        category: "Content Working Group",
+        image,
+        approvalQuorum: 60,
+        approvalThreshold: 75,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "SetContentWorkingGroupMintCapacity": {
-      description = "Set WG Mint Capacity Proposal";
-      category = "Content Working Group";
-      break;
+      return {
+        description: "Set WG Mint Capacity Proposal",
+        category: "Content Working Group",
+        image,
+        approvalQuorum: 60,
+        approvalThreshold: 75,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "Spending": {
-      description = "Spending Proposal";
-      category = "Other";
-      break;
+      return {
+        description: "Spending Proposal",
+        category: "Other",
+        image,
+        approvalQuorum: 60,
+        approvalThreshold: 80,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "SetElectionParameters": {
-      description = "Set Election Parameters Proposal";
-      category = "Council";
-      break;
+      return {
+        description: "Set Election Parameters Proposal",
+        category: "Council",
+        image,
+        approvalQuorum: 66,
+        approvalThreshold: 80,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     case "RuntimeUpgrade": {
-      description = "Runtime Upgrade Proposal";
-      category = "Other";
-      break;
+      return {
+        description: "Runtime Upgrade Proposal",
+        category: "Other",
+        image,
+        approvalQuorum: 80,
+        approvalThreshold: 100,
+        slashingQuorum: 60,
+        slashingThreshold: 80,
+      }
     }
     default: {
       throw new Error("'Proposal Type is invalid. Can't calculate metadata.");
     }
   }
-  return { description, image, category };
 }

+ 26 - 13
packages/joy-proposals/src/validationSchema.ts

@@ -1,6 +1,13 @@
 import * as Yup from "yup";
 import { checkAddress } from "@polkadot/util-crypto";
 
+// TODO: If we really need this (currency unit) we can we make "Validation" a functiction that returns an object.
+// We could then "instantialize" it in "withFormContainer" where instead of passing
+// "validationSchema" (in each form component file) we would just pass "validationSchemaKey" or just "proposalType" (ie. SetLead).
+// Then we could let the "withFormContainer" handle the actual "validationSchema" for "withFormik". In that case it could easily
+// pass stuff like totalIssuance or currencyUnit here (ie.: const validationSchema = Validation(currencyUnit, totalIssuance)[proposalType];)
+const CURRENCY_UNIT = undefined;
+
 // All
 const TITLE_MAX_LENGTH = 40;
 const RATIONALE_MAX_LENGTH = 3000;
@@ -61,11 +68,11 @@ const MIN_SERVICE_PERIOD_MIN = 600;
 const MIN_SERVICE_PERIOD_MAX = 28800;
 const STARTUP_GRACE_PERIOD_MIN = 600;
 const STARTUP_GRACE_PERIOD_MAX = 28800;
-// const ENTRY_REQUEST_FEE_MIN = 0;
+const ENTRY_REQUEST_FEE_MIN = 1;
 const ENTRY_REQUEST_FEE_MAX = 100000;
 
 function errorMessage(name: string, min?: number | string, max?: number | string, unit?: string): string {
-  return `${name} should be at least ${min} and no more than ${max} ${unit ? `${unit}.` : "."}`;
+  return `${name} should be at least ${min} and no more than ${max}${unit ? ` ${unit}.` : "."}`;
 }
 
 /*
@@ -177,11 +184,11 @@ const Validation: ValidationType = {
       .integer("This field must be an integer.")
       .min(
         MIN_VOTING_STAKE_MIN,
-        errorMessage("The minimum voting stake", MIN_VOTING_STAKE_MIN, MIN_VOTING_STAKE_MAX, "tJOY")
+        errorMessage("The minimum voting stake", MIN_VOTING_STAKE_MIN, MIN_VOTING_STAKE_MAX, CURRENCY_UNIT)
       )
       .max(
         MIN_VOTING_STAKE_MAX,
-        errorMessage("The minimum voting stake", MIN_VOTING_STAKE_MIN, MIN_VOTING_STAKE_MAX, "tJOY")
+        errorMessage("The minimum voting stake", MIN_VOTING_STAKE_MIN, MIN_VOTING_STAKE_MAX, CURRENCY_UNIT)
       ),
     revealingPeriod: Yup.number()
       .required("All fields must be filled!")
@@ -199,11 +206,11 @@ const Validation: ValidationType = {
       .integer("This field must be an integer.")
       .min(
         MIN_COUNCIL_STAKE_MIN,
-        errorMessage("The minimum council stake", MIN_COUNCIL_STAKE_MIN, MIN_COUNCIL_STAKE_MAX, "tJOY")
+        errorMessage("The minimum council stake", MIN_COUNCIL_STAKE_MIN, MIN_COUNCIL_STAKE_MAX, CURRENCY_UNIT)
       )
       .max(
         MIN_COUNCIL_STAKE_MAX,
-        errorMessage("The minimum council stake", MIN_COUNCIL_STAKE_MIN, MIN_COUNCIL_STAKE_MAX, "tJOY")
+        errorMessage("The minimum council stake", MIN_COUNCIL_STAKE_MIN, MIN_COUNCIL_STAKE_MAX, CURRENCY_UNIT)
       ),
     newTermDuration: Yup.number()
       .required("All fields must be filled!")
@@ -244,8 +251,8 @@ const Validation: ValidationType = {
     mintCapacity: Yup.number()
       .positive("Mint capacity should be positive.")
       .integer("This field must be an integer.")
-      .min(MINT_CAPACITY_MIN, errorMessage("Mint capacity", MINT_CAPACITY_MIN, MINT_CAPACITY_MAX, "tJOY"))
-      .max(MINT_CAPACITY_MAX, errorMessage("Mint capacity", MINT_CAPACITY_MIN, MINT_CAPACITY_MAX, "tJOY"))
+      .min(MINT_CAPACITY_MIN, errorMessage("Mint capacity", MINT_CAPACITY_MIN, MINT_CAPACITY_MAX, CURRENCY_UNIT))
+      .max(MINT_CAPACITY_MAX, errorMessage("Mint capacity", MINT_CAPACITY_MIN, MINT_CAPACITY_MAX, CURRENCY_UNIT))
       .required("You need to specify a mint capacity.")
   },
   EvictStorageProvider: {
@@ -271,7 +278,7 @@ const Validation: ValidationType = {
       .required("All parameters are required")
       .positive("The minimum stake should be positive.")
       .integer("This field must be an integer.")
-      .max(MIN_STAKE_MAX, errorMessage("Minimum stake", MIN_STAKE_MIN, MIN_STAKE_MAX, "tJOY")),
+      .max(MIN_STAKE_MAX, errorMessage("Minimum stake", MIN_STAKE_MIN, MIN_STAKE_MAX, CURRENCY_UNIT)),
     min_actors: Yup.number()
       .required("All parameters are required")
       .integer("This field must be an integer.")
@@ -285,8 +292,8 @@ const Validation: ValidationType = {
     reward: Yup.number()
       .required("All parameters are required")
       .integer("This field must be an integer.")
-      .min(REWARD_MIN, errorMessage("Reward", REWARD_MIN, REWARD_MAX, "tJOY"))
-      .max(REWARD_MAX, errorMessage("Reward", REWARD_MIN, REWARD_MAX, "tJOY")),
+      .min(REWARD_MIN, errorMessage("Reward", REWARD_MIN, REWARD_MAX, CURRENCY_UNIT))
+      .max(REWARD_MAX, errorMessage("Reward", REWARD_MIN, REWARD_MAX, CURRENCY_UNIT)),
     reward_period: Yup.number()
       .required("All parameters are required")
       .integer("This field must be an integer.")
@@ -332,9 +339,15 @@ const Validation: ValidationType = {
       ),
     entry_request_fee: Yup.number()
       .required("All parameters are required")
-      .positive("The entry request fee should be positive.")
       .integer("This field must be an integer.")
-      .max(ENTRY_REQUEST_FEE_MAX, `The entry request fee should be less than ${ENTRY_REQUEST_FEE_MAX} tJOY.`)
+      .min(
+        ENTRY_REQUEST_FEE_MIN,
+        errorMessage("The entry request fee", ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT)
+      )
+      .max(
+        STARTUP_GRACE_PERIOD_MAX,
+        errorMessage("The entry request fee", ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT)
+      ),
   }
 };
 

+ 21 - 42
packages/joy-storage/src/index.tsx

@@ -19,18 +19,20 @@ import './index.css';
 
 import translate from './translate';
 
-type Props = AppProps & ApiProps & I18nProps & {
-  requests?: Array<Request>,
-  actorAccountIds?: Array<AccountId>,
-  roles?: Array<Role>,
-  allAccounts?: SubjectInfo,
-};
+type Props = AppProps &
+  ApiProps &
+  I18nProps & {
+    requests?: Array<Request>;
+    actorAccountIds?: Array<AccountId>;
+    roles?: Array<Role>;
+    allAccounts?: SubjectInfo;
+  };
 
 type State = {
-  tabs: Array<TabItem>,
-  actorAccountIds: Array<string>,
-  requests: Array<Request>,
-  roles: Array<Role>,
+  tabs: Array<TabItem>;
+  actorAccountIds: Array<string>;
+  requests: Array<Request>;
+  roles: Array<Role>;
 };
 
 class App extends React.PureComponent<Props, State> {
@@ -58,43 +60,27 @@ class App extends React.PureComponent<Props, State> {
         {
           name: 'requests',
           text: t('My Staking Requests')
-        },
-      ],
+        }
+      ]
     };
   }
 
   static getDerivedStateFromProps({ actorAccountIds, requests, roles }: Props): State {
     return {
-      actorAccountIds: (actorAccountIds || []).map((accountId) =>
-        accountId.toString()
-      ),
-      requests: (requests || []).map((request) =>
-        request
-      ),
-      roles: (roles || []).map((role) =>
-        role
-      ),
+      actorAccountIds: (actorAccountIds || []).map(accountId => accountId.toString()),
+      requests: (requests || []).map(request => request),
+      roles: (roles || []).map(role => role)
     } as State;
   }
 
   render() {
-    const { allAccounts } = this.props;
     const { tabs } = this.state;
     const { basePath } = this.props;
-    const hasAccounts = allAccounts && Object.keys(allAccounts).length;
-    const filteredTabs = hasAccounts
-      ? tabs
-      : tabs.filter(({ name }) =>
-        !['requests'].includes(name)
-      );
 
     return (
-      <main className='actors--App'>
+      <main className="actors--App">
         <header>
-          <Tabs
-            basePath={basePath}
-            items={filteredTabs}
-          />
+          <Tabs basePath={basePath} items={tabs} />
         </header>
         <Switch>
           <Route path={`${basePath}/requests`} render={this.renderComponent(MyRequests)} />
@@ -109,16 +95,9 @@ class App extends React.PureComponent<Props, State> {
     return (): React.ReactNode => {
       const { actorAccountIds, requests, roles } = this.state;
 
-      return (
-        <Component
-          actorAccountIds={actorAccountIds}
-          requests={requests}
-          roles={roles}
-        />
-      );
+      return <Component actorAccountIds={actorAccountIds} requests={requests} roles={roles} />;
     };
   }
-
 }
 
 export default withMulti(
@@ -128,6 +107,6 @@ export default withMulti(
   withCalls<Props>(
     ['query.actors.actorAccountIds', { propName: 'actorAccountIds' }],
     ['query.actors.roleEntryRequests', { propName: 'requests' }],
-    ['query.actors.availableRoles', { propName: 'roles' }],
+    ['query.actors.availableRoles', { propName: 'roles' }]
   )
 );

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 2
packages/joy-types/joystream.json


+ 4 - 3
packages/joy-types/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@joystream/types",
-  "version": "0.7.0",
-  "description": "Types for Joystream Substrate Runtime 6.0.0 (Rome)",
+  "version": "0.9.1",
+  "description": "Types for Joystream Substrate Runtime 6.13.0 (Constantinople)",
   "main": "lib/index.js",
   "scripts": {
     "prepublish": "npm run build",
@@ -12,7 +12,8 @@
   "dependencies": {
     "@polkadot/types": "^0.96.1",
     "@types/vfile": "^4.0.0",
-    "ajv": "^6.11.0"
+    "ajv": "^6.11.0",
+    "lodash": "^4.17.15"
   },
   "directories": {
     "lib": "lib"

+ 1 - 1
packages/joy-types/src/forum.ts

@@ -90,7 +90,7 @@ export class CategoryId extends u64 {}
 export class OptionCategoryId extends Option.with(CategoryId) {}
 export class VecCategoryId extends Vector.with(CategoryId) {}
 
-export class ThreadId extends u32 {}
+export class ThreadId extends u64 {}
 export class VecThreadId extends Vector.with(ThreadId) {}
 
 export class PostId extends u64 {}

+ 33 - 1
packages/joy-types/src/proposals.ts

@@ -168,6 +168,10 @@ export class ExecutionFailedStatus extends Struct {
       value
     );
   }
+
+  get error() {
+    return this.get('error') as Vec<u8>;
+  }
 }
 
 class ExecutionFailed extends ExecutionFailedStatus {}
@@ -237,6 +241,13 @@ export class ProposalStatus extends Enum {
   }
 }
 
+export const VoteKinds = [
+  "Approve",
+  "Reject",
+  "Slash",
+  "Abstain"
+] as const;
+
 export class VoteKind extends Enum {
   constructor(value?: any, index?: number) {
     super(["Approve", "Reject", "Slash", "Abstain"], value, index);
@@ -440,6 +451,26 @@ export class Seat extends Struct {
 
 export class Seats extends Vec.with(Seat) {}
 
+export class ThreadCounter extends Struct {
+  constructor(value?: any) {
+    super(
+      {
+        author_id: MemberId,
+        counter: "u32"
+      },
+      value
+    );
+  }
+
+  get author_id(): MemberId {
+    return this.get("author_id") as MemberId;
+  }
+
+  get counter(): u32 {
+    return this.get("counter") as u32;
+  }
+}
+
 // export default proposalTypes;
 export function registerProposalTypes() {
   try {
@@ -454,7 +485,8 @@ export function registerProposalTypes() {
       Seat,
       Seats,
       Backer,
-      Backers
+      Backers,
+      ThreadCounter
     });
   } catch (err) {
     console.error("Failed to register custom types of proposals module", err);

+ 141 - 128
packages/joy-utils/src/MyAccount.tsx

@@ -14,40 +14,43 @@ import { CuratorId, LeadId, Lead, CurationActor, Curator } from '@joystream/type
 import { queryMembershipToProp } from '@polkadot/joy-members/utils';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { queryToProp, MultipleLinkedMapEntry, SingleLinkedMapEntry } from '@polkadot/joy-utils/index';
+import { useMyMembership } from './MyMembershipContext';
 
 export type MyAddressProps = {
-  myAddress?: string
+  myAddress?: string;
 };
 
 export type MyAccountProps = MyAddressProps & {
-  myAccountId?: AccountId,
-  myMemberId?: MemberId,
-  memberIdsByRootAccountId?: Vec<MemberId>,
-  memberIdsByControllerAccountId?: Vec<MemberId>,
-  myMemberIdChecked?: boolean,
-  iAmMember?: boolean,
-  memberProfile?: Option<any>,
+  myAccountId?: AccountId;
+  myMemberId?: MemberId;
+  memberIdsByRootAccountId?: Vec<MemberId>;
+  memberIdsByControllerAccountId?: Vec<MemberId>;
+  myMemberIdChecked?: boolean;
+  iAmMember?: boolean;
+  memberProfile?: Option<any>;
 
   // Content Working Group
-  curatorEntries?: any, //entire linked_map: CuratorId => Curator
-  isLeadSet?: Option<LeadId>
-  contentLeadId? : LeadId
-  contentLeadEntry?: any // linked_map value
+  curatorEntries?: any; //entire linked_map: CuratorId => Curator
+  isLeadSet?: Option<LeadId>;
+  contentLeadId?: LeadId;
+  contentLeadEntry?: any; // linked_map value
 
   // From member's roles
-  myContentLeadId?: LeadId,
-  myCuratorIds?: CuratorId[],
-  memberIsCurator?: boolean,
-  memberIsContentLead?: boolean,
+  myContentLeadId?: LeadId;
+  myCuratorIds?: CuratorId[];
+  memberIsCurator?: boolean;
+  memberIsContentLead?: boolean;
 
-  curationActor?: any,
-  allAccounts?: SubjectInfo,
+  curationActor?: any;
+  allAccounts?: SubjectInfo;
 };
 
-function withMyAddress<P extends MyAccountProps> (Component: React.ComponentType<P>) {
-  return function (props: P) {
-    const { state: { address } } = useMyAccount();
-    const myAccountId = address ? new GenericAccountId(address) : undefined
+function withMyAddress<P extends MyAccountProps>(Component: React.ComponentType<P>) {
+  return function(props: P) {
+    const {
+      state: { address }
+    } = useMyAccount();
+    const myAccountId = address ? new GenericAccountId(address) : undefined;
     return <Component myAddress={address} myAccountId={myAccountId} {...props} />;
   };
 }
@@ -57,8 +60,8 @@ const withMyMemberIds = withCalls<MyAccountProps>(
   queryMembershipToProp('memberIdsByControllerAccountId', 'myAddress')
 );
 
-function withMyMembership<P extends MyAccountProps> (Component: React.ComponentType<P>) {
-  return function (props: P) {
+function withMyMembership<P extends MyAccountProps>(Component: React.ComponentType<P>) {
+  return function(props: P) {
     const { memberIdsByRootAccountId, memberIdsByControllerAccountId } = props;
 
     const myMemberIdChecked = memberIdsByRootAccountId && memberIdsByControllerAccountId;
@@ -76,55 +79,47 @@ function withMyMembership<P extends MyAccountProps> (Component: React.ComponentT
     const newProps = {
       myMemberIdChecked,
       myMemberId,
-      iAmMember,
+      iAmMember
     };
 
     return <Component {...props} {...newProps} />;
   };
 }
 
-const withMyProfile = withCalls<MyAccountProps>(
-  queryMembershipToProp('memberProfile', 'myMemberId'),
-);
+const withMyProfile = withCalls<MyAccountProps>(queryMembershipToProp('memberProfile', 'myMemberId'));
 
 const withContentWorkingGroupDetails = withCalls<MyAccountProps>(
-  queryToProp('query.contentWorkingGroup.currentLeadId', { propName: 'isLeadSet'}),
-  queryToProp('query.contentWorkingGroup.curatorById', { propName: 'curatorEntries' }),
+  queryToProp('query.contentWorkingGroup.currentLeadId', { propName: 'isLeadSet' }),
+  queryToProp('query.contentWorkingGroup.curatorById', { propName: 'curatorEntries' })
 );
 
-function resolveLead<P extends MyAccountProps> (Component: React.ComponentType<P>) {
-  return function (props: P) {
+function resolveLead<P extends MyAccountProps>(Component: React.ComponentType<P>) {
+  return function(props: P) {
     const { isLeadSet } = props;
 
     let contentLeadId;
 
     if (isLeadSet && isLeadSet.isSome) {
-      contentLeadId = isLeadSet.unwrap()
+      contentLeadId = isLeadSet.unwrap();
     }
 
     let newProps = {
       contentLeadId
-    }
+    };
 
     return <Component {...props} {...newProps} />;
-  }
+  };
 }
 
 const resolveLeadEntry = withCalls<MyAccountProps>(
-  queryToProp('query.contentWorkingGroup.leadById', { propName: 'contentLeadEntry',  paramName: 'contentLeadId' }),
-);
-
-const withContentWorkingGroup = <P extends MyAccountProps> (Component: React.ComponentType<P>) =>
-withMulti(
-  Component,
-  withContentWorkingGroupDetails,
-  resolveLead,
-  resolveLeadEntry,
+  queryToProp('query.contentWorkingGroup.leadById', { propName: 'contentLeadEntry', paramName: 'contentLeadId' })
 );
 
-function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P>) {
-  return function (props: P) {
+const withContentWorkingGroup = <P extends MyAccountProps>(Component: React.ComponentType<P>) =>
+  withMulti(Component, withContentWorkingGroupDetails, resolveLead, resolveLeadEntry);
 
+function withMyRoles<P extends MyAccountProps>(Component: React.ComponentType<P>) {
+  return function(props: P) {
     const { iAmMember, memberProfile } = props;
 
     let myContentLeadId;
@@ -132,7 +127,7 @@ function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P
 
     if (iAmMember && memberProfile && memberProfile.isSome) {
       const profile = memberProfile.unwrap() as Profile;
-      profile.roles.forEach((role) => {
+      profile.roles.forEach(role => {
         if (role.isContentLead) {
           myContentLeadId = role.actor_id;
         } else if (role.isCurator) {
@@ -148,96 +143,90 @@ function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P
       memberIsContentLead,
       memberIsCurator,
       myContentLeadId,
-      myCuratorIds,
+      myCuratorIds
     };
 
     return <Component {...props} {...newProps} />;
-  }
+  };
 }
 
 const canUseAccount = (account: AccountId, allAccounts: SubjectInfo | undefined) => {
   if (!allAccounts || !Object.keys(allAccounts).length) {
-    return false
+    return false;
   }
 
-  const ix = Object.keys(allAccounts).findIndex((key) => {
-    return account.eq(allAccounts[key].json.address)
+  const ix = Object.keys(allAccounts).findIndex(key => {
+    return account.eq(allAccounts[key].json.address);
   });
 
-  return ix != -1
-}
-
-function withCurationActor<P extends MyAccountProps> (Component: React.ComponentType<P>) {
-  return function (props: P) {
+  return ix != -1;
+};
 
+function withCurationActor<P extends MyAccountProps>(Component: React.ComponentType<P>) {
+  return function(props: P) {
     const {
-      myAccountId, isLeadSet, contentLeadEntry,
-      myCuratorIds, curatorEntries, allAccounts,
-      memberIsContentLead, memberIsCurator
+      myAccountId,
+      isLeadSet,
+      contentLeadEntry,
+      myCuratorIds,
+      curatorEntries,
+      allAccounts,
+      memberIsContentLead,
+      memberIsCurator
     } = props;
 
     if (!myAccountId || !isLeadSet || !contentLeadEntry || !curatorEntries || !allAccounts) {
       return <Component {...props} />;
     }
 
-    const leadRoleAccount = isLeadSet.isSome ?
-      new SingleLinkedMapEntry<Lead>(Lead, contentLeadEntry).value.role_account : null;
+    const leadRoleAccount = isLeadSet.isSome
+      ? new SingleLinkedMapEntry<Lead>(Lead, contentLeadEntry).value.role_account
+      : null;
 
     // Is current key the content lead key?
     if (leadRoleAccount && leadRoleAccount.eq(myAccountId)) {
-      return <Component {...props} curationActor={[
-        new CurationActor('Lead'),
-        myAccountId
-      ]} />
+      return <Component {...props} curationActor={[new CurationActor('Lead'), myAccountId]} />;
     }
 
-    const curators = new MultipleLinkedMapEntry<CuratorId, Curator>(
-      CuratorId,
-      Curator,
-      curatorEntries
-    );
+    const curators = new MultipleLinkedMapEntry<CuratorId, Curator>(CuratorId, Curator, curatorEntries);
 
     const correspondingCurationActor = (accountId: AccountId, curators: MultipleLinkedMapEntry<CuratorId, Curator>) => {
-      const ix = curators.linked_values.findIndex(
-        curator => myAccountId.eq(curator.role_account) && curator.is_active
-      );
+      const ix = curators.linked_values.findIndex(curator => myAccountId.eq(curator.role_account) && curator.is_active);
 
-      return ix >= 0 ? new CurationActor({
-          'Curator':  curators.linked_keys[ix]
-        }) : null;
-    }
+      return ix >= 0
+        ? new CurationActor({
+            Curator: curators.linked_keys[ix]
+          })
+        : null;
+    };
 
     const firstMatchingCurationActor = correspondingCurationActor(myAccountId, curators);
 
     // Is the current key corresponding to an active curator role key?
     if (firstMatchingCurationActor) {
-      return <Component {...props} curationActor={[
-        firstMatchingCurationActor,
-        myAccountId
-      ]} />;
+      return <Component {...props} curationActor={[firstMatchingCurationActor, myAccountId]} />;
     }
 
     // See if we have the member's lead role account
-    if(leadRoleAccount && memberIsContentLead && canUseAccount(leadRoleAccount, allAccounts)) {
-      return <Component {...props} curationActor={[
-        new CurationActor('Lead'),
-        leadRoleAccount
-      ]} />
+    if (leadRoleAccount && memberIsContentLead && canUseAccount(leadRoleAccount, allAccounts)) {
+      return <Component {...props} curationActor={[new CurationActor('Lead'), leadRoleAccount]} />;
     }
 
     // See if we have one of the member's curator role accounts
-    if(memberIsCurator && myCuratorIds && curators.linked_keys.length) {
-      for(let i = 0; i < myCuratorIds.length; i++) {
+    if (memberIsCurator && myCuratorIds && curators.linked_keys.length) {
+      for (let i = 0; i < myCuratorIds.length; i++) {
         const curator_id = myCuratorIds[i];
-        const ix = curators.linked_keys.findIndex((id) => id.eq(curator_id));
+        const ix = curators.linked_keys.findIndex(id => id.eq(curator_id));
 
         if (ix >= 0) {
           const curator = curators.linked_values[ix];
           if (curator.is_active && canUseAccount(curator.role_account, allAccounts)) {
-            return <Component {...props} curationActor={[
-              new CurationActor({ 'Curator': curator_id }),
-              curator.role_account
-            ]} />;
+            return (
+              <Component
+                {...props}
+                curationActor={[new CurationActor({ Curator: curator_id }), curator.role_account]}
+              />
+            );
           }
         }
       }
@@ -247,58 +236,62 @@ function withCurationActor<P extends MyAccountProps> (Component: React.Component
 
     // Use lead role key if available
     if (leadRoleAccount && canUseAccount(leadRoleAccount, allAccounts)) {
-      return <Component {...props} curationActor={[
-        new CurationActor('Lead'),
-        leadRoleAccount
-      ]} />
+      return <Component {...props} curationActor={[new CurationActor('Lead'), leadRoleAccount]} />;
     }
 
     // Use first available active curator role key if available
-    if(curators.linked_keys.length) {
+    if (curators.linked_keys.length) {
       for (let i = 0; i < curators.linked_keys.length; i++) {
         let curator = curators.linked_values[i];
         if (curator.is_active && canUseAccount(curator.role_account, allAccounts)) {
-          return <Component {...props} curationActor={[
-            new CurationActor({ 'Curator':  curators.linked_keys[i] }),
-            curator.role_account
-          ]} />
+          return (
+            <Component
+              {...props}
+              curationActor={[new CurationActor({ Curator: curators.linked_keys[i] }), curator.role_account]}
+            />
+          );
         }
       }
     }
 
     // we don't have any key that can fulfill a curation action
     return <Component {...props} />;
-  }
+  };
 }
 
-export const withMyAccount = <P extends MyAccountProps> (Component: React.ComponentType<P>) =>
-withMulti(
-  Component,
-  withObservable(accountObservable.subject, { propName: 'allAccounts' }),
-  withMyAddress,
-  withMyMemberIds,
-  withMyMembership,
-  withMyProfile,
-  withContentWorkingGroup,
-  withMyRoles,
-  withCurationActor,
-);
-
-function OnlyMembers<P extends MyAccountProps> (Component: React.ComponentType<P>) {
-  return function (props: P) {
+export const withMyAccount = <P extends MyAccountProps>(Component: React.ComponentType<P>) =>
+  withMulti(
+    Component,
+    withObservable(accountObservable.subject, { propName: 'allAccounts' }),
+    withMyAddress,
+    withMyMemberIds,
+    withMyMembership,
+    withMyProfile,
+    withContentWorkingGroup,
+    withMyRoles,
+    withCurationActor
+  );
+
+function OnlyMembers<P extends MyAccountProps>(Component: React.ComponentType<P>) {
+  return function(props: P) {
     const { myMemberIdChecked, iAmMember } = props;
+
     if (!myMemberIdChecked) {
       return <em>Loading...</em>;
     } else if (iAmMember) {
       return <Component {...props} />;
     } else {
       return (
-        <Message warning className='JoyMainStatus'>
+        <Message warning className="JoyMainStatus">
           <Message.Header>Only members can access this functionality.</Message.Header>
           <div style={{ marginTop: '1rem' }}>
-            <Link to={`/members/edit`} className='ui button orange'>Register here</Link>
+            <Link to={`/members/edit`} className="ui button orange">
+              Register here
+            </Link>
             <span style={{ margin: '0 .5rem' }}> or </span>
-            <Link to={`/accounts`} className='ui button'>Change key</Link>
+            <Link to={`/accounts`} className="ui button">
+              Change key
+            </Link>
           </div>
         </Message>
       );
@@ -306,9 +299,29 @@ function OnlyMembers<P extends MyAccountProps> (Component: React.ComponentType<P
   };
 }
 
-export const withOnlyMembers = <P extends MyAccountProps> (Component: React.ComponentType<P>) =>
-withMulti(
-  Component,
-  withMyAccount,
-  OnlyMembers
-);
+export function AccountRequired<P extends {}>(Component: React.ComponentType<P>): React.ComponentType<P> {
+  return function(props: P) {
+    const { allAccounts } = useMyMembership();
+
+    if (allAccounts && !Object.keys(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>
+      );
+    }
+
+    return <Component {...props} />;
+  };
+}
+
+export const withOnlyMembers = <P extends MyAccountProps>(Component: React.ComponentType<P>) =>
+  withMulti(Component, withMyAccount, AccountRequired, OnlyMembers);
+
+export const withAccountRequired = <P extends MyAccountProps>(Component: React.ComponentType<P>) =>
+  withMulti(Component, withMyAccount, AccountRequired);

+ 46 - 5
packages/joy-utils/src/Section.tsx

@@ -1,34 +1,75 @@
 import React from 'react';
 import { BareProps } from '@polkadot/react-components/types';
+import styled from 'styled-components';
+
+const Header = styled.div`
+  display: flex;
+  width: 100%;
+  justify-content: space-between;
+  align-items: flex-end;
+  margin-bottom: ${ (props: { withPagination: boolean }) => props.withPagination ? '1rem' : 0 };
+  flex-wrap: wrap;
+`;
+
+const Title = styled.div`
+  flex-grow: 1;
+`;
+
+const ResponsivePagination = styled.div`
+  @media screen and (max-width: 767px) {
+    & a[type=firstItem],
+    & a[type=lastItem] {
+      display: none !important;
+    }
+    & a {
+      font-size: 0.8rem !important;
+    }
+  }
+`;
+
+const TopPagination = styled(ResponsivePagination)`
+  margin-left: auto;
+`;
+
+const BottomPagination = styled(ResponsivePagination)`
+  display: flex;
+  justify-content: flex-end;
+`;
 
 type Props = BareProps & {
   className?: string,
   title?: JSX.Element | string,
-  level?: number
+  level?: number,
+  pagination?: JSX.Element
 };
 
 export default class Section extends React.PureComponent<Props> {
 
   render () {
-    let { className, children } = this.props;
+    let { className, children, pagination } = this.props;
     className = (className || '') + ' JoySection';
 
     return (
       <section className={className}>
-        {this.renderTitle()}
+        <Header withPagination={ Boolean(pagination) }>
+          <Title>{this.renderTitle()}</Title>
+          { pagination && <TopPagination>{ pagination }</TopPagination> }
+        </Header>
         <div>{children}</div>
+        { pagination && <BottomPagination>{ pagination }</BottomPagination> }
       </section>
     );
   }
 
   private renderTitle = () => {
-    const { title, level = 2 } = this.props;
+    const { title, level = 2, pagination } = this.props;
     if (!title) return null;
 
     const className = 'JoySection-title';
+    const style = pagination ? { margin: '0' } : {};
     return React.createElement(
       `h${level}`,
-      { className },
+      { className, style },
       title
     );
   }

+ 2 - 3
packages/react-components/src/FilterOverlay.tsx

@@ -13,9 +13,9 @@ interface Props extends BareProps {
   children: React.ReactNode;
 }
 
-function FilterOverlay ({ children, className }: Props): React.ReactElement<Props> {
+function FilterOverlay({ children, className, style }: Props): React.ReactElement<Props> {
   return (
-    <div className={className}>
+    <div className={className} style={style}>
       {children}
     </div>
   );
@@ -33,7 +33,6 @@ export default styled(FilterOverlay)`
     justify-content: flex-end;
     position: absolute;
     right: 5rem;
-    top: 5.5rem;
 
     > div {
       max-width: 35rem !important;

+ 7 - 18
packages/react-components/src/HelpOverlay.tsx

@@ -14,31 +14,21 @@ interface Props extends BareProps {
   md: string;
 }
 
-function HelpOverlay ({ className, md }: Props): React.ReactElement<Props> {
+function HelpOverlay({ className, md, style }: Props): React.ReactElement<Props> {
   const [isVisible, setIsVisible] = useState(false);
 
   const _toggleVisible = (): void => setIsVisible(!isVisible);
 
   return (
     <div className={className}>
-      <div className='help-button'>
-        <Icon
-          name='help circle'
-          onClick={_toggleVisible}
-        />
+      <div className="help-button" style={style}>
+        <Icon name="help circle" onClick={_toggleVisible} />
       </div>
       <div className={`help-slideout ${isVisible ? 'open' : 'closed'}`}>
-        <div className='help-button'>
-          <Icon
-            name='close'
-            onClick={_toggleVisible}
-          />
+        <div className="help-button">
+          <Icon name="close" onClick={_toggleVisible} />
         </div>
-        <ReactMd
-          className='help-content'
-          escapeHtml={false}
-          source={md}
-        />
+        <ReactMd className="help-content" escapeHtml={false} source={md} />
       </div>
     </div>
   );
@@ -55,7 +45,6 @@ export default styled(HelpOverlay)`
   > .help-button {
     position: absolute;
     right: 0rem;
-    top: 5.25rem;
   }
 
   .help-slideout {
@@ -67,7 +56,7 @@ export default styled(HelpOverlay)`
     position: fixed;
     right: -50rem;
     top: 0;
-    transition-duration: .5s;
+    transition-duration: 0.5s;
     transition-property: all;
     z-index: 10;
 

+ 4 - 1
packages/react-components/src/Tabs.tsx

@@ -22,6 +22,7 @@ export interface TabItem {
   hasParams?: boolean;
   isExact?: boolean;
   isRoot?: boolean;
+  forcedExact?: boolean;
   name: string;
   text: React.ReactNode;
 }
@@ -40,7 +41,9 @@ function renderItem ({ basePath, isSequence, items }: Props): (tabItem: TabItem,
       : `${basePath}/${name}`;
     // only do exact matching when not the fallback (first position tab),
     // params are problematic for dynamic hidden such as app-accounts
-    const isExact = tab.isExact || !hasParams || (!isSequence && index === 0);
+    const isExact = tab.forcedExact !== undefined ? tab.forcedExact : (
+      tab.isExact || !hasParams || (!isSequence && index === 0)
+    );
 
     return (
       <React.Fragment key={to}>

Vissa filer visades inte eftersom för många filer har ändrats