Browse Source

Merge branch 'nicaea' into update-storage-node

Mokhtar Naamani 4 years ago
parent
commit
53a80a95a4
62 changed files with 2571 additions and 1532 deletions
  1. 39 0
      .github/workflows/joystream-cli.yml
  2. 6 2
      .github/workflows/pioneer.yml
  3. 1 1
      Cargo.lock
  4. 17 16
      cli/src/Api.ts
  5. 3 8
      cli/src/Types.ts
  6. 3 3
      cli/src/base/WorkingGroupsCommandBase.ts
  7. 1 1
      cli/src/commands/council/info.ts
  8. 2 2
      cli/src/commands/working-groups/overview.ts
  9. 20 34
      pioneer/packages/joy-forum/src/CategoryList.tsx
  10. 71 70
      pioneer/packages/joy-forum/src/EditReply.tsx
  11. 167 0
      pioneer/packages/joy-forum/src/ForumRoot.tsx
  12. 17 0
      pioneer/packages/joy-forum/src/LegacyPagingRedirect.tsx
  13. 107 38
      pioneer/packages/joy-forum/src/ViewReply.tsx
  14. 243 70
      pioneer/packages/joy-forum/src/ViewThread.tsx
  15. 19 39
      pioneer/packages/joy-forum/src/index.tsx
  16. 108 12
      pioneer/packages/joy-forum/src/utils.tsx
  17. 19 11
      pioneer/packages/joy-members/src/MemberPreview.tsx
  18. 35 25
      pioneer/packages/joy-roles/src/transport.substrate.ts
  19. 5 0
      pioneer/packages/joy-utils/src/functions/date.ts
  20. 1 4
      pioneer/packages/react-components/src/styles/index.ts
  21. 1 1
      runtime-modules/common/src/lib.rs
  22. 26 0
      runtime-modules/common/src/origin.rs
  23. 0 5
      runtime-modules/common/src/origin_validator.rs
  24. 7 1
      runtime-modules/hiring/src/hiring/staking_policy.rs
  25. 3 22
      runtime-modules/proposals/codex/src/lib.rs
  26. 1 1
      runtime-modules/proposals/codex/src/tests/mock.rs
  27. 1 1
      runtime-modules/proposals/discussion/src/lib.rs
  28. 1 1
      runtime-modules/proposals/engine/src/lib.rs
  29. 1 1
      runtime-modules/proposals/engine/src/tests/mock/mod.rs
  30. 3 3
      runtime-modules/recurring-reward/src/lib.rs
  31. 6 1
      runtime-modules/service-discovery/src/mock.rs
  32. 1 1
      runtime-modules/storage/src/data_directory.rs
  33. 18 6
      runtime-modules/storage/src/tests/data_object_type_registry.rs
  34. 7 2
      runtime-modules/storage/src/tests/mock.rs
  35. 19 6
      runtime-modules/working-group/src/errors.rs
  36. 311 319
      runtime-modules/working-group/src/lib.rs
  37. 304 185
      runtime-modules/working-group/src/tests/fixtures.rs
  38. 200 0
      runtime-modules/working-group/src/tests/hiring_workflow.rs
  39. 55 3
      runtime-modules/working-group/src/tests/mock.rs
  40. 262 322
      runtime-modules/working-group/src/tests/mod.rs
  41. 67 50
      runtime-modules/working-group/src/types.rs
  42. 1 2
      runtime/Cargo.toml
  43. 141 0
      runtime/src/integration/content_working_group.rs
  44. 2 0
      runtime/src/integration/mod.rs
  45. 2 2
      runtime/src/integration/proposals/council_origin_validator.rs
  46. 2 2
      runtime/src/integration/proposals/membership_origin_validator.rs
  47. 1 1
      runtime/src/integration/storage.rs
  48. 49 0
      runtime/src/integration/working_group.rs
  49. 17 139
      runtime/src/lib.rs
  50. 1 3
      scripts/run-test-chain.sh
  51. 1 1
      tests/network-tests/src/constantinople/tests/impl/electingCouncil.ts
  52. 1 1
      tests/network-tests/src/constantinople/tests/proposals/impl/storageRoleParametersProposal.ts
  53. 5 5
      tests/network-tests/src/constantinople/utils/apiWrapper.ts
  54. 1 1
      tests/network-tests/src/constantinople/utils/utils.ts
  55. 12 1
      types/.gitignore
  56. 6 0
      types/.npmignore
  57. 10 9
      types/package.json
  58. 19 0
      types/src/common.ts
  59. 16 1
      types/src/content-working-group/index.ts
  60. 89 91
      types/src/working-group/index.ts
  61. 11 6
      types/tsconfig.json
  62. 6 0
      yarn.lock

+ 39 - 0
.github/workflows/joystream-cli.yml

@@ -0,0 +1,39 @@
+name: joystream-cli
+on: [pull_request, push]
+
+jobs:
+  cli_build_ubuntu:
+    name: Ubuntu Build
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [12.x]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: build
+      run: |
+        yarn install --frozen-lockfile
+        yarn madge --circular types/
+        yarn workspace joystream-cli build
+
+  cli_build_osx:
+    name: MacOS Build
+    runs-on: macos-latest
+    strategy:
+      matrix:
+        node-version: [12.x]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: build
+      run: |
+        yarn install --frozen-lockfile --network-timeout 120000
+        yarn madge --circular types/
+        yarn workspace joystream-cli build

+ 6 - 2
.github/workflows/pioneer-pr.yml → .github/workflows/pioneer.yml

@@ -17,6 +17,7 @@ jobs:
     - name: build
       run: |
         yarn install --frozen-lockfile
+        yarn madge --circular types/
         yarn workspace pioneer build
 
   pioneer_build_osx:
@@ -33,7 +34,8 @@ jobs:
         node-version: ${{ matrix.node-version }}
     - name: build
       run: |
-        yarn install --frozen-lockfile
+        yarn install --frozen-lockfile --network-timeout 120000
+        yarn madge --circular types/
         yarn workspace pioneer build
 
   pioneer_lint_ubuntu:
@@ -51,6 +53,7 @@ jobs:
     - name: lint
       run: |
         yarn install --frozen-lockfile
+        yarn madge --circular types/
         yarn workspace pioneer lint
 
   pioneer_lint_osx:
@@ -67,5 +70,6 @@ jobs:
         node-version: ${{ matrix.node-version }}
     - name: lint
       run: |
-        yarn install --frozen-lockfile
+        yarn install --frozen-lockfile --network-timeout 120000
+        yarn madge --circular types/
         yarn workspace pioneer lint

+ 1 - 1
Cargo.lock

