Browse Source

Merge pull request #742 from Lezek123/storage-working-group-applying

Pioneer: Applying for storage working group opening and final "Working groups" tab issues
Mokhtar Naamani 4 years ago
parent
commit
51f61af45f
28 changed files with 610 additions and 648 deletions
  1. 2 1
      pioneer/.eslintrc.js
  2. 5 1
      pioneer/packages/joy-roles/src/OpeningMetadata.ts
  3. 2 0
      pioneer/packages/joy-roles/src/elements.tsx
  4. 42 18
      pioneer/packages/joy-roles/src/flows/apply.controller.tsx
  5. 2 0
      pioneer/packages/joy-roles/src/flows/apply.elements.stories.tsx
  6. 2 0
      pioneer/packages/joy-roles/src/flows/apply.stories.tsx
  7. 55 101
      pioneer/packages/joy-roles/src/flows/apply.tsx
  8. 3 3
      pioneer/packages/joy-roles/src/index.tsx
  9. 130 273
      pioneer/packages/joy-roles/src/tabs/Admin.controller.tsx
  10. 10 10
      pioneer/packages/joy-roles/src/tabs/MyRoles.controller.tsx
  11. 12 9
      pioneer/packages/joy-roles/src/tabs/MyRoles.elements.stories.tsx
  12. 16 4
      pioneer/packages/joy-roles/src/tabs/MyRoles.tsx
  13. 1 0
      pioneer/packages/joy-roles/src/tabs/Opportunities.controller.tsx
  14. 2 1
      pioneer/packages/joy-roles/src/tabs/Opportunities.elements.stories.tsx
  15. 2 1
      pioneer/packages/joy-roles/src/tabs/Opportunities.stories.tsx
  16. 44 19
      pioneer/packages/joy-roles/src/tabs/Opportunities.tsx
  17. 2 22
      pioneer/packages/joy-roles/src/tabs/WorkingGroup.controller.tsx
  18. 1 1
      pioneer/packages/joy-roles/src/tabs/WorkingGroup.stories.tsx
  19. 24 13
      pioneer/packages/joy-roles/src/tabs/WorkingGroup.tsx
  20. 26 27
      pioneer/packages/joy-roles/src/transport.mock.ts
  21. 171 117
      pioneer/packages/joy-roles/src/transport.substrate.ts
  22. 10 9
      pioneer/packages/joy-roles/src/transport.ts
  23. 5 0
      pioneer/packages/joy-roles/src/working_groups.ts
  24. 2 1
      pioneer/packages/joy-utils/src/View.tsx
  25. 1 1
      pioneer/packages/react-components/src/AddressCard.tsx
  26. 3 3
      types/src/common.ts
  27. 28 2
      types/src/hiring/index.ts
  28. 7 11
      types/src/working-group/index.ts

+ 2 - 1
pioneer/.eslintrc.js

@@ -15,6 +15,7 @@ module.exports = {
     '@typescript-eslint/camelcase': 'off',
     'react/prop-types': 'off',
     'new-cap': 'off',
-    '@typescript-eslint/interface-name-prefix': 'off'
+    '@typescript-eslint/interface-name-prefix': 'off',
+    '@typescript-eslint/ban-ts-comment': 'error'
   }
 };

+ 5 - 1
pioneer/packages/joy-roles/src/OpeningMetadata.ts

