Browse Source

Add Leader Opening Proposal

Leszek Wiesner 4 years ago
parent
commit
2dfc2299bb

+ 53 - 3
pioneer/packages/joy-proposals/src/Proposal/Body.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import { Card, Header, Button, Icon, Message } from 'semantic-ui-react';
 import { ProposalType } from '@polkadot/joy-utils/types/proposals';
+import { bytesToString } from '@polkadot/joy-utils/functions/misc';
 import { blake2AsHex } from '@polkadot/util-crypto';
 import styled from 'styled-components';
 import AddressMini from '@polkadot/react-components/AddressMiniJoy';
@@ -9,11 +10,16 @@ import { ProposalId } from '@joystream/types/proposals';
 import { MemberId, Profile } from '@joystream/types/members';
 import ProfilePreview from '@polkadot/joy-utils/MemberProfilePreview';
 import { useTransport, usePromise } from '@polkadot/joy-utils/react/hooks';
-
-import { Option } from '@polkadot/types/';
+import { Option, Bytes } from '@polkadot/types/';
 import { formatBalance } from '@polkadot/util';
 import { PromiseComponent } from '@polkadot/joy-utils/react/components';
 import ReactMarkdown from 'react-markdown';
+import { WorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+import {
+  ActivateOpeningAt,
+  ActivateOpeningAtKeys
+} from '@joystream/types/hiring';
+import { WorkingGroup } from '@joystream/types/common';
 
 type BodyProps = {
   title: string;
@@ -65,6 +71,16 @@ function ProposedMember (props: { memberId?: MemberId | number | null }) {
   );
 }
 
+const ParsedHRT = styled.pre`
+  font-size: 14px;
+  font-weight: normal;
+  background: #eee;
+  border-radius: 0.5rem;
+  padding: 1rem;
+  margin: 0;
+  white-space: pre-wrap;
+`;
+
 // The methods for parsing params by Proposal type.
 // They take the params as array and return { LABEL: VALUE } object.
 const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: string | number | JSX.Element } } = {
@@ -116,7 +132,41 @@ const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: s
     // "Min. service period": params.min_service_period + " blocks",
     // "Startup grace period": params.startup_grace_period + " blocks",
     'Entry request fee': formatBalance(params.entry_request_fee)
-  })
+  }),
+  AddWorkingGroupLeaderOpening: ([{ activate_at, commitment, human_readable_text, working_group }]) => {
+    const workingGroup = new WorkingGroup(working_group);
+    const activateAt = new ActivateOpeningAt(activate_at);
+    const activateAtBlock = activateAt.type === ActivateOpeningAtKeys.ExactBlock ? activateAt.value : null;
+    const OPCommitment = new WorkingGroupOpeningPolicyCommitment(commitment);
+    const {
+      application_staking_policy: aSP,
+      role_staking_policy: rSP,
+      application_rationing_policy: rationingPolicy
+    } = OPCommitment;
+    let HRT = bytesToString(new Bytes(human_readable_text));
+    try { HRT = JSON.stringify(JSON.parse(HRT), undefined, 4); } catch (e) { /* Do nothing */ }
+    return {
+      'Working group': workingGroup.type,
+      'Activate at': `${activateAt.type}${activateAtBlock ? `(${activateAtBlock.toString()})` : ''}`,
+      'Application stake': aSP.isSome ? aSP.unwrap().amount_mode.type + `(${aSP.unwrap().amount})` : 'NONE',
+      'Role stake': rSP.isSome ? rSP.unwrap().amount_mode.type + `(${rSP.unwrap().amount})` : 'NONE',
+      'Max. applications': rationingPolicy.isSome ? rationingPolicy.unwrap().max_active_applicants.toNumber() : 'UNLIMITED',
+      'Terminate unstaking period (role stake)': OPCommitment.terminate_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Exit unstaking period (role stake)': OPCommitment.exit_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      // <required_to_prevent_sneaking>
+      'Terminate unstaking period (appl. stake)': OPCommitment.terminate_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Exit unstaking period (appl. stake)': OPCommitment.exit_role_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. accepted unstaking period (appl. stake)': OPCommitment.fill_opening_successful_applicant_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. failed unstaking period (role stake)': OPCommitment.fill_opening_failed_applicant_role_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Appl. failed unstaking period (appl. stake)': OPCommitment.fill_opening_failed_applicant_application_stake_unstaking_period.unwrapOr(0) + ' blocks',
+      'Crowded out unstaking period (role stake)': ((rSP.isSome && rSP.unwrap().crowded_out_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Review period expierd unstaking period (role stake)': ((rSP.isSome && rSP.unwrap().review_period_expired_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Crowded out unstaking period (appl. stake)': ((aSP.isSome && aSP.unwrap().crowded_out_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      'Review period expierd unstaking period (appl. stake)': ((aSP.isSome && aSP.unwrap().review_period_expired_unstaking_period_length.unwrapOr(0)) || 0) + ' blocks',
+      // </required_to_prevent_sneaking>
+      'Human readable text': <ParsedHRT>{ HRT }</ParsedHRT>
+    };
+  }
 };
 
 const StyledProposalDescription = styled(Card.Description)`

+ 1 - 1
pioneer/packages/joy-proposals/src/Proposal/Votes.tsx

@@ -12,7 +12,7 @@ type VotesProps = {
 
 export default function Votes ({ votes }: VotesProps) {
   if (!votes.votes.length) {
-    return <Header as="h4">No votes has been submitted!</Header>;
+    return <Header as="h4">No votes have been submitted!</Header>;
   }
 
   return (

+ 353 - 0
pioneer/packages/joy-proposals/src/forms/AddWorkingGroupOpeningForm.tsx

@@ -0,0 +1,353 @@
+import React, { useEffect } from 'react';
+import { getFormErrorLabelsProps, FormErrorLabelsProps } from './errorHandling';
+import * as Yup from 'yup';
+import {
+  withProposalFormData,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps,
+  genericFormDefaultOptions
+} from './GenericProposalForm';
+import {
+  GenericWorkingGroupProposalForm,
+  FormValues as WGFormValues,
+  defaultValues as wgFromDefaultValues
+} from './GenericWorkingGroupProposalForm';
+import { FormField, InputFormField, TextareaFormField } from './FormFields';
+import { withFormContainer } from './FormContainer';
+import './forms.css';
+import { ActivateOpeningAtKey, ActivateOpeningAtDef, StakingAmountLimitModeKeys, IApplicationRationingPolicy, IStakingPolicy } from '@joystream/types/hiring';
+import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings';
+import { Dropdown, Grid, Message, Checkbox } from 'semantic-ui-react';
+import { formatBalance } from '@polkadot/util';
+import _ from 'lodash';
+import { IWorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group';
+import { IAddOpeningParameters } from '@joystream/types/proposals';
+import { WorkingGroupKeys } from '@joystream/types/common';
+import { BlockNumber } from '@polkadot/types/interfaces';
+import { withCalls } from '@polkadot/react-api';
+import { SimplifiedTypeInterface } from '@polkadot/joy-utils/types/common';
+import Validation from '../validationSchema';
+
+type FormValues = WGFormValues & {
+  activateAt: ActivateOpeningAtKey;
+  activateAtBlock: string;
+  maxReviewPeriodLength: string;
+  applicationsLimited: boolean;
+  maxApplications: string;
+  applicationStakeRequired: boolean;
+  applicationStakeMode: StakingAmountLimitModeKeys;
+  applicationStakeValue: string;
+  roleStakeRequired: boolean;
+  roleStakeMode: StakingAmountLimitModeKeys;
+  roleStakeValue: string;
+  terminateRoleUnstakingPeriod: string;
+  leaveRoleUnstakingPeriod: string;
+  humanReadableText: string;
+};
+
+const defaultValues: FormValues = {
+  ...wgFromDefaultValues,
+  activateAt: 'CurrentBlock',
+  activateAtBlock: '',
+  maxReviewPeriodLength: '',
+  applicationsLimited: false,
+  maxApplications: '',
+  applicationStakeRequired: false,
+  applicationStakeMode: StakingAmountLimitModeKeys.Exact,
+  applicationStakeValue: '',
+  roleStakeRequired: false,
+  roleStakeMode: StakingAmountLimitModeKeys.Exact,
+  roleStakeValue: '',
+  terminateRoleUnstakingPeriod: '',
+  leaveRoleUnstakingPeriod: '',
+  humanReadableText: ''
+};
+
+const HRTDefault: (memberHandle: string, group: WorkingGroupKeys) => GenericJoyStreamRoleSchema =
+  (memberHandle, group) => ({
+    version: 1,
+    headline: `Looking for ${group} Working Group Leader!`,
+    job: {
+      title: `${group} Working Group Leader`,
+      description: `Become ${group} Working Group Leader! This is a great opportunity to support Joystream!`
+    },
+    application: {
+      sections: [
+        {
+          title: 'About you',
+          questions: [
+            {
+              title: 'Your name',
+              type: 'text'
+            },
+            {
+              title: 'What makes you a good fit for the job?',
+              type: 'text area'
+            }
+          ]
+        }
+      ]
+    },
+    reward: '100 JOY per block',
+    creator: {
+      membership: {
+        handle: memberHandle
+      }
+    }
+  });
+
+type FormAdditionalProps = {}; // Aditional props coming all the way from export component into the inner form.
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps> & {
+  currentBlock?: BlockNumber;
+};
+type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+type StakeFieldsProps = Pick<FormInnerProps, 'values' | 'handleChange' | 'setFieldValue'> & {
+  errorLabelsProps: FormErrorLabelsProps<FormValues>;
+  stakeType: 'role' | 'application';
+};
+const StakeFields: React.FunctionComponent<StakeFieldsProps> = ({
+  values,
+  errorLabelsProps,
+  handleChange,
+  stakeType,
+  setFieldValue
+}) => {
+  return (
+  <>
+    <FormField label={`${_.startCase(stakeType)} stake` }>
+      <Checkbox
+        toggle
+        onChange={(e, data) => { setFieldValue(`${stakeType}StakeRequired`, data.checked); }}
+        label={ `Require ${stakeType} stake` }
+        checked={ stakeType === 'role' ? values.roleStakeRequired : values.applicationStakeRequired }/>
+    </FormField>
+    { (stakeType === 'role' ? values.roleStakeRequired : values.applicationStakeRequired) && (<>
+      <FormField label="Stake mode">
+        <Dropdown
+          onChange={handleChange}
+          name={ `${stakeType}StakeMode` }
+          selection
+          options={[StakingAmountLimitModeKeys.Exact, StakingAmountLimitModeKeys.AtLeast].map(mode => ({ text: mode, value: mode }))}
+          value={ stakeType === 'role' ? values.roleStakeMode : values.applicationStakeMode }
+        />
+      </FormField>
+      <InputFormField
+        label="Stake value"
+        unit={formatBalance.getDefaults().unit}
+        onChange={handleChange}
+        name={ `${stakeType}StakeValue` }
+        error={ stakeType === 'role' ? errorLabelsProps.roleStakeValue : errorLabelsProps.applicationStakeValue}
+        value={ stakeType === 'role' ? values.roleStakeValue : values.applicationStakeValue}
+        placeholder={'ie. 100'}
+      />
+    </>) }
+  </>
+  );
+};
+
+const valuesToAddOpeningParams = (values: FormValues): SimplifiedTypeInterface<IAddOpeningParameters> => {
+  const commitment: SimplifiedTypeInterface<IWorkingGroupOpeningPolicyCommitment> = {
+    max_review_period_length: parseInt(values.maxReviewPeriodLength),
+    terminate_role_stake_unstaking_period: parseInt(values.terminateRoleUnstakingPeriod),
+    exit_role_stake_unstaking_period: parseInt(values.leaveRoleUnstakingPeriod)
+  };
+  if (values.applicationsLimited) {
+    const rationingPolicy: SimplifiedTypeInterface<IApplicationRationingPolicy> = {
+      max_active_applicants: parseInt(values.maxApplications)
+    };
+    commitment.application_rationing_policy = rationingPolicy;
+  }
+  if (values.applicationStakeRequired) {
+    const applicationStakingPolicy: SimplifiedTypeInterface<IStakingPolicy> = {
+      amount: parseInt(values.applicationStakeValue),
+      amount_mode: values.applicationStakeMode
+    };
+    commitment.application_staking_policy = applicationStakingPolicy;
+  }
+  if (values.roleStakeRequired) {
+    const roleStakingPolicy: SimplifiedTypeInterface<IStakingPolicy> = {
+      amount: parseInt(values.roleStakeValue),
+      amount_mode: values.roleStakeMode
+    };
+    commitment.role_staking_policy = roleStakingPolicy;
+  }
+  return {
+    activate_at: { [values.activateAt]: values.activateAt === 'ExactBlock' ? parseInt(values.activateAtBlock) : null },
+    commitment: commitment,
+    human_readable_text: values.humanReadableText,
+    working_group: values.workingGroup
+  };
+};
+
+const AddWorkingGroupOpeningForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, setFieldValue, myMemberId, memberProfile } = props;
+  useEffect(() => {
+    if (memberProfile?.isSome && !touched.humanReadableText) {
+      setFieldValue(
+        'humanReadableText',
+        JSON.stringify(HRTDefault(memberProfile.unwrap().handle.toString(), values.workingGroup), undefined, 4)
+      );
+    }
+  }, [values.workingGroup, memberProfile]);
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+
+  return (
+    <GenericWorkingGroupProposalForm
+      {...props}
+      txMethod="createAddWorkingGroupLeaderOpeningProposal"
+      proposalType="AddWorkingGroupLeaderOpening"
+      submitParams={[
+        myMemberId,
+        values.title,
+        values.rationale,
+        '{STAKE}',
+        valuesToAddOpeningParams(values)
+      ]}
+    >
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <FormField label="Activate opening at">
+              <Dropdown
+                onChange={handleChange}
+                name="activateAt"
+                selection
+                options={Object.keys(ActivateOpeningAtDef).map(wgKey => ({ text: wgKey, value: wgKey }))}
+                value={values.activateAt}
+              />
+            </FormField>
+          </Grid.Column>
+          <Grid.Column>
+            { values.activateAt === 'ExactBlock' && (
+              <InputFormField
+                onChange={handleChange}
+                name="activateAtBlock"
+                error={errorLabelsProps.activateAtBlock}
+                value={values.activateAtBlock}
+                placeholder={'Provide the block number'}
+              />
+            ) }
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      { values.activateAt === 'ExactBlock' && (
+        <Message info>
+          In case <b>ExactBlock</b> is specified, the opening will remain in <i>Waiting to Begin</i> stage (which means it will be visible,
+          but no applicants will be able to apply yet) until current block number will equal the specified number.
+        </Message>
+      ) }
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <InputFormField
+              label="Max. review period"
+              onChange={handleChange}
+              name="maxReviewPeriodLength"
+              error={errorLabelsProps.maxReviewPeriodLength}
+              value={values.maxReviewPeriodLength}
+              placeholder={'ie. 72000'}
+              unit="blocks"
+            />
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="4" doubling stackable verticalAlign="bottom">
+        <Grid.Row>
+          <Grid.Column>
+            <FormField label="Applications limit">
+              <Checkbox
+                toggle
+                onChange={(e, data) => { setFieldValue('applicationsLimited', data.checked); }}
+                label="Limit applications"
+                checked={values.applicationsLimited}/>
+            </FormField>
+            { values.applicationsLimited && (
+              <InputFormField
+                onChange={handleChange}
+                name="maxApplications"
+                error={errorLabelsProps.maxApplications}
+                value={values.maxApplications}
+                placeholder={'Max. number of applications'}
+              />
+            ) }
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="2" stackable style={{ marginBottom: 0 }}>
+        <Grid.Row>
+          <Grid.Column>
+            <StakeFields stakeType="application" {...{ errorLabelsProps, values, handleChange, setFieldValue }}/>
+          </Grid.Column>
+          <Grid.Column>
+            <StakeFields stakeType="role" {...{ errorLabelsProps, values, handleChange, setFieldValue }}/>
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <Grid columns="2" stackable style={{ marginBottom: 0 }}>
+        <Grid.Row>
+          <Grid.Column>
+            <InputFormField
+              onChange={handleChange}
+              name="terminateRoleUnstakingPeriod"
+              error={errorLabelsProps.terminateRoleUnstakingPeriod}
+              value={values.terminateRoleUnstakingPeriod}
+              label={'Terminate role unstaking period'}
+              placeholder={'ie. 14400'}
+              unit="blocks"
+              help={
+                'In case leader role or application is terminated - this will be the unstaking period for the role stake (in blocks).'
+              }
+            />
+          </Grid.Column>
+          <Grid.Column>
+            <InputFormField
+              onChange={handleChange}
+              name="leaveRoleUnstakingPeriod"
+              error={errorLabelsProps.leaveRoleUnstakingPeriod}
+              value={values.leaveRoleUnstakingPeriod}
+              label={'Leave role unstaking period'}
+              placeholder={'ie. 14400'}
+              unit="blocks"
+              help={
+                'In case leader leaves/exits his role - this will be the unstaking period for his role stake (in blocks). ' +
+                'It also applies when user is withdrawing an active leader application.'
+              }
+            />
+          </Grid.Column>
+        </Grid.Row>
+      </Grid>
+      <TextareaFormField
+        label="Opening schema (human_readable_text)"
+        help="JSON schema that describes some characteristics of the opening presented in the UI (headers, content, application form etc.)"
+        onChange={handleChange}
+        name="humanReadableText"
+        placeholder="Paste the JSON schema here..."
+        error={errorLabelsProps.humanReadableText}
+        value={values.humanReadableText}
+        rows={20}
+      />
+    </GenericWorkingGroupProposalForm>
+  );
+};
+
+const FormContainer = withFormContainer<FormContainerProps, FormValues>({
+  mapPropsToValues: (props: FormContainerProps) => ({
+    ...defaultValues,
+    ...(props.initialData || {})
+  }),
+  validationSchema: (props: FormContainerProps) => Yup.object().shape({
+    ...genericFormDefaultOptions.validationSchema,
+    ...Validation.AddWorkingGroupLeaderOpening(props.currentBlock?.toNumber() || 0)
+  }),
+  handleSubmit: genericFormDefaultOptions.handleSubmit,
+  displayName: 'AddWorkingGroupOpeningForm'
+})(AddWorkingGroupOpeningForm);
+
+export default withCalls<ExportComponentProps>(
+  ['derive.chain.bestNumber', { propName: 'currentBlock' }]
+)(
+  withProposalFormData<FormContainerProps, ExportComponentProps>(FormContainer)
+);

+ 31 - 9
pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
 import { FormikProps, WithFormikConfig } from 'formik';
 import { Form, Icon, Button, Message } from 'semantic-ui-react';
 import { getFormErrorLabelsProps } from './errorHandling';
@@ -81,25 +81,42 @@ export const genericFormDefaultOptions: GenericFormDefaultOptions = {
 export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps> = props => {
   const {
     handleChange,
+    handleSubmit,
     errors,
     isSubmitting,
+    isValidating,
+    isValid,
     touched,
-    handleSubmit,
+    submitForm,
     children,
     handleReset,
     values,
     txMethod,
     submitParams,
-    isValid,
     setSubmitting,
     history,
     balances_totalIssuance,
     proposalType
   } = props;
   const errorLabelsProps = getFormErrorLabelsProps<GenericFormValues>(errors, touched);
+  const [afterSubmit, setAfterSubmit] = useState(null as (() => () => void) | null);
+
+  // After-submit effect
+  // (with current version of Formik, there seems to be no other viable way to use ie. for sendTx)
+  useEffect(() => {
+    if (!isValidating && afterSubmit) {
+      if (isValid) {
+        afterSubmit();
+      }
+      setAfterSubmit(null);
+      setSubmitting(false);
+    }
+  }, [isValidating, isValid, afterSubmit]);
 
-  const onSubmit = (sendTx: () => void) => {
-    if (isValid) sendTx();
+  // Replaces standard submit handler (in order to work with TxButton)
+  const onTxButtonClick = (sendTx: () => void) => {
+    submitForm();
+    setAfterSubmit(() => sendTx);
   };
 
   const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
@@ -128,7 +145,12 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
 
   return (
     <div className="Forms">
-      <Form className="proposal-form" onSubmit={handleSubmit}>
+      <Form
+        className="proposal-form"
+        onSubmit={txMethod
+          ? () => { /* Do nothing. Tx button uses custom submit handler - "onTxButtonClick" */ }
+          : handleSubmit
+        }>
         <InputFormField
           label="Title"
           help="The title of your proposal"
@@ -157,15 +179,15 @@ export const GenericProposalForm: React.FunctionComponent<GenericFormInnerProps>
         <div className="form-buttons">
           {txMethod ? (
             <TxButton
-              type="submit"
+              type="button" // Tx button uses custom submit handler - "onTxButtonClick"
               label="Submit proposal"
               icon="paper plane"
-              isDisabled={isSubmitting || !isValid}
+              isDisabled={isSubmitting}
               params={(submitParams || []).map(p => (p === '{STAKE}' ? requiredStake : p))}
               tx={`proposalsCodex.${txMethod}`}
-              onClick={onSubmit}
               txFailedCb={onTxFailed}
               txSuccessCb={onTxSuccess}
+              onClick={onTxButtonClick} // This replaces standard submit
             />
           ) : (
             <Button type="submit" color="blue" loading={isSubmitting}>

+ 84 - 0
pioneer/packages/joy-proposals/src/forms/GenericWorkingGroupProposalForm.tsx

@@ -0,0 +1,84 @@
+import React from 'react';
+import { getFormErrorLabelsProps } from './errorHandling';
+import {
+  GenericProposalForm,
+  GenericFormValues,
+  genericFormDefaultValues,
+  ProposalFormExportProps,
+  ProposalFormContainerProps,
+  ProposalFormInnerProps
+} from './GenericProposalForm';
+import { FormField } from './FormFields';
+import { ProposalType } from '@polkadot/joy-utils/types/proposals';
+import { WorkingGroupKeys, WorkingGroupDef } from '@joystream/types/common';
+import './forms.css';
+import { Dropdown, Message } from 'semantic-ui-react';
+import { usePromise, useTransport } from '@polkadot/joy-utils/react/hooks';
+import { PromiseComponent } from '@polkadot/joy-utils/react/components';
+import { ProfilePreviewFromStruct as MemberPreview } from '@polkadot/joy-utils/MemberProfilePreview';
+
+export type FormValues = GenericFormValues & {
+  workingGroup: WorkingGroupKeys;
+};
+
+export const defaultValues: FormValues = {
+  ...genericFormDefaultValues,
+  workingGroup: 'Storage'
+};
+
+// Aditional props coming all the way from export comonent into the inner form.
+type FormAdditionalProps = {
+  txMethod: string;
+  submitParams: any[];
+  proposalType: ProposalType;
+  showLead?: boolean;
+};
+
+// We don't exactly use "container" and "export" components here, but those types are useful for
+// generiting the right "FormInnerProps"
+type ExportComponentProps = ProposalFormExportProps<FormAdditionalProps, FormValues>;
+type FormContainerProps = ProposalFormContainerProps<ExportComponentProps>;
+export type FormInnerProps = ProposalFormInnerProps<FormContainerProps, FormValues>;
+
+export const GenericWorkingGroupProposalForm: React.FunctionComponent<FormInnerProps> = props => {
+  const { handleChange, errors, touched, values, showLead = true } = props;
+  const transport = useTransport();
+  const [lead, error, loading] = usePromise(
+    () => transport.workingGroups.currentLead(values.workingGroup),
+    null,
+    [values.workingGroup]
+  );
+  const leadRes = { lead, error, loading };
+
+  const errorLabelsProps = getFormErrorLabelsProps<FormValues>(errors, touched);
+  return (
+    <GenericProposalForm {...props}>
+      <FormField
+        error={errorLabelsProps.workingGroup}
+        label="Working group"
+      >
+        <Dropdown
+          name="workingGroup"
+          placeholder="Select the working group"
+          selection
+          options={Object.keys(WorkingGroupDef).map(wgKey => ({ text: wgKey + ' Wroking Group', value: wgKey }))}
+          value={values.workingGroup}
+          onChange={ handleChange }
+        />
+      </FormField>
+      { showLead && (
+        <PromiseComponent message={'Fetching current lead...'} {...leadRes}>
+          <Message info>
+            <Message.Content>
+              <Message.Header>Current {values.workingGroup} Working Group lead:</Message.Header>
+              <div style={{ padding: '0.5rem 0' }}>
+                { leadRes.lead ? <MemberPreview profile={leadRes.lead.profile} /> : 'NONE' }
+              </div>
+            </Message.Content>
+          </Message>
+        </PromiseComponent>
+      ) }
+      { props.children }
+    </GenericProposalForm>
+  );
+};

+ 1 - 1
pioneer/packages/joy-proposals/src/forms/errorHandling.ts

@@ -2,7 +2,7 @@ import { FormikErrors, FormikTouched } from 'formik';
 import { LabelProps } from 'semantic-ui-react';
 
 type FieldErrorLabelProps = LabelProps | null; // This is used for displaying semantic-ui errors
-type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
+export type FormErrorLabelsProps<ValuesT> = { [T in keyof ValuesT]: FieldErrorLabelProps };
 
 // Single form field error state.
 // Takes formik "errors" and "touched" objects and the field name as arguments.

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

@@ -9,3 +9,4 @@ export { default as RuntimeUpgradeForm } from './RuntimeUpgradeForm';
 export { default as SetContentWorkingGroupMintCapForm } from './SetContentWorkingGroupMintCapForm';
 export { default as SetCouncilMintCapForm } from './SetCouncilMintCapForm';
 export { default as SetMaxValidatorCountForm } from './SetMaxValidatorCountForm';
+export { default as AddWorkingGroupOpeningForm } from './AddWorkingGroupOpeningForm';

+ 3 - 1
pioneer/packages/joy-proposals/src/index.tsx

@@ -21,7 +21,8 @@ import {
   SetCouncilParamsForm,
   SetStorageRoleParamsForm,
   SetMaxValidatorCountForm,
-  RuntimeUpgradeForm
+  RuntimeUpgradeForm,
+  AddWorkingGroupOpeningForm
 } from './forms';
 
 interface Props extends AppProps, I18nProps {}
@@ -70,6 +71,7 @@ function App (props: Props): React.ReactElement<Props> {
           <Route exact path={`${basePath}/new/evict-storage-provider`} component={EvictStorageProviderForm} />
           <Route exact path={`${basePath}/new/set-validator-count`} component={SetMaxValidatorCountForm} />
           <Route exact path={`${basePath}/new/set-storage-role-parameters`} component={SetStorageRoleParamsForm} />
+          <Route exact path={`${basePath}/new/add-working-group-leader-opening`} component={AddWorkingGroupOpeningForm} />
           <Route exact path={`${basePath}/active`} component={NotDone} />
           <Route exact path={`${basePath}/finalized`} component={NotDone} />
           <Route exact path={`${basePath}/:id`} component={ProposalFromId} />

+ 105 - 1
pioneer/packages/joy-proposals/src/validationSchema.ts

@@ -1,4 +1,5 @@
 import * as Yup from 'yup';
+import { schemaValidator, ActivateOpeningAtKeys } from '@joystream/types/hiring';
 
 // TODO: If we really need this (currency unit) we can we make "Validation" a functiction that returns an object.
 // We could then "instantialize" it in "withFormContainer" where instead of passing
@@ -70,6 +71,23 @@ const STARTUP_GRACE_PERIOD_MAX = 28800;
 const ENTRY_REQUEST_FEE_MIN = 1;
 const ENTRY_REQUEST_FEE_MAX = 100000;
 
+// Add Working Group Leader Opening Parameters
+// TODO: Discuss the actual values
+const MIN_EXACT_BLOCK_MINUS_CURRENT = 14400 * 5; // ~5 days
+const MAX_EXACT_BLOCK_MINUS_CURRENT = 14400 * 60; // 2 months
+const MAX_REVIEW_PERIOD_LENGTH_MIN = 14400 * 5; // ~5 days
+const MAX_REVIEW_PERIOD_LENGTH_MAX = 14400 * 60; // 2 months
+const MAX_APPLICATIONS_MIN = 1;
+const MAX_APPLICATIONS_MAX = 1000;
+const APPLICATION_STAKE_VALUE_MIN = 1;
+const APPLICATION_STAKE_VALUE_MAX = 1000000;
+const ROLE_STAKE_VALUE_MIN = 1;
+const ROLE_STAKE_VALUE_MAX = 1000000;
+const TERMINATE_ROLE_UNSTAKING_MIN = 0;
+const TERMINATE_ROLE_UNSTAKING_MAX = 14 * 14400; // 14 days
+const LEAVE_ROLE_UNSTAKING_MIN = 0;
+const LEAVE_ROLE_UNSTAKING_MAX = 14 * 14400; // 14 days
+
 function errorMessage (name: string, min?: number | string, max?: number | string, unit?: string): string {
   return `${name} should be at least ${min} and no more than ${max}${unit ? ` ${unit}.` : '.'}`;
 }
@@ -139,8 +157,31 @@ type ValidationType = {
     startup_grace_period: Yup.NumberSchema<number>;
     entry_request_fee: Yup.NumberSchema<number>;
   };
+  AddWorkingGroupLeaderOpening: (currentBlock: number) => {
+    applicationsLimited: Yup.BooleanSchema<boolean>;
+    activateAt: Yup.StringSchema<string>;
+    activateAtBlock: Yup.NumberSchema<number>;
+    maxReviewPeriodLength: Yup.NumberSchema<number>;
+    maxApplications: Yup.NumberSchema<number>;
+    applicationStakeRequired: Yup.BooleanSchema<boolean>;
+    applicationStakeValue: Yup.NumberSchema<number>;
+    roleStakeRequired: Yup.BooleanSchema<boolean>;
+    roleStakeValue: Yup.NumberSchema<number>;
+    terminateRoleUnstakingPeriod: Yup.NumberSchema<number>;
+    leaveRoleUnstakingPeriod: Yup.NumberSchema<number>;
+    humanReadableText: Yup.StringSchema<string>;
+  };
 };
 
+// Helpers for common validation
+function minMaxInt (min: number, max: number, fieldName: string) {
+  return Yup.number()
+    .required(`${fieldName} is required!`)
+    .integer(`${fieldName} must be an integer!`)
+    .min(min, errorMessage(fieldName, min, max))
+    .max(max, errorMessage(fieldName, min, max));
+}
+
 const Validation: ValidationType = {
   All: {
     title: Yup.string()
@@ -346,7 +387,70 @@ const Validation: ValidationType = {
         STARTUP_GRACE_PERIOD_MAX,
         errorMessage('The entry request fee', ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT)
       )
-  }
+  },
+  AddWorkingGroupLeaderOpening: (currentBlock: number) => ({
+    activateAt: Yup.string().required(),
+    activateAtBlock: Yup.number()
+      .when('activateAt', {
+        is: ActivateOpeningAtKeys.ExactBlock,
+        then: minMaxInt(
+          MIN_EXACT_BLOCK_MINUS_CURRENT + currentBlock,
+          MAX_EXACT_BLOCK_MINUS_CURRENT + currentBlock,
+          'Exact block'
+        )
+      }),
+    maxReviewPeriodLength: minMaxInt(MAX_REVIEW_PERIOD_LENGTH_MIN, MAX_REVIEW_PERIOD_LENGTH_MAX, 'Max. review period length'),
+    applicationsLimited: Yup.boolean(),
+    maxApplications: Yup.number()
+      .when('applicationsLimited', {
+        is: true,
+        then: minMaxInt(MAX_APPLICATIONS_MIN, MAX_APPLICATIONS_MAX, 'Max. number of applications')
+      }),
+    applicationStakeRequired: Yup.boolean(),
+    applicationStakeValue: Yup.number()
+      .when('applicationStakeRequired', {
+        is: true,
+        then: minMaxInt(APPLICATION_STAKE_VALUE_MIN, APPLICATION_STAKE_VALUE_MAX, 'Application stake value')
+      }),
+    roleStakeRequired: Yup.boolean(),
+    roleStakeValue: Yup.number()
+      .when('roleStakeRequired', {
+        is: true,
+        then: minMaxInt(ROLE_STAKE_VALUE_MIN, ROLE_STAKE_VALUE_MAX, 'Role stake value')
+      }),
+    terminateRoleUnstakingPeriod: minMaxInt(
+      TERMINATE_ROLE_UNSTAKING_MIN,
+      TERMINATE_ROLE_UNSTAKING_MAX,
+      'Terminate role unstaking period'
+    ),
+    leaveRoleUnstakingPeriod: minMaxInt(
+      LEAVE_ROLE_UNSTAKING_MIN,
+      LEAVE_ROLE_UNSTAKING_MAX,
+      'Leave role unstaking period'
+    ),
+    humanReadableText: Yup.string()
+      .required()
+      .test(
+        'schemaIsValid',
+        'Schema validation failed!',
+        function (val) {
+          let schemaObj: any;
+          try {
+            schemaObj = JSON.parse(val);
+          } catch (e) {
+            return this.createError({ message: 'Schema validation failed: Invalid JSON' });
+          }
+          const isValid = schemaValidator(schemaObj);
+          const errors = schemaValidator.errors || [];
+          if (!isValid) {
+            return this.createError({
+              message: 'Schema validation failed: ' + errors.map(e => `${e.message}${e.dataPath && ` (${e.dataPath})`}`).join(', ')
+            });
+          }
+          return true;
+        }
+      )
+  })
 };
 
 export default Validation;

+ 24 - 10
pioneer/packages/joy-utils/src/MemberProfilePreview.tsx

@@ -2,14 +2,17 @@ import React from 'react';
 import { Image } from 'semantic-ui-react';
 import { IdentityIcon } from '@polkadot/react-components';
 import { Link } from 'react-router-dom';
+import { Text } from '@polkadot/types';
+import { AccountId } from '@polkadot/types/interfaces';
+import { MemberId, Profile } from '@joystream/types/members';
 import styled from 'styled-components';
 
 type ProfileItemProps = {
-  avatar_uri: string;
-  root_account: string;
-  handle: string;
+  avatar_uri: string | Text;
+  root_account: string | AccountId;
+  handle: string | Text;
   link?: boolean;
-  id?: number;
+  id?: number | MemberId;
 };
 
 const StyledProfilePreview = styled.div`
@@ -41,21 +44,32 @@ const DetailsID = styled.div`
 export default function ProfilePreview ({ id, avatar_uri, root_account, handle, link = false }: ProfileItemProps) {
   const Preview = (
     <StyledProfilePreview>
-      {avatar_uri ? (
-        <Image src={avatar_uri} avatar floated="left" />
+      {avatar_uri.toString() ? (
+        <Image src={avatar_uri.toString()} avatar floated="left" />
       ) : (
-        <IdentityIcon className="image" value={root_account} size={40} />
+        <IdentityIcon className="image" value={root_account.toString()} size={40} />
       )}
       <Details>
-        <DetailsHandle>{handle}</DetailsHandle>
-        { id !== undefined && <DetailsID>ID: {id}</DetailsID> }
+        <DetailsHandle>{handle.toString()}</DetailsHandle>
+        { id !== undefined && <DetailsID>ID: {id.toString()}</DetailsID> }
       </Details>
     </StyledProfilePreview>
   );
 
   if (link) {
-    return <Link to={ `/members/${handle}` }>{ Preview }</Link>;
+    return <Link to={ `/members/${handle.toString()}` }>{ Preview }</Link>;
   }
 
   return Preview;
 }
+
+type ProfilePreviewFromStructProps = {
+  profile: Profile;
+  link?: boolean;
+  id?: number | MemberId;
+};
+
+export function ProfilePreviewFromStruct ({ profile, link, id }: ProfilePreviewFromStructProps) {
+  const { avatar_uri, root_account, handle } = profile;
+  return <ProfilePreview {...{ avatar_uri, root_account, handle, link, id }} />;
+}

+ 2 - 2
pioneer/packages/joy-utils/src/MyAccount.tsx

@@ -29,7 +29,7 @@ export type MyAccountProps = MyAddressProps & {
   memberIdsByControllerAccountId?: Vec<MemberId>;
   myMemberIdChecked?: boolean;
   iAmMember?: boolean;
-  memberProfile?: Option<any>;
+  memberProfile?: Option<Profile>;
 
   // Content Working Group
   curatorEntries?: any; // entire linked_map: CuratorId => Curator
@@ -134,7 +134,7 @@ function withMyRoles<P extends MyAccountProps> (Component: React.ComponentType<P
     const myCuratorIds: Array<CuratorId> = [];
 
     if (iAmMember && memberProfile && memberProfile.isSome) {
-      const profile = memberProfile.unwrap() as Profile;
+      const profile = memberProfile.unwrap();
       profile.roles.forEach(role => {
         if (role.isContentLead) {
           myContentLeadId = role.actor_id;

+ 57 - 1
pioneer/packages/joy-utils/src/consts/proposals.ts

@@ -1,6 +1,6 @@
 import { ProposalType, ProposalMeta } from '../types/proposals';
 
-const metadata: { [k in ProposalType]: ProposalMeta } = {
+export const metadata: { [k in ProposalType]: ProposalMeta } = {
   EvictStorageProvider: {
     description: 'Evicting Storage Provider Proposal',
     category: 'Storage',
@@ -81,7 +81,63 @@ const metadata: { [k in ProposalType]: ProposalMeta } = {
     approvalThreshold: 100,
     slashingQuorum: 60,
     slashingThreshold: 80
+  },
+  AddWorkingGroupLeaderOpening: {
+    description: 'Add Working Group Leader Opening Proposal',
+    category: 'Other',
+    stake: 100000,
+    approvalQuorum: 60,
+    approvalThreshold: 80,
+    slashingQuorum: 60,
+    slashingThreshold: 80
   }
 };
 
+type ProposalsApiMethodNames = {
+  votingPeriod: string;
+  gracePeriod: string;
+}
+export const apiMethods: { [k in ProposalType]: ProposalsApiMethodNames } = {
+  EvictStorageProvider: {
+    votingPeriod: 'evictStorageProviderProposalVotingPeriod',
+    gracePeriod: 'evictStorageProviderProposalPeriod'
+  },
+  Text: {
+    votingPeriod: 'textProposalVotingPeriod',
+    gracePeriod: 'textProposalGracePeriod'
+  },
+  SetStorageRoleParameters: {
+    votingPeriod: 'setStorageRoleParametersProposalVotingPeriod',
+    gracePeriod: 'setStorageRoleParametersProposalGracePeriod'
+  },
+  SetValidatorCount: {
+    votingPeriod: 'setValidatorCountProposalVotingPeriod',
+    gracePeriod: 'setValidatorCountProposalGracePeriod'
+  },
+  SetLead: {
+    votingPeriod: 'setLeadProposalVotingPeriod',
+    gracePeriod: 'setLeadProposalGracePeriod'
+  },
+  SetContentWorkingGroupMintCapacity: {
+    votingPeriod: 'setContentWorkingGroupMintCapacityProposalVotingPeriod',
+    gracePeriod: 'setContentWorkingGroupMintCapacityProposalGracePeriod'
+  },
+  Spending: {
+    votingPeriod: 'spendingProposalVotingPeriod',
+    gracePeriod: 'spendingProposalGracePeriod'
+  },
+  SetElectionParameters: {
+    votingPeriod: 'setElectionParametersProposalVotingPeriod',
+    gracePeriod: 'setElectionParametersProposalGracePeriod'
+  },
+  RuntimeUpgrade: {
+    votingPeriod: 'runtimeUpgradeProposalVotingPeriod',
+    gracePeriod: 'runtimeUpgradeProposalGracePeriod'
+  },
+  AddWorkingGroupLeaderOpening: {
+    votingPeriod: 'addWorkingGroupOpeningProposalVotingPeriod',
+    gracePeriod: 'addWorkingGroupOpeningProposalGracePeriod'
+  }
+} as const;
+
 export default metadata;

+ 4 - 0
pioneer/packages/joy-utils/src/consts/workingGroups.ts

@@ -0,0 +1,4 @@
+import { WorkingGroupKeys } from '@joystream/types/common';
+export const apiModuleByGroup: { [k in WorkingGroupKeys]: string } = {
+  Storage: 'storageWorkingGroup'
+};

+ 4 - 2
pioneer/packages/joy-utils/src/react/hooks/usePromise.tsx

@@ -1,6 +1,8 @@
 import { useState, useEffect, useCallback } from 'react';
 
-export default function usePromise<T> (promise: () => Promise<T>, defaultValue: T): [T, any, boolean, () => Promise<void|null>] {
+export type UsePromiseReturnValues<T> = [T, any, boolean, () => Promise<void|null>];
+
+export default function usePromise<T> (promise: () => Promise<T>, defaultValue: T, dependsOn: any[] = []): UsePromiseReturnValues<T> {
   const [state, setState] = useState<{
     value: T;
     error: any;
@@ -19,7 +21,7 @@ export default function usePromise<T> (promise: () => Promise<T>, defaultValue:
     return () => {
       isSubscribed = false;
     };
-  }, []);
+  }, dependsOn);
 
   const { value, error, isPending } = state;
   return [value, error, isPending, execute];

+ 3 - 0
pioneer/packages/joy-utils/src/transport/index.ts

@@ -6,6 +6,7 @@ import MembersTransport from './members';
 import CouncilTransport from './council';
 import StorageProvidersTransport from './storageProviders';
 import ValidatorsTransport from './validators';
+import WorkingGroupsTransport from './workingGroups';
 
 export default class Transport {
   protected api: ApiPromise;
@@ -17,6 +18,7 @@ export default class Transport {
   public contentWorkingGroup: ContentWorkingGroupTransport;
   public storageProviders: StorageProvidersTransport;
   public validators: ValidatorsTransport;
+  public workingGroups: WorkingGroupsTransport;
 
   constructor (api: ApiPromise) {
     this.api = api;
@@ -27,5 +29,6 @@ export default class Transport {
     this.council = new CouncilTransport(api, this.members, this.chain);
     this.contentWorkingGroup = new ContentWorkingGroupTransport(api, this.members);
     this.proposals = new ProposalsTransport(api, this.members, this.chain, this.council);
+    this.workingGroups = new WorkingGroupsTransport(api, this.members);
   }
 }

+ 5 - 0
pioneer/packages/joy-utils/src/transport/members.ts

@@ -7,6 +7,11 @@ export default class MembersTransport extends BaseTransport {
     return this.members.memberProfile(id) as Promise<Option<Profile>>;
   }
 
+  // Throws if profile not found
+  async expectedMemberProfile (id: MemberId | number): Promise<Profile> {
+    return (await this.memberProfile(id)).unwrap();
+  }
+
   async membersCreated (): Promise<number> {
     return (await this.members.membersCreated() as MemberId).toNumber();
   }

+ 10 - 28
pioneer/packages/joy-utils/src/transport/proposals.ts

@@ -18,9 +18,9 @@ import { MemberId } from '@joystream/types/members';
 import { u32, u64 } from '@polkadot/types/';
 import { BalanceOf } from '@polkadot/types/interfaces';
 
-import { includeKeys, bytesToString } from '../functions/misc';
+import { bytesToString } from '../functions/misc';
 import _ from 'lodash';
-import proposalsConsts from '../consts/proposals';
+import { metadata as proposalsConsts, apiMethods as proposalsApiMethods } from '../consts/proposals';
 import { FIRST_MEMBER_ID } from '../consts/members';
 
 import { ApiPromise } from '@polkadot/api';
@@ -153,33 +153,15 @@ export default class ProposalsTransport extends BaseTransport {
     };
   }
 
-  async fetchProposalMethodsFromCodex (includeKey: string) {
-    const methods = includeKeys(this.proposalsCodex, includeKey);
-    // methods = [proposalTypeVotingPeriod...]
-    return methods.reduce(async (prevProm, method) => {
-      const obj = await prevProm;
-      const period = (await this.proposalsCodex[method]()) as u32;
-      // setValidatorCountProposalVotingPeriod to SetValidatorCount
-      const key = _.words(_.startCase(method))
-        .slice(0, -3)
-        .map((w, i) => (i === 0 ? w.slice(0, 1).toUpperCase() + w.slice(1) : w))
-        .join('') as ProposalType;
-
-      return { ...obj, [`${key}`]: period.toNumber() };
-    }, Promise.resolve({}) as Promise<{ [k in ProposalType]: number }>);
-  }
-
-  async proposalTypesGracePeriod (): Promise<{ [k in ProposalType]: number }> {
-    return this.fetchProposalMethodsFromCodex('GracePeriod');
-  }
-
-  async proposalTypesVotingPeriod (): Promise<{ [k in ProposalType]: number }> {
-    return this.fetchProposalMethodsFromCodex('VotingPeriod');
-  }
-
   async parametersFromProposalType (type: ProposalType) {
-    const votingPeriod = (await this.proposalTypesVotingPeriod())[type];
-    const gracePeriod = (await this.proposalTypesGracePeriod())[type];
+    const { votingPeriod: votingPeriodMethod, gracePeriod: gracePeriodMethod } = proposalsApiMethods[type];
+    // TODO: Remove the fallback after outdated proposals are removed
+    const votingPeriod = this.proposalsCodex[votingPeriodMethod]
+      ? ((await this.proposalsCodex[votingPeriodMethod]()) as u32).toNumber()
+      : 0;
+    const gracePeriod = this.proposalsCodex[gracePeriodMethod]
+      ? ((await this.proposalsCodex[gracePeriodMethod]()) as u32).toNumber()
+      : 0;
     // Currently it's same for all types, but this will change soon
     const cancellationFee = this.cancellationFee();
     return {

+ 47 - 0
pioneer/packages/joy-utils/src/transport/workingGroups.ts

@@ -0,0 +1,47 @@
+import { Option } from '@polkadot/types/';
+import BaseTransport from './base';
+import { ApiPromise } from '@polkadot/api';
+import MembersTransport from './members';
+import { SingleLinkedMapEntry } from '../index';
+import { Worker, WorkerId } from '@joystream/types/working-group';
+import { apiModuleByGroup } from '../consts/workingGroups';
+import { WorkingGroupKeys } from '@joystream/types/common';
+import { LeadWithProfile } from '../types/workingGroups';
+
+export default class WorkingGroupsTransport extends BaseTransport {
+  private membersT: MembersTransport;
+
+  constructor (api: ApiPromise, membersTransport: MembersTransport) {
+    super(api);
+    this.membersT = membersTransport;
+  }
+
+  protected queryByGroup (group: WorkingGroupKeys) {
+    const module = apiModuleByGroup[group];
+    return this.api.query[module];
+  }
+
+  public async currentLead (group: WorkingGroupKeys): Promise <LeadWithProfile | null> {
+    const optLeadId = (await this.queryByGroup(group).currentLead()) as Option<WorkerId>;
+
+    if (!optLeadId.isSome) {
+      return null;
+    }
+
+    const leadWorkerId = optLeadId.unwrap();
+    const leadWorkerLink = new SingleLinkedMapEntry(
+      Worker,
+      await this.queryByGroup(group).workerById(leadWorkerId)
+    );
+    const leadWorker = leadWorkerLink.value;
+
+    if (!leadWorker.is_active) {
+      return null;
+    }
+
+    return {
+      worker: leadWorker,
+      profile: await this.membersT.expectedMemberProfile(leadWorker.member_id)
+    };
+  }
+}

+ 1 - 0
pioneer/packages/joy-utils/src/types/common.ts

@@ -0,0 +1 @@
+export type SimplifiedTypeInterface<I> = Partial<{ [k in keyof I]: any }>;

+ 2 - 1
pioneer/packages/joy-utils/src/types/proposals.ts

@@ -12,7 +12,8 @@ export const ProposalTypes = [
   'SetContentWorkingGroupMintCapacity',
   'EvictStorageProvider',
   'SetValidatorCount',
-  'SetStorageRoleParameters'
+  'SetStorageRoleParameters',
+  'AddWorkingGroupLeaderOpening'
 ] as const;
 
 export type ProposalType = typeof ProposalTypes[number];

+ 7 - 0
pioneer/packages/joy-utils/src/types/workingGroups.ts

@@ -0,0 +1,7 @@
+import { Worker } from '@joystream/types/working-group';
+import { Profile } from '@joystream/types/members';
+
+export type LeadWithProfile = {
+  worker: Worker;
+  profile: Profile;
+};

+ 13 - 11
types/src/hiring/index.ts

@@ -14,16 +14,18 @@ export class OpeningId extends u64 { };
 export class CurrentBlock extends Null { };
 export class ExactBlock extends u32 { }; // BlockNumber
 
-export class ActivateOpeningAt extends Enum {
-  constructor(value?: any, index?: number) {
-    super(
-      {
-        CurrentBlock,
-        ExactBlock,
-      },
-      value, index);
-  }
-}
+
+export const ActivateOpeningAtDef = {
+  CurrentBlock,
+  ExactBlock,
+} as const;
+export const ActivateOpeningAtKeys: { [k in keyof typeof ActivateOpeningAtDef]: k } = {
+  CurrentBlock: 'CurrentBlock',
+  ExactBlock: 'ExactBlock'
+} as const;
+export type ActivateOpeningAtKey = keyof typeof ActivateOpeningAtDef;
+// TODO: Replace with JoyEnum
+export class ActivateOpeningAt extends Enum.with(ActivateOpeningAtDef) { }
 
 export enum ApplicationDeactivationCauseKeys {
   External = 'External',
@@ -345,7 +347,7 @@ export class StakingPolicy extends JoyStruct<IStakingPolicy> {
 };
 
 import * as role_schema_json from './schemas/role.schema.json'
-const schemaValidator = new ajv({ allErrors: true }).compile(role_schema_json)
+export const schemaValidator = new ajv({ allErrors: true }).compile(role_schema_json)
 
 export type IOpening = {
   created: BlockNumber,