@@ -1614,7 +1614,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node-runtime"
-version = "6.16.0"
+version = "6.17.0"
 dependencies = [
  "parity-scale-codec",
  "safe-mix",

+ 17 - 16
cli/src/Api.ts

@@ -12,16 +12,15 @@ import {
     AccountSummary,
     CouncilInfoObj, CouncilInfoTuple, createCouncilInfoObj,
     WorkingGroups,
-    GroupLeadWithProfile,
     GroupMember,
 } from './Types';
 import { DerivedFees, DerivedBalances } from '@polkadot/api-derive/types';
 import { CLIError } from '@oclif/errors';
 import ExitCodes from './ExitCodes';
-import { Worker, Lead as WorkerLead, WorkerId, WorkerRoleStakeProfile } from '@joystream/types/lib/working-group';
-import { MemberId, Profile } from '@joystream/types/lib/members';
-import { RewardRelationship, RewardRelationshipId } from '@joystream/types/lib/recurring-rewards';
-import { Stake, StakeId } from '@joystream/types/lib/stake';
+import { Worker, WorkerId, RoleStakeProfile } from '@joystream/types/working-group';
+import { MemberId, Profile } from '@joystream/types/members';
+import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards';
+import { Stake, StakeId } from '@joystream/types/stake';
 import { LinkageResult } from '@polkadot/types/codec/Linkage';
 
 export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
@@ -166,21 +165,23 @@ export default class Api {
         return profile.unwrapOr(null);
     }
 
-    async groupLead (group: WorkingGroups): Promise <GroupLeadWithProfile | null> {
-        const optLead = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerLead>;
+    async groupLead(group: WorkingGroups): Promise<GroupMember | null> {
+        const optLeadId = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerId>;
 
-        if (!optLead.isSome) {
-          return null;
+        if (!optLeadId.isSome) {
+            return null;
         }
 
-        const lead = optLead.unwrap();
-        const profile = await this.memberProfileById(lead.member_id);
+        const leadWorkerId = optLeadId.unwrap();
+        const leadWorker = this.singleLinkageResult<Worker>(
+            await this.workingGroupApiQuery(group).workerById(leadWorkerId) as LinkageResult
+        );
 
-        if (!profile) {
-            throw new Error(`Group lead profile not found! (member id: ${lead.member_id.toNumber()})`);
+        if (!leadWorker.is_active) {
+            return null;
         }
 
-        return { lead, profile };
+        return await this.groupMember(leadWorkerId, leadWorker);
     }
 
     protected async stakeValue (stakeId: StakeId): Promise<Balance> {
@@ -188,7 +189,7 @@ export default class Api {
         return stake.value;
     }
 
-    protected async workerStake (stakeProfile: WorkerRoleStakeProfile): Promise<Balance> {
+    protected async workerStake (stakeProfile: RoleStakeProfile): Promise<Balance> {
         return this.stakeValue(stakeProfile.stake_id);
     }
 
@@ -203,7 +204,7 @@ export default class Api {
         id: WorkerId,
         worker: Worker
       ): Promise<GroupMember> {
-        const roleAccount = worker.role_account;
+        const roleAccount = worker.role_account_id;
         const memberId = worker.member_id;
 
         const profile = await this.memberProfileById(memberId);

+ 3 - 8
cli/src/Types.ts

@@ -1,11 +1,11 @@
 import BN from 'bn.js';
-import { ElectionStage, Seat } from '@joystream/types/lib/council';
+import { ElectionStage, Seat } from '@joystream/types/council';
 import { Option } from '@polkadot/types';
 import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces';
 import { DerivedBalances } from '@polkadot/api-derive/types';
 import { KeyringPair } from '@polkadot/keyring/types';
-import { WorkerId, Lead } from '@joystream/types/lib/working-group';
-import { Profile, MemberId } from '@joystream/types/lib/members';
+import { WorkerId } from '@joystream/types/working-group';
+import { Profile, MemberId } from '@joystream/types/members';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -75,11 +75,6 @@ export const AvailableGroups: readonly WorkingGroups[] = [
 ] as const;
 
 // Compound working group types
-export type GroupLeadWithProfile = {
-    lead: Lead;
-    profile: Profile;
-}
-
 export type GroupMember = {
     workerId: WorkerId;
     memberId: MemberId;

+ 3 - 3
cli/src/base/WorkingGroupsCommandBase.ts

@@ -1,7 +1,7 @@
 import ExitCodes from '../ExitCodes';
 import AccountsCommandBase from './AccountsCommandBase';
 import { flags } from '@oclif/command';
-import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupLeadWithProfile, GroupMember } from '../Types';
+import { WorkingGroups, AvailableGroups, NamedKeyringPair, GroupMember } from '../Types';
 import { CLIError } from '@oclif/errors';
 import inquirer from 'inquirer';
 
@@ -25,11 +25,11 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     };
 
     // Use when lead access is required in given command
-    async getRequiredLead(): Promise<GroupLeadWithProfile> {
+    async getRequiredLead(): Promise<GroupMember> {
         let selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount();
         let lead = await this.getApi().groupLead(this.group);
 
-        if (!lead || lead.lead.role_account_id.toString() !== selectedAccount.address) {
+        if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
             this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied });
         }
 

+ 1 - 1
cli/src/commands/council/info.ts

@@ -1,4 +1,4 @@
-import { ElectionStage } from '@joystream/types/lib/council';
+import { ElectionStage } from '@joystream/types/council';
 import { formatNumber, formatBalance } from '@polkadot/util';
 import { BlockNumber } from '@polkadot/types/interfaces';
 import { CouncilInfoObj, NameValueObj } from '../../Types';

+ 2 - 2
cli/src/commands/working-groups/overview.ts

@@ -16,9 +16,9 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
         displayHeader('Group lead');
         if (lead) {
             displayNameValueTable([
-                { name: 'Member id:', value: lead.lead.member_id.toString() },
+                { name: 'Member id:', value: lead.memberId.toString() },
                 { name: 'Member handle:', value: lead.profile.handle.toString() },
-                { name: 'Role account:', value: lead.lead.role_account_id.toString() },
+                { name: 'Role account:', value: lead.roleAccount.toString() },
             ]);
         }
         else {

+ 20 - 34
pioneer/packages/joy-forum/src/CategoryList.tsx

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
 import { Table, Dropdown, Button, Segment, Label } from 'semantic-ui-react';
-import { History } from 'history';
+import styled from 'styled-components';
 import orderBy from 'lodash/orderBy';
 import BN from 'bn.js';
 
@@ -11,7 +11,7 @@ import { ThreadId } from '@joystream/types/common';
 import { CategoryId, Category, Thread } from '@joystream/types/forum';
 import { ViewThread } from './ViewThread';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
-import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage } from './utils';
+import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage, usePagination } from './utils';
 import Section from '@polkadot/joy-utils/Section';
 import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { withForumCalls } from './calls';
@@ -100,17 +100,19 @@ function CategoryActions (props: CategoryActionsProps) {
 
 type InnerViewCategoryProps = {
   category?: Category;
-  page?: number;
   preview?: boolean;
-  history?: History;
 };
 
 type ViewCategoryProps = InnerViewCategoryProps & {
   id: CategoryId;
 };
 
+const CategoryPreviewRow = styled(Table.Row)`
+  height: 55px;
+`;
+
 function InnerViewCategory (props: InnerViewCategoryProps) {
-  const { history, category, page = 1, preview = false } = props;
+  const { category, preview = false } = props;
 
   if (!category) {
     return <em>Loading...</em>;
@@ -128,7 +130,7 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
 
   if (preview) {
     return (
-      <Table.Row>
+      <CategoryPreviewRow>
         <Table.Cell>
           <Link to={`/forum/categories/${id.toString()}`}>
             {category.archived
@@ -144,19 +146,12 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
           {category.num_direct_subcategories.toString()}
         </Table.Cell>
         <Table.Cell>
-          {renderCategoryActions()}
-        </Table.Cell>
-        <Table.Cell>
-          <MemberPreview accountId={category.moderator_id} />
+          {category.description}
         </Table.Cell>
-      </Table.Row>
+      </CategoryPreviewRow>
     );
   }
 
-  if (!history) {
-    return <em>Error: <code>history</code> property was not found.</em>;
-  }
-
   const renderSubCategoriesAndThreads = () => <>
     {category.archived &&
       <JoyWarn title={'This category is archived.'}>
@@ -180,7 +175,7 @@ function InnerViewCategory (props: InnerViewCategoryProps) {
     }
 
     <Section title={`Threads (${category.num_direct_unmoderated_threads.toString()})`}>
-      <CategoryThreads category={category} page={page} history={history} />
+      <CategoryThreads category={category} />
     </Section>
   </>;
 
@@ -204,8 +199,6 @@ const ViewCategory = withForumCalls<ViewCategoryProps>(
 
 type InnerCategoryThreadsProps = {
   category: Category;
-  page: number;
-  history: History;
 };
 
 type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & {
@@ -213,7 +206,8 @@ type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & {
 };
 
 function InnerCategoryThreads (props: CategoryThreadsProps) {
-  const { api, category, nextThreadId, page, history } = props;
+  const { api, category, nextThreadId } = props;
+  const [currentPage, setCurrentPage] = usePagination();
 
   if (!category.hasUnmoderatedThreads) {
     return <em>No threads in this category</em>;
@@ -272,20 +266,16 @@ function InnerCategoryThreads (props: CategoryThreadsProps) {
     return <em>No threads in this category</em>;
   }
 
-  const onPageChange = (activePage?: string | number) => {
-    history.push(`/forum/categories/${category.id.toString()}/page/${activePage}`);
-  };
-
   const itemsPerPage = ThreadsPerPage;
-  const minIdx = (page - 1) * itemsPerPage;
+  const minIdx = (currentPage - 1) * itemsPerPage;
   const maxIdx = minIdx + itemsPerPage - 1;
 
   const pagination =
     <Pagination
-      currentPage={page}
+      currentPage={currentPage}
       totalItems={threadCount}
       itemsPerPage={itemsPerPage}
-      onPageChange={onPageChange}
+      onPageChange={setCurrentPage}
     />;
 
   const pageOfItems = threads
@@ -300,6 +290,7 @@ function InnerCategoryThreads (props: CategoryThreadsProps) {
           <Table.HeaderCell>Thread</Table.HeaderCell>
           <Table.HeaderCell>Replies</Table.HeaderCell>
           <Table.HeaderCell>Creator</Table.HeaderCell>
+          <Table.HeaderCell>Created</Table.HeaderCell>
         </Table.Row>
       </Table.Header>
       <Table.Body>
@@ -319,21 +310,17 @@ export const CategoryThreads = withMulti(
 );
 
 type ViewCategoryByIdProps = UrlHasIdProps & {
-  history: History;
   match: {
     params: {
       id: string;
-      page?: string;
     };
   };
 };
 
 export function ViewCategoryById (props: ViewCategoryByIdProps) {
-  const { history, match: { params: { id, page: pageStr } } } = props;
+  const { match: { params: { id } } } = props;
   try {
-    // tslint:disable-next-line:radix
-    const page = pageStr ? parseInt(pageStr) : 1;
-    return <ViewCategory id={new CategoryId(id)} page={page} history={history} />;
+    return <ViewCategory id={new CategoryId(id)} />;
   } catch (err) {
     return <em>Invalid category ID: {id}</em>;
   }
@@ -392,8 +379,7 @@ function InnerCategoryList (props: CategoryListProps) {
           <Table.HeaderCell>Category</Table.HeaderCell>
           <Table.HeaderCell>Threads</Table.HeaderCell>
           <Table.HeaderCell>Subcategories</Table.HeaderCell>
-          <Table.HeaderCell>Actions</Table.HeaderCell>
-          <Table.HeaderCell>Creator</Table.HeaderCell>
+          <Table.HeaderCell>Description</Table.HeaderCell>
         </Table.Row>
       </Table.Header>
       <Table.Body>{categories.map((category, i) => (

+ 71 - 70
pioneer/packages/joy-forum/src/EditReply.tsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import { Button, Message } from 'semantic-ui-react';
+import styled from 'styled-components';
 import { Form, Field, withFormik, FormikProps } from 'formik';
 import * as Yup from 'yup';
-import { History } from 'history';
 
 import TxButton from '@polkadot/joy-utils/TxButton';
 import { SubmittableResult } from '@polkadot/api';
@@ -15,7 +15,6 @@ import { Post } from '@joystream/types/forum';
 import { withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
 import Section from '@polkadot/joy-utils/Section';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
-import { UrlHasIdProps, CategoryCrumbs } from './utils';
 import { withForumCalls } from './calls';
 import { ValidationProps, withReplyValidation } from './validation';
 import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
@@ -40,11 +39,18 @@ const buildSchema = (props: ValidationProps) => {
   });
 };
 
+const FormActionsContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
+
 type OuterProps = ValidationProps & {
-  history?: History;
   id?: PostId;
   struct?: Post;
   threadId: ThreadId;
+  quotedPost?: Post | null;
+  onEditSuccess?: () => void;
+  onEditCancel?: () => void;
 };
 
 type FormValues = {
@@ -57,7 +63,6 @@ const LabelledField = JoyForms.LabelledField<FormValues>();
 
 const InnerForm = (props: FormProps) => {
   const {
-    history,
     id,
     threadId,
     struct,
@@ -66,18 +71,16 @@ const InnerForm = (props: FormProps) => {
     isValid,
     isSubmitting,
     setSubmitting,
-    resetForm
+    resetForm,
+    onEditSuccess,
+    onEditCancel
   } = props;
 
   const {
     text
   } = values;
 
-  const goToThreadView = () => {
-    if (history) {
-      history.push('/forum/threads/' + threadId.toString());
-    }
-  };
+  const isNew = struct === undefined;
 
   const onSubmit = (sendTx: () => void) => {
     if (isValid) sendTx();
@@ -93,11 +96,12 @@ const InnerForm = (props: FormProps) => {
 
   const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
     setSubmitting(false);
-    goToThreadView();
+    resetForm();
+    if (!isNew && onEditSuccess) {
+      onEditSuccess();
+    }
   };
 
-  const isNew = struct === undefined;
-
   const buildTxParams = () => {
     if (!isValid) return [];
 
@@ -117,52 +121,77 @@ const InnerForm = (props: FormProps) => {
       </LabelledField>
 
       <LabelledField {...props}>
-        <TxButton
-          type='submit'
-          size='large'
-          label={isNew
-            ? 'Post a reply'
-            : 'Update a reply'
-          }
-          isDisabled={!dirty || isSubmitting}
-          params={buildTxParams()}
-          tx={isNew
-            ? 'forum.addPost'
-            : 'forum.editPostText'
+        <FormActionsContainer>
+          <div>
+            <TxButton
+              type='submit'
+              size='large'
+              label={isNew
+                ? 'Post a reply'
+                : 'Update a reply'
+              }
+              isDisabled={!dirty || isSubmitting}
+              params={buildTxParams()}
+              tx={isNew
+                ? 'forum.addPost'
+                : 'forum.editPostText'
+              }
+              onClick={onSubmit}
+              txFailedCb={onTxFailed}
+              txSuccessCb={onTxSuccess}
+            />
+            <Button
+              type='button'
+              size='large'
+              disabled={!dirty || isSubmitting}
+              onClick={() => resetForm()}
+              content='Reset form'
+            />
+          </div>
+          {
+            !isNew && (
+              <Button
+                type='button'
+                size='large'
+                disabled={isSubmitting}
+                content='Cancel edit'
+                onClick={() => onEditCancel && onEditCancel()}
+              />
+            )
           }
-          onClick={onSubmit}
-          txFailedCb={onTxFailed}
-          txSuccessCb={onTxSuccess}
-        />
-        <Button
-          type='button'
-          size='large'
-          disabled={!dirty || isSubmitting}
-          onClick={() => resetForm()}
-          content='Reset form'
-        />
+        </FormActionsContainer>
       </LabelledField>
     </Form>;
 
   const sectionTitle = isNew
     ? 'New reply'
-    : 'Edit my reply';
+    : `Edit my reply #${struct?.nr_in_thread}`;
 
-  return <>
-    <CategoryCrumbs threadId={threadId} />
+  return (
     <Section className='EditEntityBox' title={sectionTitle}>
       {form}
     </Section>
-  </>;
+  );
+};
+
+const getQuotedPostString = (post: Post) => {
+  const lines = post.current_text.split('\n');
+  return lines.reduce((acc, line) => {
+    return `${acc}> ${line}\n`;
+  }, '');
 };
 
 const EditForm = withFormik<OuterProps, FormValues>({
 
   // Transform outer props into form values
   mapPropsToValues: props => {
-    const { struct } = props;
+    const { struct, quotedPost } = props;
     return {
-      text: (struct && struct.current_text) || ''
+      text: struct
+        ? struct.current_text
+        : quotedPost
+          ? getQuotedPostString(quotedPost)
+          : ''
     };
   },
 
@@ -193,43 +222,15 @@ function FormOrLoading (props: OuterProps) {
   return <Message error className='JoyMainStatus' header='You are not allowed edit this reply.' />;
 }
 
-function withThreadIdFromUrl (Component: React.ComponentType<OuterProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} threadId={new ThreadId(id)} />;
-    } catch (err) {
-      return <em>Invalid thread ID: {id}</em>;
-    }
-  };
-}
-
-type HasPostIdProps = {
-  id: PostId;
-};
-
-function withIdFromUrl (Component: React.ComponentType<HasPostIdProps>) {
-  return function (props: UrlHasIdProps) {
-    const { match: { params: { id } } } = props;
-    try {
-      return <Component {...props} id={new PostId(id)} />;
-    } catch (err) {
-      return <em>Invalid reply ID: {id}</em>;
-    }
-  };
-}
-
 export const NewReply = withMulti(
   EditForm,
   withOnlyMembers,
-  withThreadIdFromUrl,
   withReplyValidation
 );
 
 export const EditReply = withMulti(
   FormOrLoading,
   withOnlyMembers,
-  withIdFromUrl,
   withReplyValidation,
   withForumCalls<OuterProps>(
     ['postById', { paramName: 'id', propName: 'struct' }]

+ 167 - 0
pioneer/packages/joy-forum/src/ForumRoot.tsx

@@ -0,0 +1,167 @@
+import React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { orderBy } from 'lodash';
+import BN from 'bn.js';
+
+import Section from '@polkadot/joy-utils/Section';
+import { withMulti, withApi } from '@polkadot/react-api';
+import { PostId } from '@joystream/types/common';
+import { Post, Thread } from '@joystream/types/forum';
+import { bnToStr } from '@polkadot/joy-utils/';
+import { ApiProps } from '@polkadot/react-api/types';
+import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
+
+import { CategoryCrumbs, RecentActivityPostsCount, ReplyIdxQueryParam, TimeAgoDate } from './utils';
+import { withForumCalls } from './calls';
+import { CategoryList } from './CategoryList';
+
+const ForumRoot: React.FC = () => {
+  return (
+    <>
+      <CategoryCrumbs root />
+      <RecentActivity />
+      <Section title="Top categories">
+        <CategoryList />
+      </Section>
+    </>
+  );
+};
+
+const RecentActivityEntry = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px 0;
+
+  &:not(:last-child) {
+    border-bottom: 1px solid #ddd;
+  }
+`;
+
+const StyledMemberPreview = styled(MemberPreview)`
+  && {
+    margin-right: .3rem;
+  }
+`;
+
+const StyledPostLink = styled(Link)`
+  margin: 0 .3rem;
+  font-weight: 700;
+`;
+
+type RecentActivityProps = ApiProps & {
+  nextPostId?: PostId;
+};
+
+const InnerRecentActivity: React.FC<RecentActivityProps> = ({ nextPostId, api }) => {
+  const [recentPosts, setRecentPosts] = useState<Post[]>([]);
+  const [loaded, setLoaded] = useState(false);
+  const [threadsLookup, setThreadsLookup] = useState<Record<number, Thread>>({});
+
+  useEffect(() => {
+    const loadPosts = async () => {
+      if (!nextPostId) return;
+
+      const newId = (id: number | BN) => new PostId(id);
+      const apiCalls: Promise<Post>[] = [];
+      let id = newId(1);
+      while (nextPostId.gt(id)) {
+        apiCalls.push(api.query.forum.postById(id) as Promise<Post>);
+        id = newId(id.add(newId(1)));
+      }
+
+      const allPosts = await Promise.all(apiCalls);
+      const sortedPosts = orderBy(
+        allPosts,
+        [x => x.id.toNumber()],
+        ['desc']
+      );
+
+      const threadsIdsLookup = {} as Record<number, boolean>;
+      const postsWithUniqueThreads = sortedPosts.reduce((acc, post) => {
+        const threadId = post.thread_id.toNumber();
+        if (threadsIdsLookup[threadId]) return acc;
+
+        threadsIdsLookup[threadId] = true;
+        return [
+          ...acc,
+          post
+        ];
+      }, [] as Post[]);
+
+      const recentUniquePosts = postsWithUniqueThreads.slice(0, RecentActivityPostsCount);
+      setRecentPosts(recentUniquePosts);
+      setLoaded(true);
+    };
+
+    loadPosts();
+  }, [bnToStr(nextPostId)]);
+
+  useEffect(() => {
+    const loadThreads = async () => {
+      const apiCalls: Promise<Thread>[] = recentPosts
+        .filter(p => !threadsLookup[p.thread_id.toNumber()])
+        .map(p => api.query.forum.threadById(p.thread_id) as Promise<Thread>);
+
+      const threads = await Promise.all(apiCalls);
+      const newThreadsLookup = threads.reduce((acc, thread) => {
+        acc[thread.id.toNumber()] = thread;
+        return acc;
+      }, {} as Record<number, Thread>);
+      const newLookup = {
+        ...threadsLookup,
+        ...newThreadsLookup
+      };
+
+      setThreadsLookup(newLookup);
+    };
+
+    loadThreads();
+  }, [recentPosts]);
+
+  const renderSectionContent = () => {
+    if (!loaded) {
+      return <i>Loading recent activity...</i>;
+    }
+    if (loaded && !recentPosts.length) {
+      return <span>No recent activity</span>;
+    }
+
+    return recentPosts.map(p => {
+      const threadId = p.thread_id.toNumber();
+
+      const postLinkSearch = new URLSearchParams();
+      postLinkSearch.set(ReplyIdxQueryParam, p.nr_in_thread.toString());
+      const postLinkPathname = `/forum/threads/${threadId}`;
+
+      const thread = threadsLookup[threadId];
+
+      return (
+        <RecentActivityEntry key={p.id.toNumber()}>
+          <StyledMemberPreview accountId={p.author_id} inline />
+          posted in
+          {thread && (
+            <StyledPostLink to={{ pathname: postLinkPathname, search: postLinkSearch.toString() }}>{thread.title}</StyledPostLink>
+          )}
+          <TimeAgoDate date={p.created_at.momentDate} id={p.id.toNumber()} />
+        </RecentActivityEntry>
+      );
+    });
+  };
+
+  return (
+    <Section title="Recent activity">
+      {renderSectionContent()}
+    </Section>
+  );
+};
+
+const RecentActivity = withMulti<RecentActivityProps>(
+  InnerRecentActivity,
+  withApi,
+  withForumCalls(
+    ['nextPostId', { propName: 'nextPostId' }]
+  )
+);
+
+export default ForumRoot;

+ 17 - 0
pioneer/packages/joy-forum/src/LegacyPagingRedirect.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { useLocation, Redirect } from 'react-router-dom';
+
+export const LegacyPagingRedirect: React.FC = () => {
+  const { pathname } = useLocation();
+  const parsingRegexp = /(.+)\/page\/(\d+)/;
+  const groups = parsingRegexp.exec(pathname);
+  if (!groups) {
+    return <em>Failed to parse the URL</em>;
+  }
+
+  const basePath = groups[1];
+  const page = groups[2];
+  const search = new URLSearchParams();
+  search.set('page', page);
+  return <Redirect to={{ pathname: basePath, search: search.toString() }} />;
+};

+ 107 - 38
pioneer/packages/joy-forum/src/ViewReply.tsx

@@ -1,7 +1,8 @@
 import React, { useState } from 'react';
-import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { Link, useLocation } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
-import { Segment, Button } from 'semantic-ui-react';
+import { Button, Icon } from 'semantic-ui-react';
 
 import { Post, Category, Thread } from '@joystream/types/forum';
 import { Moderate } from './Moderate';
@@ -9,18 +10,68 @@ import { JoyWarn } from '@polkadot/joy-utils/JoyStatus';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
-import { FlexCenter } from '@polkadot/joy-utils/FlexCenter';
+import { TimeAgoDate, ReplyIdxQueryParam } from './utils';
+
+const HORIZONTAL_PADDING = '1em';
+const ReplyMarkdown = styled(ReactMarkdown)`
+  font-size: 1.15rem;
+
+  blockquote {
+    color: rgba(78, 78, 78, 0.6);
+    margin-left: 15px;
+    padding-left: 15px;
+    border-left: 2px solid rgba(78, 78, 78, 0.6);
+  }
+`;
+const ReplyContainer = styled.div<{ selected?: boolean }>`
+  && {
+    padding: 0;
+
+    outline: ${({ selected }) => selected ? '2px solid #ffc87b' : 'none'};
+  }
+  overflow: hidden;
+`;
+const ReplyHeader = styled.div`
+  background-color: #fafcfc;
+`;
+const ReplyHeaderAuthorRow = styled.div`
+  padding: 0.7em ${HORIZONTAL_PADDING};
+`;
+const ReplyHeaderDetailsRow = styled.div`
+  padding: 0.5em ${HORIZONTAL_PADDING};
+  border-top: 1px dashed rgba(34, 36, 38, .15);
+  border-bottom: 1px solid rgba(34, 36, 38, .15);
+  display: flex;
+  justify-content: space-between;
+`;
+const ReplyContent = styled.div`
+  padding: 1em ${HORIZONTAL_PADDING};
+`;
+const ReplyFooter = styled.div`
+  border-top: 1px solid rgba(34, 36, 38, .15);
+  background-color: #fafcfc;
+  padding: 0.35em ${HORIZONTAL_PADDING};
+`;
+const ReplyFooterActionsRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
 
 type ViewReplyProps = {
   reply: Post;
   thread: Thread;
   category: Category;
+  selected?: boolean;
+  onEdit: () => void;
+  onQuote: () => void;
 };
 
-export function ViewReply (props: ViewReplyProps) {
+// eslint-disable-next-line react/display-name
+export const ViewReply = React.forwardRef((props: ViewReplyProps, ref: React.Ref<HTMLDivElement>) => {
   const { state: { address: myAddress } } = useMyAccount();
   const [showModerateForm, setShowModerateForm] = useState(false);
-  const { reply, thread, category } = props;
+  const { pathname, search } = useLocation();
+  const { reply, thread, category, selected = false, onEdit, onQuote } = props;
   const { id } = reply;
 
   if (reply.isEmpty) {
@@ -28,7 +79,7 @@ export function ViewReply (props: ViewReplyProps) {
   }
 
   const renderReplyDetails = () => {
-    return <ReactMarkdown className='JoyMemo--full' source={reply.current_text} linkTarget='_blank' />;
+    return <ReplyMarkdown className='JoyMemo--full' source={reply.current_text} linkTarget='_blank' />;
   };
 
   const renderModerationRationale = () => {
@@ -46,43 +97,61 @@ export function ViewReply (props: ViewReplyProps) {
       return null;
     }
     const isMyPost = reply.author_id.eq(myAddress);
-    return <span className='JoyInlineActions' style={{ marginLeft: '.5rem' }}>
-      {isMyPost &&
-        <Link
-          to={`/forum/replies/${id.toString()}/edit`}
-          className='ui small button'
-        >
-          <i className='pencil alternate icon' />
-          Edit
-        </Link>
-      }
-
-      <IfIAmForumSudo>
-        <Button
-          type='button'
-          size='small'
-          content={'Moderate'}
-          onClick={() => setShowModerateForm(!showModerateForm)}
-        />
-      </IfIAmForumSudo>
-    </span>;
+    return <ReplyFooterActionsRow>
+      <div>
+        {isMyPost &&
+          <Button onClick={onEdit} size="mini">
+            <Icon name="pencil" />
+            Edit
+          </Button>
+        }
+
+        <IfIAmForumSudo>
+          <Button
+            size="mini"
+            onClick={() => setShowModerateForm(!showModerateForm)}
+          >
+            Moderate
+          </Button>
+        </IfIAmForumSudo>
+      </div>
+      <Button onClick={onQuote} size="mini">
+        <Icon name="quote left" />
+        Quote
+      </Button>
+    </ReplyFooterActionsRow>;
   };
 
+  const replyLinkSearch = new URLSearchParams(search);
+  replyLinkSearch.set(ReplyIdxQueryParam, reply.nr_in_thread.toString());
+
   return (
-    <Segment>
-      <FlexCenter>
-        <MemberPreview accountId={reply.author_id} />
-        {renderActions()}
-      </FlexCenter>
-      <div style={{ marginTop: '1rem' }}>
-        {showModerateForm &&
-          <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
-        }
+    <ReplyContainer className="ui segment" ref={ref} selected={selected}>
+      <ReplyHeader>
+        <ReplyHeaderAuthorRow>
+          <MemberPreview accountId={reply.author_id} />
+        </ReplyHeaderAuthorRow>
+        <ReplyHeaderDetailsRow>
+          <TimeAgoDate date={reply.created_at.momentDate} id={reply.id} />
+          <Link to={{ pathname, search: replyLinkSearch.toString() }}>
+            #{reply.nr_in_thread.toNumber()}
+          </Link>
+        </ReplyHeaderDetailsRow>
+      </ReplyHeader>
+
+      <ReplyContent>
         {reply.moderated
           ? renderModerationRationale()
           : renderReplyDetails()
         }
-      </div>
-    </Segment>
+      </ReplyContent>
+
+      <ReplyFooter>
+        {renderActions()}
+        {showModerateForm &&
+          <Moderate id={id} onCloseForm={() => setShowModerateForm(false)} />
+        }
+      </ReplyFooter>
+    </ReplyContainer>
   );
-}
+});

+ 243 - 70
pioneer/packages/joy-forum/src/ViewThread.tsx

@@ -1,13 +1,13 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
 import { Link } from 'react-router-dom';
 import ReactMarkdown from 'react-markdown';
-import { Table, Button, Label } from 'semantic-ui-react';
-import { History } from 'history';
+import styled from 'styled-components';
+import { Table, Button, Label, Icon } from 'semantic-ui-react';
 import BN from 'bn.js';
 
 import { ThreadId, PostId } from '@joystream/types/common';
 import { Category, Thread, Post } from '@joystream/types/forum';
-import { Pagination, RepliesPerPage, CategoryCrumbs } from './utils';
+import { Pagination, RepliesPerPage, CategoryCrumbs, TimeAgoDate, usePagination, useQueryParam, ReplyIdxQueryParam, ReplyEditIdQueryParam } from './utils';
 import { ViewReply } from './ViewReply';
 import { Moderate } from './Moderate';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
@@ -19,6 +19,8 @@ import { orderBy } from 'lodash';
 import { bnToStr } from '@polkadot/joy-utils/index';
 import { IfIAmForumSudo } from './ForumSudo';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
+import { formatDate } from '@polkadot/joy-utils/functions/date';
+import { NewReply, EditReply } from './EditReply';
 
 type ThreadTitleProps = {
   thread: Thread;
@@ -37,12 +39,90 @@ function ThreadTitle (props: ThreadTitleProps) {
   </span>;
 }
 
+const ThreadHeader = styled.div`
+  margin: 1rem 0;
+
+  h1 {
+    margin: 0;
+  }
+`;
+
+const ThreadInfoAndActions = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  margin-top: .3rem;
+
+  h1 {
+    margin: 0;
+  }
+`;
+
+const ThreadInfo = styled.span`
+  display: inline-flex;
+  align-items: center;
+
+  font-size: .85rem;
+  color: rgba(0, 0, 0, 0.5);
+`;
+
+const ThreadInfoMemberPreview = styled(MemberPreview)`
+  && {
+    margin: 0 .2rem;
+
+    .PrefixLabel {
+      color: inherit;
+      margin-right: .2rem;
+    }
+  }
+`;
+
+const ReplyEditContainer = styled.div`
+  margin-top: 30px;
+  padding-bottom: 60px;
+`;
+
+type ThreadPreviewProps = {
+  thread: Thread;
+  repliesCount: number;
+}
+
+const ThreadPreview: React.FC<ThreadPreviewProps> = ({ thread, repliesCount }) => {
+  const title = <ThreadTitle thread={thread} />;
+
+  return (
+    <Table.Row>
+      <Table.Cell>
+        <Link to={`/forum/threads/${thread.id.toString()}`}>
+          {
+            thread.moderated
+              ? (
+                <MutedSpan>
+                  <Label color='orange'>Moderated</Label> {title}
+                </MutedSpan>
+              )
+              : title
+          }
+        </Link>
+      </Table.Cell>
+      <Table.Cell>
+        {repliesCount}
+      </Table.Cell>
+      <Table.Cell>
+        <MemberPreview accountId={thread.author_id} />
+      </Table.Cell>
+      <Table.Cell>
+        {formatDate(thread.created_at.momentDate)}
+      </Table.Cell>
+    </Table.Row>
+  );
+};
+
 type InnerViewThreadProps = {
   category: Category;
   thread: Thread;
-  page?: number;
   preview?: boolean;
-  history?: History;
 };
 
 type ViewThreadProps = ApiProps & InnerViewThreadProps & {
@@ -51,7 +131,22 @@ type ViewThreadProps = ApiProps & InnerViewThreadProps & {
 
 function InnerViewThread (props: ViewThreadProps) {
   const [showModerateForm, setShowModerateForm] = useState(false);
-  const { history, category, thread, page = 1, preview = false } = props;
+  const [displayedPosts, setDisplayedPosts] = useState<Post[]>([]);
+  const [quotedPost, setQuotedPost] = useState<Post | null>(null);
+
+  const postsRefs = useRef<Record<number, React.RefObject<HTMLDivElement>>>({});
+  const replyFormRef = useRef<HTMLDivElement>(null);
+
+  const [rawSelectedPostIdx, setSelectedPostIdx] = useQueryParam(ReplyIdxQueryParam);
+  const [rawEditedPostId, setEditedPostId] = useQueryParam(ReplyEditIdQueryParam);
+  const [currentPage, setCurrentPage] = usePagination();
+
+  const parsedSelectedPostIdx = rawSelectedPostIdx ? parseInt(rawSelectedPostIdx) : null;
+  const selectedPostIdx = (parsedSelectedPostIdx && !Number.isNaN(parsedSelectedPostIdx)) ? parsedSelectedPostIdx : null;
+
+  const { category, thread, preview = false } = props;
+
+  const editedPostId = rawEditedPostId && new PostId(rawEditedPostId);
 
   if (!thread) {
     return <em>Loading thread details...</em>;
@@ -68,6 +163,11 @@ function InnerViewThread (props: ViewThreadProps) {
   const { id } = thread;
   const totalPostsInThread = thread.num_posts_ever_created.toNumber();
 
+  const changePageAndClearSelectedPost = (page?: number | string) => {
+    setSelectedPostIdx(null);
+    setCurrentPage(page, [ReplyIdxQueryParam]);
+  };
+
   if (!category) {
     return <em>{'Thread\'s category was not found.'}</em>;
   } else if (category.deleted) {
@@ -75,34 +175,14 @@ function InnerViewThread (props: ViewThreadProps) {
   }
 
   if (preview) {
-    const title = <ThreadTitle thread={thread} />;
-    const repliesCount = totalPostsInThread - 1;
-    return (
-      <Table.Row>
-        <Table.Cell>
-          <Link to={`/forum/threads/${id.toString()}`}>{thread.moderated
-            ? <MutedSpan><Label color='orange'>Moderated</Label> {title}</MutedSpan>
-            : title
-          }</Link>
-        </Table.Cell>
-        <Table.Cell>
-          {repliesCount}
-        </Table.Cell>
-        <Table.Cell>
-          <MemberPreview accountId={thread.author_id} />
-        </Table.Cell>
-      </Table.Row>
-    );
-  }
-
-  if (!history) {
-    return <em>History propoerty is undefined</em>;
+    return <ThreadPreview thread={thread} repliesCount={totalPostsInThread - 1} />;
   }
 
   const { api, nextPostId } = props;
   const [loaded, setLoaded] = useState(false);
   const [posts, setPosts] = useState(new Array<Post>());
 
+  // fetch posts
   useEffect(() => {
     const loadPosts = async () => {
       if (!nextPostId || totalPostsInThread === 0) return;
@@ -126,6 +206,13 @@ function InnerViewThread (props: ViewThreadProps) {
         ['asc']
       );
 
+      // initialize refs for posts
+      postsRefs.current = sortedPosts.reduce((acc, reply) => {
+        const refKey = reply.nr_in_thread.toNumber();
+        acc[refKey] = React.createRef();
+        return acc;
+      }, postsRefs.current);
+
       setPosts(sortedPosts);
       setLoaded(true);
     };
@@ -133,6 +220,73 @@ function InnerViewThread (props: ViewThreadProps) {
     loadPosts();
   }, [bnToStr(thread.id), bnToStr(nextPostId)]);
 
+  // handle selected post
+  useEffect(() => {
+    if (!selectedPostIdx) return;
+
+    const selectedPostPage = Math.ceil(selectedPostIdx / RepliesPerPage);
+    if (currentPage !== selectedPostPage) {
+      setCurrentPage(selectedPostPage);
+    }
+
+    if (!loaded) return;
+    if (selectedPostIdx > posts.length) {
+      // eslint-disable-next-line no-console
+      console.warn(`Tried to open nonexistent reply with idx: ${selectedPostIdx}`);
+      return;
+    }
+
+    const postRef = postsRefs.current[selectedPostIdx];
+
+    // postpone scrolling for one render to make sure the ref is set
+    setTimeout(() => {
+      if (postRef.current) {
+        postRef.current.scrollIntoView();
+      } else {
+        // eslint-disable-next-line no-console
+        console.warn('Ref for selected post empty');
+      }
+    });
+  }, [loaded, selectedPostIdx, currentPage]);
+
+  // handle displayed posts based on pagination
+  useEffect(() => {
+    if (!loaded) return;
+    const minIdx = (currentPage - 1) * RepliesPerPage;
+    const maxIdx = minIdx + RepliesPerPage - 1;
+    const postsToDisplay = posts.filter((_id, i) => i >= minIdx && i <= maxIdx);
+    setDisplayedPosts(postsToDisplay);
+  }, [loaded, posts, currentPage]);
+
+  const scrollToReplyForm = () => {
+    if (!replyFormRef.current) return;
+    replyFormRef.current.scrollIntoView();
+  };
+
+  const clearEditedPost = () => {
+    setEditedPostId(null);
+  };
+
+  const onThreadReplyClick = () => {
+    clearEditedPost();
+    setQuotedPost(null);
+    scrollToReplyForm();
+  };
+
+  const onPostEditSuccess = async () => {
+    if (!editedPostId) {
+      // eslint-disable-next-line no-console
+      console.error('editedPostId not set!');
+      return;
+    }
+
+    const updatedPost = await api.query.forum.postById(editedPostId) as Post;
+    const updatedPosts = posts.map(post => post.id.eq(editedPostId) ? updatedPost : post);
+
+    setPosts(updatedPosts);
+    clearEditedPost();
+  };
+
   // console.log({ nextPostId: bnToStr(nextPostId), loaded, posts });
 
   const renderPageOfPosts = () => {
@@ -140,29 +294,44 @@ function InnerViewThread (props: ViewThreadProps) {
       return <em>Loading posts...</em>;
     }
 
-    const onPageChange = (activePage?: string | number) => {
-      history.push(`/forum/threads/${id.toString()}/page/${activePage}`);
-    };
-
-    const itemsPerPage = RepliesPerPage;
-    const minIdx = (page - 1) * RepliesPerPage;
-    const maxIdx = minIdx + RepliesPerPage - 1;
-
     const pagination =
       <Pagination
-        currentPage={page}
-        totalItems={totalPostsInThread}
-        itemsPerPage={itemsPerPage}
-        onPageChange={onPageChange}
+        currentPage={currentPage}
+        totalItems={posts.length}
+        itemsPerPage={RepliesPerPage}
+        onPageChange={changePageAndClearSelectedPost}
       />;
 
-    const pageOfItems = posts
-      .filter((_id, i) => i >= minIdx && i <= maxIdx)
-      .map((reply, i) => <ViewReply key={i} category={category} thread={thread} reply={reply} />);
+    const renderedReplies = displayedPosts.map((reply) => {
+      const replyIdx = reply.nr_in_thread.toNumber();
+
+      const onReplyEditClick = () => {
+        setEditedPostId(reply.id.toString());
+        scrollToReplyForm();
+      };
+
+      const onReplyQuoteClick = () => {
+        setQuotedPost(reply);
+        scrollToReplyForm();
+      };
+
+      return (
+        <ViewReply
+          ref={postsRefs.current[replyIdx]}
+          key={replyIdx}
+          category={category}
+          thread={thread}
+          reply={reply}
+          selected={selectedPostIdx === replyIdx}
+          onEdit={onReplyEditClick}
+          onQuote={onReplyQuoteClick}
+        />
+      );
+    });
 
     return <>
       {pagination}
-      {pageOfItems}
+      {renderedReplies}
       {pagination}
     </>;
   };
@@ -172,13 +341,10 @@ function InnerViewThread (props: ViewThreadProps) {
       return null;
     }
     return <span className='JoyInlineActions'>
-      <Link
-        to={`/forum/threads/${id.toString()}/reply`}
-        className='ui small button'
-      >
-        <i className='reply icon' />
+      <Button onClick={onThreadReplyClick}>
+        <Icon name="reply" />
         Reply
-      </Link>
+      </Button>
 
       {/* TODO show 'Edit' button only if I am owner */}
       {/* <Link
@@ -212,10 +378,20 @@ function InnerViewThread (props: ViewThreadProps) {
 
   return <div style={{ marginBottom: '1rem' }}>
     <CategoryCrumbs categoryId={thread.category_id} />
-    <h1 className='ForumPageTitle'>
-      <ThreadTitle thread={thread} className='TitleText' />
-      {renderActions()}
-    </h1>
+    <ThreadHeader>
+      <h1 className='ForumPageTitle'>
+        <ThreadTitle thread={thread} className='TitleText' />
+      </h1>
+      <ThreadInfoAndActions>
+        <ThreadInfo>
+          Created
+          <ThreadInfoMemberPreview accountId={thread.author_id} inline prefixLabel="by" />
+          <TimeAgoDate date={thread.created_at.momentDate} id="thread" />
+        </ThreadInfo>
+        {renderActions()}
+      </ThreadInfoAndActions>
+    </ThreadHeader>
+
     {category.archived &&
       <JoyWarn title={'This thread is in archived category.'}>
         No new replies can be posted.
@@ -228,6 +404,15 @@ function InnerViewThread (props: ViewThreadProps) {
       ? renderModerationRationale()
       : renderPageOfPosts()
     }
+    <ReplyEditContainer ref={replyFormRef}>
+      {
+        editedPostId ? (
+          <EditReply id={editedPostId} key={editedPostId.toString()} onEditSuccess={onPostEditSuccess} onEditCancel={clearEditedPost} />
+        ) : (
+          <NewReply threadId={thread.id} key={quotedPost?.id.toString()} quotedPost={quotedPost} />
+        )
+      }
+    </ReplyEditContainer>
   </div>;
 }
 
@@ -240,27 +425,15 @@ export const ViewThread = withMulti(
 );
 
 type ViewThreadByIdProps = ApiProps & {
-  history: History;
   match: {
     params: {
       id: string;
-      page?: string;
     };
   };
 };
 
 function InnerViewThreadById (props: ViewThreadByIdProps) {
-  const { api, history, match: { params: { id, page: pageStr } } } = props;
-
-  let page = 1;
-  if (pageStr) {
-    try {
-      // tslint:disable-next-line:radix
-      page = parseInt(pageStr);
-    } catch (err) {
-      console.log('Failed to parse page number form URL');
-    }
-  }
+  const { api, match: { params: { id } } } = props;
 
   let threadId: ThreadId;
   try {
@@ -287,7 +460,7 @@ function InnerViewThreadById (props: ViewThreadByIdProps) {
     };
 
     loadThreadAndCategory();
-  }, [id, page]);
+  }, [id]);
 
   // console.log({ threadId: id, page });
 
@@ -303,7 +476,7 @@ function InnerViewThreadById (props: ViewThreadByIdProps) {
     return <em>{ 'Thread\'s category was not found' }</em>;
   }
 
-  return <ViewThread id={threadId} category={category} thread={thread} page={page} history={history} />;
+  return <ViewThread id={threadId} category={category} thread={thread} />;
 }
 
 export const ViewThreadById = withApi(InnerViewThreadById);

+ 19 - 39
pioneer/packages/joy-forum/src/index.tsx

@@ -1,76 +1,56 @@
 
 import React from 'react';
 import { Route, Switch } from 'react-router';
+import styled from 'styled-components';
 
 import { AppProps, I18nProps } from '@polkadot/react-components/types';
-import Tabs, { TabItem } from '@polkadot/react-components/Tabs';
 
 import './index.css';
 
 import translate from './translate';
 import { ForumProvider } from './Context';
-import { EditForumSudo, ForumSudoProvider } from './ForumSudo';
-import { NewCategory, NewSubcategory, EditCategory } from './EditCategory';
+import { ForumSudoProvider } from './ForumSudo';
+import { NewSubcategory, EditCategory } from './EditCategory';
 import { NewThread, EditThread } from './EditThread';
-import { NewReply, EditReply } from './EditReply';
 import { CategoryList, ViewCategoryById } from './CategoryList';
 import { ViewThreadById } from './ViewThread';
+import { LegacyPagingRedirect } from './LegacyPagingRedirect';
+import ForumRoot from './ForumRoot';
+
+const ForumContentWrapper = styled.main`
+  padding-top: 1.5rem;
+`;
 
 type Props = AppProps & I18nProps & {};
 
 class App extends React.PureComponent<Props> {
-  private buildTabs (): TabItem[] {
-    const { t } = this.props;
-    return [
-      {
-        isRoot: true,
-        name: 'forum',
-        text: t('Forum')
-      },
-      {
-        // TODO show this tab only if current user is the sudo:
-        name: 'categories/new',
-        text: t('New category')
-      },
-      {
-        name: 'sudo',
-        text: t('Forum sudo')
-      }
-    ];
-  }
-
   render () {
     const { basePath } = this.props;
-    const tabs = this.buildTabs();
     return (
       <ForumProvider>
         <ForumSudoProvider>
-          <main className='forum--App'>
-            <header>
-              <Tabs basePath={basePath} items={tabs} />
-            </header>
+          <ForumContentWrapper className='forum--App'>
             <Switch>
-              <Route path={`${basePath}/sudo`} component={EditForumSudo} />
+              {/* routes for handling legacy format of forum paging within the routing path */}
+              {/* translate page param to search query */}
+              <Route path={`${basePath}/categories/:id/page/:page`} component={LegacyPagingRedirect} />
+              <Route path={`${basePath}/threads/:id/page/:page`} component={LegacyPagingRedirect} />
+
+              {/* <Route path={`${basePath}/sudo`} component={EditForumSudo} /> */}
+              {/* <Route path={`${basePath}/categories/new`} component={NewCategory} /> */}
 
-              <Route path={`${basePath}/categories/new`} component={NewCategory} />
               <Route path={`${basePath}/categories/:id/newSubcategory`} component={NewSubcategory} />
               <Route path={`${basePath}/categories/:id/newThread`} component={NewThread} />
               <Route path={`${basePath}/categories/:id/edit`} component={EditCategory} />
-              <Route path={`${basePath}/categories/:id/page/:page`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories/:id`} component={ViewCategoryById} />
               <Route path={`${basePath}/categories`} component={CategoryList} />
 
-              <Route path={`${basePath}/threads/:id/reply`} component={NewReply} />
               <Route path={`${basePath}/threads/:id/edit`} component={EditThread} />
-              <Route path={`${basePath}/threads/:id/page/:page`} component={ViewThreadById} />
               <Route path={`${basePath}/threads/:id`} component={ViewThreadById} />
 
-              <Route path={`${basePath}/replies/:id/edit`} component={EditReply} />
-              {/* <Route path={`${basePath}/replies/:id`} component={ViewReplyById} /> */}
-
-              <Route component={CategoryList} />
+              <Route component={ForumRoot} />
             </Switch>
-          </main>
+          </ForumContentWrapper>
         </ForumSudoProvider>
       </ForumProvider>
     );

+ 108 - 12
pioneer/packages/joy-forum/src/utils.tsx

@@ -1,6 +1,10 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
+import { useHistory, useLocation } from 'react-router';
 import { Link } from 'react-router-dom';
-import { Pagination as SuiPagination } from 'semantic-ui-react';
+import { Breadcrumb, Pagination as SuiPagination } from 'semantic-ui-react';
+import styled from 'styled-components';
+import moment from 'moment';
+import Tooltip from 'react-tooltip';
 
 import { ThreadId } from '@joystream/types/common';
 import { Category, CategoryId, Thread } from '@joystream/types/forum';
@@ -9,6 +13,10 @@ import { withMulti } from '@polkadot/react-api';
 
 export const ThreadsPerPage = 10;
 export const RepliesPerPage = 10;
+export const RecentActivityPostsCount = 7;
+export const ReplyIdxQueryParam = 'replyIdx';
+export const ReplyEditIdQueryParam = 'editReplyId';
+export const PagingQueryParam = 'page';
 
 type PaginationProps = {
   currentPage?: number;
@@ -37,6 +45,7 @@ type CategoryCrumbsProps = {
   category?: Category;
   threadId?: ThreadId;
   thread?: Thread;
+  root?: boolean;
 };
 
 function InnerCategoryCrumb (p: CategoryCrumbsProps) {
@@ -47,8 +56,8 @@ function InnerCategoryCrumb (p: CategoryCrumbsProps) {
       const url = `/forum/categories/${category.id.toString()}`;
       return <>
         {category.parent_id ? <CategoryCrumb categoryId={category.parent_id} /> : null}
-        <i className='right angle icon divider'></i>
-        <Link className='section' to={url}>{category.title}</Link>
+        <Breadcrumb.Divider icon="right angle" />
+        <Breadcrumb.Section as={Link} to={url}>{category.title}</Breadcrumb.Section>
       </>;
     } catch (err) {
       console.log('Failed to create a category breadcrumb', err);
@@ -73,8 +82,8 @@ function InnerThreadCrumb (p: CategoryCrumbsProps) {
       const url = `/forum/threads/${thread.id.toString()}`;
       return <>
         <CategoryCrumb categoryId={thread.category_id} />
-        <i className='right angle icon divider'></i>
-        <Link className='section' to={url}>{thread.title}</Link>
+        <Breadcrumb.Divider icon="right angle" />
+        <Breadcrumb.Section as={Link} to={url}>{thread.title}</Breadcrumb.Section>
       </>;
     } catch (err) {
       console.log('Failed to create a thread breadcrumb', err);
@@ -91,16 +100,45 @@ const ThreadCrumb = withMulti(
   )
 );
 
-export const CategoryCrumbs = (p: CategoryCrumbsProps) => {
+const StyledBreadcrumbs = styled(Breadcrumb)`
+  && {
+    font-size: 1.3rem;
+    line-height: 1.2;
+  }
+`;
+
+export const CategoryCrumbs = ({ categoryId, threadId, root }: CategoryCrumbsProps) => {
   return (
-    <div className='ui breadcrumb'>
-      <Link className='section' to='/forum'>Top categories</Link>
-      <CategoryCrumb categoryId={p.categoryId} />
-      <ThreadCrumb threadId={p.threadId} />
-    </div>
+    <StyledBreadcrumbs>
+      <Breadcrumb.Section>Forum</Breadcrumb.Section>
+      {!root && (
+        <>
+          <Breadcrumb.Divider icon="right angle" />
+          <Breadcrumb.Section as={Link} to="/forum">Top categories</Breadcrumb.Section>
+          <CategoryCrumb categoryId={categoryId} />
+          <ThreadCrumb threadId={threadId} />
+        </>
+      )}
+    </StyledBreadcrumbs>
   );
 };
 
+type TimeAgoDateProps = {
+  date: moment.Moment;
+  id: any;
+};
+
+export const TimeAgoDate: React.FC<TimeAgoDateProps> = ({ date, id }) => (
+  <>
+    <span data-tip data-for={`${id}-date-tooltip`}>
+      {date.fromNow()}
+    </span>
+    <Tooltip id={`${id}-date-tooltip`} place="top" effect="solid">
+      {date.toLocaleString()}
+    </Tooltip>
+  </>
+);
+
 // It's used on such routes as:
 //   /categories/:id
 //   /categories/:id/edit
@@ -113,3 +151,61 @@ export type UrlHasIdProps = {
     };
   };
 };
+
+type QueryValueType = string | null;
+type QuerySetValueType = (value?: QueryValueType | number, paramsToReset?: string[]) => void;
+type QueryReturnType = [QueryValueType, QuerySetValueType];
+
+export const useQueryParam = (queryParam: string): QueryReturnType => {
+  const { pathname, search } = useLocation();
+  const history = useHistory();
+  const [value, setValue] = useState<QueryValueType>(null);
+
+  useEffect(() => {
+    const params = new URLSearchParams(search);
+    const paramValue = params.get(queryParam);
+    if (paramValue !== value) {
+      setValue(paramValue);
+    }
+  }, [search, setValue, queryParam]);
+
+  const setParam: QuerySetValueType = (rawValue, paramsToReset = []) => {
+    let parsedValue: string | null;
+    if (!rawValue && rawValue !== 0) {
+      parsedValue = null;
+    } else {
+      parsedValue = rawValue.toString();
+    }
+
+    const params = new URLSearchParams(search);
+    if (parsedValue) {
+      params.set(queryParam, parsedValue);
+    } else {
+      params.delete(queryParam);
+    }
+
+    paramsToReset.forEach(p => params.delete(p));
+
+    setValue(parsedValue);
+    history.push({ pathname, search: params.toString() });
+  };
+
+  return [value, setParam];
+};
+
+export const usePagination = (): [number, QuerySetValueType] => {
+  const [rawCurrentPage, setCurrentPage] = useQueryParam(PagingQueryParam);
+
+  let currentPage = 1;
+  if (rawCurrentPage) {
+    const parsedPage = Number.parseInt(rawCurrentPage);
+    if (!Number.isNaN(parsedPage)) {
+      currentPage = parsedPage;
+    } else {
+      // eslint-disable-next-line no-console
+      console.warn('Failed to parse URL page idx');
+    }
+  }
+
+  return [currentPage, setCurrentPage];
+};

+ 19 - 11
pioneer/packages/joy-members/src/MemberPreview.tsx

@@ -17,6 +17,7 @@ import { FlexCenter } from '@polkadot/joy-utils/FlexCenter';
 import { MutedSpan } from '@polkadot/joy-utils/MutedText';
 
 const AvatarSizePx = 36;
+const InlineAvatarSizePx = 24;
 
 type MemberPreviewProps = ApiProps & I18nProps & {
   accountId: AccountId;
@@ -24,6 +25,7 @@ type MemberPreviewProps = ApiProps & I18nProps & {
   memberProfile?: Option<any>; // TODO refactor to Option<Profile>
   activeCouncil?: Seat[];
   prefixLabel?: string;
+  inline?: boolean;
   className?: string;
   style?: React.CSSProperties;
 };
@@ -37,32 +39,38 @@ class InnerMemberPreview extends React.PureComponent<MemberPreviewProps> {
   }
 
   private renderProfile (memberProfile: Profile) {
-    const { activeCouncil = [], accountId, prefixLabel, className, style } = this.props;
+    const { activeCouncil = [], accountId, prefixLabel, inline, className, style } = this.props;
     const { handle, avatar_uri } = memberProfile;
 
     const hasAvatar = avatar_uri && nonEmptyStr(avatar_uri.toString());
     const isCouncilor: boolean = accountId !== undefined && activeCouncil.find(x => accountId.eq(x.member)) !== undefined;
 
+    const avatarSize = inline ? InlineAvatarSizePx : AvatarSizePx;
+
     return <div className={`JoyMemberPreview ${className}`} style={style}>
       <FlexCenter>
         {prefixLabel &&
           <MutedSpan className='PrefixLabel'>{prefixLabel}</MutedSpan>
         }
-        {hasAvatar
-          ? <img className='Avatar' src={avatar_uri.toString()} width={AvatarSizePx} height={AvatarSizePx} />
-          : <IdentityIcon className='Avatar' value={accountId} size={AvatarSizePx} />
+        {hasAvatar ? (
+          <img className="Avatar" src={avatar_uri.toString()} width={avatarSize} height={avatarSize} />
+        ) : (
+          <IdentityIcon className="Avatar" value={accountId} size={avatarSize} />
+        )
         }
         <div className='Content'>
           <div className='Username'>
             <Link to={`/members/${handle.toString()}`} className='handle'>{handle.toString()}</Link>
           </div>
-          <div className='Details'>
-            {isCouncilor &&
-              <b className='muted text' style={{ color: '#607d8b' }}>
-                <i className='university icon'></i>
-                Council member
-              </b>}
-          </div>
+          {!inline && (
+            <div className='Details'>
+              {isCouncilor &&
+                <b className='muted text' style={{ color: '#607d8b' }}>
+                  <i className='university icon'></i>
+                  Council member
+                </b>}
+            </div>
+          )}
         </div>
       </FlexCenter>
     </div>;

+ 35 - 25
pioneer/packages/joy-roles/src/transport.substrate.ts

@@ -24,14 +24,13 @@ import {
 } from '@joystream/types/content-working-group';
 
 import {
-  WorkerApplication, WorkerApplicationId,
-  WorkerOpening, WorkerOpeningId,
+  Application as WGApplication,
+  Opening as WGOpening,
   Worker, WorkerId,
-  WorkerRoleStakeProfile,
-  Lead as LeadOf
+  RoleStakeProfile
 } from '@joystream/types/working-group';
 
-import { Application, Opening, OpeningId } from '@joystream/types/hiring';
+import { Application, Opening, OpeningId, ApplicationId } 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';
@@ -72,14 +71,14 @@ type WGApiMethodType =
   | 'workerById';
 type WGApiMethodsMapping = { [key in WGApiMethodType]: string };
 
-type GroupApplication = CuratorApplication | WorkerApplication;
-type GroupApplicationId = CuratorApplicationId | WorkerApplicationId;
-type GroupOpening = CuratorOpening | WorkerOpening;
-type GroupOpeningId = CuratorOpeningId | WorkerOpeningId;
+type GroupApplication = CuratorApplication | WGApplication;
+type GroupApplicationId = CuratorApplicationId | ApplicationId;
+type GroupOpening = CuratorOpening | WGOpening;
+type GroupOpeningId = CuratorOpeningId | OpeningId;
 type GroupWorker = Worker | Curator;
 type GroupWorkerId = CuratorId | WorkerId;
-type GroupWorkerStakeProfile = WorkerRoleStakeProfile | CuratorRoleStakeProfile;
-type GroupLead = Lead | LeadOf;
+type GroupWorkerStakeProfile = RoleStakeProfile | CuratorRoleStakeProfile;
+type GroupLead = Lead | Worker;
 type GroupLeadWithMemberId = {
   lead: GroupLead;
   memberId: MemberId;
@@ -99,15 +98,15 @@ const workingGroupsApiMapping: WGApiMapping = {
   [WorkingGroups.StorageProviders]: {
     module: 'storageWorkingGroup',
     methods: {
-      nextOpeningId: 'nextWorkerOpeningId',
-      openingById: 'workerOpeningById',
-      nextApplicationId: 'nextWorkerApplicationId',
-      applicationById: 'workerApplicationById',
+      nextOpeningId: 'nextOpeningId',
+      openingById: 'openingById',
+      nextApplicationId: 'nextApplicationId',
+      applicationById: 'applicationById',
       nextWorkerId: 'nextWorkerId',
       workerById: 'workerById'
     },
-    openingType: WorkerOpening,
-    applicationType: WorkerApplication,
+    openingType: WGOpening,
+    applicationType: WGApplication,
     workerType: Worker
   },
   [WorkingGroups.ContentCurators]: {
@@ -210,7 +209,7 @@ export class Transport extends TransportBase implements ITransport {
     id: GroupWorkerId,
     worker: GroupWorker
   ): Promise<GroupMember> {
-    const roleAccount = worker.role_account;
+    const roleAccount = worker.role_account_id;
     const memberId = group === WorkingGroups.ContentCurators
       ? await this.memberIdFromCuratorId(id)
       : (worker as Worker).member_id;
@@ -255,7 +254,7 @@ export class Transport extends TransportBase implements ITransport {
     );
 
     for (let i = 0; i < groupOpenings.linked_values.length; i++) {
-      const opening = await this.opening(groupOpenings.linked_values[i].opening_id.toNumber());
+      const opening = await this.opening(groupOpenings.linked_values[i].hiring_opening_id.toNumber());
       if (opening.is_active) {
         return true;
       }
@@ -291,15 +290,26 @@ export class Transport extends TransportBase implements ITransport {
   }
 
   protected async currentStorageLead (): Promise <GroupLeadWithMemberId | null> {
-    const optLead = (await this.cachedApi.query.storageWorkingGroup.currentLead()) as Option<LeadOf>;
+    const optLeadId = (await this.cachedApi.query.storageWorkingGroup.currentLead()) as Option<WorkerId>;
 
-    if (!optLead.isSome) {
+    if (!optLeadId.isSome) {
+      return null;
+    }
+
+    const leadWorkerId = optLeadId.unwrap();
+    const leadWorkerLink = new SingleLinkedMapEntry(
+      Worker,
+      await this.cachedApi.query.storageWorkingGroup.workerById(leadWorkerId)
+    );
+    const leadWorker = leadWorkerLink.value;
+
+    if (!leadWorker.is_active) {
       return null;
     }
 
     return {
-      lead: optLead.unwrap(),
-      memberId: optLead.unwrap().member_id
+      lead: leadWorker,
+      memberId: leadWorker.member_id
     };
   }
 
@@ -415,7 +425,7 @@ export class Transport extends TransportBase implements ITransport {
         await this.cachedApiMethodByGroup(group, 'applicationById')(i)
       );
 
-      if (cApplication.value.worker_opening_id.toNumber() !== groupOpeningId) {
+      if (cApplication.value.opening_id.toNumber() !== groupOpeningId) {
         continue;
       }
 
@@ -454,7 +464,7 @@ export class Transport extends TransportBase implements ITransport {
     );
 
     const opening = await this.opening(
-      groupOpening.value.opening_id.toNumber()
+      groupOpening.value.hiring_opening_id.toNumber()
     );
 
     const applications = await this.groupOpeningApplications(group, id);

+ 5 - 0
pioneer/packages/joy-utils/src/functions/date.ts

@@ -0,0 +1,5 @@
+import moment from 'moment';
+
+export function formatDate (date: moment.Moment): string {
+  return date.format('DD/MM/YYYY LT');
+}

+ 1 - 4
pioneer/packages/react-components/src/styles/index.ts

@@ -124,10 +124,7 @@ export default createGlobalStyle`
     font-weight: 100;
   }
 
-  h1 {
-    text-transform: lowercase;
-
-    em {
+  h1 em {
       font-style: normal;
       text-transform: none;
     }

+ 1 - 1
runtime-modules/common/src/lib.rs

@@ -3,7 +3,7 @@
 
 pub mod constraints;
 pub mod currency;
-pub mod origin_validator;
+pub mod origin;
 
 use codec::{Decode, Encode};
 #[cfg(feature = "std")]

+ 26 - 0
runtime-modules/common/src/origin.rs

@@ -0,0 +1,26 @@
+use system::RawOrigin;
+
+/// Abstract validator for the origin(account_id) and actor_id (eg.: thread author id).
+pub trait ActorOriginValidator<Origin, ActorId, AccountId> {
+    /// Check for valid combination of origin and actor_id.
+    fn ensure_actor_origin(origin: Origin, actor_id: ActorId) -> Result<AccountId, &'static str>;
+}
+
+// Multiplies the T::Origin.
+// In our current substrate version system::Origin doesn't support clone(),
+// but it will be supported in latest up-to-date substrate version.
+// TODO: delete when T::Origin will support the clone()
+pub fn double_origin<T: system::Trait>(origin: T::Origin) -> (T::Origin, T::Origin) {
+    let coerced_origin = origin.into().ok().unwrap_or(RawOrigin::None);
+
+    let (cloned_origin1, cloned_origin2) = match coerced_origin {
+        RawOrigin::None => (RawOrigin::None, RawOrigin::None),
+        RawOrigin::Root => (RawOrigin::Root, RawOrigin::Root),
+        RawOrigin::Signed(account_id) => (
+            RawOrigin::Signed(account_id.clone()),
+            RawOrigin::Signed(account_id),
+        ),
+    };
+
+    (cloned_origin1.into(), cloned_origin2.into())
+}

+ 0 - 5
runtime-modules/common/src/origin_validator.rs

@@ -1,5 +0,0 @@
-/// Abstract validator for the origin(account_id) and actor_id (eg.: thread author id).
-pub trait ActorOriginValidator<Origin, ActorId, AccountId> {
-    /// Check for valid combination of origin and actor_id.
-    fn ensure_actor_origin(origin: Origin, actor_id: ActorId) -> Result<AccountId, &'static str>;
-}

+ 7 - 1
runtime-modules/hiring/src/hiring/staking_policy.rs

@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
 
 /// Policy for staking
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
-#[derive(Encode, Decode, Debug, Eq, PartialEq, Clone)]
+#[derive(Encode, Decode, Debug, Eq, PartialEq, Clone, Default)]
 pub struct StakingPolicy<Balance, BlockNumber> {
     /// Staking amount
     pub amount: Balance,
@@ -77,3 +77,9 @@ pub enum StakingAmountLimitMode {
     /// Stake should be equal to provided value
     Exact,
 }
+
+impl Default for StakingAmountLimitMode {
+    fn default() -> Self {
+        StakingAmountLimitMode::Exact
+    }
+}

+ 3 - 22
runtime-modules/proposals/codex/src/lib.rs

@@ -54,7 +54,7 @@ mod proposal_types;
 #[cfg(test)]
 mod tests;
 
-use common::origin_validator::ActorOriginValidator;
+use common::origin::ActorOriginValidator;
 use governance::election_params::ElectionParameters;
 use proposal_engine::ProposalParameters;
 use rstd::clone::Clone;
@@ -65,7 +65,7 @@ use sr_primitives::traits::Zero;
 use srml_support::dispatch::DispatchResult;
 use srml_support::traits::{Currency, Get};
 use srml_support::{decl_error, decl_module, decl_storage, ensure, print};
-use system::{ensure_root, RawOrigin};
+use system::ensure_root;
 
 pub use crate::proposal_types::ProposalsConfigParameters;
 pub use proposal_types::{ProposalDetails, ProposalDetailsOf, ProposalEncoder};
@@ -577,7 +577,7 @@ decl_module! {
             origin,
             wasm: Vec<u8>,
         ) {
-            let (cloned_origin1, cloned_origin2) =  Self::double_origin(origin);
+            let (cloned_origin1, cloned_origin2) = common::origin::double_origin::<T>(origin);
             ensure_root(cloned_origin1)?;
 
             print("Runtime upgrade proposal execution started.");
@@ -590,25 +590,6 @@ decl_module! {
 }
 
 impl<T: Trait> Module<T> {
-    // Multiplies the T::Origin.
-    // In our current substrate version system::Origin doesn't support clone(),
-    // but it will be supported in latest up-to-date substrate version.
-    // TODO: delete when T::Origin will support the clone()
-    fn double_origin(origin: T::Origin) -> (T::Origin, T::Origin) {
-        let coerced_origin = origin.into().ok().unwrap_or(RawOrigin::None);
-
-        let (cloned_origin1, cloned_origin2) = match coerced_origin {
-            RawOrigin::None => (RawOrigin::None, RawOrigin::None),
-            RawOrigin::Root => (RawOrigin::Root, RawOrigin::Root),
-            RawOrigin::Signed(account_id) => (
-                RawOrigin::Signed(account_id.clone()),
-                RawOrigin::Signed(account_id),
-            ),
-        };
-
-        (cloned_origin1.into(), cloned_origin2.into())
-    }
-
     // Generic template proposal builder
     fn create_proposal(
         origin: T::Origin,

+ 1 - 1
runtime-modules/proposals/codex/src/tests/mock.rs

@@ -123,7 +123,7 @@ impl governance::council::Trait for Test {
     type CouncilTermEnded = ();
 }
 
-impl common::origin_validator::ActorOriginValidator<Origin, u64, u64> for () {
+impl common::origin::ActorOriginValidator<Origin, u64, u64> for () {
     fn ensure_actor_origin(origin: Origin, _: u64) -> Result<u64, &'static str> {
         let account_id = system::ensure_signed(origin)?;
 

+ 1 - 1
runtime-modules/proposals/discussion/src/lib.rs

@@ -56,7 +56,7 @@ use srml_support::{decl_error, decl_event, decl_module, decl_storage, ensure, Pa
 use srml_support::traits::Get;
 use types::{DiscussionPost, DiscussionThread, ThreadCounter};
 
-use common::origin_validator::ActorOriginValidator;
+use common::origin::ActorOriginValidator;
 use srml_support::dispatch::DispatchResult;
 
 type MemberId<T> = <T as membership::members::Trait>::MemberId;

+ 1 - 1
runtime-modules/proposals/engine/src/lib.rs

@@ -135,7 +135,7 @@ use srml_support::{
 use system::{ensure_root, RawOrigin};
 
 use crate::types::ApprovedProposalData;
-use common::origin_validator::ActorOriginValidator;
+use common::origin::ActorOriginValidator;
 use srml_support::dispatch::Dispatchable;
 
 type MemberId<T> = <T as membership::members::Trait>::MemberId;

+ 1 - 1
runtime-modules/proposals/engine/src/tests/mock/mod.rs

@@ -124,7 +124,7 @@ impl Default for proposals::Call<Test> {
     }
 }
 
-impl common::origin_validator::ActorOriginValidator<Origin, u64, u64> for () {
+impl common::origin::ActorOriginValidator<Origin, u64, u64> for () {
     fn ensure_actor_origin(origin: Origin, _account_id: u64) -> Result<u64, &'static str> {
         let signed_account_id = system::ensure_signed(origin)?;
 

+ 3 - 3
runtime-modules/recurring-reward/src/lib.rs

@@ -94,10 +94,10 @@ pub struct RewardRelationship<AccountId, Balance, BlockNumber, MintId, Recipient
     mint_id: MintId,
 
     /// Destination account for reward
-    account: AccountId,
+    pub account: AccountId,
 
     /// The payout amount at the next payout
-    amount_per_payout: Balance,
+    pub amount_per_payout: Balance,
 
     /// When set, identifies block when next payout should be processed,
     /// otherwise there is no pending payout
@@ -146,7 +146,7 @@ decl_storage! {
 
         RecipientsCreated get(recipients_created): T::RecipientId;
 
-        RewardRelationships get(reward_relationships): linked_map T::RewardRelationshipId => RewardRelationship<T::AccountId, BalanceOf<T>, T::BlockNumber, T::MintId, T::RecipientId>;
+        pub RewardRelationships get(reward_relationships): linked_map T::RewardRelationshipId => RewardRelationship<T::AccountId, BalanceOf<T>, T::BlockNumber, T::MintId, T::RecipientId>;
 
         RewardRelationshipsCreated get(reward_relationships_created): T::RewardRelationshipId;
     }

+ 6 - 1
runtime-modules/service-discovery/src/mock.rs

@@ -130,8 +130,13 @@ impl recurringrewards::Trait for Test {
     type RewardRelationshipId = u64;
 }
 
+parameter_types! {
+    pub const MaxWorkerNumberLimit: u32 = 3;
+}
+
 impl working_group::Trait<StorageWorkingGroupInstance> for Test {
     type Event = MetaEvent;
+    type MaxWorkerNumberLimit = MaxWorkerNumberLimit;
 }
 
 impl timestamp::Trait for Test {
@@ -158,7 +163,7 @@ pub(crate) fn hire_storage_provider() -> (u64, u64) {
 
     let storage_provider = working_group::Worker {
         member_id: 1,
-        role_account: role_account_id,
+        role_account_id,
         reward_relationship: None,
         role_stake_profile: None,
     };

+ 1 - 1
runtime-modules/storage/src/data_directory.rs

@@ -27,7 +27,7 @@ use sr_primitives::traits::{MaybeSerialize, Member};
 use srml_support::{decl_error, decl_event, decl_module, decl_storage, ensure, Parameter};
 use system::{self, ensure_root};
 
-use common::origin_validator::ActorOriginValidator;
+use common::origin::ActorOriginValidator;
 pub(crate) use common::BlockAndTime;
 
 use crate::data_object_type_registry;

+ 18 - 6
runtime-modules/storage/src/tests/data_object_type_registry.rs

@@ -1,21 +1,33 @@
 #![cfg(test)]
 
 use super::mock::*;
-use crate::StorageWorkingGroup;
+use srml_support::{StorageLinkedMap, StorageValue};
 use system::{self, EventRecord, Phase, RawOrigin};
 
 const DEFAULT_LEADER_ACCOUNT_ID: u64 = 1;
 const DEFAULT_LEADER_MEMBER_ID: u64 = 1;
+const DEFAULT_LEADER_WORKER_ID: u32 = 1;
 
 struct SetLeadFixture;
 impl SetLeadFixture {
     fn set_default_lead() {
-        let set_lead_result = <StorageWorkingGroup<Test>>::set_lead(
-            RawOrigin::Root.into(),
-            DEFAULT_LEADER_MEMBER_ID,
-            DEFAULT_LEADER_ACCOUNT_ID,
+        let worker = working_group::Worker {
+            member_id: DEFAULT_LEADER_MEMBER_ID,
+            role_account_id: DEFAULT_LEADER_ACCOUNT_ID,
+            reward_relationship: None,
+            role_stake_profile: None,
+        };
+
+        // Create the worker.
+        <working_group::WorkerById<Test, StorageWorkingGroupInstance>>::insert(
+            DEFAULT_LEADER_WORKER_ID,
+            worker,
+        );
+
+        // Update current lead.
+        <working_group::CurrentLead<Test, StorageWorkingGroupInstance>>::put(
+            DEFAULT_LEADER_WORKER_ID,
         );
-        assert!(set_lead_result.is_ok());
     }
 }
 

+ 7 - 2
runtime-modules/storage/src/tests/mock.rs

@@ -146,8 +146,13 @@ impl GovernanceCurrency for Test {
     type Currency = balances::Module<Self>;
 }
 
+parameter_types! {
+    pub const MaxWorkerNumberLimit: u32 = 3;
+}
+
 impl working_group::Trait<StorageWorkingGroupInstance> for Test {
     type Event = MetaEvent;
+    type MaxWorkerNumberLimit = MaxWorkerNumberLimit;
 }
 
 impl data_object_type_registry::Trait for Test {
@@ -169,7 +174,7 @@ impl crate::data_directory::StorageProviderHelper<Test> for () {
     }
 }
 
-impl common::origin_validator::ActorOriginValidator<Origin, u64, u64> for () {
+impl common::origin::ActorOriginValidator<Origin, u64, u64> for () {
     fn ensure_actor_origin(origin: Origin, _account_id: u64) -> Result<u64, &'static str> {
         let signed_account_id = system::ensure_signed(origin)?;
 
@@ -304,7 +309,7 @@ pub(crate) fn hire_storage_provider() -> (u64, u32) {
 
     let storage_provider = working_group::Worker {
         member_id: 1,
-        role_account: role_account_id,
+        role_account_id,
         reward_relationship: None,
         role_stake_profile: None,
     };

+ 19 - 6
runtime-modules/working-group/src/errors.rs

@@ -14,6 +14,12 @@ decl_error! {
         /// Current lead is not set.
         CurrentLeadNotSet,
 
+        /// There is leader already, cannot hire another one.
+        CannotHireLeaderWhenLeaderExists,
+
+        /// Cannot fill opening with multiple applications.
+        CannotHireMultipleLeaders,
+
         /// Not a lead account.
         IsNotLeadAccount,
 
@@ -23,8 +29,8 @@ decl_error! {
         /// Opening text too long.
         OpeningTextTooLong,
 
-        /// Worker opening does not exist.
-        WorkerOpeningDoesNotExist,
+        /// Opening does not exist.
+        OpeningDoesNotExist,
 
         /// Insufficient balance to apply.
         InsufficientBalanceToApply,
@@ -63,10 +69,10 @@ decl_error! {
         SuccessfulWorkerApplicationDoesNotExist,
 
         /// Reward policy has invalid next payment block number.
-        FillWorkerOpeningInvalidNextPaymentBlock,
+        FillOpeningInvalidNextPaymentBlock,
 
         /// Working group mint does not exist.
-        FillWorkerOpeningMintDoesNotExist,
+        FillOpeningMintDoesNotExist,
 
         ///Relationship must exist.
         RelationshipMustExist,
@@ -235,6 +241,12 @@ decl_error! {
 
         /// Require root origin in extrinsics.
         RequireRootOrigin,
+
+        /// Require signed origin in extrinsics.
+        RequireSignedOrigin,
+
+        /// Working group size limit exceeded.
+        MaxActiveWorkerNumberExceeded,
     }
 }
 
@@ -243,6 +255,7 @@ impl From<system::Error> for Error {
         match error {
             system::Error::Other(msg) => Error::Other(msg),
             system::Error::RequireRootOrigin => Error::RequireRootOrigin,
+            system::Error::RequireSignedOrigin => Error::RequireSignedOrigin,
             _ => Error::Other(error.into()),
         }
     }
@@ -324,10 +337,10 @@ impl<T: hiring::Trait> rstd::convert::From<WrappedError<hiring::FillOpeningError
             ) => match stake_purpose {
                 hiring::StakePurpose::Application => match outcome_in_filled_opening {
                     hiring::ApplicationOutcomeInFilledOpening::Success => {
-                        Error::FullWorkerOpeningUnsuccessfulApplicationStakeUnstakingPeriodTooShort
+                        Error::FullWorkerOpeningSuccessfulApplicationStakeUnstakingPeriodTooShort
                     }
                     hiring::ApplicationOutcomeInFilledOpening::Failure => {
-                        Error::FullWorkerOpeningSuccessfulApplicationStakeUnstakingPeriodTooShort
+                        Error::FullWorkerOpeningUnsuccessfulApplicationStakeUnstakingPeriodTooShort
                     }
                 },
                 hiring::StakePurpose::Role => match outcome_in_filled_opening {

File diff suppressed because it is too large
+ 311 - 319
runtime-modules/working-group/src/lib.rs


+ 304 - 185
runtime-modules/working-group/src/tests/fixtures.rs

@@ -1,9 +1,10 @@
 use super::mock::{
     Balances, Membership, System, Test, TestEvent, TestWorkingGroup, TestWorkingGroupInstance,
 };
+use crate::tests::fill_worker_position;
 use crate::types::{
-    OpeningPolicyCommitment, RewardPolicy, Worker, WorkerApplication, WorkerOpening,
-    WorkerRoleStakeProfile,
+    Application, Opening, OpeningPolicyCommitment, OpeningType, RewardPolicy, RoleStakeProfile,
+    Worker,
 };
 use crate::Error;
 use crate::RawEvent;
@@ -22,7 +23,7 @@ pub struct IncreaseWorkerStakeFixture {
 impl IncreaseWorkerStakeFixture {
     pub fn default_for_worker_id(worker_id: u64) -> Self {
         let account_id = 1;
-        IncreaseWorkerStakeFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             worker_id,
             balance: 10,
@@ -30,18 +31,18 @@ impl IncreaseWorkerStakeFixture {
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        IncreaseWorkerStakeFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn with_balance(self, balance: u64) -> Self {
-        IncreaseWorkerStakeFixture { balance, ..self }
+        Self { balance, ..self }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
         let stake_id = 0;
         let old_stake = <stake::Module<Test>>::stakes(stake_id);
         let old_balance = Balances::free_balance(&self.account_id);
-        let actual_result = TestWorkingGroup::increase_worker_stake(
+        let actual_result = TestWorkingGroup::increase_stake(
             self.origin.clone().into(),
             self.worker_id,
             self.balance,
@@ -71,11 +72,12 @@ pub struct TerminateWorkerRoleFixture {
     origin: RawOrigin<u64>,
     text: Vec<u8>,
     constraint: InputValidationLengthConstraint,
+    slash_stake: bool,
 }
 
 impl TerminateWorkerRoleFixture {
     pub fn default_for_worker_id(worker_id: u64) -> Self {
-        TerminateWorkerRoleFixture {
+        Self {
             worker_id,
             origin: RawOrigin::Signed(1),
             text: b"rationale_text".to_vec(),
@@ -83,23 +85,32 @@ impl TerminateWorkerRoleFixture {
                 min: 1,
                 max_min_diff: 20,
             },
+            slash_stake: false,
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        TerminateWorkerRoleFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn with_text(self, text: Vec<u8>) -> Self {
-        TerminateWorkerRoleFixture { text, ..self }
+        Self { text, ..self }
+    }
+
+    pub fn with_slashing(self) -> Self {
+        Self {
+            slash_stake: true,
+            ..self
+        }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
         <crate::WorkerExitRationaleText<TestWorkingGroupInstance>>::put(self.constraint.clone());
 
-        let actual_result = TestWorkingGroup::terminate_worker_role(
+        let actual_result = TestWorkingGroup::terminate_role(
             self.origin.clone().into(),
             self.worker_id,
             self.text.clone(),
+            self.slash_stake,
         );
         assert_eq!(actual_result, expected_result);
 
@@ -120,18 +131,18 @@ pub(crate) struct LeaveWorkerRoleFixture {
 
 impl LeaveWorkerRoleFixture {
     pub fn default_for_worker_id(worker_id: u64) -> Self {
-        LeaveWorkerRoleFixture {
+        Self {
             worker_id,
             origin: RawOrigin::Signed(1),
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        LeaveWorkerRoleFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
         let rationale_text = b"rationale_text".to_vec();
-        let actual_result = TestWorkingGroup::leave_worker_role(
+        let actual_result = TestWorkingGroup::leave_role(
             self.origin.clone().into(),
             self.worker_id,
             rationale_text.clone(),
@@ -144,33 +155,80 @@ impl LeaveWorkerRoleFixture {
     }
 }
 
+pub struct UpdateWorkerRewardAmountFixture {
+    worker_id: u64,
+    amount: u64,
+    origin: RawOrigin<u64>,
+}
+
+impl UpdateWorkerRewardAmountFixture {
+    pub fn default_for_worker_id(worker_id: u64) -> Self {
+        let lead_account_id = get_current_lead_account_id();
+
+        Self {
+            worker_id,
+            amount: 120,
+            origin: RawOrigin::Signed(lead_account_id),
+        }
+    }
+    pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
+        Self { origin, ..self }
+    }
+
+    pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
+        let actual_result = TestWorkingGroup::update_reward_amount(
+            self.origin.clone().into(),
+            self.worker_id,
+            self.amount,
+        );
+
+        assert_eq!(actual_result.clone(), expected_result);
+
+        if actual_result.is_ok() {
+            let worker = TestWorkingGroup::worker_by_id(self.worker_id);
+            let relationship_id = worker.reward_relationship.unwrap();
+
+            let relationship = recurringrewards::RewardRelationships::<Test>::get(relationship_id);
+
+            assert_eq!(relationship.amount_per_payout, self.amount);
+        }
+    }
+}
 pub struct UpdateWorkerRewardAccountFixture {
     worker_id: u64,
-    new_role_account_id: u64,
+    new_reward_account_id: u64,
     origin: RawOrigin<u64>,
 }
 
 impl UpdateWorkerRewardAccountFixture {
-    pub fn default_with_ids(worker_id: u64, new_role_account_id: u64) -> Self {
-        UpdateWorkerRewardAccountFixture {
+    pub fn default_with_ids(worker_id: u64, new_reward_account_id: u64) -> Self {
+        Self {
             worker_id,
-            new_role_account_id,
+            new_reward_account_id,
             origin: RawOrigin::Signed(1),
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        UpdateWorkerRewardAccountFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
-        assert_eq!(
-            TestWorkingGroup::update_worker_reward_account(
-                self.origin.clone().into(),
-                self.worker_id,
-                self.new_role_account_id
-            ),
-            expected_result
+        let actual_result = TestWorkingGroup::update_reward_account(
+            self.origin.clone().into(),
+            self.worker_id,
+            self.new_reward_account_id,
         );
+
+        assert_eq!(actual_result.clone(), expected_result);
+
+        if actual_result.is_ok() {
+            let worker = TestWorkingGroup::worker_by_id(self.worker_id);
+            let relationship_id = worker.reward_relationship.unwrap();
+
+            let relationship = recurringrewards::RewardRelationships::<Test>::get(relationship_id);
+
+            assert_eq!(relationship.account, self.new_reward_account_id);
+        }
     }
 }
 
@@ -182,18 +240,18 @@ pub struct UpdateWorkerRoleAccountFixture {
 
 impl UpdateWorkerRoleAccountFixture {
     pub fn default_with_ids(worker_id: u64, new_role_account_id: u64) -> Self {
-        UpdateWorkerRoleAccountFixture {
+        Self {
             worker_id,
             new_role_account_id,
             origin: RawOrigin::Signed(1),
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        UpdateWorkerRoleAccountFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
-        let actual_result = TestWorkingGroup::update_worker_role_account(
+        let actual_result = TestWorkingGroup::update_role_account(
             self.origin.clone().into(),
             self.worker_id,
             self.new_role_account_id,
@@ -203,22 +261,11 @@ impl UpdateWorkerRoleAccountFixture {
         if actual_result.is_ok() {
             let worker = TestWorkingGroup::worker_by_id(self.worker_id);
 
-            assert_eq!(worker.role_account, self.new_role_account_id);
+            assert_eq!(worker.role_account_id, self.new_role_account_id);
         }
     }
 }
 
-pub struct UnsetLeadFixture;
-impl UnsetLeadFixture {
-    pub fn unset_lead() {
-        assert_eq!(TestWorkingGroup::unset_lead(RawOrigin::Root.into()), Ok(()));
-    }
-
-    pub fn call_and_assert(origin: RawOrigin<u64>, expected_result: Result<(), Error>) {
-        assert_eq!(TestWorkingGroup::unset_lead(origin.into()), expected_result);
-    }
-}
-
 pub fn set_mint_id(mint_id: u64) {
     <crate::Mint<Test, TestWorkingGroupInstance>>::put(mint_id);
 }
@@ -230,8 +277,8 @@ pub fn create_mint() -> u64 {
 pub struct FillWorkerOpeningFixture {
     origin: RawOrigin<u64>,
     opening_id: u64,
-    successful_worker_application_ids: BTreeSet<u64>,
-    role_account: u64,
+    successful_application_ids: BTreeSet<u64>,
+    role_account_id: u64,
     reward_policy: Option<RewardPolicy<u64, u64>>,
 }
 
@@ -239,41 +286,48 @@ impl FillWorkerOpeningFixture {
     pub fn default_for_ids(opening_id: u64, application_ids: Vec<u64>) -> Self {
         let application_ids: BTreeSet<u64> = application_ids.iter().map(|x| *x).collect();
 
-        FillWorkerOpeningFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             opening_id,
-            successful_worker_application_ids: application_ids,
-            role_account: 1,
+            successful_application_ids: application_ids,
+            role_account_id: 1,
             reward_policy: None,
         }
     }
 
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        FillWorkerOpeningFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn with_reward_policy(self, reward_policy: RewardPolicy<u64, u64>) -> Self {
-        FillWorkerOpeningFixture {
+        Self {
             reward_policy: Some(reward_policy),
             ..self
         }
     }
 
-    pub fn call_and_assert(&self, expected_result: Result<(), Error>) -> u64 {
+    pub fn call(&self) -> Result<u64, Error> {
         let saved_worker_next_id = TestWorkingGroup::next_worker_id();
-        let actual_result = TestWorkingGroup::fill_worker_opening(
+        TestWorkingGroup::fill_opening(
             self.origin.clone().into(),
             self.opening_id,
-            self.successful_worker_application_ids.clone(),
+            self.successful_application_ids.clone(),
             self.reward_policy.clone(),
-        );
+        )?;
+
+        Ok(saved_worker_next_id)
+    }
+
+    pub fn call_and_assert(&self, expected_result: Result<(), Error>) -> u64 {
+        let saved_worker_next_id = TestWorkingGroup::next_worker_id();
+        let actual_result = self.call().map(|_| ());
         assert_eq!(actual_result.clone(), expected_result);
 
         if actual_result.is_ok() {
             assert_eq!(TestWorkingGroup::next_worker_id(), saved_worker_next_id + 1);
             let worker_id = saved_worker_next_id;
 
-            let opening = TestWorkingGroup::worker_opening_by_id(self.opening_id);
+            let opening = TestWorkingGroup::opening_by_id(self.opening_id);
 
             let role_stake_profile = if opening
                 .policy_commitment
@@ -282,14 +336,12 @@ impl FillWorkerOpeningFixture {
                 || opening.policy_commitment.role_staking_policy.is_some()
             {
                 let stake_id = 0;
-                Some(WorkerRoleStakeProfile::new(
+                Some(RoleStakeProfile::new(
                     &stake_id,
                     &opening
                         .policy_commitment
-                        .terminate_worker_role_stake_unstaking_period,
-                    &opening
-                        .policy_commitment
-                        .exit_worker_role_stake_unstaking_period,
+                        .terminate_role_stake_unstaking_period,
+                    &opening.policy_commitment.exit_role_stake_unstaking_period,
                 ))
             } else {
                 None
@@ -298,7 +350,7 @@ impl FillWorkerOpeningFixture {
 
             let expected_worker = Worker {
                 member_id: 1,
-                role_account: self.role_account,
+                role_account_id: self.role_account_id,
                 reward_relationship,
                 role_stake_profile,
             };
@@ -319,19 +371,17 @@ pub struct BeginReviewWorkerApplicationsFixture {
 
 impl BeginReviewWorkerApplicationsFixture {
     pub fn default_for_opening_id(opening_id: u64) -> Self {
-        BeginReviewWorkerApplicationsFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             opening_id,
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        BeginReviewWorkerApplicationsFixture { origin, ..self }
+        Self { origin, ..self }
     }
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
-        let actual_result = TestWorkingGroup::begin_worker_applicant_review(
-            self.origin.clone().into(),
-            self.opening_id,
-        );
+        let actual_result =
+            TestWorkingGroup::begin_applicant_review(self.origin.clone().into(), self.opening_id);
         assert_eq!(actual_result, expected_result);
     }
 }
@@ -343,22 +393,22 @@ pub struct TerminateApplicationFixture {
 
 impl TerminateApplicationFixture {
     pub fn with_signer(self, account_id: u64) -> Self {
-        TerminateApplicationFixture {
+        Self {
             origin: RawOrigin::Signed(account_id),
             ..self
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        TerminateApplicationFixture { origin, ..self }
+        Self { origin, ..self }
     }
     pub fn default_for_application_id(application_id: u64) -> Self {
-        TerminateApplicationFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             worker_application_id: application_id,
         }
     }
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
-        let actual_result = TestWorkingGroup::terminate_worker_application(
+        let actual_result = TestWorkingGroup::terminate_application(
             self.origin.clone().into(),
             self.worker_application_id,
         );
@@ -372,22 +422,22 @@ pub struct WithdrawApplicationFixture {
 
 impl WithdrawApplicationFixture {
     pub fn with_signer(self, account_id: u64) -> Self {
-        WithdrawApplicationFixture {
+        Self {
             origin: RawOrigin::Signed(account_id),
             ..self
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        WithdrawApplicationFixture { origin, ..self }
+        Self { origin, ..self }
     }
     pub fn default_for_application_id(application_id: u64) -> Self {
-        WithdrawApplicationFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             worker_application_id: application_id,
         }
     }
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
-        let actual_result = TestWorkingGroup::withdraw_worker_application(
+        let actual_result = TestWorkingGroup::withdraw_application(
             self.origin.clone().into(),
             self.worker_application_id,
         );
@@ -400,6 +450,10 @@ pub fn increase_total_balance_issuance_using_account_id(account_id: u64, balance
         <Balances as srml_support::traits::Currency<u64>>::deposit_creating(&account_id, balance);
 }
 
+pub fn get_balance(account_id: u64) -> u64 {
+    <super::mock::Balances as srml_support::traits::Currency<u64>>::total_balance(&account_id)
+}
+
 pub fn setup_members(count: u8) {
     let authority_account_id = 1;
     Membership::set_screening_authority(RawOrigin::Root.into(), authority_account_id).unwrap();
@@ -424,7 +478,7 @@ pub struct ApplyOnWorkerOpeningFixture {
     origin: RawOrigin<u64>,
     member_id: u64,
     worker_opening_id: u64,
-    role_account: u64,
+    role_account_id: u64,
     opt_role_stake_balance: Option<u64>,
     opt_application_stake_balance: Option<u64>,
     human_readable_text: Vec<u8>,
@@ -432,73 +486,86 @@ pub struct ApplyOnWorkerOpeningFixture {
 
 impl ApplyOnWorkerOpeningFixture {
     pub fn with_text(self, text: Vec<u8>) -> Self {
-        ApplyOnWorkerOpeningFixture {
+        Self {
             human_readable_text: text,
             ..self
         }
     }
 
-    pub fn with_role_stake(self, stake: u64) -> Self {
-        ApplyOnWorkerOpeningFixture {
-            opt_role_stake_balance: Some(stake),
+    pub fn with_origin(self, origin: RawOrigin<u64>, member_id: u64) -> Self {
+        Self {
+            origin,
+            member_id,
+            ..self
+        }
+    }
+
+    pub fn with_role_stake(self, stake: Option<u64>) -> Self {
+        Self {
+            opt_role_stake_balance: stake,
             ..self
         }
     }
 
     pub fn with_application_stake(self, stake: u64) -> Self {
-        ApplyOnWorkerOpeningFixture {
+        Self {
             opt_application_stake_balance: Some(stake),
             ..self
         }
     }
 
     pub fn default_for_opening_id(opening_id: u64) -> Self {
-        ApplyOnWorkerOpeningFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             member_id: 1,
             worker_opening_id: opening_id,
-            role_account: 1,
+            role_account_id: 1,
             opt_role_stake_balance: None,
             opt_application_stake_balance: None,
             human_readable_text: b"human_text".to_vec(),
         }
     }
 
-    pub fn call_and_assert(&self, expected_result: Result<(), Error>) -> u64 {
-        let saved_application_next_id = TestWorkingGroup::next_worker_application_id();
-        let actual_result = TestWorkingGroup::apply_on_worker_opening(
+    pub fn call(&self) -> Result<u64, Error> {
+        let saved_application_next_id = TestWorkingGroup::next_application_id();
+        TestWorkingGroup::apply_on_opening(
             self.origin.clone().into(),
             self.member_id,
             self.worker_opening_id,
-            self.role_account,
+            self.role_account_id,
             self.opt_role_stake_balance,
             self.opt_application_stake_balance,
             self.human_readable_text.clone(),
-        );
+        )?;
+
+        Ok(saved_application_next_id)
+    }
+    pub fn call_and_assert(&self, expected_result: Result<(), Error>) -> u64 {
+        let saved_application_next_id = TestWorkingGroup::next_application_id();
+
+        let actual_result = self.call().map(|_| ());
         assert_eq!(actual_result.clone(), expected_result);
 
         if actual_result.is_ok() {
             assert_eq!(
-                TestWorkingGroup::next_worker_application_id(),
+                TestWorkingGroup::next_application_id(),
                 saved_application_next_id + 1
             );
             let application_id = saved_application_next_id;
 
-            let actual_application = TestWorkingGroup::worker_application_by_id(application_id);
+            let actual_application = TestWorkingGroup::application_by_id(application_id);
 
-            let expected_application = WorkerApplication {
-                role_account: self.role_account,
-                worker_opening_id: self.worker_opening_id,
+            let expected_application = Application {
+                role_account_id: self.role_account_id,
+                opening_id: self.worker_opening_id,
                 member_id: self.member_id,
-                application_id,
+                hiring_application_id: application_id,
             };
 
             assert_eq!(actual_application, expected_application);
 
-            let current_opening = TestWorkingGroup::worker_opening_by_id(self.worker_opening_id);
-            assert!(current_opening
-                .worker_applications
-                .contains(&application_id));
+            let current_opening = TestWorkingGroup::opening_by_id(self.worker_opening_id);
+            assert!(current_opening.applications.contains(&application_id));
         }
 
         saved_application_next_id
@@ -512,57 +579,112 @@ pub struct AcceptWorkerApplicationsFixture {
 
 impl AcceptWorkerApplicationsFixture {
     pub fn default_for_opening_id(opening_id: u64) -> Self {
-        AcceptWorkerApplicationsFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             opening_id,
         }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
-        let actual_result = TestWorkingGroup::accept_worker_applications(
-            self.origin.clone().into(),
-            self.opening_id,
-        );
+        let actual_result =
+            TestWorkingGroup::accept_applications(self.origin.clone().into(), self.opening_id);
         assert_eq!(actual_result, expected_result);
     }
 }
 
-pub struct SetLeadFixture;
+pub struct SetLeadFixture {
+    pub member_id: u64,
+    pub role_account_id: u64,
+    pub worker_id: u64,
+}
+impl Default for SetLeadFixture {
+    fn default() -> Self {
+        SetLeadFixture {
+            member_id: 1,
+            role_account_id: 1,
+            worker_id: 1,
+        }
+    }
+}
+
 impl SetLeadFixture {
-    pub fn set_lead(lead_account_id: u64) {
-        assert_eq!(
-            TestWorkingGroup::set_lead(RawOrigin::Root.into(), 1, lead_account_id),
-            Ok(())
-        );
+    pub fn unset_lead() {
+        TestWorkingGroup::unset_lead();
     }
 
-    pub fn call_and_assert(
-        origin: RawOrigin<u64>,
-        member_id: u64,
-        account_id: u64,
-        expected_result: Result<(), Error>,
-    ) {
-        assert_eq!(
-            TestWorkingGroup::set_lead(origin.into(), member_id, account_id),
-            expected_result
-        );
+    pub fn set_lead(self) {
+        TestWorkingGroup::set_lead(self.worker_id);
+    }
+    pub fn set_lead_with_ids(member_id: u64, role_account_id: u64, worker_id: u64) {
+        Self {
+            member_id,
+            role_account_id,
+            worker_id,
+        }
+        .set_lead();
     }
 }
 
+pub struct HireLeadFixture {
+    setup_environment: bool,
+    stake: Option<u64>,
+    reward_policy: Option<RewardPolicy<u64, u64>>,
+}
+
+impl Default for HireLeadFixture {
+    fn default() -> Self {
+        Self {
+            setup_environment: true,
+            stake: None,
+            reward_policy: None,
+        }
+    }
+}
+impl HireLeadFixture {
+    pub fn with_stake(self, stake: u64) -> Self {
+        Self {
+            stake: Some(stake),
+            ..self
+        }
+    }
+    pub fn with_reward_policy(self, reward_policy: RewardPolicy<u64, u64>) -> Self {
+        Self {
+            reward_policy: Some(reward_policy),
+            ..self
+        }
+    }
+
+    pub fn hire_lead(self) -> u64 {
+        fill_worker_position(
+            self.reward_policy,
+            self.stake,
+            self.setup_environment,
+            OpeningType::Leader,
+            Some(b"leader".to_vec()),
+        )
+    }
+}
+
+pub fn get_worker_by_id(worker_id: u64) -> Worker<u64, u64, u64, u64, u64> {
+    TestWorkingGroup::worker_by_id(worker_id)
+}
+
 pub struct AddWorkerOpeningFixture {
     origin: RawOrigin<u64>,
     activate_at: hiring::ActivateOpeningAt<u64>,
     commitment: OpeningPolicyCommitment<u64, u64>,
     human_readable_text: Vec<u8>,
+    opening_type: OpeningType,
 }
 
 impl Default for AddWorkerOpeningFixture {
     fn default() -> Self {
-        AddWorkerOpeningFixture {
+        Self {
             origin: RawOrigin::Signed(1),
             activate_at: hiring::ActivateOpeningAt::CurrentBlock,
             commitment: <OpeningPolicyCommitment<u64, u64>>::default(),
             human_readable_text: b"human_text".to_vec(),
+            opening_type: OpeningType::Worker,
         }
     }
 }
@@ -572,35 +694,32 @@ impl AddWorkerOpeningFixture {
         self,
         policy_commitment: OpeningPolicyCommitment<u64, u64>,
     ) -> Self {
-        AddWorkerOpeningFixture {
+        Self {
             commitment: policy_commitment,
             ..self
         }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) -> u64 {
-        let saved_opening_next_id = TestWorkingGroup::next_worker_opening_id();
-        let actual_result = TestWorkingGroup::add_worker_opening(
-            self.origin.clone().into(),
-            self.activate_at.clone(),
-            self.commitment.clone(),
-            self.human_readable_text.clone(),
-        );
+        let saved_opening_next_id = TestWorkingGroup::next_opening_id();
+        let actual_result = self.call().map(|_| ());
+
         assert_eq!(actual_result.clone(), expected_result);
 
         if actual_result.is_ok() {
             assert_eq!(
-                TestWorkingGroup::next_worker_opening_id(),
+                TestWorkingGroup::next_opening_id(),
                 saved_opening_next_id + 1
             );
             let opening_id = saved_opening_next_id;
 
-            let actual_opening = TestWorkingGroup::worker_opening_by_id(opening_id);
+            let actual_opening = TestWorkingGroup::opening_by_id(opening_id);
 
-            let expected_opening = WorkerOpening::<u64, u64, u64, u64> {
-                opening_id,
-                worker_applications: BTreeSet::new(),
+            let expected_opening = Opening::<u64, u64, u64, u64> {
+                hiring_opening_id: opening_id,
+                applications: BTreeSet::new(),
                 policy_commitment: self.commitment.clone(),
+                opening_type: self.opening_type,
             };
 
             assert_eq!(actual_opening, expected_opening);
@@ -609,15 +728,39 @@ impl AddWorkerOpeningFixture {
         saved_opening_next_id
     }
 
+    pub fn call(&self) -> Result<u64, Error> {
+        let saved_opening_next_id = TestWorkingGroup::next_opening_id();
+        TestWorkingGroup::add_opening(
+            self.origin.clone().into(),
+            self.activate_at.clone(),
+            self.commitment.clone(),
+            self.human_readable_text.clone(),
+            self.opening_type,
+        )?;
+
+        Ok(saved_opening_next_id)
+    }
+
     pub fn with_text(self, text: Vec<u8>) -> Self {
-        AddWorkerOpeningFixture {
+        Self {
             human_readable_text: text,
             ..self
         }
     }
 
+    pub fn with_opening_type(self, opening_type: OpeningType) -> Self {
+        Self {
+            opening_type,
+            ..self
+        }
+    }
+
+    pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
+        Self { origin, ..self }
+    }
+
     pub fn with_activate_at(self, activate_at: hiring::ActivateOpeningAt<u64>) -> Self {
-        AddWorkerOpeningFixture {
+        Self {
             activate_at,
             ..self
         }
@@ -626,43 +769,6 @@ impl AddWorkerOpeningFixture {
 
 pub struct EventFixture;
 impl EventFixture {
-    pub fn assert_crate_events(
-        expected_raw_events: Vec<
-            RawEvent<
-                u64,
-                u64,
-                u64,
-                u64,
-                u64,
-                u64,
-                std::collections::BTreeMap<u64, u64>,
-                Vec<u8>,
-                u64,
-                u64,
-                TestWorkingGroupInstance,
-            >,
-        >,
-    ) {
-        let converted_events = expected_raw_events
-            .iter()
-            .map(|ev| TestEvent::working_group_TestWorkingGroupInstance(ev.clone()))
-            .collect::<Vec<TestEvent>>();
-
-        Self::assert_global_events(converted_events)
-    }
-    pub fn assert_global_events(expected_raw_events: Vec<TestEvent>) {
-        let expected_events = expected_raw_events
-            .iter()
-            .map(|ev| EventRecord {
-                phase: Phase::ApplyExtrinsic(0),
-                event: ev.clone(),
-                topics: vec![],
-            })
-            .collect::<Vec<EventRecord<_, _>>>();
-
-        assert_eq!(System::events(), expected_events);
-    }
-
     pub fn assert_last_crate_event(
         expected_raw_event: RawEvent<
             u64,
@@ -670,7 +776,6 @@ impl EventFixture {
             u64,
             u64,
             u64,
-            u64,
             std::collections::BTreeMap<u64, u64>,
             Vec<u8>,
             u64,
@@ -704,26 +809,29 @@ pub struct DecreaseWorkerStakeFixture {
 impl DecreaseWorkerStakeFixture {
     pub fn default_for_worker_id(worker_id: u64) -> Self {
         let account_id = 1;
-        DecreaseWorkerStakeFixture {
-            origin: RawOrigin::Signed(account_id),
+
+        let lead_account_id = get_current_lead_account_id();
+
+        Self {
+            origin: RawOrigin::Signed(lead_account_id),
             worker_id,
             balance: 10,
             account_id,
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        DecreaseWorkerStakeFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn with_balance(self, balance: u64) -> Self {
-        DecreaseWorkerStakeFixture { balance, ..self }
+        Self { balance, ..self }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
         let stake_id = 0;
         let old_balance = Balances::free_balance(&self.account_id);
         let old_stake = <stake::Module<Test>>::stakes(stake_id);
-        let actual_result = TestWorkingGroup::decrease_worker_stake(
+        let actual_result = TestWorkingGroup::decrease_stake(
             self.origin.clone().into(),
             self.worker_id,
             self.balance,
@@ -748,7 +856,7 @@ impl DecreaseWorkerStakeFixture {
     }
 }
 
-fn get_stake_balance(stake: stake::Stake<u64, u64, u64>) -> u64 {
+pub(crate) fn get_stake_balance(stake: stake::Stake<u64, u64, u64>) -> u64 {
     if let stake::StakingStatus::Staked(stake) = stake.staking_status {
         return stake.staked_amount;
     }
@@ -756,6 +864,17 @@ fn get_stake_balance(stake: stake::Stake<u64, u64, u64>) -> u64 {
     panic!("Not staked.");
 }
 
+fn get_current_lead_account_id() -> u64 {
+    let leader_worker_id = TestWorkingGroup::current_lead();
+
+    if let Some(leader_worker_id) = leader_worker_id {
+        let leader = TestWorkingGroup::worker_by_id(leader_worker_id);
+        leader.role_account_id
+    } else {
+        0 // return invalid lead_account_id for testing
+    }
+}
+
 pub struct SlashWorkerStakeFixture {
     origin: RawOrigin<u64>,
     worker_id: u64,
@@ -766,30 +885,30 @@ pub struct SlashWorkerStakeFixture {
 impl SlashWorkerStakeFixture {
     pub fn default_for_worker_id(worker_id: u64) -> Self {
         let account_id = 1;
-        SlashWorkerStakeFixture {
-            origin: RawOrigin::Signed(account_id),
+
+        let lead_account_id = get_current_lead_account_id();
+
+        Self {
+            origin: RawOrigin::Signed(lead_account_id),
             worker_id,
             balance: 10,
             account_id,
         }
     }
     pub fn with_origin(self, origin: RawOrigin<u64>) -> Self {
-        SlashWorkerStakeFixture { origin, ..self }
+        Self { origin, ..self }
     }
 
     pub fn with_balance(self, balance: u64) -> Self {
-        SlashWorkerStakeFixture { balance, ..self }
+        Self { balance, ..self }
     }
 
     pub fn call_and_assert(&self, expected_result: Result<(), Error>) {
         let stake_id = 0;
         let old_balance = Balances::free_balance(&self.account_id);
         let old_stake = <stake::Module<Test>>::stakes(stake_id);
-        let actual_result = TestWorkingGroup::slash_worker_stake(
-            self.origin.clone().into(),
-            self.worker_id,
-            self.balance,
-        );
+        let actual_result =
+            TestWorkingGroup::slash_stake(self.origin.clone().into(), self.worker_id, self.balance);
 
         assert_eq!(actual_result, expected_result);
 

+ 200 - 0
runtime-modules/working-group/src/tests/hiring_workflow.rs

@@ -0,0 +1,200 @@
+use crate::tests::fixtures::{
+    create_mint, increase_total_balance_issuance_using_account_id, set_mint_id, setup_members,
+    AddWorkerOpeningFixture, ApplyOnWorkerOpeningFixture, BeginReviewWorkerApplicationsFixture,
+    FillWorkerOpeningFixture, SetLeadFixture,
+};
+use crate::tests::mock::TestWorkingGroup;
+use crate::Error;
+use crate::{OpeningPolicyCommitment, OpeningType, RewardPolicy};
+use system::RawOrigin;
+
+#[derive(Clone)]
+struct HiringWorkflowApplication {
+    stake: Option<u64>,
+    worker_handle: Vec<u8>,
+    origin: RawOrigin<u64>,
+    member_id: u64,
+}
+
+pub struct HiringWorkflow {
+    opening_type: OpeningType,
+    expected_result: Result<(), Error>,
+    role_stake: Option<u64>,
+    applications: Vec<HiringWorkflowApplication>,
+    setup_environment: bool,
+    reward_policy: Option<RewardPolicy<u64, u64>>,
+}
+
+impl Default for HiringWorkflow {
+    fn default() -> Self {
+        Self {
+            opening_type: OpeningType::Worker,
+            expected_result: Ok(()),
+            role_stake: None,
+            applications: Vec::new(),
+            setup_environment: true,
+            reward_policy: None,
+        }
+    }
+}
+
+impl HiringWorkflow {
+    pub fn expect(self, result: Result<(), Error>) -> Self {
+        Self {
+            expected_result: result,
+            ..self
+        }
+    }
+
+    pub fn disable_setup_environment(self) -> Self {
+        Self {
+            setup_environment: false,
+            ..self
+        }
+    }
+
+    pub fn with_setup_environment(self, setup_environment: bool) -> Self {
+        Self {
+            setup_environment,
+            ..self
+        }
+    }
+
+    pub fn with_opening_type(self, opening_type: OpeningType) -> Self {
+        Self {
+            opening_type,
+            ..self
+        }
+    }
+
+    pub fn with_role_stake(self, role_stake: Option<u64>) -> Self {
+        Self { role_stake, ..self }
+    }
+
+    pub fn with_reward_policy(self, reward_policy: Option<RewardPolicy<u64, u64>>) -> Self {
+        Self {
+            reward_policy,
+            ..self
+        }
+    }
+
+    pub fn add_default_application(self) -> Self {
+        let worker_handle = b"default worker handle".to_vec();
+
+        self.add_application(worker_handle)
+    }
+
+    pub fn add_application(self, worker_handle: Vec<u8>) -> Self {
+        self.add_application_with_origin(worker_handle, RawOrigin::Signed(1), 1)
+    }
+
+    pub fn add_application_with_origin(
+        self,
+        worker_handle: Vec<u8>,
+        origin: RawOrigin<u64>,
+        member_id: u64,
+    ) -> Self {
+        let mut applications = self.applications;
+        applications.push(HiringWorkflowApplication {
+            worker_handle,
+            stake: self.role_stake.clone(),
+            origin,
+            member_id,
+        });
+
+        Self {
+            applications,
+            ..self
+        }
+    }
+
+    fn setup_environment(&self) {
+        if matches!(self.opening_type, OpeningType::Worker) {
+            SetLeadFixture::default().set_lead();
+        }
+        increase_total_balance_issuance_using_account_id(1, 10000);
+        setup_members(4);
+        set_mint_id(create_mint());
+    }
+
+    pub fn execute(&self) -> Option<u64> {
+        if self.setup_environment {
+            self.setup_environment()
+        }
+
+        let result = self.fill_worker_position();
+
+        let check_result = result.clone().map(|_| ());
+
+        assert_eq!(check_result, self.expected_result);
+
+        result.ok()
+    }
+
+    fn fill_worker_position(&self) -> Result<u64, Error> {
+        let origin = match self.opening_type {
+            OpeningType::Leader => RawOrigin::Root,
+            OpeningType::Worker => {
+                let leader_worker_id = TestWorkingGroup::current_lead().unwrap();
+                let leader = TestWorkingGroup::worker_by_id(leader_worker_id);
+                let lead_account_id = leader.role_account_id;
+
+                RawOrigin::Signed(lead_account_id)
+            }
+        };
+
+        // create the opening
+        let mut add_worker_opening_fixture = AddWorkerOpeningFixture::default()
+            .with_opening_type(self.opening_type)
+            .with_origin(origin.clone());
+
+        if let Some(stake) = self.role_stake.clone() {
+            add_worker_opening_fixture =
+                add_worker_opening_fixture.with_policy_commitment(OpeningPolicyCommitment {
+                    role_staking_policy: Some(hiring::StakingPolicy {
+                        amount: stake,
+                        amount_mode: hiring::StakingAmountLimitMode::AtLeast,
+                        crowded_out_unstaking_period_length: None,
+                        review_period_expired_unstaking_period_length: None,
+                    }),
+                    ..OpeningPolicyCommitment::default()
+                });
+        }
+
+        let opening_id = add_worker_opening_fixture.call()?;
+
+        // Fill applications.
+        let mut application_ids = Vec::new();
+        for application in self.applications.clone() {
+            let apply_on_worker_opening_fixture =
+                ApplyOnWorkerOpeningFixture::default_for_opening_id(opening_id)
+                    .with_text(application.worker_handle)
+                    .with_origin(application.origin, application.member_id)
+                    .with_role_stake(self.role_stake);
+
+            let application_id = apply_on_worker_opening_fixture.call()?;
+            application_ids.push(application_id);
+        }
+
+        // begin application review
+
+        let begin_review_worker_applications_fixture =
+            BeginReviewWorkerApplicationsFixture::default_for_opening_id(opening_id)
+                .with_origin(origin.clone());
+        begin_review_worker_applications_fixture.call_and_assert(Ok(()));
+
+        // fill opening
+        let mut fill_worker_opening_fixture =
+            FillWorkerOpeningFixture::default_for_ids(opening_id, application_ids)
+                .with_origin(origin.clone());
+
+        if let Some(reward_policy) = self.reward_policy.clone() {
+            fill_worker_opening_fixture =
+                fill_worker_opening_fixture.with_reward_policy(reward_policy);
+        }
+
+        let worker_id = fill_worker_opening_fixture.call()?;
+
+        Ok(worker_id)
+    }
+}

+ 55 - 3
runtime-modules/working-group/src/tests/mock.rs

@@ -1,4 +1,4 @@
-use crate::{Module, Trait};
+use crate::{BalanceOf, Module, NegativeImbalance, Trait};
 use common::constraints::InputValidationLengthConstraint;
 use primitives::H256;
 use sr_primitives::{
@@ -6,7 +6,10 @@ use sr_primitives::{
     traits::{BlakeTwo256, IdentityLookup},
     Perbill,
 };
-use srml_support::{impl_outer_event, impl_outer_origin, parameter_types};
+use srml_support::{
+    impl_outer_event, impl_outer_origin, parameter_types, StorageLinkedMap, StorageMap,
+};
+use std::marker::PhantomData;
 
 impl_outer_origin! {
         pub enum Origin for Test {}
@@ -79,7 +82,7 @@ impl minting::Trait for Test {
 impl stake::Trait for Test {
     type Currency = Balances;
     type StakePoolId = StakePoolId;
-    type StakingEventsHandler = ();
+    type StakingEventsHandler = StakingEventsHandler<Test>;
     type StakeId = u64;
     type SlashId = u64;
 }
@@ -124,8 +127,13 @@ impl recurringrewards::Trait for Test {
 pub type Balances = balances::Module<Test>;
 pub type System = system::Module<Test>;
 
+parameter_types! {
+    pub const MaxWorkerNumberLimit: u32 = 3;
+}
+
 impl Trait<TestWorkingGroupInstance> for Test {
     type Event = TestEvent;
+    type MaxWorkerNumberLimit = MaxWorkerNumberLimit;
 }
 
 pub type Membership = membership::members::Module<Test>;
@@ -163,3 +171,47 @@ pub fn build_test_externalities() -> runtime_io::TestExternalities {
 
     t.into()
 }
+
+pub struct StakingEventsHandler<T> {
+    pub marker: PhantomData<T>,
+}
+
+impl<T: stake::Trait + crate::Trait<TestWorkingGroupInstance>> stake::StakingEventsHandler<T>
+    for StakingEventsHandler<T>
+{
+    /// Unstake remaining sum back to the source_account_id
+    fn unstaked(
+        stake_id: &<T as stake::Trait>::StakeId,
+        _unstaked_amount: BalanceOf<T>,
+        remaining_imbalance: NegativeImbalance<T>,
+    ) -> NegativeImbalance<T> {
+        // Stake not related to a staked role managed by the hiring module.
+        if !hiring::ApplicationIdByStakingId::<T>::exists(*stake_id) {
+            return remaining_imbalance;
+        }
+
+        let hiring_application_id = hiring::ApplicationIdByStakingId::<T>::get(*stake_id);
+
+        if crate::MemberIdByHiringApplicationId::<T, TestWorkingGroupInstance>::exists(
+            hiring_application_id,
+        ) {
+            return <crate::Module<T, TestWorkingGroupInstance>>::refund_working_group_stake(
+                *stake_id,
+                remaining_imbalance,
+            );
+        }
+
+        remaining_imbalance
+    }
+
+    /// Empty handler for slashing
+    fn slashed(
+        _: &<T as stake::Trait>::StakeId,
+        _: Option<<T as stake::Trait>::SlashId>,
+        _: BalanceOf<T>,
+        _: BalanceOf<T>,
+        remaining_imbalance: NegativeImbalance<T>,
+    ) -> NegativeImbalance<T> {
+        remaining_imbalance
+    }
+}

File diff suppressed because it is too large
+ 262 - 322
runtime-modules/working-group/src/tests/mod.rs


+ 67 - 50
runtime-modules/working-group/src/types.rs

@@ -6,7 +6,7 @@ use rstd::collections::btree_set::BTreeSet;
 #[cfg(feature = "std")]
 use serde::{Deserialize, Serialize};
 
-/// Terms for slashings applied to a given role.
+/// Terms for slashes applied to a given role.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Debug, Clone, PartialEq, Eq)]
 pub struct SlashableTerms {
@@ -28,7 +28,7 @@ pub enum SlashingTerms {
     Slashable(SlashableTerms),
 }
 
-/// Must be default constructible because it indirectly is a value in a storage map.
+/// Must be default constructable because it indirectly is a value in a storage map.
 /// ***SHOULD NEVER ACTUALLY GET CALLED, IS REQUIRED TO DUE BAD STORAGE MODEL IN SUBSTRATE***
 impl Default for SlashingTerms {
     fn default() -> Self {
@@ -67,83 +67,94 @@ pub struct OpeningPolicyCommitment<BlockNumber, Balance> {
     pub fill_opening_failed_applicant_role_stake_unstaking_period: Option<BlockNumber>,
 
     /// When terminating a worker: unstaking period for application stake.
-    pub terminate_worker_application_stake_unstaking_period: Option<BlockNumber>,
+    pub terminate_application_stake_unstaking_period: Option<BlockNumber>,
 
-    /// When terminating a worker: unstaking period for role stake.
-    pub terminate_worker_role_stake_unstaking_period: Option<BlockNumber>,
+    /// When terminating a worke/leadr: unstaking period for role stake.
+    pub terminate_role_stake_unstaking_period: Option<BlockNumber>,
 
-    /// When a worker exists: unstaking period for application stake.
-    pub exit_worker_role_application_stake_unstaking_period: Option<BlockNumber>,
+    /// When a worker/lead exists: unstaking period for application stake.
+    pub exit_role_application_stake_unstaking_period: Option<BlockNumber>,
 
-    /// When a worker exists: unstaking period for role stake.
-    pub exit_worker_role_stake_unstaking_period: Option<BlockNumber>,
+    /// When a worker/lead exists: unstaking period for role stake.
+    pub exit_role_stake_unstaking_period: Option<BlockNumber>,
 }
 
-/// An opening for a worker role.
+/// An opening for a worker or lead role.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Default, Debug, Clone, PartialEq)]
-pub struct WorkerOpening<OpeningId, BlockNumber, Balance, WorkerApplicationId: core::cmp::Ord> {
-    /// Identifer for underlying opening in the hiring module.
-    pub opening_id: OpeningId,
+pub struct Opening<OpeningId, BlockNumber, Balance, WorkerApplicationId: core::cmp::Ord> {
+    /// Identifier for underlying opening in the hiring module.
+    pub hiring_opening_id: OpeningId,
 
     /// Set of identifiers for all worker applications ever added.
-    pub worker_applications: BTreeSet<WorkerApplicationId>,
+    pub applications: BTreeSet<WorkerApplicationId>,
 
     /// Commitment to policies in opening.
     pub policy_commitment: OpeningPolicyCommitment<BlockNumber, Balance>,
+
+    /// Defines opening type: Leader or worker.
+    pub opening_type: OpeningType,
 }
 
-/// Working group lead: worker lead.
+/// Defines type of the opening: regular working group fellow or group leader.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
-#[derive(Encode, Decode, Default, Debug, Clone, PartialEq)]
-pub struct Lead<MemberId, AccountId> {
-    /// Member id of the leader.
-    pub member_id: MemberId,
+#[derive(Encode, Decode, Debug, Clone, PartialEq, Eq, Copy)]
+pub enum OpeningType {
+    /// Group leader.
+    Leader,
 
-    /// Account used to authenticate in this role.
-    pub role_account_id: AccountId,
+    /// Regular worker.
+    Worker,
 }
 
-/// An application for the worker role.
+/// Must be default constructible because it indirectly is a value in a storage map.
+/// ***SHOULD NEVER ACTUALLY GET CALLED, IS REQUIRED TO DUE BAD STORAGE MODEL IN SUBSTRATE***
+impl Default for OpeningType {
+    fn default() -> Self {
+        Self::Worker
+    }
+}
+
+/// An application for the worker/lead role.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Default, Debug, Clone, PartialEq)]
-pub struct WorkerApplication<AccountId, WorkerOpeningId, MemberId, ApplicationId> {
+pub struct Application<AccountId, OpeningId, MemberId, ApplicationId> {
     /// Account used to authenticate in this role.
-    pub role_account: AccountId,
+    pub role_account_id: AccountId,
 
     /// Opening on which this application applies.
-    pub worker_opening_id: WorkerOpeningId,
+    pub opening_id: OpeningId,
 
     /// Member applying.
     pub member_id: MemberId,
 
     /// Underlying application in hiring module.
-    pub application_id: ApplicationId,
+    pub hiring_application_id: ApplicationId,
 }
 
-impl<AccountId: Clone, WorkerOpeningId: Clone, MemberId: Clone, ApplicationId: Clone>
-    WorkerApplication<AccountId, WorkerOpeningId, MemberId, ApplicationId>
+impl<AccountId: Clone, OpeningId: Clone, MemberId: Clone, ApplicationId: Clone>
+    Application<AccountId, OpeningId, MemberId, ApplicationId>
 {
     /// Creates a new worker application using parameters.
     pub fn new(
-        role_account: &AccountId,
-        worker_opening_id: &WorkerOpeningId,
+        role_account_id: &AccountId,
+        opening_id: &OpeningId,
         member_id: &MemberId,
         application_id: &ApplicationId,
     ) -> Self {
-        WorkerApplication {
-            role_account: role_account.clone(),
-            worker_opening_id: worker_opening_id.clone(),
+        Application {
+            role_account_id: role_account_id.clone(),
+            opening_id: opening_id.clone(),
             member_id: member_id.clone(),
-            application_id: application_id.clone(),
+            hiring_application_id: application_id.clone(),
         }
     }
 }
 
-/// Role stake information for a worker.
+/// Role stake information for a worker/lead.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Default, Debug, Clone, PartialEq)]
-pub struct WorkerRoleStakeProfile<StakeId, BlockNumber> {
+pub struct RoleStakeProfile<StakeId, BlockNumber> {
     /// Whether participant is staked, and if so, the identifier for this staking in the staking module.
     pub stake_id: StakeId,
 
@@ -154,8 +165,8 @@ pub struct WorkerRoleStakeProfile<StakeId, BlockNumber> {
     pub exit_unstaking_period: Option<BlockNumber>,
 }
 
-impl<StakeId: Clone, BlockNumber: Clone> WorkerRoleStakeProfile<StakeId, BlockNumber> {
-    /// Creates a new worker role stake profile using stake parameters.
+impl<StakeId: Clone, BlockNumber: Clone> RoleStakeProfile<StakeId, BlockNumber> {
+    /// Creates a new worker/lead role stake profile using stake parameters.
     pub fn new(
         stake_id: &StakeId,
         termination_unstaking_period: &Option<BlockNumber>,
@@ -169,19 +180,22 @@ impl<StakeId: Clone, BlockNumber: Clone> WorkerRoleStakeProfile<StakeId, BlockNu
     }
 }
 
-/// Working group participant: worker.
+/// Working group participant: worker/lead.
 /// This role can be staked, have reward and be inducted through the hiring module.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Default, Debug, Clone, PartialEq)]
 pub struct Worker<AccountId, RewardRelationshipId, StakeId, BlockNumber, MemberId> {
-    /// Member id related to the worker
+    /// Member id related to the worker/lead.
     pub member_id: MemberId,
+
     /// Account used to authenticate in this role.
-    pub role_account: AccountId,
+    pub role_account_id: AccountId,
+
     /// Whether the role has recurring reward, and if so an identifier for this.
     pub reward_relationship: Option<RewardRelationshipId>,
-    /// When set, describes role stake of worker.
-    pub role_stake_profile: Option<WorkerRoleStakeProfile<StakeId, BlockNumber>>,
+
+    /// When set, describes role stake of the worker/lead.
+    pub role_stake_profile: Option<RoleStakeProfile<StakeId, BlockNumber>>,
 }
 
 impl<
@@ -195,28 +209,31 @@ impl<
     /// Creates a new _Worker_ using parameters.
     pub fn new(
         member_id: &MemberId,
-        role_account: &AccountId,
+        role_account_id: &AccountId,
         reward_relationship: &Option<RewardRelationshipId>,
-        role_stake_profile: &Option<WorkerRoleStakeProfile<StakeId, BlockNumber>>,
+        role_stake_profile: &Option<RoleStakeProfile<StakeId, BlockNumber>>,
     ) -> Self {
         Worker {
             member_id: member_id.clone(),
-            role_account: role_account.clone(),
+            role_account_id: role_account_id.clone(),
             reward_relationship: reward_relationship.clone(),
             role_stake_profile: role_stake_profile.clone(),
         }
     }
 }
 
-/// Origin of exit initiation on behalf of a curator.'
+/// Origin of exit initiation.
 #[cfg_attr(feature = "std", derive(Serialize, Deserialize))]
 #[derive(Encode, Decode, Debug, Clone, PartialEq)]
-pub enum WorkerExitInitiationOrigin {
-    /// Lead is origin.
+pub enum ExitInitiationOrigin {
+    /// Lead fires the worker.
     Lead,
 
-    /// The curator exiting is the origin.
+    /// Worker leaves the position.
     Worker,
+
+    /// Council fires the leader.
+    Sudo,
 }
 
 /// The recurring reward if any to be assigned to an actor when filling in the position.

+ 1 - 2
runtime/Cargo.toml

@@ -1,11 +1,10 @@
-
 [package]
 authors = ['Joystream contributors']
 edition = '2018'
 name = 'joystream-node-runtime'
 # Follow convention: https://github.com/Joystream/substrate-runtime-joystream/issues/1
 # {Authoring}.{Spec}.{Impl} of the RuntimeVersion
-version = '6.16.0'
+version = '6.17.0'
 
 [features]
 default = ['std']

+ 141 - 0
runtime/src/integration/content_working_group.rs

@@ -0,0 +1,141 @@
+use crate::{AccountId, Credential, Runtime};
+
+use srml_support::traits::{Currency, Imbalance};
+use srml_support::{parameter_types, StorageLinkedMap, StorageMap};
+
+parameter_types! {
+    pub const CurrentLeadCredential: Credential = 0;
+    pub const AnyActiveCuratorCredential: Credential = 1;
+    pub const AnyActiveChannelOwnerCredential: Credential = 2;
+    pub const PrincipalIdMappingStartsAtCredential: Credential = 1000;
+}
+
+pub struct ContentWorkingGroupCredentials {}
+impl versioned_store_permissions::CredentialChecker<Runtime> for ContentWorkingGroupCredentials {
+    fn account_has_credential(
+        account: &AccountId,
+        credential: <Runtime as versioned_store_permissions::Trait>::Credential,
+    ) -> bool {
+        match credential {
+            // Credentials from 0..999 represents groups or more complex requirements
+            // Current Lead if set
+            credential if credential == CurrentLeadCredential::get() => {
+                match <content_working_group::Module<Runtime>>::ensure_lead_is_set() {
+                    Ok((_, lead)) => lead.role_account == *account,
+                    _ => false,
+                }
+            }
+            // Any Active Curator
+            credential if credential == AnyActiveCuratorCredential::get() => {
+                // Look for a Curator with a matching role account
+                for (_principal_id, principal) in
+                    <content_working_group::PrincipalById<Runtime>>::enumerate()
+                {
+                    if let content_working_group::Principal::Curator(curator_id) = principal {
+                        let curator =
+                            <content_working_group::CuratorById<Runtime>>::get(curator_id);
+                        if curator.role_account == *account
+                            && curator.stage == content_working_group::CuratorRoleStage::Active
+                        {
+                            return true;
+                        }
+                    }
+                }
+
+                false
+            }
+            // Any Active Channel Owner
+            credential if credential == AnyActiveChannelOwnerCredential::get() => {
+                // Look for a ChannelOwner with a matching role account
+                for (_principal_id, principal) in
+                    <content_working_group::PrincipalById<Runtime>>::enumerate()
+                {
+                    if let content_working_group::Principal::ChannelOwner(channel_id) = principal {
+                        let channel =
+                            <content_working_group::ChannelById<Runtime>>::get(channel_id);
+                        if channel.role_account == *account {
+                            return true; // should we also take publishing_status/curation_status into account ?
+                        }
+                    }
+                }
+
+                false
+            }
+            // mapping to working group principal id
+            n if n >= PrincipalIdMappingStartsAtCredential::get() => {
+                <content_working_group::Module<Runtime>>::account_has_credential(
+                    account,
+                    n - PrincipalIdMappingStartsAtCredential::get(),
+                )
+            }
+            _ => false,
+        }
+    }
+}
+
+pub struct ContentWorkingGroupStakingEventHandler {}
+impl stake::StakingEventsHandler<Runtime> for ContentWorkingGroupStakingEventHandler {
+    fn unstaked(
+        stake_id: &<Runtime as stake::Trait>::StakeId,
+        _unstaked_amount: stake::BalanceOf<Runtime>,
+        remaining_imbalance: stake::NegativeImbalance<Runtime>,
+    ) -> stake::NegativeImbalance<Runtime> {
+        if !hiring::ApplicationIdByStakingId::<Runtime>::exists(stake_id) {
+            // Stake not related to a staked role managed by the hiring module
+            return remaining_imbalance;
+        }
+
+        let application_id = hiring::ApplicationIdByStakingId::<Runtime>::get(stake_id);
+
+        if !content_working_group::CuratorApplicationById::<Runtime>::exists(application_id) {
+            // Stake not for a Curator
+            return remaining_imbalance;
+        }
+
+        // Notify the Hiring module - is there a potential re-entrancy bug if
+        // instant unstaking is occuring?
+        hiring::Module::<Runtime>::unstaked(*stake_id);
+
+        // Only notify working group module if non instantaneous unstaking occured
+        if content_working_group::UnstakerByStakeId::<Runtime>::exists(stake_id) {
+            content_working_group::Module::<Runtime>::unstaked(*stake_id);
+        }
+
+        // Determine member id of the curator
+        let curator_application =
+            content_working_group::CuratorApplicationById::<Runtime>::get(application_id);
+        let member_id = curator_application.member_id;
+
+        // get member's profile
+        let member_profile = membership::members::MemberProfile::<Runtime>::get(member_id).unwrap();
+
+        // deposit funds to member's root_account
+        // The application doesn't recorded the original source_account from which staked funds were
+        // provided, so we don't really have another option at the moment.
+        <Runtime as stake::Trait>::Currency::resolve_creating(
+            &member_profile.root_account,
+            remaining_imbalance,
+        );
+
+        stake::NegativeImbalance::<Runtime>::zero()
+    }
+
+    // Handler for slashing event
+    fn slashed(
+        _id: &<Runtime as stake::Trait>::StakeId,
+        _slash_id: Option<<Runtime as stake::Trait>::SlashId>,
+        _slashed_amount: stake::BalanceOf<Runtime>,
+        _remaining_stake: stake::BalanceOf<Runtime>,
+        remaining_imbalance: stake::NegativeImbalance<Runtime>,
+    ) -> stake::NegativeImbalance<Runtime> {
+        // Check if the stake is associated with a hired curator or applicant
+        // if their stake goes below minimum required for the role,
+        // they should get deactivated.
+        // Since we don't currently implement any slash initiation in working group,
+        // there is nothing to do for now.
+
+        // Not interested in transfering the slashed amount anywhere for now,
+        // so return it to next handler.
+        remaining_imbalance
+    }
+}

+ 2 - 0
runtime/src/integration/mod.rs

@@ -1,2 +1,4 @@
+pub mod content_working_group;
 pub mod proposals;
 pub mod storage;
+pub mod working_group;

+ 2 - 2
runtime/src/integration/proposals/council_origin_validator.rs

@@ -2,7 +2,7 @@
 
 use rstd::marker::PhantomData;
 
-use common::origin_validator::ActorOriginValidator;
+use common::origin::ActorOriginValidator;
 use proposals_engine::VotersParameters;
 
 use super::{MemberId, MembershipOriginValidator};
@@ -44,7 +44,7 @@ impl<T: governance::council::Trait> VotersParameters for CouncilManager<T> {
 mod tests {
     use super::CouncilManager;
     use crate::Runtime;
-    use common::origin_validator::ActorOriginValidator;
+    use common::origin::ActorOriginValidator;
     use membership::members::UserInfo;
     use proposals_engine::VotersParameters;
     use sr_primitives::AccountId32;

+ 2 - 2
runtime/src/integration/proposals/membership_origin_validator.rs

@@ -2,7 +2,7 @@
 
 use rstd::marker::PhantomData;
 
-use common::origin_validator::ActorOriginValidator;
+use common::origin::ActorOriginValidator;
 use system::ensure_signed;
 
 /// Member of the Joystream organization
@@ -46,7 +46,7 @@ impl<T: crate::members::Trait>
 mod tests {
     use super::MembershipOriginValidator;
     use crate::Runtime;
-    use common::origin_validator::ActorOriginValidator;
+    use common::origin::ActorOriginValidator;
     use membership::members::UserInfo;
     use sr_primitives::AccountId32;
     use system::RawOrigin;

+ 1 - 1
runtime/src/integration/storage.rs

@@ -8,7 +8,7 @@ pub struct StorageProviderHelper;
 
 impl storage::data_directory::StorageProviderHelper<Runtime> for StorageProviderHelper {
     fn get_random_storage_provider() -> Result<ActorId, &'static str> {
-        let ids = crate::StorageWorkingGroup::get_all_worker_ids();
+        let ids = crate::StorageWorkingGroup::get_regular_worker_ids();
 
         let live_ids: Vec<ActorId> = ids
             .into_iter()

+ 49 - 0
runtime/src/integration/working_group.rs

@@ -0,0 +1,49 @@
+use rstd::marker::PhantomData;
+use srml_support::{StorageLinkedMap, StorageMap};
+
+use crate::StorageWorkingGroupInstance;
+use stake::{BalanceOf, NegativeImbalance};
+
+pub struct StakingEventsHandler<T> {
+    pub marker: PhantomData<T>,
+}
+
+impl<T: stake::Trait + working_group::Trait<StorageWorkingGroupInstance>>
+    stake::StakingEventsHandler<T> for StakingEventsHandler<T>
+{
+    /// Unstake remaining sum back to the source_account_id
+    fn unstaked(
+        stake_id: &<T as stake::Trait>::StakeId,
+        _unstaked_amount: BalanceOf<T>,
+        remaining_imbalance: NegativeImbalance<T>,
+    ) -> NegativeImbalance<T> {
+        // Stake not related to a staked role managed by the hiring module.
+        if !hiring::ApplicationIdByStakingId::<T>::exists(*stake_id) {
+            return remaining_imbalance;
+        }
+
+        let hiring_application_id = hiring::ApplicationIdByStakingId::<T>::get(*stake_id);
+
+        if working_group::MemberIdByHiringApplicationId::<T, StorageWorkingGroupInstance>::exists(
+            hiring_application_id,
+        ) {
+            return <working_group::Module<T, StorageWorkingGroupInstance>>::refund_working_group_stake(
+				*stake_id,
+				remaining_imbalance,
+			);
+        }
+
+        remaining_imbalance
+    }
+
+    /// Empty handler for the slashing.
+    fn slashed(
+        _: &<T as stake::Trait>::StakeId,
+        _: Option<<T as stake::Trait>::SlashId>,
+        _: BalanceOf<T>,
+        _: BalanceOf<T>,
+        remaining_imbalance: NegativeImbalance<T>,
+    ) -> NegativeImbalance<T> {
+        remaining_imbalance
+    }
+}

+ 17 - 139
runtime/src/lib.rs

@@ -161,7 +161,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion {
     spec_name: create_runtime_str!("joystream-node"),
     impl_name: create_runtime_str!("joystream-node"),
     authoring_version: 6,
-    spec_version: 16,
+    spec_version: 17,
     impl_version: 0,
     apis: RUNTIME_API_VERSIONS,
 };
@@ -319,7 +319,7 @@ impl transaction_payment::Trait for Runtime {
     type TransactionBaseFee = TransactionBaseFee;
     type TransactionByteFee = TransactionByteFee;
     type WeightToFee = ();
-    type FeeMultiplierUpdate = (); // FeeMultiplierUpdateHandler;
+    type FeeMultiplierUpdate = ();
 }
 
 impl sudo::Trait for Runtime {
@@ -447,7 +447,10 @@ impl versioned_store::Trait for Runtime {
 
 impl versioned_store_permissions::Trait for Runtime {
     type Credential = Credential;
-    type CredentialChecker = (ContentWorkingGroupCredentials, SudoKeyHasAllCredentials);
+    type CredentialChecker = (
+        integration::content_working_group::ContentWorkingGroupCredentials,
+        SudoKeyHasAllCredentials,
+    );
     type CreateClassPermissionsChecker = ContentLeadOrSudoKeyCanCreateClasses;
 }
 
@@ -462,72 +465,6 @@ impl versioned_store_permissions::CredentialChecker<Runtime> for SudoKeyHasAllCr
     }
 }
 
-parameter_types! {
-    pub const CurrentLeadCredential: Credential = 0;
-    pub const AnyActiveCuratorCredential: Credential = 1;
-    pub const AnyActiveChannelOwnerCredential: Credential = 2;
-    pub const PrincipalIdMappingStartsAtCredential: Credential = 1000;
-}
-
-pub struct ContentWorkingGroupCredentials {}
-impl versioned_store_permissions::CredentialChecker<Runtime> for ContentWorkingGroupCredentials {
-    fn account_has_credential(
-        account: &AccountId,
-        credential: <Runtime as versioned_store_permissions::Trait>::Credential,
-    ) -> bool {
-        match credential {
-            // Credentials from 0..999 represents groups or more complex requirements
-            // Current Lead if set
-            credential if credential == CurrentLeadCredential::get() => {
-                match <content_wg::Module<Runtime>>::ensure_lead_is_set() {
-                    Ok((_, lead)) => lead.role_account == *account,
-                    _ => false,
-                }
-            }
-            // Any Active Curator
-            credential if credential == AnyActiveCuratorCredential::get() => {
-                // Look for a Curator with a matching role account
-                for (_principal_id, principal) in <content_wg::PrincipalById<Runtime>>::enumerate()
-                {
-                    if let content_wg::Principal::Curator(curator_id) = principal {
-                        let curator = <content_wg::CuratorById<Runtime>>::get(curator_id);
-                        if curator.role_account == *account
-                            && curator.stage == content_wg::CuratorRoleStage::Active
-                        {
-                            return true;
-                        }
-                    }
-                }
-
-                false
-            }
-            // Any Active Channel Owner
-            credential if credential == AnyActiveChannelOwnerCredential::get() => {
-                // Look for a ChannelOwner with a matching role account
-                for (_principal_id, principal) in <content_wg::PrincipalById<Runtime>>::enumerate()
-                {
-                    if let content_wg::Principal::ChannelOwner(channel_id) = principal {
-                        let channel = <content_wg::ChannelById<Runtime>>::get(channel_id);
-                        if channel.role_account == *account {
-                            return true; // should we also take publishing_status/curation_status into account ?
-                        }
-                    }
-                }
-
-                false
-            }
-            // mapping to workging group principal id
-            n if n >= PrincipalIdMappingStartsAtCredential::get() => {
-                <content_wg::Module<Runtime>>::account_has_credential(
-                    account,
-                    n - PrincipalIdMappingStartsAtCredential::get(),
-                )
-            }
-            _ => false,
-        }
-    }
-}
-
 // Allow sudo key holder permission to create classes
 pub struct SudoKeyCanCreateClasses {}
 impl versioned_store_permissions::CreateClassPermissionsChecker<Runtime>
@@ -591,80 +528,16 @@ impl stake::Trait for Runtime {
     type Currency = <Self as common::currency::GovernanceCurrency>::Currency;
     type StakePoolId = StakePoolId;
     type StakingEventsHandler = (
-        ContentWorkingGroupStakingEventHandler,
-        crate::integration::proposals::StakingEventsHandler<Self>,
+        crate::integration::content_working_group::ContentWorkingGroupStakingEventHandler,
+        (
+            crate::integration::proposals::StakingEventsHandler<Self>,
+            crate::integration::working_group::StakingEventsHandler<Self>,
+        ),
     );
     type StakeId = u64;
     type SlashId = u64;
 }
 
-pub struct ContentWorkingGroupStakingEventHandler {}
-impl stake::StakingEventsHandler<Runtime> for ContentWorkingGroupStakingEventHandler {
-    fn unstaked(
-        stake_id: &<Runtime as stake::Trait>::StakeId,
-        _unstaked_amount: stake::BalanceOf<Runtime>,
-        remaining_imbalance: stake::NegativeImbalance<Runtime>,
-    ) -> stake::NegativeImbalance<Runtime> {
-        if !hiring::ApplicationIdByStakingId::<Runtime>::exists(stake_id) {
-            // Stake not related to a staked role managed by the hiring module
-            return remaining_imbalance;
-        }
-
-        let application_id = hiring::ApplicationIdByStakingId::<Runtime>::get(stake_id);
-
-        if !content_wg::CuratorApplicationById::<Runtime>::exists(application_id) {
-            // Stake not for a Curator
-            return remaining_imbalance;
-        }
-
-        // Notify the Hiring module - is there a potential re-entrancy bug if
-        // instant unstaking is occuring?
-        hiring::Module::<Runtime>::unstaked(*stake_id);
-
-        // Only notify working group module if non instantaneous unstaking occured
-        if content_wg::UnstakerByStakeId::<Runtime>::exists(stake_id) {
-            content_wg::Module::<Runtime>::unstaked(*stake_id);
-        }
-
-        // Determine member id of the curator
-        let curator_application =
-            content_wg::CuratorApplicationById::<Runtime>::get(application_id);
-        let member_id = curator_application.member_id;
-
-        // get member's profile
-        let member_profile = membership::members::MemberProfile::<Runtime>::get(member_id).unwrap();
-
-        // deposit funds to member's root_account
-        // The application doesn't recorded the original source_account from which staked funds were
-        // provided, so we don't really have another option at the moment.
-        <Runtime as stake::Trait>::Currency::resolve_creating(
-            &member_profile.root_account,
-            remaining_imbalance,
-        );
-
-        stake::NegativeImbalance::<Runtime>::zero()
-    }
-
-    // Handler for slashing event
-    fn slashed(
-        _id: &<Runtime as stake::Trait>::StakeId,
-        _slash_id: Option<<Runtime as stake::Trait>::SlashId>,
-        _slashed_amount: stake::BalanceOf<Runtime>,
-        _remaining_stake: stake::BalanceOf<Runtime>,
-        remaining_imbalance: stake::NegativeImbalance<Runtime>,
-    ) -> stake::NegativeImbalance<Runtime> {
-        // Check if the stake is associated with a hired curator or applicant
-        // if their stake goes below minimum required for the role,
-        // they should get deactivated.
-        // Since we don't currently implement any slash initiation in working group,
-        // there is nothing to do for now.
-
-        // Not interested in transfering the slashed amount anywhere for now,
-        // so return it to next handler.
-        remaining_imbalance
-    }
-}
-
 impl content_wg::Trait for Runtime {
     type Event = Event;
 }
@@ -720,7 +593,7 @@ impl members::Trait for Runtime {
  *
  * ForumUserRegistry could have been implemented directly on
  * the membership module, and likewise ForumUser on Profile,
- * however this approach is more loosley coupled.
+ * however this approach is more loosely coupled.
  *
  * Further exploration required to decide what the long
  * run convention should be.
@@ -758,8 +631,13 @@ impl migration::Trait for Runtime {
 // The storage working group instance alias.
 pub type StorageWorkingGroupInstance = working_group::Instance2;
 
+parameter_types! {
+    pub const MaxWorkerNumberLimit: u32 = 100;
+}
+
 impl working_group::Trait<StorageWorkingGroupInstance> for Runtime {
     type Event = Event;
+    type MaxWorkerNumberLimit = MaxWorkerNumberLimit;
 }
 
 impl service_discovery::Trait for Runtime {

+ 1 - 3
scripts/run-test-chain.sh

@@ -7,8 +7,6 @@ perl -i -pe's/"setElectionParametersProposalGracePeriod":.*/"setElectionParamete
 perl -i -pe's/"textProposalGracePeriod":.*/"textProposalGracePeriod": 0,/' .tmp/chainspec.json
 perl -i -pe's/"setContentWorkingGroupMintCapacityProposalGracePeriod":.*/"setContentWorkingGroupMintCapacityProposalGracePeriod": 0,/' .tmp/chainspec.json
 perl -i -pe's/"setLeadProposalGracePeriod":.*/"setLeadProposalGracePeriod": 0,/' .tmp/chainspec.json
-perl -i -pe's/"spendingProposalGracePeriod":.*/"spendingProposalGracePeriod": 0,/' .tmp/chainspec.json
-perl -i -pe's/"evictStorageProviderProposalGracePeriod":.*/"evictStorageProviderProposalGracePeriod": 0,/' .tmp/chainspec.json
-perl -i -pe's/"setStorageRoleParametersProposalGracePeriod":.*/"setStorageRoleParametersProposalGracePeriod": 0/' .tmp/chainspec.json
+perl -i -pe's/"spendingProposalGracePeriod":.*/"spendingProposalGracePeriod": 0/' .tmp/chainspec.json
 yes | cargo run --release -p joystream-node -- purge-chain --dev
 cargo run --release -p joystream-node -- --chain=.tmp/chainspec.json --alice --validator

+ 1 - 1
tests/network-tests/src/constantinople/tests/impl/electingCouncil.ts

@@ -2,7 +2,7 @@ import { KeyringPair } from '@polkadot/keyring/types';
 import { ApiWrapper } from '../../utils/apiWrapper';
 import { Keyring } from '@polkadot/api';
 import BN from 'bn.js';
-import { Seat } from '@constantinople/types/lib/council';
+import { Seat } from '@constantinople/types/council';
 import { assert } from 'chai';
 import { v4 as uuid } from 'uuid';
 import { Utils } from '../../utils/utils';

+ 1 - 1
tests/network-tests/src/constantinople/tests/proposals/impl/storageRoleParametersProposal.ts

@@ -4,7 +4,7 @@ import { ApiWrapper } from '../../../utils/apiWrapper';
 import { v4 as uuid } from 'uuid';
 import BN from 'bn.js';
 import { assert } from 'chai';
-import { RoleParameters } from '@constantinople/types/lib/roles';
+import { RoleParameters } from '@constantinople/types/roles';
 import tap from 'tap';
 
 export function storageRoleParametersProposalTest(

+ 5 - 5
tests/network-tests/src/constantinople/utils/apiWrapper.ts

@@ -2,11 +2,11 @@ import { ApiPromise, WsProvider } from '@polkadot/api';
 import { Option, Vec, Bytes, u32 } from '@polkadot/types';
 import { Codec } from '@polkadot/types/types';
 import { KeyringPair } from '@polkadot/keyring/types';
-import { UserInfo, PaidMembershipTerms, MemberId } from '@constantinople/types/lib/members';
-import { Mint, MintId } from '@constantinople/types/lib/mint';
-import { Lead, LeadId } from '@constantinople/types/lib/content-working-group';
-import { RoleParameters } from '@constantinople/types/lib/roles';
-import { Seat } from '@constantinople/types/lib/council';
+import { UserInfo, PaidMembershipTerms, MemberId } from '@constantinople/types/members';
+import { Mint, MintId } from '@constantinople/types/mint';
+import { Lead, LeadId } from '@constantinople/types/content-working-group';
+import { RoleParameters } from '@constantinople/types/roles';
+import { Seat } from '@constantinople/types/council';
 import { Balance, EventRecord, AccountId, BlockNumber, BalanceOf } from '@polkadot/types/interfaces';
 import BN from 'bn.js';
 import { SubmittableExtrinsic } from '@polkadot/api/types';

+ 1 - 1
tests/network-tests/src/constantinople/utils/utils.ts

@@ -4,7 +4,7 @@ import { blake2AsHex } from '@polkadot/util-crypto';
 import BN from 'bn.js';
 import fs from 'fs';
 import { decodeAddress } from '@polkadot/keyring';
-import { Seat } from '@constantinople/types/lib/council';
+import { Seat } from '@constantinople/types/council';
 
 export class Utils {
   private static LENGTH_ADDRESS = 32 + 1; // publicKey + prefix

+ 12 - 1
types/.gitignore

@@ -1 +1,12 @@
-/lib/
+# Don't track build artifacts
+**/*.js
+**/*.d.ts
+
+# JSON files imported by hiring types
+hiring/schemas/role.schema.json
+
+# from prior versions
+lib/
+
+# artifact of webpack when building pioneer?
+build/

+ 6 - 0
types/.npmignore

@@ -0,0 +1,6 @@
+# keep src/ files, packages is compiled when installed
+# src/
+
+# old build artifacts
+lib/
+build/

+ 10 - 9
types/package.json

@@ -2,7 +2,8 @@
   "name": "@joystream/types",
   "version": "0.11.0",
   "description": "Types for Joystream Substrate Runtime - nicaea release",
-  "main": "lib/index.js",
+  "main": "index.js",
+  "types": "index.d.ts",
   "scripts": {
     "prepublish": "npm run build",
     "build": "tsc --build tsconfig.json"
@@ -11,15 +12,15 @@
   "maintainers": [],
   "dependencies": {
     "@polkadot/types": "^0.96.1",
+    "@polkadot/keyring": "^1.7.0-beta.5",
+    "@types/lodash": "^4.14.157",
     "@types/vfile": "^4.0.0",
     "ajv": "^6.11.0",
-    "lodash": "^4.17.15"
-  },
-  "directories": {
-    "lib": "lib"
+    "lodash": "^4.17.15",
+    "moment": "^2.24.0"
   },
   "devDependencies": {
-    "typescript": "^3.6.4"
+    "typescript": "^3.7.2"
   },
   "engines": {
     "node": ">=10.0"
@@ -30,7 +31,7 @@
   },
   "repository": {
     "type": "git",
-    "url": "git+https://github.com/Joystream/apps.git"
+    "url": "git+https://github.com/Joystream/joystream.git"
   },
   "keywords": [
     "substrate",
@@ -39,7 +40,7 @@
   ],
   "license": "Apache-2.0",
   "bugs": {
-    "url": "https://github.com/Joystream/apps/issues"
+    "url": "https://github.com/Joystream/joystream/issues"
   },
-  "homepage": "https://github.com/Joystream/packages/joy-types/README.md"
+  "homepage": "https://github.com/Joystream/joystream"
 }

+ 19 - 0
types/src/common.ts

@@ -1,6 +1,8 @@
 import { Struct, Option, Text, bool, Vec, u16, u32, u64, getTypeRegistry } 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';
 
@@ -38,6 +40,23 @@ export class BlockAndTime extends Struct {
     static newEmpty (): BlockAndTime {
         return new BlockAndTime({} as BlockAndTime);
     }
+
+    get momentDate (): moment.Moment {
+        const YEAR_2000_MILLISECONDS = 946684801000;
+
+        // overflowing in ~270,000 years
+        const timestamp = this.time.toNumber();
+
+        // TODO: remove once https://github.com/Joystream/joystream/issues/705 is resolved
+        // due to a bug, timestamp can be either in seconds or milliseconds
+        let timestampInMillis = timestamp;
+        if (timestamp < YEAR_2000_MILLISECONDS) {
+          // timestamp is in seconds
+          timestampInMillis = timestamp * 1000;
+        }
+
+        return moment(timestampInMillis);
+      }
 }
 
 export function getTextPropAsString(struct: Struct, fieldName: string): string {

+ 16 - 1
types/src/content-working-group/index.ts

@@ -245,6 +245,11 @@ export class Curator extends JoyStruct<ICurator> {
     return this.getField<GenericAccountId>('role_account')
   }
 
+  // Helper for working-group compatibility
+  get role_account_id(): GenericAccountId {
+    return this.role_account;
+  }
+
   get reward_relationship(): Option<RewardRelationshipId> {
     return this.getField<Option<RewardRelationshipId>>('reward_relationship')
   }
@@ -290,12 +295,17 @@ export class CuratorApplication extends JoyStruct<ICuratorApplication> {
     return this.getField<GenericAccountId>('role_account')
   }
 
+  // Helper for working-group compatibility
+  get role_account_id(): GenericAccountId {
+    return this.role_account;
+  }
+
   get curator_opening_id(): CuratorOpeningId {
     return this.getField<CuratorOpeningId>('curator_opening_id')
   }
 
   // Helper for working-group compatibility
-  get worker_opening_id(): CuratorOpeningId {
+  get opening_id(): CuratorOpeningId {
     return this.curator_opening_id;
   }
 
@@ -431,6 +441,11 @@ export class CuratorOpening extends JoyStruct<ICuratorOpening> {
   get opening_id(): OpeningId {
     return this.getField<OpeningId>('opening_id')
   }
+
+  // Helper for working-group compatibility
+  get hiring_opening_id(): OpeningId {
+    return this.opening_id;
+  }
 };
 
 export type IExitedLeadRole = {

+ 89 - 91
types/src/working-group/index.ts

@@ -7,60 +7,34 @@ import { RewardRelationshipId } from '../recurring-rewards';
 import { StakeId } from '../stake';
 import { ApplicationId, OpeningId, ApplicationRationingPolicy, StakingPolicy } from '../hiring';
 
-export type ILead = {
-  member_id: MemberId,
-  role_account_id: AccountId
-};
-
-// This type is also defined in /content-workig-group (and those are incosistent), but here
-// it is beeing registered as "LeadOf" (which is an alias used by the runtime working-group module),
-// so it shouldn't cause any conflicts)
-export class Lead extends JoyStruct<ILead> {
-  constructor (value?: ILead) {
-    super({
-      member_id: MemberId,
-      role_account_id: "AccountId"
-    }, value);
-  }
-
-  get member_id(): MemberId {
-    return this.getField<MemberId>('member_id')
-  }
-
-  get role_account_id(): AccountId {
-    return this.getField<AccountId>('role_account_id')
-  }
-};
-
-export class WorkerApplicationId extends ApplicationId { };
-
-export class WorkerOpeningId extends OpeningId { };
-
 export class RationaleText extends Bytes { };
 
-export type IWorkerApplication = {
-  role_account: AccountId,
-  worker_opening_id: WorkerOpeningId,
+export type IApplication = {
+  role_account_id: AccountId,
+  opening_id: OpeningId,
   member_id: MemberId,
   application_id: ApplicationId
 };
 
-export class WorkerApplication extends JoyStruct<IWorkerApplication> {
-  constructor (value?: IWorkerApplication) {
+// This type is also defined in /hiring (and those are incosistent), but here
+// it is beeing registered as "ApplicationOf" (which is an alias used by the runtime working-group module),
+// so it shouldn't cause any conflicts
+export class Application extends JoyStruct<IApplication> {
+  constructor (value?: IApplication) {
     super({
-      role_account: "AccountId",
-      worker_opening_id: WorkerOpeningId,
+      role_account_id: "AccountId",
+      opening_id: OpeningId,
       member_id: MemberId,
       application_id: ApplicationId
     }, value);
   }
 
-  get role_account(): AccountId {
-    return this.getField<AccountId>('role_account');
+  get role_account_id(): AccountId {
+    return this.getField<AccountId>('role_account_id');
   }
 
-  get worker_opening_id(): WorkerOpeningId {
-    return this.getField<WorkerOpeningId>('worker_opening_id');
+  get opening_id(): OpeningId {
+    return this.getField<OpeningId>('opening_id');
   }
 
   get member_id(): MemberId {
@@ -76,19 +50,19 @@ export class WorkerId extends ActorId { };
 
 export class StorageProviderId extends WorkerId { };
 
-export class WorkerApplicationIdSet extends BTreeSet.with(WorkerApplicationId) { };
+export class ApplicationIdSet extends BTreeSet.with(ApplicationId) { };
 
-export class WorkerApplicationIdToWorkerIdMap extends BTreeMap.with(WorkerApplicationId, WorkerId) { };
+export class ApplicationIdToWorkerIdMap extends BTreeMap.with(ApplicationId, WorkerId) { };
 
 
-export type IWorkerRoleStakeProfile = {
+export type IRoleStakeProfile = {
   stake_id: StakeId,
   termination_unstaking_period: Option<BlockNumber>,
   exit_unstaking_period: Option<BlockNumber>,
 };
 
-export class WorkerRoleStakeProfile extends JoyStruct<IWorkerRoleStakeProfile> {
-  constructor (value?: IWorkerRoleStakeProfile) {
+export class RoleStakeProfile extends JoyStruct<IRoleStakeProfile> {
+  constructor (value?: IRoleStakeProfile) {
     super({
       stake_id: StakeId,
       termination_unstaking_period: "Option<BlockNumber>",
@@ -111,18 +85,18 @@ export class WorkerRoleStakeProfile extends JoyStruct<IWorkerRoleStakeProfile> {
 
 export type IWorker = {
   member_id: MemberId,
-  role_account: AccountId,
+  role_account_id: AccountId,
   reward_relationship: Option<RewardRelationshipId>,
-  role_stake_profile: Option<WorkerRoleStakeProfile>,
+  role_stake_profile: Option<RoleStakeProfile>,
 }
 
 export class Worker extends JoyStruct<IWorker> {
   constructor (value?: IWorker) {
     super({
       member_id: MemberId,
-      role_account: "AccountId",
+      role_account_id: "AccountId",
       reward_relationship: Option.with(RewardRelationshipId),
-      role_stake_profile: Option.with(WorkerRoleStakeProfile),
+      role_stake_profile: Option.with(RoleStakeProfile),
     }, value);
   }
 
@@ -130,16 +104,16 @@ export class Worker extends JoyStruct<IWorker> {
     return this.getField<MemberId>('member_id');
   }
 
-  get role_account(): AccountId {
-    return this.getField<AccountId>('role_account');
+  get role_account_id(): AccountId {
+    return this.getField<AccountId>('role_account_id');
   }
 
   get reward_relationship(): Option<RewardRelationshipId> {
     return this.getField<Option<RewardRelationshipId>>('reward_relationship');
   }
 
-  get role_stake_profile(): Option<WorkerRoleStakeProfile> {
-    return this.getField<Option<WorkerRoleStakeProfile>>('role_stake_profile');
+  get role_stake_profile(): Option<RoleStakeProfile> {
+    return this.getField<Option<RoleStakeProfile>>('role_stake_profile');
   }
 
   get is_active(): boolean {
@@ -185,20 +159,20 @@ export type IWorkingGroupOpeningPolicyCommitment = {
   fill_opening_successful_applicant_application_stake_unstaking_period: Option<BlockNumber>,
   fill_opening_failed_applicant_application_stake_unstaking_period: Option<BlockNumber>,
   fill_opening_failed_applicant_role_stake_unstaking_period: Option<BlockNumber>,
-  terminate_worker_application_stake_unstaking_period: Option<BlockNumber>,
-  terminate_worker_role_stake_unstaking_period: Option<BlockNumber>,
-  exit_worker_role_application_stake_unstaking_period: Option<BlockNumber>,
-  exit_worker_role_stake_unstaking_period: Option<BlockNumber>,
+  terminate_application_stake_unstaking_period: Option<BlockNumber>,
+  terminate_role_stake_unstaking_period: Option<BlockNumber>,
+  exit_role_application_stake_unstaking_period: Option<BlockNumber>,
+  exit_role_stake_unstaking_period: Option<BlockNumber>,
 };
 
 // This type represents OpeningPolicyCommitment defined inside the runtime's working-grpup module.
 // The only difference between this and the one defined in /content-working-group is in the names of some fields.
 //
 // There is also a minor issue here:
-// Because api metadata still says that ie. the "commitment" argument of "storageWorkingGroup.addWorkerOpening" extrinsic
+// Because api metadata still says that ie. the "commitment" argument of "storageWorkingGroup.addOpening" extrinsic
 // is of type "OpeningPolicyCommitment" (not the "WorkingGroupOpeningPolicyCommitment" defined here), the CWG's OpeningPolicyCommitment
 // type is used when sending this extrinsic (it has "terminate_curator_role_stake_unstaking_period" field insted
-// of "terminate_worker_role_stake_unstaking_period" etc.).
+// of "terminate_role_stake_unstaking_period" etc.).
 // Since both those types are basically the same structs (only filed names are different) nothing seems to break, but it's
 // very fragile atm and any change to this type in working-group module could result in "unsolvable" inconsistencies
 // (this won't be an issue after CWG gets refactored to use the working-grpup module too)
@@ -213,10 +187,10 @@ export class WorkingGroupOpeningPolicyCommitment extends JoyStruct<IWorkingGroup
       fill_opening_successful_applicant_application_stake_unstaking_period: "Option<BlockNumber>",
       fill_opening_failed_applicant_application_stake_unstaking_period: "Option<BlockNumber>",
       fill_opening_failed_applicant_role_stake_unstaking_period: "Option<BlockNumber>",
-      terminate_worker_application_stake_unstaking_period: "Option<BlockNumber>",
-      terminate_worker_role_stake_unstaking_period: "Option<BlockNumber>",
-      exit_worker_role_application_stake_unstaking_period: "Option<BlockNumber>",
-      exit_worker_role_stake_unstaking_period: "Option<BlockNumber>",
+      terminate_application_stake_unstaking_period: "Option<BlockNumber>",
+      terminate_role_stake_unstaking_period: "Option<BlockNumber>",
+      exit_role_application_stake_unstaking_period: "Option<BlockNumber>",
+      exit_role_stake_unstaking_period: "Option<BlockNumber>",
     }, value);
   }
 
@@ -252,67 +226,91 @@ export class WorkingGroupOpeningPolicyCommitment extends JoyStruct<IWorkingGroup
     return this.getField<Option<BlockNumber>>('fill_opening_failed_applicant_role_stake_unstaking_period')
   }
 
-  get terminate_worker_application_stake_unstaking_period(): Option<BlockNumber> {
-    return this.getField<Option<BlockNumber>>('terminate_worker_application_stake_unstaking_period')
+  get terminate_application_stake_unstaking_period(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('terminate_application_stake_unstaking_period')
   }
 
-  get terminate_worker_role_stake_unstaking_period(): Option<BlockNumber> {
-    return this.getField<Option<BlockNumber>>('terminate_worker_role_stake_unstaking_period')
+  get terminate_role_stake_unstaking_period(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('terminate_role_stake_unstaking_period')
   }
 
-  get exit_worker_role_application_stake_unstaking_period(): Option<BlockNumber> {
-    return this.getField<Option<BlockNumber>>('exit_worker_role_application_stake_unstaking_period')
+  get exit_role_application_stake_unstaking_period(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('exit_role_application_stake_unstaking_period')
   }
 
-  get exit_worker_role_stake_unstaking_period(): Option<BlockNumber> {
-    return this.getField<Option<BlockNumber>>('exit_worker_role_stake_unstaking_period')
+  get exit_role_stake_unstaking_period(): Option<BlockNumber> {
+    return this.getField<Option<BlockNumber>>('exit_role_stake_unstaking_period')
   }
 };
 
-export type IWorkerOpening = {
-  opening_id: OpeningId,
-  worker_applications: BTreeSet<WorkerApplicationId>,
+export enum OpeningTypeKeys {
+  Leader = 'Leader',
+  Worker = 'Worker'
+};
+
+export class OpeningType extends Enum {
+  constructor (value?: any, index?: number) {
+    super(
+      {
+        Leader: Null,
+        Worker: Null
+      },
+      value, index
+    );
+  }
+};
+
+export type IOpening = {
+  hiring_opening_id: OpeningId,
+  applications: BTreeSet<ApplicationId>,
   policy_commitment: WorkingGroupOpeningPolicyCommitment,
+  opening_type: OpeningType
 }
 
-export class WorkerOpening extends JoyStruct<IWorkerOpening> {
+// This type is also defined in /hiring (and those are incosistent), but here
+// it is beeing registered as "OpeningOf" (which is an alias used by the runtime working-group module),
+// so it shouldn't cause any conflicts
+export class Opening extends JoyStruct<IOpening> {
   constructor (value?: IWorker) {
     super({
-      opening_id: OpeningId,
-      worker_applications: BTreeSet.with(WorkerApplicationId),
+      hiring_opening_id: OpeningId,
+      applications: BTreeSet.with(ApplicationId),
       policy_commitment: WorkingGroupOpeningPolicyCommitment,
+      opening_type: OpeningType
     }, value);
   }
 
-  get opening_id(): OpeningId {
-    return this.getField<OpeningId>('opening_id');
+  get hiring_opening_id(): OpeningId {
+    return this.getField<OpeningId>('hiring_opening_id');
   }
 
-  get worker_applications(): BTreeSet<WorkerApplicationId> {
-    return this.getField<BTreeSet<WorkerApplicationId>>('worker_applications');
+  get applications(): BTreeSet<ApplicationId> {
+    return this.getField<BTreeSet<ApplicationId>>('applications');
   }
 
   get policy_commitment(): WorkingGroupOpeningPolicyCommitment {
     return this.getField<WorkingGroupOpeningPolicyCommitment>('policy_commitment');
   }
+
+  get opening_type(): OpeningType {
+    return this.getField<OpeningType>('opening_type');
+  }
 }
 
 export function registerWorkingGroupTypes() {
   try {
     getTypeRegistry().register({
-      // Note that it actually HAS TO be "LeadOf" in the runtime,
-      // otherwise there would be conflicts with the current content-workig-group module
-      LeadOf: Lead,
       RationaleText,
-      WorkerApplication,
-      WorkerApplicationId,
-      WorkerApplicationIdSet,
-      WorkerApplicationIdToWorkerIdMap,
+      ApplicationOf: Application,
+      ApplicationIdSet,
+      ApplicationIdToWorkerIdMap,
       WorkerId,
       WorkerOf: Worker,
-      WorkerOpening,
-      WorkerOpeningId,
-      StorageProviderId
+      OpeningOf: Opening,
+      StorageProviderId,
+      OpeningType,
+      /// Alias used by the runtime working-group module
+      HiringApplicationId: ApplicationId
     });
   } catch (err) {
     console.error('Failed to register custom types of working-group module', err);

+ 11 - 6
types/tsconfig.json

@@ -11,14 +11,19 @@
     "esModuleInterop": true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
     "experimentalDecorators": true,           /* Enables experimental support for ES7 decorators. */
     "declaration": true,
-    "outDir": "lib",
-	"resolveJsonModule": true,
-	"types" : [
-		"node"
-	]
-
+    "outDir": "./",
+    "resolveJsonModule": true,
+    "types" : [
+      "node"
+    ],
+    "forceConsistentCasingInFileNames": true
   },
   "include": [
     "src/**/*.ts"
   ],
+  "exclude": [
+    "node_modules",
+    "**/*.spec.ts",
+    "**/*.d.ts"
+  ]
 }

+ 6 - 0
yarn.lock

@@ -1359,6 +1359,7 @@
   version "0.11.0"
   dependencies:
     "@polkadot/types" "^0.96.1"
+    "@types/lodash" "^4.14.157"
     "@types/vfile" "^4.0.0"
     ajv "^6.11.0"
     lodash "^4.17.15"
@@ -3806,6 +3807,11 @@
   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
   integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
 
+"@types/lodash@^4.14.157":
+  version "4.14.157"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.157.tgz#fdac1c52448861dfde1a2e1515dbc46e54926dc8"
+  integrity sha512-Ft5BNFmv2pHDgxV5JDsndOWTRJ+56zte0ZpYLowp03tW+K+t8u8YMOzAnpuqPgzX6WO1XpDIUm7u04M8vdDiVQ==
+
 "@types/marked@^0.7.0":
   version "0.7.2"
   resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.7.2.tgz#1393f076773b55cc7078c0fbeb86a497c69db97e"

Some files were not shown because too many files changed in this diff