@@ -1,6 +1,10 @@
+import { WorkingGroups } from './working_groups';
+import { OpeningType } from '@joystream/types/working-group';
+
 export type OpeningMetadata = {
   id: string;
-  group: string;
+  group: WorkingGroups;
+  type?: OpeningType;
 }
 
 export type OpeningMetadataProps = {

+ 2 - 0
pioneer/packages/joy-roles/src/elements.tsx

@@ -9,6 +9,7 @@ import Identicon from '@polkadot/react-identicon';
 import { IProfile, MemberId } from '@joystream/types/members';
 import { GenericAccountId } from '@polkadot/types';
 import { LeadRoleState } from '@joystream/types/content-working-group';
+import { WorkerId } from '@joystream/types/working-group';
 
 type BalanceProps = {
   balance?: Balance;
@@ -47,6 +48,7 @@ export type GroupMember = {
 
 export type GroupLead = {
   memberId: MemberId;
+  workerId?: WorkerId; // In case of "working-group" module
   roleAccount: GenericAccountId;
   profile: IProfile;
   title: string;

+ 42 - 18
pioneer/packages/joy-roles/src/flows/apply.controller.tsx

@@ -17,7 +17,7 @@ import { keyPairDetails, FlowModal, ProgressSteps } from './apply';
 
 import { OpeningStakeAndApplicationStatus } from '../tabs/Opportunities';
 import { Min, Step, Sum } from '../balances';
-import { WorkingGroups } from '../working_groups';
+import { WorkingGroups, AvailableGroups } from '../working_groups';
 
 type State = {
   // Input data from state
@@ -39,6 +39,7 @@ type State = {
 
   // Data generated for transaction
   transactionDetails: Map<string, string>;
+  roleKeyNameBase: string;
   roleKeyName: string;
 
   // Error capture and display
@@ -52,6 +53,7 @@ const newEmptyState = (): State => {
     appDetails: {},
     hasError: false,
     transactionDetails: new Map<string, string>(),
+    roleKeyNameBase: '',
     roleKeyName: '',
     txKeyAddress: new AccountId(),
     activeStep: 0,
@@ -61,41 +63,51 @@ const newEmptyState = (): State => {
 };
 
 export class ApplyController extends Controller<State, ITransport> {
-  protected currentOpeningId = -1
+  protected currentOpeningId = -1;
+  protected currentGroup: WorkingGroups | null = null;
 
-  constructor (transport: ITransport, initialState: State = newEmptyState()) {
+  constructor (
+    transport: ITransport,
+    initialState: State = newEmptyState()
+  ) {
     super(transport, initialState);
 
     this.transport.accounts().subscribe((keys) => this.updateAccounts(keys));
   }
 
+  protected parseGroup (group: string | undefined): WorkingGroups | undefined {
+    return AvailableGroups.find(availableGroup => availableGroup === group);
+  }
+
   protected updateAccounts (keys: keyPairDetails[]) {
     this.state.keypairs = keys;
     this.dispatch();
   }
 
-  findOpening (rawId: string | undefined) {
+  findOpening (rawId: string | undefined, rawGroup: string | undefined) {
     if (!rawId) {
       return this.onError('ApplyController: no ID provided in params');
     }
     const id = parseInt(rawId);
+    const group = this.parseGroup(rawGroup);
+
+    if (!group) {
+      return this.onError('ApplyController: invalid group');
+    }
 
-    if (this.currentOpeningId === id) {
+    if (this.currentOpeningId === id && this.currentGroup === group) {
       return;
     }
 
     Promise.all(
       [
-        this.transport.curationGroupOpening(id),
-        this.transport.openingApplicationRanks(id)
+        this.transport.groupOpening(group, id),
+        this.transport.openingApplicationRanks(group, id)
       ]
     )
       .then(
         ([opening, ranks]) => {
-          const hrt = opening.opening.parse_human_readable_text();
-          if (typeof hrt !== 'object') {
-            return this.onError('human_readable_text is not an object');
-          }
+          const hrt = opening.opening.parse_human_readable_text_with_fallback();
 
           this.state.role = hrt;
           this.state.applications = opening.applications;
@@ -112,7 +124,7 @@ export class ApplyController extends Controller<State, ITransport> {
             ? ProgressSteps.ConfirmStakes
             : ProgressSteps.ApplicationDetails;
 
-          this.state.roleKeyName = hrt.job.title + ' role key';
+          this.state.roleKeyNameBase = hrt.job.title + ' role key';
 
           // When everything is collected, update the view
           this.dispatch();
@@ -121,11 +133,13 @@ export class ApplyController extends Controller<State, ITransport> {
       .catch(
         (err: any) => {
           this.currentOpeningId = -1;
+          this.currentGroup = null;
           this.onError(err);
         }
       );
 
     this.currentOpeningId = id;
+    this.currentGroup = group;
   }
 
   setApplicationStake (b: Balance): void {
@@ -183,8 +197,22 @@ export class ApplyController extends Controller<State, ITransport> {
     return true;
   }
 
+  private updateRoleKeyName () {
+    let roleKeyNamePrefix = 0;
+    do {
+      this.state.roleKeyName = `${this.state.roleKeyNameBase}${(++roleKeyNamePrefix > 1 ? ` ${roleKeyNamePrefix}` : '')}`;
+    } while (this.state.keypairs?.some(k => (
+      k.shortName.toLowerCase() === this.state.roleKeyName.toLowerCase()
+    )));
+  }
+
   async makeApplicationTransaction (): Promise<number> {
-    return this.transport.applyToCuratorOpening(
+    if (!this.currentGroup || this.currentOpeningId < 0) {
+      throw new Error('Trying to apply to unfetched opening');
+    }
+    this.updateRoleKeyName();
+    return this.transport.applyToOpening(
+      this.currentGroup,
       this.currentOpeningId,
       this.state.roleKeyName,
       this.state.txKeyAddress.toString(),
@@ -197,14 +225,10 @@ export class ApplyController extends Controller<State, ITransport> {
 
 export const ApplyView = View<ApplyController, State>(
   (state, controller, params) => {
-    if (params.get('group') !== WorkingGroups.ContentCurators) {
-      return <h1>Applying not yet implemented for this group!</h1>;
-    }
-    controller.findOpening(params.get('id'));
+    controller.findOpening(params.get('id'), params.get('group'));
     return (
       <Container className="apply-flow">
         <div className="dimmer"></div>
-        // @ts-ignore
         <FlowModal
           role={state.role!}
           applications={state.applications!}

+ 2 - 0
pioneer/packages/joy-roles/src/flows/apply.elements.stories.tsx

@@ -1,3 +1,5 @@
+// TODO: FIXME: Remove the ts-nocheck and fix errors!
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-nocheck
 import React, { useState } from 'react';
 import { number, object, withKnobs } from '@storybook/addon-knobs';

+ 2 - 0
pioneer/packages/joy-roles/src/flows/apply.stories.tsx

@@ -1,3 +1,5 @@
+// TODO: FIXME: Remove the ts-nocheck and fix errors!
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-nocheck
 import React from 'react';
 import { number, object, select, text, withKnobs } from '@storybook/addon-knobs';

+ 55 - 101
pioneer/packages/joy-roles/src/flows/apply.tsx

@@ -172,17 +172,17 @@ export function FundSourceSelector (props: FundSourceSelectorProps & FundSourceC
   );
 }
 
-function rankIcon (place: number, slots: number): SemanticICONS {
-  if (place <= 1) {
-    return 'thermometer empty';
-  } else if (place <= (slots / 4)) {
-    return 'thermometer quarter';
-  } else if (place <= (slots / 2)) {
-    return 'thermometer half';
-  } else if (place > (slots / 2) && place < slots) {
+function rankIcon (estimatedSlot: number, slots: number): SemanticICONS {
+  if (estimatedSlot === 1) { // 1st place
+    return 'thermometer';
+  } else if (estimatedSlot <= (slots / 3)) { // Places 2-33 if slotsCount == 100
     return 'thermometer three quarters';
+  } else if (estimatedSlot <= (slots / 1.5)) { // Places 34-66 if slotsCount == 100
+    return 'thermometer half';
+  } else if (estimatedSlot <= slots) { // Places 67-100 if slotsCount == 100
+    return 'thermometer quarter';
   }
-  return 'thermometer';
+  return 'thermometer empty'; // Places >100 for slotsCount == 100
 }
 
 export type StakeRankSelectorProps = {
@@ -192,40 +192,28 @@ export type StakeRankSelectorProps = {
   step: Balance;
   otherStake: Balance;
   requirement: IStakeRequirement;
+  maxNumberOfApplications: number;
 }
 
 export function StakeRankSelector (props: StakeRankSelectorProps) {
   const slotCount = props.slots.length;
-  const [rank, setRank] = useState(1);
-  const minStake = props.requirement.value;
+  const minStake = props.maxNumberOfApplications && props.slots.length === props.maxNumberOfApplications
+    ? props.slots[0].sub(props.otherStake).addn(1) // Slots are ordered by stake ASC
+    : props.requirement.value;
+  const stakeSufficient = props.stake.gte(minStake);
 
   const ticks = [];
   for (let i = 0; i < slotCount; i++) {
     ticks.push(<div key={i} className="tick" style={{ width: (100 / slotCount) + '%' }}>{slotCount - i}</div>);
   }
 
-  const findRankValue = (newStake: Balance): number => {
-    if (newStake.add(props.otherStake).gt(props.slots[slotCount - 1])) {
-      return slotCount;
-    }
-
-    for (let i = slotCount; i--; i >= 0) {
-      if (newStake.add(props.otherStake).gt(props.slots[i])) {
-        return i + 1;
-      }
-    }
-
-    return 0;
-  };
+  let estimatedSlot = slotCount + 1;
+  props.slots.forEach(slotStake => props.stake.gt(slotStake.sub(props.otherStake)) && --estimatedSlot);
 
   const changeValue = (e: any, { value }: any) => {
     const newStake = new u128(value);
     props.setStake(newStake);
-    setRank(findRankValue(newStake));
   };
-  useEffect(() => {
-    props.setStake(props.slots[0]);
-  }, []);
 
   const slider = null;
   return (
@@ -238,21 +226,26 @@ export function StakeRankSelector (props: StakeRankSelectorProps) {
           type="number"
           step={slotCount > 1 ? props.step.toNumber() : 1}
           value={props.stake.toNumber() > 0 ? props.stake.toNumber() : 0}
-          min={props.slots.length > 0 ? props.slots[0].sub(props.otherStake).toNumber() : 0}
-          error={props.stake.lt(minStake)}
+          min={minStake}
+          error={!stakeSufficient}
         />
-        <Label size='large'>
-          <Icon name={rankIcon(rank, slotCount)} />
-          Estimated rank
-          <Label.Detail>{(slotCount + 1) - rank} / {slotCount}</Label.Detail>
-        </Label>
-        <Label size='large'>
+        { props.maxNumberOfApplications > 0 && (
+          <Label size='large'>
+            <Icon name={rankIcon(estimatedSlot, slotCount)} />
+            Estimated rank
+            <Label.Detail>{estimatedSlot} / {props.maxNumberOfApplications}</Label.Detail>
+          </Label>
+        ) }
+        <Label size='large' color={stakeSufficient ? 'green' : 'red'}>
           <Icon name="shield" />
           Your stake
           <Label.Detail>{formatBalance(props.stake)}</Label.Detail>
         </Label>
       </Container>
       {slider}
+      { !stakeSufficient && (
+        <Label color="red">Currently you need to stake at least {formatBalance(minStake)} to be considered for this position!</Label>
+      ) }
     </Container>
   );
 }
@@ -377,24 +370,17 @@ export type StageTransitionProps = {
   prevTransition: () => void;
 }
 
-export type ApplicationStatusProps = {
-  numberOfApplications: number;
-}
-
 type CaptureKeyAndPassphraseProps = {
   keyAddress: AccountId;
   setKeyAddress: (a: AccountId) => void;
-  keyPassphrase: string;
-  setKeyPassphrase: (p: string) => void;
-  minStake: Balance;
+  // keyPassphrase: string;
+  // setKeyPassphrase: (p: string) => void;
+  // minStake: Balance;
 }
 
 export type ConfirmStakesStageProps =
-  StakeRequirementProps &
   FundSourceSelectorProps &
-  ApplicationStatusProps &
-  StakeRankSelectorProps &
-  CaptureKeyAndPassphraseProps & {
+  Pick<StakeRankSelectorProps, 'slots' | 'step'> & {
     applications: OpeningStakeAndApplicationStatus;
     selectedApplicationStake: Balance;
     setSelectedApplicationStake: (b: Balance) => void;
@@ -426,7 +412,7 @@ export function ConfirmStakesStage (props: ConfirmStakesStageProps & StageTransi
   );
 }
 
-type StakeSelectorProps = ConfirmStakesStageProps & ApplicationStatusProps
+type StakeSelectorProps = ConfirmStakesStageProps;
 
 function ConfirmStakes (props: StakeSelectorProps) {
   if (bothStakesVariable(props.applications)) {
@@ -488,55 +474,25 @@ export type ConfirmStakes2UpProps = {
 }
 
 export function ConfirmStakes2Up (props: ConfirmStakes2UpProps) {
-  const [valid, setValid] = useState(true);
   const slotCount = props.slots.length;
-  const [rank, setRank] = useState(1);
-  const minStake = props.slots[0];
-  const [combined, setCombined] = useState(new u128(0));
-
-  const findRankValue = (newStake: Balance): number => {
-    if (slotCount === 0) {
-      return 0;
-    }
+  const { maxNumberOfApplications, requiredApplicationStake, requiredRoleStake } = props.applications;
+  const minStake = maxNumberOfApplications && props.slots.length === maxNumberOfApplications
+    ? props.slots[0].addn(1) // Slots are sorted by combined stake ASC
+    : requiredApplicationStake.value.add(requiredRoleStake.value);
+  const combined = Add(props.selectedApplicationStake, props.selectedRoleStake);
+  const valid = combined.gte(minStake);
 
-    if (newStake.gt(props.slots[slotCount - 1])) {
-      return slotCount;
-    }
-
-    for (let i = slotCount; i--; i >= 0) {
-      if (newStake.gt(props.slots[i])) {
-        return i + 1;
-      }
-    }
-
-    return 0;
-  };
-
-  // Watch stake values
-  useEffect(() => {
-    const newCombined = Add(props.selectedApplicationStake, props.selectedRoleStake);
-    setCombined(newCombined);
-  },
-  [props.selectedApplicationStake, props.selectedRoleStake]
-  );
-
-  useEffect(() => {
-    setRank(findRankValue(combined));
-    if (slotCount > 0) {
-      setValid(combined.gte(minStake));
-    }
-  },
-  [combined]
-  );
+  let estimatedSlot = slotCount + 1;
+  props.slots.forEach(slotStake => combined.gt(slotStake) && --estimatedSlot);
 
   const ticks = [];
   for (let i = 0; i < slotCount; i++) {
-    ticks.push(<div key={i} className="tick" style={{ width: (100 / slotCount) + '%' }}>{slotCount - i}</div>);
+    ticks.push(<div key={i} className="tick" style={{ width: (100 / slotCount) + '%' }}>{i + 1}</div>);
   }
 
-  const tickLabel = <div className="ui pointing below label" style={{ left: ((100 / slotCount) * rank) + '%' }}>
+  const tickLabel = <div className="ui pointing below label" style={{ left: ((100 / slotCount) * (estimatedSlot - 1)) + '%' }}>
     Your rank
-    <div className="detail">{(slotCount - rank) + 1}/{props.applications.maxNumberOfApplications}</div>
+    <div className="detail">{estimatedSlot}/{props.applications.maxNumberOfApplications}</div>
   </div>;
 
   let tickContainer = null;
@@ -630,11 +586,13 @@ export function ConfirmStakes2Up (props: ConfirmStakes2UpProps) {
                   Your current combined stake
                   <Label.Detail>{formatBalance(new u128(props.selectedApplicationStake.add(props.selectedRoleStake)))}</Label.Detail>
                 </Label>
-                <Label color='grey'>
-                  <Icon name={rankIcon(rank, slotCount)} />
-                  Estimated rank
-                  <Label.Detail>{(slotCount - rank) + 1}/{props.applications.maxNumberOfApplications}</Label.Detail>
-                </Label>
+                { maxNumberOfApplications > 0 && (
+                  <Label color='grey'>
+                    <Icon name={rankIcon(estimatedSlot, slotCount)} />
+                    Estimated rank
+                    <Label.Detail>{estimatedSlot}/{props.applications.maxNumberOfApplications}</Label.Detail>
+                  </Label>
+                ) }
               </Grid.Column>
             </Grid.Row>
           </Grid>
@@ -677,7 +635,8 @@ function StakeRankMiniSelector (props: StakeRankMiniSelectorProps) {
   );
 }
 
-type CaptureStake1UpProps = ApplicationStatusProps & {
+type CaptureStake1UpProps = {
+  numberOfApplications: number;
   name: string;
   stakeReturnPolicy: string;
   colour: string;
@@ -710,11 +669,6 @@ function CaptureStake1Up (props: CaptureStake1UpProps) {
     );
   }
 
-  // Set default value
-  useEffect(() => {
-    props.setValue(props.requirement.value);
-  }, []);
-
   let slider = null;
   let atLeast = null;
   if (props.requirement.atLeast()) {
@@ -1030,7 +984,7 @@ export function DoneStage (props: DoneStageProps) {
   );
 }
 
-export type FlowModalProps = ConfirmStakesStageProps & FundSourceSelectorProps & {
+export type FlowModalProps = Pick<StakeRankSelectorProps, 'slots' | 'step'> & FundSourceSelectorProps & {
   role: GenericJoyStreamRoleSchema;
   applications: OpeningStakeAndApplicationStatus;
   hasConfirmStep: boolean;

+ 3 - 3
pioneer/packages/joy-roles/src/index.tsx

@@ -86,9 +86,9 @@ export const App: React.FC<Props> = (props: Props) => {
         />
       </header>
       <Switch>
-        <Route path={`${basePath}/opportunities/:group/:id/apply`} render={(props) => renderViewComponent(ApplyView(applyCtrl), props)} />
-        <Route path={`${basePath}/opportunities/:group/:id`} render={(props) => renderViewComponent(OpportunityView(oppCtrl), props)} />
-        <Route path={`${basePath}/opportunities/:group`} render={(props) => renderViewComponent(OpportunitiesView(oppsCtrl), props)} />
+        <Route path={`${basePath}/opportunities/:group/:id([0-9]+)/apply`} render={(props) => renderViewComponent(ApplyView(applyCtrl), props)} />
+        <Route path={`${basePath}/opportunities/:group/:id([0-9]+)`} render={(props) => renderViewComponent(OpportunityView(oppCtrl), props)} />
+        <Route path={`${basePath}/opportunities/:group/:lead(lead)?`} render={(props) => renderViewComponent(OpportunitiesView(oppsCtrl), props)} />
         <Route path={`${basePath}/opportunities`} render={() => renderViewComponent(OpportunitiesView(oppsCtrl))} />
         <Route path={`${basePath}/my-roles`} render={() => renderViewComponent(MyRolesView(myRolesCtrl))} />
         <Route path={`${basePath}/admin`} render={() => renderViewComponent(AdminView(adminCtrl))} />

+ 130 - 273
pioneer/packages/joy-roles/src/tabs/Admin.controller.tsx

@@ -1,14 +1,14 @@
-// @ts-nocheck
 import React, { useState } from 'react';
 import { Link } from 'react-router-dom';
 import { formatBalance } from '@polkadot/util';
 
 import { ApiPromise } from '@polkadot/api';
+import { GenericAccountId, Option, Text, Vec, u32, u128 } from '@polkadot/types';
 import { Balance } from '@polkadot/types/interfaces';
-import { GenericAccountId, Option, u32, u64, u128, Set, Text, Vec } from '@polkadot/types';
 
 import { SingleLinkedMapEntry, Controller, View } from '@polkadot/joy-utils/index';
 import { MyAccountProvider, useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
+import { SubmittableExtrinsic } from '@polkadot/api/promise/types';
 
 import {
   Accordion,
@@ -35,11 +35,12 @@ import {
   ApplicationStage,
   ActivateOpeningAt,
   ApplicationRationingPolicy,
-  CurrentBlock, ExactBlock,
+  CurrentBlock,
   Opening,
   OpeningStage,
   StakingPolicy,
-  StakingAmountLimitModeKeys
+  StakingAmountLimitModeKeys,
+  StakingAmountLimitMode
 } from '@joystream/types/hiring';
 
 import {
@@ -49,13 +50,10 @@ import {
 
 import { Stake, StakeId } from '@joystream/types/stake';
 
-import {
-  GenericJoyStreamRoleSchema
-} from '@joystream/types/hiring/schemas/role.schema.typings';
 import {
   CuratorApplication, CuratorApplicationId,
   CuratorOpening,
-  OpeningPolicyCommitment, IOpeningPolicyCommitment
+  IOpeningPolicyCommitment, CuratorOpeningId
 } from '@joystream/types/content-working-group';
 
 import {
@@ -68,7 +66,7 @@ import {
   openingDescription
 } from '../openingStateMarkup';
 
-import { Add, Sort, Sum, Zero } from '../balances';
+import { Add, Zero } from '../balances';
 
 type ids = {
   curatorId: number;
@@ -92,6 +90,18 @@ type opening = ids & {
   classification: OpeningStageClassification;
 }
 
+// Only max_review_period_length is not optional, so other fields can be "undefined"
+type policyDescriptor = Pick<IOpeningPolicyCommitment, 'max_review_period_length'> & Partial<IOpeningPolicyCommitment>;
+
+type stakingFieldName = 'application_staking_policy' | 'role_staking_policy';
+
+type openingDescriptor = {
+  title: string;
+  start: ActivateOpeningAt;
+  policy: policyDescriptor;
+  text: Text;
+}
+
 type State = {
   openings: Map<number, opening>;
   currentDescriptor: openingDescriptor;
@@ -143,6 +153,27 @@ function newHRT (title: string): Text {
   );
 }
 
+const createRationingPolicyOpt = (maxApplicants: number) =>
+  new Option<ApplicationRationingPolicy>(
+    ApplicationRationingPolicy,
+    new ApplicationRationingPolicy({
+      max_active_applicants: new u32(maxApplicants)
+    })
+  );
+const createStakingPolicyOpt = (amount: number, amount_mode: StakingAmountLimitMode): Option<StakingPolicy> =>
+  new Option(
+    StakingPolicy,
+    new StakingPolicy({
+      amount: new u128(amount),
+      amount_mode,
+      crowded_out_unstaking_period_length: new Option('BlockNumber', null),
+      review_period_expired_unstaking_period_length: new Option('BlockNumber', null)
+    })
+  );
+
+const STAKING_MODE_EXACT = new StakingAmountLimitMode(StakingAmountLimitModeKeys.Exact);
+const STAKING_MODE_AT_LEAST = new StakingAmountLimitMode(StakingAmountLimitModeKeys.AtLeast);
+
 const stockOpenings: openingDescriptor[] = [
   {
     title: 'Test config A: no application stake, no role stake, no applicant limit',
@@ -165,13 +196,7 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration C')
   },
@@ -180,19 +205,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration D')
   },
@@ -201,13 +215,7 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      role_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration E')
   },
@@ -216,19 +224,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      role_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration F')
   },
@@ -237,13 +234,7 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration G')
   },
@@ -252,19 +243,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration H')
   },
@@ -273,13 +253,7 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      role_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration I')
   },
@@ -288,19 +262,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      role_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration J')
   },
@@ -309,20 +272,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration K')
   },
@@ -331,26 +282,9 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration L')
   },
@@ -359,20 +293,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration M')
   },
@@ -381,26 +303,9 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration N')
   },
@@ -409,20 +314,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration O')
   },
@@ -437,20 +330,8 @@ const stockOpenings: openingDescriptor[] = [
           max_active_applicants: new u32(10)
         })
       ),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_EXACT),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_AT_LEAST)
     },
     text: newHRT('Test configuration P')
   },
@@ -459,20 +340,8 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration Q')
   },
@@ -481,26 +350,9 @@ const stockOpenings: openingDescriptor[] = [
     start: new ActivateOpeningAt(CurrentBlock),
     policy: {
       max_review_period_length: new u32(99999),
-      application_rationing_policy: new Option<ApplicationRationingPolicy>(
-        ApplicationRationingPolicy,
-        new ApplicationRationingPolicy({
-          max_active_applicants: new u32(10)
-        })
-      ),
-      application_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(100),
-          amount_mode: StakingAmountLimitModeKeys.AtLeast
-        })
-      ),
-      role_staking_policy: new Option<StakingPolicy>(
-        StakingPolicy,
-        new StakingPolicy({
-          amount: new u128(200),
-          amount_mode: StakingAmountLimitModeKeys.Exact
-        })
-      )
+      application_rationing_policy: createRationingPolicyOpt(10),
+      application_staking_policy: createStakingPolicyOpt(100, STAKING_MODE_AT_LEAST),
+      role_staking_policy: createStakingPolicyOpt(200, STAKING_MODE_EXACT)
     },
     text: newHRT('Test configuration R')
   }
@@ -509,35 +361,29 @@ const stockOpenings: openingDescriptor[] = [
 const newEmptyState = (): State => {
   return {
     openings: new Map<number, opening>(),
-    openingDescriptor: stockOpenings[0],
+    currentDescriptor: stockOpenings[0],
     modalOpen: false
   };
 };
 
-// TODO: Make a list of stock openings
-type openingDescriptor = {
-  title: string;
-  start: ActivateOpeningAt;
-  policy: IOpeningPolicyCommitment;
-  text: Text;
-}
-
 export class AdminController extends Controller<State, ITransport> {
   api: ApiPromise
   constructor (transport: ITransport, api: ApiPromise, initialState: State = newEmptyState()) {
     super(transport, initialState);
     this.api = api;
-    this.state.openingDescriptor = stockOpenings[0];
+    this.state.currentDescriptor = stockOpenings[0];
     this.updateState();
   }
 
   newOpening (creatorAddress: string, desc: openingDescriptor) {
     const tx = this.api.tx.contentWorkingGroup.addCuratorOpening(
       desc.start,
-      new OpeningPolicyCommitment(desc.policy),
+      desc.policy,
       desc.text
-    );
+    ) as unknown as SubmittableExtrinsic;
 
+    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
+    // and probably the reason why it always appears as succesful
     tx.signAndSend(creatorAddress, ({ events = [], status }) => {
       if (status.isFinalized) {
         this.updateState();
@@ -553,7 +399,9 @@ export class AdminController extends Controller<State, ITransport> {
   }
 
   startAcceptingApplications (creatorAddress: string, id = 0) {
-    const tx = this.api.tx.contentWorkingGroup.acceptCuratorApplications(new u32(id));
+    const tx = this.api.tx.contentWorkingGroup.acceptCuratorApplications(id);
+    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
+    // and probably the reason why it always appears as succesful
     tx.signAndSend(creatorAddress, ({ events = [], status }) => {
       if (status.isFinalized) {
         this.updateState();
@@ -576,12 +424,14 @@ export class AdminController extends Controller<State, ITransport> {
     }
     const tx = this.api.tx.contentWorkingGroup.applyOnCuratorOpening(
       membershipIds[0],
-      new u32(openingId),
+      openingId,
       new GenericAccountId(creatorAddress),
       new Option(u128, 400),
       new Option(u128, 400),
       new Text('This is my application')
-    );
+    ) as unknown as SubmittableExtrinsic;
+    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
+    // and probably the reason why it always appears as succesful
     tx.signAndSend(creatorAddress, ({ events = [], status }) => {
       if (status.isFinalized) {
         this.updateState();
@@ -597,9 +447,9 @@ export class AdminController extends Controller<State, ITransport> {
   }
 
   beginApplicantReview (creatorAddress: string, openingId: number) {
-    const tx = this.api.tx.contentWorkingGroup.beginCuratorApplicantReview(
-      new u32(openingId)
-    );
+    const tx = this.api.tx.contentWorkingGroup.beginCuratorApplicantReview(openingId);
+    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
+    // and probably the reason why it always appears as succesful
     tx.signAndSend(creatorAddress, ({ events = [], status }) => {
       if (status.isFinalized) {
         this.updateState();
@@ -616,10 +466,12 @@ export class AdminController extends Controller<State, ITransport> {
 
   acceptCuratorApplications (creatorAddress: string, openingId: number, applications: Array<number>) {
     const tx = this.api.tx.contentWorkingGroup.fillCuratorOpening(
-      new u32(openingId),
+      openingId,
       applications,
       null
-    );
+    ) as unknown as SubmittableExtrinsic;
+    // FIXME: That's a bad way to send extrinsic in Pioneer (without "queueExtrinsic" etc.)
+    // and probably the reason why it always appears as succesful
     tx.signAndSend(creatorAddress, ({ events = [], status }) => {
       if (status.isFinalized) {
         this.updateState();
@@ -634,8 +486,8 @@ export class AdminController extends Controller<State, ITransport> {
     });
   }
 
-  protected async profile (id: MemberId): Promise<Profile> {
-    return (await this.api.query.members.memberProfile(id)) as Profile;
+  protected async profile (id: MemberId): Promise<Option<Profile>> {
+    return (await this.api.query.members.memberProfile(id)) as Option<Profile>;
   }
 
   protected async stakeValue (stakeId: StakeId): Promise<Balance> {
@@ -667,14 +519,14 @@ export class AdminController extends Controller<State, ITransport> {
   async updateState () {
     this.state.openings = new Map<number, opening>();
 
-    const nextOpeningId = await this.api.query.contentWorkingGroup.nextCuratorOpeningId() as u64;
+    const nextOpeningId = await this.api.query.contentWorkingGroup.nextCuratorOpeningId() as CuratorOpeningId;
     for (let i = nextOpeningId.toNumber() - 1; i >= 0; i--) {
       const curatorOpening = new SingleLinkedMapEntry<CuratorOpening>(
         CuratorOpening,
         await this.api.query.contentWorkingGroup.curatorOpeningById(i)
       );
 
-      const openingId = curatorOpening.value.getField<u32>('opening_id');
+      const openingId = curatorOpening.value.opening_id;
 
       const baseOpening = new SingleLinkedMapEntry<Opening>(
         Opening,
@@ -683,11 +535,8 @@ export class AdminController extends Controller<State, ITransport> {
         )
       );
 
-      let title = 'unknown (JSON schema invalid)';
-      const hrt = baseOpening.value.parse_human_readable_text();
-      if (typeof hrt === 'object') {
-        title = (hrt).job.title;
-      }
+      const hrt = baseOpening.value.parse_human_readable_text_with_fallback();
+      const title = hrt.job.title;
 
       this.state.openings.set(i, {
         openingId: openingId.toNumber(),
@@ -699,14 +548,14 @@ export class AdminController extends Controller<State, ITransport> {
       });
     }
 
-    const nextAppid = await this.api.query.contentWorkingGroup.nextCuratorApplicationId() as u64;
+    const nextAppid = await this.api.query.contentWorkingGroup.nextCuratorApplicationId() as CuratorApplicationId;
     for (let i = 0; i < nextAppid.toNumber(); i++) {
       const cApplication = new SingleLinkedMapEntry<CuratorApplication>(
         CuratorApplication,
         await this.api.query.contentWorkingGroup.curatorApplicationById(i)
       );
 
-      const appId = cApplication.value.getField<u32>('application_id');
+      const appId = cApplication.value.application_id;
       const baseApplications = new SingleLinkedMapEntry<Application>(
         Application,
         await this.api.query.hiring.applicationById(
@@ -715,16 +564,16 @@ export class AdminController extends Controller<State, ITransport> {
       );
 
       const curatorOpening = this.state.openings.get(
-        cApplication.value.getField<u32>('curator_opening_id').toNumber()
+        cApplication.value.curator_opening_id.toNumber()
       ) as opening;
 
       curatorOpening.applications.push({
         openingId: appId.toNumber(),
         curatorId: i,
-        stage: baseApplications.value.getField<ApplicationStage>('stage'),
-        account: cApplication.value.getField('role_account').toString(),
-        memberId: cApplication.value.getField<u32>('member_id').toNumber(),
-        profile: (await this.profile(cApplication.value.getField<u32>('member_id'))).unwrap(),
+        stage: baseApplications.value.stage,
+        account: cApplication.value.role_account_id.toString(),
+        memberId: cApplication.value.member_id.toNumber(),
+        profile: (await this.profile(cApplication.value.member_id)).unwrap(),
         applicationStake: await this.applicationStake(baseApplications.value),
         roleStake: await this.roleStake(baseApplications.value),
         application: baseApplications.value
@@ -736,7 +585,7 @@ export class AdminController extends Controller<State, ITransport> {
 
   showNewOpeningModal (desc: openingDescriptor) {
     this.state.modalOpen = true;
-    this.state.openingDescriptor = desc;
+    this.state.currentDescriptor = desc;
     this.dispatch();
   }
 
@@ -772,7 +621,7 @@ export const AdminView = View<AdminController, State>(
               <Modal open={state.modalOpen} onClose={() => controller.closeModal()}>
                 <Modal.Content image>
                   <Modal.Description>
-                    <NewOpening desc={state.openingDescriptor} fn={(desc) => controller.newOpening(address, desc)} />
+                    <NewOpening desc={state.currentDescriptor} fn={(desc) => controller.newOpening(address, desc)} />
                   </Modal.Description>
                 </Modal.Content>
               </Modal>
@@ -832,8 +681,8 @@ const NewOpening = (props: NewOpeningProps) => {
 
   const [policy, setPolicy] = useState(props.desc.policy);
 
-  const onChangePolicyField = (fieldName, value) => {
-    const newState = Object.assign({}, policy);
+  const onChangePolicyField = <PolicyKey extends keyof policyDescriptor>(fieldName: PolicyKey, value: policyDescriptor[PolicyKey]) => {
+    const newState = { ...policy };
     newState[fieldName] = value;
     setPolicy(newState);
   };
@@ -863,24 +712,31 @@ const NewOpening = (props: NewOpeningProps) => {
     }
   ];
 
-  const changeStakingMode = (fieldName: string, mode: string, stakeValue: number) => {
-    const value = new Option<StakingPolic>(
-      StakingPolicy,
-      new StakingPolicy({
-        amount: new u128(stakeValue),
-        amount_mode: mode === '' && policy[fieldName].isSome ? policy[fieldName].type : mode
-      })
+  const changeStakingMode = (
+    fieldName: stakingFieldName,
+    mode: StakingAmountLimitModeKeys | '',
+    stakeValue: number
+  ) => {
+    if (mode === '') {
+      const policyField = policy[fieldName];
+      mode = policyField && policyField.isSome
+        ? (policyField.unwrap().amount_mode.type as StakingAmountLimitModeKeys)
+        : StakingAmountLimitModeKeys.Exact; // Default
+    }
+    const value = createStakingPolicyOpt(
+      stakeValue,
+      mode === StakingAmountLimitModeKeys.Exact ? STAKING_MODE_EXACT : STAKING_MODE_AT_LEAST
     );
     onChangePolicyField(fieldName, value);
   };
 
-  const onStakeModeCheckboxChange = (fn: (v: boolean) => void, fieldName: string, checked: boolean, stakeValue: number) => {
+  const onStakeModeCheckboxChange = (fn: (v: boolean) => void, fieldName: stakingFieldName, checked: boolean, stakeValue: number) => {
     fn(checked);
 
     if (checked) {
       changeStakingMode(fieldName, StakingAmountLimitModeKeys.AtLeast, stakeValue);
     } else {
-      onChangePolicyField(fieldName, null);
+      onChangePolicyField(fieldName, undefined);
     }
   };
 
@@ -890,7 +746,8 @@ const NewOpening = (props: NewOpeningProps) => {
     props.fn({
       start: start,
       policy: policy,
-      text: new Text(text)
+      text: new Text(text),
+      title: ''
     });
   };
 
@@ -932,13 +789,13 @@ const NewOpening = (props: NewOpeningProps) => {
               selection
               onChange={(e, { value }: any) => changeStakingMode('application_staking_policy', value, 0)}
               options={stakeLimitOptions}
-              value={policy.application_staking_policy.unwrap().amount_mode.type}
+              value={policy.application_staking_policy?.unwrap().amount_mode.type}
             />
 
             <label>Stake value</label>
             <Input
               type="number"
-              value={policy.application_staking_policy.unwrap().amount.toNumber()}
+              value={policy.application_staking_policy?.unwrap().amount.toNumber()}
               onChange={(e: any, { value }: any) => changeStakingMode('application_staking_policy', '', value)}
             />
           </Message>
@@ -955,13 +812,13 @@ const NewOpening = (props: NewOpeningProps) => {
               selection
               onChange={(e, { value }: any) => changeStakingMode('role_staking_policy', value, 0)}
               options={stakeLimitOptions}
-              value={policy.role_staking_policy.unwrap().amount_mode.type}
+              value={policy.role_staking_policy?.unwrap().amount_mode.type}
             />
 
             <label>Stake value</label>
             <Input
               type="number"
-              value={policy.role_staking_policy.unwrap().amount.toNumber()}
+              value={policy.role_staking_policy?.unwrap().amount.toNumber()}
               onChange={(e: any, { value }: any) => changeStakingMode('role_staking_policy', '', value)}
             />
           </Message>

+ 10 - 10
pioneer/packages/joy-roles/src/tabs/MyRoles.controller.tsx

@@ -10,14 +10,14 @@ import {
 
 type State = {
   applications: OpeningApplication[];
-  currentCurationRoles: ActiveRoleWithCTAs[];
+  currentRoles: ActiveRoleWithCTAs[];
   myAddress: string;
 }
 
 const newEmptyState = (): State => {
   return {
     applications: [],
-    currentCurationRoles: [],
+    currentRoles: [],
     myAddress: ''
   };
 };
@@ -34,18 +34,18 @@ export class MyRolesController extends Controller<State, ITransport> {
   }
 
   protected async updateApplications (myAddress: string) {
-    this.state.applications = await this.transport.openingApplications(myAddress);
+    this.state.applications = await this.transport.openingApplicationsByAddress(myAddress);
     this.dispatch();
   }
 
   protected async updateCurationGroupRoles (myAddress: string) {
-    const roles = await this.transport.myCurationGroupRoles(myAddress);
-    this.state.currentCurationRoles = roles.map(role => ({
+    const roles = await this.transport.myRoles(myAddress);
+    this.state.currentRoles = roles.map(role => ({
       ...role,
       CTAs: [
         {
           title: 'Leave role',
-          callback: (rationale: string) => { this.leaveCurationRole(role, rationale); }
+          callback: (rationale: string) => { this.leaveRole(role, rationale); }
         }
       ]
     })
@@ -53,19 +53,19 @@ export class MyRolesController extends Controller<State, ITransport> {
     this.dispatch();
   }
 
-  leaveCurationRole (role: ActiveRole, rationale: string) {
-    this.transport.leaveCurationRole(this.state.myAddress, role.curatorId.toNumber(), rationale);
+  leaveRole (role: ActiveRole, rationale: string) {
+    this.transport.leaveRole(role.group, this.state.myAddress, role.workerId.toNumber(), rationale);
   }
 
   cancelApplication (application: OpeningApplication) {
-    this.transport.withdrawCuratorApplication(this.state.myAddress, application.id);
+    this.transport.withdrawApplication(application.meta.group, this.state.myAddress, application.id);
   }
 }
 
 export const MyRolesView = View<MyRolesController, State>(
   (state, controller) => (
     <Container className="my-roles">
-      <CurrentRoles currentRoles={state.currentCurationRoles} />
+      <CurrentRoles currentRoles={state.currentRoles} />
       <Applications applications={state.applications} cancelCallback={(a) => controller.cancelApplication(a)} />
     </Container>
   )

+ 12 - 9
pioneer/packages/joy-roles/src/tabs/MyRoles.elements.stories.tsx

@@ -31,6 +31,7 @@ import {
 } from './Opportunities.stories';
 
 import { CuratorId } from '@joystream/types/content-working-group';
+import { WorkingGroups, workerRoleNameByGroup } from '../working_groups';
 
 export default {
   title: 'Roles / Components / My roles tab / Elements',
@@ -45,10 +46,11 @@ export function CurrentRolesFragment () {
   const props: CurrentRolesProps = {
     currentRoles: [
       {
-        curatorId: new CuratorId(1),
-        name: 'Storage provider',
+        workerId: new CuratorId(1),
+        name: workerRoleNameByGroup[WorkingGroups.StorageProviders],
         reward: new u128(321),
         stake: new u128(100),
+        group: WorkingGroups.StorageProviders,
         CTAs: [
           {
             title: 'Unstake',
@@ -57,11 +59,12 @@ export function CurrentRolesFragment () {
         ]
       },
       {
-        curatorId: new CuratorId(1),
+        workerId: new CuratorId(1),
         name: 'Some other role',
         url: 'some URL',
         reward: new u128(321),
         stake: new u128(12343200),
+        group: WorkingGroups.ContentCurators,
         CTAs: [
           {
             title: 'Leave role',
@@ -164,7 +167,7 @@ const permutations: (ApplicationProps & TestProps)[] = [
     id: 1,
     meta: {
       id: '1',
-      group: 'group-name'
+      group: WorkingGroups.ContentCurators
     },
     stage: {
       state: OpeningState.AcceptingApplications,
@@ -184,7 +187,7 @@ const permutations: (ApplicationProps & TestProps)[] = [
     id: 1,
     meta: {
       id: '1',
-      group: 'group-name'
+      group: WorkingGroups.ContentCurators
     },
     stage: {
       state: OpeningState.AcceptingApplications,
@@ -204,7 +207,7 @@ const permutations: (ApplicationProps & TestProps)[] = [
     id: 1,
     meta: {
       id: '1',
-      group: 'group-name'
+      group: WorkingGroups.ContentCurators
     },
     stage: {
       state: OpeningState.InReview,
@@ -226,7 +229,7 @@ const permutations: (ApplicationProps & TestProps)[] = [
     id: 1,
     meta: {
       id: '1',
-      group: 'group-name'
+      group: WorkingGroups.ContentCurators
     },
     stage: {
       state: OpeningState.InReview,
@@ -248,7 +251,7 @@ const permutations: (ApplicationProps & TestProps)[] = [
     id: 1,
     meta: {
       id: '1',
-      group: 'group-name'
+      group: WorkingGroups.ContentCurators
     },
     stage: {
       state: OpeningState.Complete,
@@ -268,7 +271,7 @@ const permutations: (ApplicationProps & TestProps)[] = [
     id: 1,
     meta: {
       id: '1',
-      group: 'group-name'
+      group: WorkingGroups.ContentCurators
     },
     stage: {
       state: OpeningState.Cancelled,

+ 16 - 4
pioneer/packages/joy-roles/src/tabs/MyRoles.tsx

@@ -21,8 +21,6 @@ import { u128 } from '@polkadot/types';
 import { Balance } from '@polkadot/types/interfaces';
 
 import { Loadable } from '@polkadot/joy-utils/index';
-
-import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings';
 import { Opening } from '@joystream/types/hiring';
 
 import {
@@ -35,6 +33,10 @@ import {
 import { CancelledReason, OpeningStageClassification, OpeningState } from '../classifiers';
 import { OpeningMetadata } from '../OpeningMetadata';
 import { CuratorId } from '@joystream/types/content-working-group';
+import { WorkerId } from '@joystream/types/working-group';
+import _ from 'lodash';
+import styled from 'styled-components';
+import { WorkingGroups } from '../working_groups';
 
 type CTACallback = (rationale: string) => void
 
@@ -102,9 +104,10 @@ function RoleName (props: NameAndURL) {
 }
 
 export interface ActiveRole extends NameAndURL {
-  curatorId: CuratorId;
+  workerId: CuratorId | WorkerId;
   reward: Balance;
   stake: Balance;
+  group: WorkingGroups;
 }
 
 export interface ActiveRoleWithCTAs extends ActiveRole {
@@ -379,14 +382,20 @@ function CancelButton (props: ApplicationProps) {
   );
 }
 
+const ApplicationLabel = styled(Label)`
+  margin-left: 1em !important;
+  border: 1px solid #999 !important;
+`;
+
 export function Application (props: ApplicationProps) {
   let countdown = null;
   if (props.stage.state === OpeningState.InReview) {
     countdown = <OpeningBodyReviewInProgress {...props.stage} />;
   }
 
-  const application = props.opening.parse_human_readable_text() as GenericJoyStreamRoleSchema;
+  const application = props.opening.parse_human_readable_text_with_fallback();
   const appState = applicationState(props);
+  const isLeadApplication = props.meta.type?.isOfType('Leader');
 
   let CTA = null;
   if (appState === ApplicationState.Positive && props.stage.state !== OpeningState.Complete) {
@@ -400,6 +409,9 @@ export function Application (props: ApplicationProps) {
         <Label.Detail className="right">
           {openingIcon(props.stage.state)}
           {openingDescription(props.stage.state)}
+          <ApplicationLabel>
+            {_.startCase(props.meta.group) + (isLeadApplication ? ' Lead' : '')}
+          </ApplicationLabel>
         </Label.Detail>
       </Label>
       <Grid columns="equal">

+ 1 - 0
pioneer/packages/joy-roles/src/tabs/Opportunities.controller.tsx

@@ -42,6 +42,7 @@ export const OpportunitiesView = View<OpportunitiesController, State>(
   (state, controller, params) => (
     <OpeningsView
       group={AvailableGroups.includes(params.get('group') as any) ? params.get('group') as WorkingGroups : undefined}
+      lead={!!params.get('lead')}
       openings={state.opportunities}
       block_time_in_seconds={state.blockTime}
       member_id={state.memberId}

+ 2 - 1
pioneer/packages/joy-roles/src/tabs/Opportunities.elements.stories.tsx

@@ -22,6 +22,7 @@ import { OpeningMetadata } from '../OpeningMetadata';
 
 import 'semantic-ui-css/semantic.min.css';
 import '@polkadot/joy-roles/index.sass';
+import { WorkingGroups } from '../working_groups';
 
 export default {
   title: 'Roles / Components / Opportunities groups tab / Elements',
@@ -34,7 +35,7 @@ type TestProps = {
 
 const meta: OpeningMetadata = {
   id: '1',
-  group: 'group-name'
+  group: WorkingGroups.ContentCurators
 };
 
 export function OpeningHeaderByState () {

+ 2 - 1
pioneer/packages/joy-roles/src/tabs/Opportunities.stories.tsx

@@ -26,6 +26,7 @@ import { OpeningMetadata } from '../OpeningMetadata';
 
 import 'semantic-ui-css/semantic.min.css';
 import '@polkadot/joy-roles/index.sass';
+import { WorkingGroups } from '../working_groups';
 
 export default {
   title: 'Roles / Components / Opportunities groups tab',
@@ -148,7 +149,7 @@ export function OpportunitySandbox () {
 
   const meta: OpeningMetadata = {
     id: '1',
-    group: 'group-name'
+    group: WorkingGroups.ContentCurators
   };
 
   return (

+ 44 - 19
pioneer/packages/joy-roles/src/tabs/Opportunities.tsx

@@ -4,7 +4,7 @@ import NumberFormat from 'react-number-format';
 import marked from 'marked';
 import CopyToClipboard from 'react-copy-to-clipboard';
 
-import { Link, useHistory } from 'react-router-dom';
+import { Link, useHistory, useLocation } from 'react-router-dom';
 import {
   Button,
   Card,
@@ -41,7 +41,7 @@ import {
 import { Loadable } from '@polkadot/joy-utils/index';
 import styled from 'styled-components';
 import _ from 'lodash';
-import { WorkingGroups, AvailableGroups } from '../working_groups';
+import { WorkingGroups, AvailableGroups, workerRoleNameByGroup } from '../working_groups';
 
 type OpeningStage = OpeningMetadataProps & {
   stage: OpeningStageClassification;
@@ -476,19 +476,16 @@ type OpeningViewProps = WorkingGroupOpening & BlockTimeProps & MemberIdProps
 export const OpeningView = Loadable<OpeningViewProps>(
   ['opening', 'block_time_in_seconds'],
   props => {
-    const hrt = props.opening.parse_human_readable_text();
-
-    if (typeof hrt === 'undefined' || typeof hrt === 'string') {
-      return null;
-    }
-
-    const text = hrt;
+    const text = props.opening.parse_human_readable_text_with_fallback();
+    const isLeadOpening = props.meta.type?.isOfType('Leader');
 
     return (
       <Container className={'opening ' + openingClass(props.stage.state)}>
         <OpeningTitle>
           {text.job.title}
-          <OpeningLabel>{ _.startCase(props.meta.group) }</OpeningLabel>
+          <OpeningLabel>
+            { _.startCase(props.meta.group) }{ isLeadOpening ? ' Lead' : '' }
+          </OpeningLabel>
         </OpeningTitle>
         <Card fluid className="container">
           <Card.Content className="header">
@@ -527,15 +524,28 @@ export type OpeningsViewProps = MemberIdProps & {
   openings?: Array<WorkingGroupOpening>;
   block_time_in_seconds?: number;
   group?: WorkingGroups;
+  lead?: boolean;
 }
 
 export const OpeningsView = Loadable<OpeningsViewProps>(
   ['openings', 'block_time_in_seconds'],
   props => {
     const history = useHistory();
-    const { group = '' } = props;
-    const onFilterChange: DropdownProps['onChange'] = (e, data) => (
-      data.value !== group && history.push(`/working-groups/opportunities/${data.value}`)
+    const location = useLocation();
+    const basePath = '/working-groups/opportunities';
+    const { group = null, lead = false } = props;
+    const onFilterChange: DropdownProps['onChange'] = (e, data) => {
+      const newPath = data.value || basePath;
+      if (newPath !== location.pathname) { history.push(newPath as string); }
+    };
+    const groupOption = (group: WorkingGroups | null, lead = false) => ({
+      value: `${basePath}${group ? `/${group}` : ''}${lead ? '/lead' : ''}`,
+      text: _.startCase(`${group || 'All opportuniries'}`) + (lead ? ' (Lead)' : '')
+    });
+    // Can assert "props.openings!" because we're using "Loadable" which prevents them from beeing undefined
+    const filteredOpenings = props.openings!.filter(o =>
+      (!group || o.meta.group === group) &&
+      (!group || !o.meta.type || (lead === o.meta.type.isOfType('Leader')))
     );
 
     return (
@@ -544,17 +554,32 @@ export const OpeningsView = Loadable<OpeningsViewProps>(
           <FilterOpportunitiesDropdown
             placeholder="All opportunities"
             options={
-              [{ value: '', text: 'All opportunities' }]
-                .concat(AvailableGroups.map(g => ({ value: g, text: _.startCase(g) })))
+              [groupOption(null, false)]
+                .concat(AvailableGroups.map(g => groupOption(g)))
+                // Currently we filter-out content curators, because they don't use the new working-group module yet
+                .concat(AvailableGroups.filter(g => g !== WorkingGroups.ContentCurators).map(g => groupOption(g, true)))
             }
-            value={group}
+            value={groupOption(group, lead).value}
             onChange={onFilterChange}
             selection
           />
         </FilterOpportunities>
-        {props.openings && props.openings.filter(o => !group || o.meta.group === group).map((opening, key) => (
-          <OpeningView key={key} {...opening} block_time_in_seconds={props.block_time_in_seconds as number} member_id={props.member_id} />
-        ))}
+        { (
+          filteredOpenings.length
+            ? filteredOpenings.map((opening, key) => (
+              <OpeningView
+                key={key}
+                {...opening}
+                block_time_in_seconds={props.block_time_in_seconds as number}
+                member_id={props.member_id} />
+            ))
+            : (
+              <h2>
+                No openings{group ? ` for ${workerRoleNameByGroup[group]}${lead ? ' Lead' : ''} role ` : ' '}
+                are currently available!
+              </h2>
+            )
+        ) }
       </Container>
     );
   }

+ 2 - 22
pioneer/packages/joy-roles/src/tabs/WorkingGroup.controller.tsx

@@ -7,18 +7,14 @@ import { ITransport } from '../transport';
 import {
   ContentCurators,
   WorkingGroupMembership,
-  GroupLeadStatus,
   StorageProviders
 } from './WorkingGroup';
 
-import { WorkingGroups } from '../working_groups';
 import styled from 'styled-components';
 
 type State = {
   contentCurators?: WorkingGroupMembership;
   storageProviders?: WorkingGroupMembership;
-  contentLeadStatus?: GroupLeadStatus;
-  storageLeadStatus?: GroupLeadStatus;
 }
 
 export class WorkingGroupsController extends Controller<State, ITransport> {
@@ -26,8 +22,6 @@ export class WorkingGroupsController extends Controller<State, ITransport> {
     super(transport, {});
     this.getCurationGroup();
     this.getStorageGroup();
-    this.getCuratorLeadStatus();
-    this.getStorageLeadStatus();
   }
 
   getCurationGroup () {
@@ -43,20 +37,6 @@ export class WorkingGroupsController extends Controller<State, ITransport> {
       this.dispatch();
     });
   }
-
-  getCuratorLeadStatus () {
-    this.transport.groupLeadStatus(WorkingGroups.ContentCurators).then((value: GroupLeadStatus) => {
-      this.setState({ contentLeadStatus: value });
-      this.dispatch();
-    });
-  }
-
-  getStorageLeadStatus () {
-    this.transport.groupLeadStatus(WorkingGroups.StorageProviders).then((value: GroupLeadStatus) => {
-      this.setState({ storageLeadStatus: value });
-      this.dispatch();
-    });
-  }
 }
 
 const WorkingGroupsOverview = styled.div`
@@ -71,8 +51,8 @@ const WorkingGroupsOverview = styled.div`
 export const WorkingGroupsView = View<WorkingGroupsController, State>(
   (state) => (
     <WorkingGroupsOverview>
-      <ContentCurators {...state.contentCurators} leadStatus={state.contentLeadStatus}/>
-      <StorageProviders {...state.storageProviders} leadStatus={state.storageLeadStatus}/>
+      <ContentCurators {...state.contentCurators}/>
+      <StorageProviders {...state.storageProviders}/>
     </WorkingGroupsOverview>
   )
 );

+ 1 - 1
pioneer/packages/joy-roles/src/tabs/WorkingGroup.stories.tsx

@@ -75,6 +75,6 @@ export function ContentCuratorsSection () {
   ];
 
   return (
-    <ContentCurators members={members} rolesAvailable={boolean('Roles available', true)} />
+    <ContentCurators workers={members} workerRolesAvailable={boolean('Roles available', true)} />
   );
 }

+ 24 - 13
pioneer/packages/joy-roles/src/tabs/WorkingGroup.tsx

@@ -10,12 +10,14 @@ import styled from 'styled-components';
 import _ from 'lodash';
 
 export type WorkingGroupMembership = {
-  members: GroupMember[];
-  rolesAvailable: boolean;
+  leadStatus: GroupLeadStatus;
+  workers: GroupMember[];
+  workerRolesAvailable: boolean;
+  leadRolesAvailable: boolean;
 }
 
 const NoRolesAvailable = () => (
-  <Message>
+  <Message info>
     <Message.Header>No open roles at the moment</Message.Header>
     <p>The team is full at the moment, but we intend to expand. Check back for open roles soon!</p>
   </Message>
@@ -25,13 +27,14 @@ type JoinRoleProps = {
   group: WorkingGroups;
   title: string;
   description: string;
+  lead?: boolean;
 };
 
-const JoinRole = ({ group, title, description }: JoinRoleProps) => (
+const JoinRole = ({ group, lead = false, title, description }: JoinRoleProps) => (
   <Message positive>
     <Message.Header>{title}</Message.Header>
     <p>{description}</p>
-    <Link to={`/working-groups/opportunities/${group}`}>
+    <Link to={`/working-groups/opportunities/${group}${lead ? '/lead' : ''}`}>
       <Button icon labelPosition="right" color="green" positive>
         Find out more
         <Icon name={'right arrow' as SemanticICONS} />
@@ -66,36 +69,44 @@ type GroupOverviewProps = GroupOverviewOuterProps & {
   customGroupName?: string;
   customJoinTitle?: string;
   customJoinDesc?: string;
+  customBecomeLeadTitle?: string;
+  customBecomeLeadDesc?: string;
 }
 
 const GroupOverview = Loadable<GroupOverviewProps>(
-  ['members', 'leadStatus'],
+  ['workers', 'leadStatus'],
   ({
     group,
     description,
-    members,
+    workers,
     leadStatus,
-    rolesAvailable,
+    workerRolesAvailable,
+    leadRolesAvailable,
     customGroupName,
     customJoinTitle,
-    customJoinDesc
+    customJoinDesc,
+    customBecomeLeadTitle,
+    customBecomeLeadDesc
   }: GroupOverviewProps) => {
     const groupName = customGroupName || _.startCase(group);
     const joinTitle = customJoinTitle || `Join the ${groupName} group!`;
     const joinDesc = customJoinDesc || `There are openings for new ${groupName}. This is a great way to support Joystream!`;
+    const becomeLeadTitle = customBecomeLeadTitle || `Become ${groupName} Lead!`;
+    const becomeLeadDesc = customBecomeLeadDesc || `An opportunity to become ${groupName} Leader is currently available! This is a great way to support Joystream!`;
     return (
       <GroupOverviewSection>
         <h2>{ groupName }</h2>
         <p>{ description }</p>
         <Card.Group>
-          { members!.map((member, key) => (
-            <GroupMemberView key={key} {...member} />
+          { workers!.map((worker, key) => (
+            <GroupMemberView key={key} {...worker} />
           )) }
         </Card.Group>
-        { rolesAvailable
+        { workerRolesAvailable
           ? <JoinRole group={group} title={joinTitle} description={joinDesc} />
           : <NoRolesAvailable /> }
         { leadStatus && <CurrentLead groupName={groupName} {...leadStatus}/> }
+        { leadRolesAvailable && <JoinRole group={group} lead title={becomeLeadTitle} description={becomeLeadDesc} /> }
       </GroupOverviewSection>
     );
   }
@@ -142,7 +153,7 @@ export const CurrentLead = Loadable<CurrentLeadProps>(
     const leadDesc = customLeadDesc || `This role is responsible for hiring ${groupName}.`;
     return (
       <LeadSection>
-        <Message positive>
+        <Message>
           <Message.Header>{ groupName } Lead</Message.Header>
           <p>{ leadDesc }</p>
           {lead

+ 26 - 27
pioneer/packages/joy-roles/src/transport.mock.ts

@@ -25,7 +25,7 @@ import { OpeningState } from './classifiers';
 
 import * as faker from 'faker';
 import { mockProfile, mockStage } from './mocks';
-import { WorkingGroups } from './working_groups';
+import { WorkingGroups, workerRoleNameByGroup } from './working_groups';
 
 export class Transport extends TransportBase implements ITransport {
   protected simulateApiResponse<T> (value: T): Promise<T> {
@@ -50,10 +50,12 @@ export class Transport extends TransportBase implements ITransport {
     });
   }
 
-  curationGroup (): Promise<WorkingGroupMembership> {
+  async curationGroup (): Promise<WorkingGroupMembership> {
     return this.simulateApiResponse<WorkingGroupMembership>({
-      rolesAvailable: true,
-      members: [
+      leadStatus: await this.groupLeadStatus(),
+      workerRolesAvailable: true,
+      leadRolesAvailable: false,
+      workers: [
         {
           memberId: new MemberId(1),
           roleAccount: new GenericAccountId('5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'),
@@ -110,10 +112,12 @@ export class Transport extends TransportBase implements ITransport {
     });
   }
 
-  storageGroup (): Promise<WorkingGroupMembership> {
+  async storageGroup (): Promise<WorkingGroupMembership> {
     return this.simulateApiResponse<WorkingGroupMembership>({
-      rolesAvailable: true,
-      members: [
+      leadStatus: await this.groupLeadStatus(),
+      workerRolesAvailable: true,
+      leadRolesAvailable: true,
+      workers: [
         {
           memberId: new MemberId(1),
           roleAccount: new GenericAccountId('5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'),
@@ -179,7 +183,7 @@ export class Transport extends TransportBase implements ITransport {
           }),
           meta: {
             id: '1',
-            group: 'somegroup'
+            group: WorkingGroups.ContentCurators
           },
           stage: {
             state: OpeningState.AcceptingApplications,
@@ -206,7 +210,8 @@ export class Transport extends TransportBase implements ITransport {
     );
   }
 
-  curationGroupOpening (id: number): Promise<WorkingGroupOpening> {
+  // eslint-disable-next-line @typescript-eslint/require-await
+  async groupOpening (group: WorkingGroups, id: number): Promise<WorkingGroupOpening> {
     return this.simulateApiResponse<WorkingGroupOpening>(
       {
         opening: new Opening({
@@ -259,7 +264,7 @@ export class Transport extends TransportBase implements ITransport {
         }),
         meta: {
           id: '1',
-          group: 'group-name'
+          group: WorkingGroups.ContentCurators
         },
         stage: {
           state: OpeningState.AcceptingApplications,
@@ -286,11 +291,7 @@ export class Transport extends TransportBase implements ITransport {
     );
   }
 
-  async groupOpening (group: WorkingGroups, id: number): Promise<WorkingGroupOpening> {
-    return await this.curationGroupOpening(id);
-  }
-
-  openingApplicationRanks (openingId: number): Promise<Balance[]> {
+  openingApplicationRanks (group: WorkingGroups, openingId: number): Promise<Balance[]> {
     const slots: Balance[] = [];
     for (let i = 0; i < 20; i++) {
       slots.push(new u128((i * 100) + 10 + i + 1));
@@ -340,12 +341,12 @@ export class Transport extends TransportBase implements ITransport {
   }
 
   // eslint-disable-next-line @typescript-eslint/require-await
-  async openingApplications (): Promise<OpeningApplication[]> {
+  async openingApplicationsByAddress (address: string): Promise<OpeningApplication[]> {
     return [{
       id: 1,
       meta: {
         id: '1',
-        group: 'group-name'
+        group: WorkingGroups.ContentCurators
       },
       stage: {
         state: OpeningState.AcceptingApplications,
@@ -409,11 +410,12 @@ export class Transport extends TransportBase implements ITransport {
   }
 
   // eslint-disable-next-line @typescript-eslint/require-await
-  async myCurationGroupRoles (): Promise<ActiveRole[]> {
+  async myRoles (address: string): Promise<ActiveRole[]> {
     return [
       {
-        curatorId: new CuratorId(1),
-        name: 'My curation group role',
+        workerId: new CuratorId(1),
+        name: workerRoleNameByGroup[WorkingGroups.ContentCurators],
+        group: WorkingGroups.ContentCurators,
         url: 'some URL',
         reward: new u128(321),
         stake: new u128(12343200)
@@ -421,12 +423,9 @@ export class Transport extends TransportBase implements ITransport {
     ];
   }
 
-  myStorageGroupRoles (): Subscribable<ActiveRole[]> {
-    return new Observable<ActiveRole[]>(observer => { /* do nothing */ });
-  }
-
   // eslint-disable-next-line @typescript-eslint/require-await
-  async applyToCuratorOpening (
+  async applyToOpening (
+    group: WorkingGroups,
     id: number,
     roleAccountName: string,
     sourceAccount: string,
@@ -436,11 +435,11 @@ export class Transport extends TransportBase implements ITransport {
     return 0;
   }
 
-  leaveCurationRole (sourceAccount: string, id: number, rationale: string) {
+  leaveRole (group: WorkingGroups, sourceAccount: string, id: number, rationale: string) {
     /* do nothing */
   }
 
-  withdrawCuratorApplication (sourceAccount: string, id: number) {
+  withdrawApplication (group: WorkingGroups, sourceAccount: string, id: number) {
     /* do nothing */
   }
 }

+ 171 - 117
pioneer/packages/joy-roles/src/transport.substrate.ts

@@ -1,9 +1,8 @@
-import { Observable } from 'rxjs';
 import { map, switchMap } from 'rxjs/operators';
 
 import ApiPromise from '@polkadot/api/promise';
 import { Balance } from '@polkadot/types/interfaces';
-import { GenericAccountId, Option, u32, u128, Vec } from '@polkadot/types';
+import { GenericAccountId, Option, u128, Vec } from '@polkadot/types';
 import { Constructor } from '@polkadot/types/types';
 import { Moment } from '@polkadot/types/interfaces/runtime';
 import { QueueTxExtrinsicAdd } from '@polkadot/react-components/Status/types';
@@ -30,7 +29,7 @@ import {
   RoleStakeProfile
 } from '@joystream/types/working-group';
 
-import { Application, Opening, OpeningId, ApplicationId } from '@joystream/types/hiring';
+import { Application, Opening, OpeningId, ApplicationId, ActiveApplicationStage } from '@joystream/types/hiring';
 import { Stake, StakeId } from '@joystream/types/stake';
 import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards';
 import { ActorInRole, Profile, MemberId, Role, RoleKeys, ActorId } from '@joystream/types/members';
@@ -48,7 +47,7 @@ import {
   classifyOpeningStakes,
   isApplicationHired
 } from './classifiers';
-import { WorkingGroups, AvailableGroups } from './working_groups';
+import { WorkingGroups, AvailableGroups, workerRoleNameByGroup } from './working_groups';
 import { Sort, Sum, Zero } from './balances';
 import _ from 'lodash';
 
@@ -69,7 +68,14 @@ type WGApiMethodType =
   | 'applicationById'
   | 'nextWorkerId'
   | 'workerById';
-type WGApiMethodsMapping = { [key in WGApiMethodType]: string };
+type WGApiTxMethodType =
+  'applyOnOpening'
+  | 'withdrawApplication'
+  | 'leaveRole';
+type WGApiMethodsMapping = {
+  query: { [key in WGApiMethodType]: string };
+  tx: { [key in WGApiTxMethodType]: string };
+};
 
 type GroupApplication = CuratorApplication | WGApplication;
 type GroupApplicationId = CuratorApplicationId | ApplicationId;
@@ -82,6 +88,7 @@ type GroupLead = Lead | Worker;
 type GroupLeadWithMemberId = {
   lead: GroupLead;
   memberId: MemberId;
+  workerId?: WorkerId; // Only when it's `working-groups` module lead
 }
 
 type WGApiMapping = {
@@ -98,12 +105,19 @@ const workingGroupsApiMapping: WGApiMapping = {
   [WorkingGroups.StorageProviders]: {
     module: 'storageWorkingGroup',
     methods: {
-      nextOpeningId: 'nextOpeningId',
-      openingById: 'openingById',
-      nextApplicationId: 'nextApplicationId',
-      applicationById: 'applicationById',
-      nextWorkerId: 'nextWorkerId',
-      workerById: 'workerById'
+      query: {
+        nextOpeningId: 'nextOpeningId',
+        openingById: 'openingById',
+        nextApplicationId: 'nextApplicationId',
+        applicationById: 'applicationById',
+        nextWorkerId: 'nextWorkerId',
+        workerById: 'workerById'
+      },
+      tx: {
+        applyOnOpening: 'applyOnOpening',
+        withdrawApplication: 'withdrawApplication',
+        leaveRole: 'leaveRole'
+      }
     },
     openingType: WGOpening,
     applicationType: WGApplication,
@@ -112,12 +126,19 @@ const workingGroupsApiMapping: WGApiMapping = {
   [WorkingGroups.ContentCurators]: {
     module: 'contentWorkingGroup',
     methods: {
-      nextOpeningId: 'nextCuratorOpeningId',
-      openingById: 'curatorOpeningById',
-      nextApplicationId: 'nextCuratorApplicationId',
-      applicationById: 'curatorApplicationById',
-      nextWorkerId: 'nextCuratorId',
-      workerById: 'curatorById'
+      query: {
+        nextOpeningId: 'nextCuratorOpeningId',
+        openingById: 'curatorOpeningById',
+        nextApplicationId: 'nextCuratorApplicationId',
+        applicationById: 'curatorApplicationById',
+        nextWorkerId: 'nextCuratorId',
+        workerById: 'curatorById'
+      },
+      tx: {
+        applyOnOpening: 'applyOnCuratorOpening',
+        withdrawApplication: 'withdrawCuratorApplication',
+        leaveRole: 'leaveCuratorRole'
+      }
     },
     openingType: CuratorOpening,
     applicationType: CuratorApplication,
@@ -139,11 +160,18 @@ export class Transport extends TransportBase implements ITransport {
 
   cachedApiMethodByGroup (group: WorkingGroups, method: WGApiMethodType) {
     const apiModule = workingGroupsApiMapping[group].module;
-    const apiMethod = workingGroupsApiMapping[group].methods[method];
+    const apiMethod = workingGroupsApiMapping[group].methods.query[method];
 
     return this.cachedApi.query[apiModule][apiMethod];
   }
 
+  apiExtrinsicByGroup (group: WorkingGroups, method: WGApiTxMethodType) {
+    const apiModule = workingGroupsApiMapping[group].module;
+    const apiMethod = workingGroupsApiMapping[group].methods.tx[method];
+
+    return this.api.tx[apiModule][apiMethod];
+  }
+
   unsubscribe () {
     this.cachedApi.unsubscribe();
   }
@@ -233,13 +261,13 @@ export class Transport extends TransportBase implements ITransport {
       roleAccount,
       memberId,
       profile: profile.unwrap(),
-      title: _.startCase(group).slice(0, -1), // FIXME: Temporary solution (just removes "s" at the end)
+      title: workerRoleNameByGroup[group],
       stake: stakeValue,
       earned: earnedValue
     });
   }
 
-  protected async areAnyGroupRolesOpen (group: WorkingGroups): Promise<boolean> {
+  protected async areGroupRolesOpen (group: WorkingGroups, lead = false): Promise<boolean> {
     const nextId = await this.cachedApiMethodByGroup(group, 'nextOpeningId')() as GroupOpeningId;
 
     // This is chain specfic, but if next id is still 0, it means no openings have been added yet
@@ -253,9 +281,16 @@ export class Transport extends TransportBase implements ITransport {
       await this.cachedApiMethodByGroup(group, 'openingById')()
     );
 
-    for (let i = 0; i < groupOpenings.linked_values.length; i++) {
-      const opening = await this.opening(groupOpenings.linked_values[i].hiring_opening_id.toNumber());
-      if (opening.is_active) {
+    for (const groupOpening of groupOpenings.linked_values) {
+      const opening = await this.opening(groupOpening.hiring_opening_id.toNumber());
+      if (
+        opening.is_active &&
+        (
+          groupOpening instanceof WGOpening
+            ? (lead === groupOpening.opening_type.isOfType('Leader'))
+            : !lead // Lead opening are never available for content working group currently
+        )
+      ) {
         return true;
       }
     }
@@ -263,11 +298,6 @@ export class Transport extends TransportBase implements ITransport {
     return false;
   }
 
-  protected async areAnyCuratorRolesOpen (): Promise<boolean> {
-    // Backward compatibility
-    return this.areAnyGroupRolesOpen(WorkingGroups.ContentCurators);
-  }
-
   protected async currentCuratorLead (): Promise<GroupLeadWithMemberId | null> {
     const optLeadId = (await this.cachedApi.query.contentWorkingGroup.currentLeadId()) as Option<LeadId>;
 
@@ -309,7 +339,8 @@ export class Transport extends TransportBase implements ITransport {
 
     return {
       lead: leadWorker,
-      memberId: leadWorker.member_id
+      memberId: leadWorker.member_id,
+      workerId: leadWorkerId
     };
   }
 
@@ -328,6 +359,7 @@ export class Transport extends TransportBase implements ITransport {
       return {
         lead: {
           memberId: currentLead.memberId,
+          workerId: currentLead.workerId,
           roleAccount: currentLead.lead.role_account_id,
           profile: profile.unwrap(),
           title: _.startCase(group) + ' Lead',
@@ -343,32 +375,35 @@ export class Transport extends TransportBase implements ITransport {
   }
 
   async groupOverview (group: WorkingGroups): Promise<WorkingGroupMembership> {
-    const rolesAvailable = await this.areAnyGroupRolesOpen(group);
+    const workerRolesAvailable = await this.areGroupRolesOpen(group);
+    const leadRolesAvailable = await this.areGroupRolesOpen(group, true);
+    const leadStatus = await this.groupLeadStatus(group);
 
     const nextId = await this.cachedApiMethodByGroup(group, 'nextWorkerId')() as GroupWorkerId;
 
-    // This is chain specfic, but if next id is still 0, it means no curators have been added yet
-    if (nextId.eq(0)) {
-      return {
-        members: [],
-        rolesAvailable
-      };
-    }
-
-    const values = new MultipleLinkedMapEntry<GroupWorkerId, GroupWorker>(
-      ActorId,
-      workingGroupsApiMapping[group].workerType,
-      await this.cachedApiMethodByGroup(group, 'workerById')() as GroupWorker
-    );
+    let workersWithIds: { worker: GroupWorker; id: GroupWorkerId }[] = [];
+    // This is chain specfic, but if next id is still 0, it means no workers have been added yet
+    if (!nextId.eq(0)) {
+      const values = new MultipleLinkedMapEntry<GroupWorkerId, GroupWorker>(
+        ActorId,
+        workingGroupsApiMapping[group].workerType,
+        await this.cachedApiMethodByGroup(group, 'workerById')() as GroupWorker
+      );
 
-    const workers = values.linked_values.filter(value => value.is_active).reverse();
-    const workerIds = values.linked_keys.filter((v, k) => values.linked_values[k].is_active).reverse();
+      workersWithIds = values.linked_values
+        // First bind workers with ids
+        .map((worker, i) => ({ worker, id: values.linked_keys[i] }))
+        // Filter by: active and "not lead"
+        .filter(({ worker, id }) => worker.is_active && (!leadStatus.lead?.workerId || !id.eq(leadStatus.lead.workerId)));
+    }
 
     return {
-      members: await Promise.all(
-        workers.map((worker, k) => this.groupMember(group, workerIds[k], worker))
+      leadStatus,
+      workers: await Promise.all(
+        workersWithIds.map(({ worker, id }) => this.groupMember(group, id, worker))
       ),
-      rolesAvailable
+      workerRolesAvailable,
+      leadRolesAvailable
     };
   }
 
@@ -446,14 +481,8 @@ export class Transport extends TransportBase implements ITransport {
     return output;
   }
 
-  protected async curatorOpeningApplications (curatorOpeningId: number): Promise<WorkingGroupPair<Application, CuratorApplication>[]> {
-    // Backwards compatibility
-    const applications = await this.groupOpeningApplications(WorkingGroups.ContentCurators, curatorOpeningId);
-    return applications as WorkingGroupPair<Application, CuratorApplication>[];
-  }
-
   async groupOpening (group: WorkingGroups, id: number): Promise<WorkingGroupOpening> {
-    const nextId = (await this.cachedApiMethodByGroup(group, 'nextOpeningId')() as u32).toNumber();
+    const nextId = (await this.cachedApiMethodByGroup(group, 'nextOpeningId')() as GroupOpeningId).toNumber();
     if (id < 0 || id >= nextId) {
       throw new Error('invalid id');
     }
@@ -461,10 +490,10 @@ export class Transport extends TransportBase implements ITransport {
     const groupOpening = new SingleLinkedMapEntry<GroupOpening>(
       workingGroupsApiMapping[group].openingType,
       await this.cachedApiMethodByGroup(group, 'openingById')(id)
-    );
+    ).value;
 
     const opening = await this.opening(
-      groupOpening.value.hiring_opening_id.toNumber()
+      groupOpening.hiring_opening_id.toNumber()
     );
 
     const applications = await this.groupOpeningApplications(group, id);
@@ -474,7 +503,8 @@ export class Transport extends TransportBase implements ITransport {
       opening: opening,
       meta: {
         id: id.toString(),
-        group
+        group,
+        type: groupOpening instanceof WGOpening ? groupOpening.opening_type : undefined
       },
       stage: await classifyOpeningStage(this, opening),
       applications: {
@@ -488,11 +518,6 @@ export class Transport extends TransportBase implements ITransport {
     });
   }
 
-  async curationGroupOpening (id: number): Promise<WorkingGroupOpening> {
-    // Backwards compatibility
-    return this.groupOpening(WorkingGroups.ContentCurators, id);
-  }
-
   protected async openingApplicationTotalStake (application: Application): Promise<Balance> {
     const promises = new Array<Promise<Balance>>();
 
@@ -507,13 +532,14 @@ export class Transport extends TransportBase implements ITransport {
     return Sum(await Promise.all(promises));
   }
 
-  async openingApplicationRanks (openingId: number): Promise<Balance[]> {
-    const applications = await this.curatorOpeningApplications(openingId);
+  async openingApplicationRanks (group: WorkingGroups, openingId: number): Promise<Balance[]> {
+    const applications = await this.groupOpeningApplications(group, openingId);
     return Sort(
       (await Promise.all(
-        applications.map(application => this.openingApplicationTotalStake(application.hiringModule))
+        applications
+          .filter(a => a.hiringModule.stage.value instanceof ActiveApplicationStage)
+          .map(application => this.openingApplicationTotalStake(application.hiringModule))
       ))
-        .filter((b) => !b.eq(Zero))
     );
   }
 
@@ -577,11 +603,12 @@ export class Transport extends TransportBase implements ITransport {
   }
 
   protected async myApplicationRank (myApp: Application, applications: Array<Application>): Promise<number> {
+    const activeApplications = applications.filter(app => app.stage.value instanceof ActiveApplicationStage);
     const stakes = await Promise.all(
-      applications.map(app => this.openingApplicationTotalStake(app))
+      activeApplications.map(app => this.openingApplicationTotalStake(app))
     );
 
-    const appvalues = applications.map((app, key) => {
+    const appvalues = activeApplications.map((app, key) => {
       return {
         app: app,
         value: stakes[key]
@@ -601,15 +628,15 @@ export class Transport extends TransportBase implements ITransport {
     return appvalues.findIndex(v => v.app.eq(myApp)) + 1;
   }
 
-  async openingApplications (roleKeyId: string): Promise<OpeningApplication[]> {
-    const curatorApps = new MultipleLinkedMapEntry<CuratorApplicationId, CuratorApplication>(
-      CuratorApplicationId,
-      CuratorApplication,
-      await this.cachedApi.query.contentWorkingGroup.curatorApplicationById()
+  async openingApplicationsByAddressAndGroup (group: WorkingGroups, roleKey: string): Promise<OpeningApplication[]> {
+    const applications = new MultipleLinkedMapEntry<GroupApplicationId, GroupApplication>(
+      ApplicationId,
+      workingGroupsApiMapping[group].applicationType,
+      await this.cachedApiMethodByGroup(group, 'applicationById')()
     );
 
-    const myApps = curatorApps.linked_values.filter(app => app.role_account.eq(roleKeyId));
-    const myAppIds = curatorApps.linked_keys.filter((id, key) => curatorApps.linked_values[key].role_account.eq(roleKeyId));
+    const myApps = applications.linked_values.filter(app => app.role_account_id.eq(roleKey));
+    const myAppIds = applications.linked_keys.filter((id, key) => applications.linked_values[key].role_account_id.eq(roleKey));
 
     const hiringAppPairs = await Promise.all(
       myApps.map(
@@ -628,73 +655,98 @@ export class Transport extends TransportBase implements ITransport {
       hiringApps.map(app => this.applicationStakes(app))
     );
 
-    const wgs = await Promise.all(
-      myApps.map(curatorOpening => {
-        return this.curationGroupOpening(curatorOpening.curator_opening_id.toNumber());
+    const openings = await Promise.all(
+      myApps.map(application => {
+        return this.groupOpening(group, application.opening_id.toNumber());
       })
     );
 
     const allAppsByOpening = (await Promise.all(
-      myApps.map(curatorOpening => {
-        return this.curatorOpeningApplications(curatorOpening.curator_opening_id.toNumber());
+      myApps.map(application => {
+        return this.groupOpeningApplications(group, application.opening_id.toNumber());
       })
     ));
 
     return await Promise.all(
-      wgs.map(async (wg, key) => {
+      openings.map(async (o, key) => {
         return {
           id: myAppIds[key].toNumber(),
           hired: isApplicationHired(hiringApps[key]),
           cancelledReason: classifyApplicationCancellation(hiringApps[key]),
           rank: await this.myApplicationRank(hiringApps[key], allAppsByOpening[key].map(a => a.hiringModule)),
-          capacity: wg.applications.maxNumberOfApplications,
-          stage: wg.stage,
-          opening: wg.opening,
-          meta: wg.meta,
+          capacity: o.applications.maxNumberOfApplications,
+          stage: o.stage,
+          opening: o.opening,
+          meta: o.meta,
           applicationStake: stakes[key].application,
           roleStake: stakes[key].role,
-          review_end_time: wg.stage.review_end_time,
-          review_end_block: wg.stage.review_end_block
+          review_end_time: o.stage.review_end_time,
+          review_end_block: o.stage.review_end_block
         };
       })
     );
   }
 
-  async myCurationGroupRoles (roleKeyId: string): Promise<ActiveRole[]> {
-    const curators = new MultipleLinkedMapEntry<CuratorId, Curator>(
-      CuratorId,
-      Curator,
-      await this.cachedApi.query.contentWorkingGroup.curatorById()
+  // Get opening applications for all groups by address
+  async openingApplicationsByAddress (roleKey: string): Promise<OpeningApplication[]> {
+    let applications: OpeningApplication[] = [];
+    for (const group of AvailableGroups) {
+      applications = applications.concat(await this.openingApplicationsByAddressAndGroup(group, roleKey));
+    }
+
+    return applications;
+  }
+
+  async myRolesByGroup (group: WorkingGroups, roleKeyId: string): Promise<ActiveRole[]> {
+    const workers = new MultipleLinkedMapEntry<GroupWorkerId, GroupWorker>(
+      ActorId,
+      workingGroupsApiMapping[group].workerType,
+      await this.cachedApiMethodByGroup(group, 'workerById')()
     );
 
+    const groupLead = (await this.groupLeadStatus(group)).lead;
+
     return Promise.all(
-      curators
+      workers
         .linked_values
         .toArray()
-        .filter(curator => curator.role_account.eq(roleKeyId) && curator.is_active)
-        .map(async (curator, key) => {
+        // We need to associate worker ids with workers BEFORE filtering the array
+        .map((worker, index) => ({ worker, id: workers.linked_keys[index] }))
+        .filter(({ worker }) => worker.role_account_id.eq(roleKeyId) && worker.is_active)
+        .map(async workerWithId => {
+          const { worker, id } = workerWithId;
+
           let stakeValue: Balance = new u128(0);
-          if (curator.role_stake_profile && curator.role_stake_profile.isSome) {
-            stakeValue = await this.workerStake(curator.role_stake_profile.unwrap());
+          if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
+            stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
           }
 
           let earnedValue: Balance = new u128(0);
-          if (curator.reward_relationship && curator.reward_relationship.isSome) {
-            earnedValue = await this.workerTotalReward(curator.reward_relationship.unwrap());
+          if (worker.reward_relationship && worker.reward_relationship.isSome) {
+            earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
           }
 
           return {
-            curatorId: curators.linked_keys[key],
-            name: 'Content curator',
+            workerId: id,
+            name: (groupLead?.workerId && groupLead.workerId.eq(id))
+              ? _.startCase(group) + ' Lead'
+              : workerRoleNameByGroup[group],
             reward: earnedValue,
-            stake: stakeValue
+            stake: stakeValue,
+            group
           };
         })
     );
   }
 
-  myStorageGroupRoles (): Subscribable<ActiveRole[]> {
-    return new Observable<ActiveRole[]>(observer => { /* do nothing */ });
+  // All groups roles by key
+  async myRoles (roleKey: string): Promise<ActiveRole[]> {
+    let roles: ActiveRole[] = [];
+    for (const group of AvailableGroups) {
+      roles = roles.concat(await this.myRolesByGroup(group, roleKey));
+    }
+
+    return roles;
   }
 
   protected generateRoleAccount (name: string, password = ''): string | null {
@@ -709,7 +761,8 @@ export class Transport extends TransportBase implements ITransport {
     return status.account as string;
   }
 
-  applyToCuratorOpening (
+  applyToOpening (
+    group: WorkingGroups,
     id: number,
     roleAccountName: string,
     sourceAccount: string,
@@ -727,13 +780,14 @@ export class Transport extends TransportBase implements ITransport {
           if (!roleAccount) {
             reject(new Error('failed to create role account'));
           }
-          const tx = this.api.tx.contentWorkingGroup.applyOnCuratorOpening(
-            membershipIds[0],
-            new u32(id),
-            new GenericAccountId(roleAccount as string),
-            roleStake.eq(Zero) ? null : roleStake,
-            appStake.eq(Zero) ? null : appStake,
-            applicationText
+          const tx = this.apiExtrinsicByGroup(group, 'applyOnOpening')(
+            membershipIds[0], // Member id
+            id, // Worker/Curator opening id
+            roleAccount, // Role account
+            // TODO: Will need to be adjusted if AtLeast Zero stakes become possible
+            roleStake.eq(Zero) ? null : roleStake, // Role stake
+            appStake.eq(Zero) ? null : appStake, // Application stake
+            applicationText // Human readable text
           ) as unknown as SubmittableExtrinsic;
 
           const txFailedCb = () => {
@@ -754,8 +808,8 @@ export class Transport extends TransportBase implements ITransport {
     });
   }
 
-  leaveCurationRole (sourceAccount: string, id: number, rationale: string) {
-    const tx = this.api.tx.contentWorkingGroup.leaveCuratorRole(
+  leaveRole (group: WorkingGroups, sourceAccount: string, id: number, rationale: string) {
+    const tx = this.apiExtrinsicByGroup(group, 'leaveRole')(
       id,
       rationale
     ) as unknown as SubmittableExtrinsic;
@@ -766,8 +820,8 @@ export class Transport extends TransportBase implements ITransport {
     });
   }
 
-  withdrawCuratorApplication (sourceAccount: string, id: number) {
-    const tx = this.api.tx.contentWorkingGroup.withdrawCuratorApplication(
+  withdrawApplication (group: WorkingGroups, sourceAccount: string, id: number) {
+    const tx = this.apiExtrinsicByGroup(group, 'withdrawApplication')(
       id
     ) as unknown as SubmittableExtrinsic;
 

+ 10 - 9
pioneer/packages/joy-roles/src/transport.ts

@@ -16,22 +16,23 @@ export interface ITransport {
   storageGroup: () => Promise<WorkingGroupMembership>;
   currentOpportunities: () => Promise<Array<WorkingGroupOpening>>;
   groupOpening: (group: WorkingGroups, id: number) => Promise<WorkingGroupOpening>;
-  curationGroupOpening: (id: number) => Promise<WorkingGroupOpening>;
-  openingApplicationRanks: (openingId: number) => Promise<Balance[]>;
+  openingApplicationRanks: (group: WorkingGroups, openingId: number) => Promise<Balance[]>;
   expectedBlockTime: () => Promise<number>;
   blockHash: (height: number) => Promise<string>;
   blockTimestamp: (height: number) => Promise<Date>;
   transactionFee: () => Promise<Balance>;
   accounts: () => Subscribable<keyPairDetails[]>;
-  openingApplications: (address: string) => Promise<OpeningApplication[]>;
-  myCurationGroupRoles: (address: string) => Promise<ActiveRole[]>;
-  myStorageGroupRoles: () => Subscribable<ActiveRole[]>;
-  applyToCuratorOpening: (id: number,
+  openingApplicationsByAddress: (address: string) => Promise<OpeningApplication[]>;
+  myRoles: (address: string) => Promise<ActiveRole[]>;
+  applyToOpening: (
+    group: WorkingGroups,
+    id: number,
     roleAccountName: string,
     sourceAccount: string,
     appStake: Balance,
     roleStake: Balance,
-    applicationText: string) => Promise<number>;
-  leaveCurationRole: (sourceAccount: string, id: number, rationale: string) => void;
-  withdrawCuratorApplication: (sourceAccount: string, id: number) => void;
+    applicationText: string
+  ) => Promise<number>;
+  leaveRole: (group: WorkingGroups, sourceAccount: string, id: number, rationale: string) => void;
+  withdrawApplication: (group: WorkingGroups, sourceAccount: string, id: number) => void;
 }

+ 5 - 0
pioneer/packages/joy-roles/src/working_groups.ts

@@ -7,3 +7,8 @@ export const AvailableGroups: readonly WorkingGroups[] = [
   WorkingGroups.ContentCurators,
   WorkingGroups.StorageProviders
 ] as const;
+
+export const workerRoleNameByGroup: { [key in WorkingGroups]: string } = {
+  [WorkingGroups.ContentCurators]: 'Content Curator',
+  [WorkingGroups.StorageProviders]: 'Storage Provider'
+};

+ 2 - 1
pioneer/packages/joy-utils/src/View.tsx

@@ -39,11 +39,12 @@ export function View<C extends Controller<S, any>, S> (args: ViewProps<C, S>): V
 
       useEffect(() => {
         controller.subscribe(onUpdate);
+        controller.dispatch(); // Dispatch on first subscription (in case there's was a re-render of the View)
 
         return () => {
           controller.unsubscribe(onUpdate);
         };
-      });
+      }, []);
 
       let context: Params;
       if (typeof props.params !== 'undefined') {

+ 1 - 1
pioneer/packages/react-components/src/AddressCard.tsx

@@ -3,7 +3,7 @@
 // of the Apache-2.0 license. See the LICENSE file for details.
 // import { I18nProps } from '@polkadot/react-components/types';
 
-// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
+// eslint-disable-next-line @typescript-eslint/ban-ts-ignore, @typescript-eslint/ban-ts-comment
 // @ts-ignore This line needed for the styled export... don't ask why
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import BN from 'bn.js';

+ 3 - 3
types/src/common.ts

@@ -1,10 +1,11 @@
-import { Struct, Option, Text, bool, Vec, u16, u32, u64, getTypeRegistry, Enum, Null } from "@polkadot/types";
+import { Struct, Option, Text, bool, Vec, u16, u32, u64, getTypeRegistry, Null } from "@polkadot/types";
 import { BlockNumber, Moment } from '@polkadot/types/interfaces';
 import { Codec } from "@polkadot/types/types";
 // we get 'moment' because it is a dependency of @polkadot/util, via @polkadot/keyring
 import moment from 'moment';
 import { JoyStruct } from './JoyStruct';
 export { JoyStruct } from './JoyStruct';
+import { JoyEnum } from './JoyEnum';
 export { JoyEnum } from './JoyEnum';
 
 // Treat a BTreeSet as a Vec since it is encoded in the same way
@@ -108,12 +109,11 @@ export class InputValidationLengthConstraint extends JoyStruct<InputValidationLe
     }
 }
 
-// TODO: Replace with JoyEnum
 export const WorkingGroupDef = {
   Storage: Null
 } as const;
 export type WorkingGroupKeys = keyof typeof WorkingGroupDef;
-export class WorkingGroup extends Enum.with(WorkingGroupDef) { };
+export class WorkingGroup extends JoyEnum(WorkingGroupDef) { };
 
 export function registerCommonTypes() {
     const typeRegistry = getTypeRegistry();

+ 28 - 2
types/src/hiring/index.ts

@@ -99,7 +99,7 @@ export class ApplicationStage extends Enum {
   constructor(value?: any, index?: number) {
     super(
       {
-        [ApplicationStageKeys.Active]: Null,
+        [ApplicationStageKeys.Active]: ActiveApplicationStage,
         [ApplicationStageKeys.Unstaking]: UnstakingApplicationStage,
         [ApplicationStageKeys.Inactive]: InactiveApplicationStage,
       },
@@ -332,7 +332,23 @@ export class StakingPolicy extends JoyStruct<IStakingPolicy> {
 };
 
 import * as role_schema_json from './schemas/role.schema.json'
-export const schemaValidator: ajv.ValidateFunction = new ajv({ allErrors: true }).compile(role_schema_json)
+export const schemaValidator: ajv.ValidateFunction = new ajv({ allErrors: true }).compile(role_schema_json);
+
+const OpeningHRTFallback: GenericJoyStreamRoleSchema = {
+  version: 1,
+  headline: "Unknown",
+  job: {
+    title: "Unknown",
+    description: "Unknown"
+  },
+  application: {},
+  reward: "Unknown",
+  creator: {
+    membership: {
+      handle: "Unknown"
+    }
+  }
+};
 
 export type IOpening = {
   created: BlockNumber,
@@ -379,6 +395,16 @@ export class Opening extends JoyStruct<IOpening> {
     return str
   }
 
+  parse_human_readable_text_with_fallback(): GenericJoyStreamRoleSchema {
+    const hrt = this.parse_human_readable_text();
+
+    if (typeof hrt !== 'object') {
+      return OpeningHRTFallback;
+    }
+
+    return hrt;
+  }
+
   get created(): BlockNumber {
     return this.getField<BlockNumber>('created')
   }

+ 7 - 11
types/src/working-group/index.ts

@@ -6,6 +6,7 @@ import { MemberId, ActorId } from '../members';
 import { RewardRelationshipId } from '../recurring-rewards';
 import { StakeId } from '../stake';
 import { ApplicationId, OpeningId, ApplicationRationingPolicy, StakingPolicy } from '../hiring';
+import { JoyEnum } from '../JoyEnum';
 
 export class RationaleText extends Bytes { };
 
@@ -248,17 +249,12 @@ export enum OpeningTypeKeys {
   Worker = 'Worker'
 };
 
-export class OpeningType extends Enum {
-  constructor (value?: any, index?: number) {
-    super(
-      {
-        Leader: Null,
-        Worker: Null
-      },
-      value, index
-    );
-  }
-};
+export class OpeningType_Leader extends Null { };
+export class OpeningType_Worker extends Null { };
+export class OpeningType extends JoyEnum({
+  Leader: OpeningType_Leader,
+  Worker: OpeningType_Worker
+} as const) { };
 
 export type IOpening = {
   hiring_opening_id: OpeningId,