Browse Source

Applications and openings - listings and details

Leszek Wiesner 4 years ago
parent
commit
9ae9cd0d5c

+ 228 - 38
cli/src/Api.ts

@@ -14,15 +14,41 @@ import {
     WorkingGroups,
     GroupLeadWithProfile,
     GroupMember,
+    OpeningStatus,
+    GroupOpeningStage,
+    GroupOpening,
+    GroupApplication
 } 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 {
+    Worker, WorkerId,
+    Lead as WorkerLead,
+    WorkerRoleStakeProfile,
+    WorkerOpening, WorkerOpeningId,
+    WorkerApplication, WorkerApplicationId
+} from '@joystream/types/lib/working-group';
+import {
+    Opening,
+    Application,
+    OpeningStage, OpeningStageKeys,
+    WaitingToBeingOpeningStageVariant,
+    ActiveOpeningStageVariant,
+    AcceptingApplications,
+    ActiveOpeningStageKeys,
+    ReviewPeriod,
+    Deactivated,
+    OpeningDeactivationCauseKeys,
+    ApplicationStageKeys,
+    ApplicationId,
+    OpeningId
+} from '@joystream/types/lib/hiring';
 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 { LinkageResult } from '@polkadot/types/codec/Linkage';
+import { Moment } from '@polkadot/types/interfaces';
 
 export const DEFAULT_API_URI = 'wss://rome-rpc-endpoint.joystream.org:9944/';
 const DEFAULT_DECIMALS = new u32(12);
@@ -36,7 +62,7 @@ export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
 export default class Api {
     private _api: ApiPromise;
 
-    private constructor(originalApi:ApiPromise) {
+    private constructor(originalApi: ApiPromise) {
         this._api = originalApi;
     }
 
@@ -45,12 +71,12 @@ export default class Api {
     }
 
     private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
-        const wsProvider:WsProvider = new WsProvider(apiUri);
+        const wsProvider: WsProvider = new WsProvider(apiUri);
         registerJoystreamTypes();
         const api = await ApiPromise.create({ provider: wsProvider });
 
         // Initializing some api params based on pioneer/packages/react-api/Api.tsx
-        const [ properties ] = await Promise.all([
+        const [properties] = await Promise.all([
             api.rpc.system.properties()
         ]);
 
@@ -59,8 +85,8 @@ export default class Api {
 
         // formatBlanace config
         formatBalance.setDefaults({
-          decimals: tokenDecimals,
-          unit: tokenSymbol
+            decimals: tokenDecimals,
+            unit: tokenSymbol
         });
 
         return api;
@@ -87,7 +113,7 @@ export default class Api {
         return results;
     }
 
-    async getAccountsBalancesInfo(accountAddresses:string[]): Promise<DerivedBalances[]> {
+    async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DerivedBalances[]> {
         let accountsBalances: DerivedBalances[] = await this._api.derive.balances.votingBalances(accountAddresses);
 
         return accountsBalances;
@@ -95,7 +121,7 @@ export default class Api {
 
     // Get on-chain data related to given account.
     // For now it's just account balances
-    async getAccountSummary(accountAddresses:string): Promise<AccountSummary> {
+    async getAccountSummary(accountAddresses: string): Promise<AccountSummary> {
         const balances: DerivedBalances = (await this.getAccountsBalancesInfo([accountAddresses]))[0];
         // TODO: Some more information can be fetched here in the future
 
@@ -104,21 +130,21 @@ export default class Api {
 
     async getCouncilInfo(): Promise<CouncilInfoObj> {
         const queries: { [P in keyof CouncilInfoObj]: QueryableStorageMultiArg<"promise"> } = {
-            activeCouncil:    this._api.query.council.activeCouncil,
-            termEndsAt:       this._api.query.council.termEndsAt,
-            autoStart:        this._api.query.councilElection.autoStart,
-            newTermDuration:  this._api.query.councilElection.newTermDuration,
-            candidacyLimit:   this._api.query.councilElection.candidacyLimit,
-            councilSize:      this._api.query.councilElection.councilSize,
-            minCouncilStake:  this._api.query.councilElection.minCouncilStake,
-            minVotingStake:   this._api.query.councilElection.minVotingStake,
+            activeCouncil: this._api.query.council.activeCouncil,
+            termEndsAt: this._api.query.council.termEndsAt,
+            autoStart: this._api.query.councilElection.autoStart,
+            newTermDuration: this._api.query.councilElection.newTermDuration,
+            candidacyLimit: this._api.query.councilElection.candidacyLimit,
+            councilSize: this._api.query.councilElection.councilSize,
+            minCouncilStake: this._api.query.councilElection.minCouncilStake,
+            minVotingStake: this._api.query.councilElection.minVotingStake,
             announcingPeriod: this._api.query.councilElection.announcingPeriod,
-            votingPeriod:     this._api.query.councilElection.votingPeriod,
-            revealingPeriod:  this._api.query.councilElection.revealingPeriod,
-            round:            this._api.query.councilElection.round,
-            stage:            this._api.query.councilElection.stage
+            votingPeriod: this._api.query.councilElection.votingPeriod,
+            revealingPeriod: this._api.query.councilElection.revealingPeriod,
+            round: this._api.query.councilElection.round,
+            stage: this._api.query.councilElection.stage
         }
-        const results: CouncilInfoTuple = <CouncilInfoTuple> await this.queryMultiOnce(Object.values(queries));
+        const results: CouncilInfoTuple = <CouncilInfoTuple>await this.queryMultiOnce(Object.values(queries));
 
         return createCouncilInfoObj(...results);
     }
@@ -127,7 +153,7 @@ export default class Api {
     async estimateFee(account: KeyringPair, recipientAddr: string, amount: BN): Promise<BN> {
         const transfer = this._api.tx.balances.transfer(recipientAddr, amount);
         const signature = account.sign(transfer.toU8a());
-        const transactionByteSize:BN = new BN(transfer.encodedLength + signature.length);
+        const transactionByteSize: BN = new BN(transfer.encodedLength + signature.length);
 
         const fees: DerivedFees = await this._api.derive.balances.fees();
 
@@ -152,7 +178,19 @@ export default class Api {
     }
 
     protected multiLinkageResult<K extends Codec, V extends Codec>(result: LinkageResult): [Vec<K>, Vec<V>] {
-        return [ result[0] as Vec<K>, result[1] as Vec<V> ];
+        return [result[0] as Vec<K>, result[1] as Vec<V>];
+    }
+
+    protected async blockHash(height: number): Promise<string> {
+        const blockHash = await this._api.rpc.chain.getBlockHash(height);
+
+        return blockHash.toString();
+    }
+
+    protected async blockTimestamp(height: number): Promise<Date> {
+        const blockTime = (await this._api.query.timestamp.now.at(await this.blockHash(height))) as Moment;
+
+        return new Date(blockTime.toNumber());
     }
 
     protected workingGroupApiQuery(group: WorkingGroups) {
@@ -166,11 +204,11 @@ export default class Api {
         return profile.unwrapOr(null);
     }
 
-    async groupLead (group: WorkingGroups): Promise <GroupLeadWithProfile | null> {
+    async groupLead(group: WorkingGroups): Promise<GroupLeadWithProfile | null> {
         const optLead = (await this.workingGroupApiQuery(group).currentLead()) as Option<WorkerLead>;
 
         if (!optLead.isSome) {
-          return null;
+            return null;
         }
 
         const lead = optLead.unwrap();
@@ -183,26 +221,28 @@ export default class Api {
         return { lead, profile };
     }
 
-    protected async stakeValue (stakeId: StakeId): Promise<Balance> {
-        const stake = (await this._api.query.stake.stakes(stakeId)) as Stake;
+    protected async stakeValue(stakeId: StakeId): Promise<Balance> {
+        const stake = this.singleLinkageResult<Stake>(
+            await this._api.query.stake.stakes(stakeId) as LinkageResult
+        );
         return stake.value;
     }
 
-    protected async workerStake (stakeProfile: WorkerRoleStakeProfile): Promise<Balance> {
+    protected async workerStake(stakeProfile: WorkerRoleStakeProfile): Promise<Balance> {
         return this.stakeValue(stakeProfile.stake_id);
     }
 
-    protected async workerTotalReward (relationshipId: RewardRelationshipId): Promise<Balance> {
+    protected async workerTotalReward(relationshipId: RewardRelationshipId): Promise<Balance> {
         const relationship = this.singleLinkageResult<RewardRelationship>(
             await this._api.query.recurringRewards.rewardRelationships(relationshipId) as LinkageResult
         );
         return relationship.total_reward_received;
     }
 
-    protected async groupMember (
+    protected async groupMember(
         id: WorkerId,
         worker: Worker
-      ): Promise<GroupMember> {
+    ): Promise<GroupMember> {
         const roleAccount = worker.role_account;
         const memberId = worker.member_id;
 
@@ -214,12 +254,12 @@ export default class Api {
 
         let stakeValue: Balance = this._api.createType("Balance", 0);
         if (worker.role_stake_profile && worker.role_stake_profile.isSome) {
-          stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
+            stakeValue = await this.workerStake(worker.role_stake_profile.unwrap());
         }
 
         let earnedValue: Balance = this._api.createType("Balance", 0);
         if (worker.reward_relationship && worker.reward_relationship.isSome) {
-          earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
+            earnedValue = await this.workerTotalReward(worker.reward_relationship.unwrap());
         }
 
         return ({
@@ -232,24 +272,174 @@ export default class Api {
         });
     }
 
-    async groupMembers (group: WorkingGroups): Promise<GroupMember[]> {
+    async groupMembers(group: WorkingGroups): Promise<GroupMember[]> {
         const nextId = (await this.workingGroupApiQuery(group).nextWorkerId()) as WorkerId;
 
         // This is chain specfic, but if next id is still 0, it means no curators have been added yet
         if (nextId.eq(0)) {
-          return [];
+            return [];
         }
 
-        const [ workerIds, workers ] = this.multiLinkageResult<WorkerId, Worker>(
+        const [workerIds, workers] = this.multiLinkageResult<WorkerId, Worker>(
             (await this.workingGroupApiQuery(group).workerById()) as LinkageResult
         );
 
         let groupMembers: GroupMember[] = [];
-        for (let [ index, worker ] of Object.entries(workers.toArray())) {
+        for (let [index, worker] of Object.entries(workers.toArray())) {
             const workerId = workerIds[parseInt(index)];
             groupMembers.push(await this.groupMember(workerId, worker));
         }
 
         return groupMembers.reverse();
-      }
+    }
+
+    async openingsByGroup(group: WorkingGroups): Promise<GroupOpening[]> {
+        const openings: GroupOpening[] = [];
+        const nextId = (await this.workingGroupApiQuery(group).nextWorkerOpeningId()) as WorkerOpeningId;
+
+        // This is chain specfic, but if next id is still 0, it means no openings have been added yet
+        if (!nextId.eq(0)) {
+            const highestId = nextId.toNumber() - 1;
+            for (let i = highestId; i >= 0; i--) {
+                openings.push(await this.groupOpening(group, i));
+            }
+        }
+
+        return openings;
+    }
+
+    protected async hiringOpeningById(id: number | OpeningId): Promise<Opening> {
+        const result = await this._api.query.hiring.openingById(id) as LinkageResult;
+        return this.singleLinkageResult<Opening>(result);
+    }
+
+    protected async hiringApplicationById(id: number | ApplicationId): Promise<Application> {
+        const result = await this._api.query.hiring.applicationById(id) as LinkageResult;
+        return this.singleLinkageResult<Application>(result);
+    }
+
+    async workerApplicationById(group: WorkingGroups, workerApplicationId: number): Promise<WorkerApplication> {
+        const nextAppId = await this.workingGroupApiQuery(group).nextWorkerApplicationId() as WorkerApplicationId;
+
+        if (workerApplicationId < 0 || workerApplicationId >= nextAppId.toNumber()) {
+            throw new CLIError('Invalid worker application ID!');
+        }
+
+        return this.singleLinkageResult<WorkerApplication>(
+            await this.workingGroupApiQuery(group).workerApplicationById(workerApplicationId) as LinkageResult
+        );
+    }
+
+    protected async parseApplication(workerApplicationId: number, workerApplication: WorkerApplication) {
+        const appId = workerApplication.application_id;
+        const application = await this.hiringApplicationById(appId);
+
+        const { active_role_staking_id: roleStakingId, active_application_staking_id: appStakingId } = application;
+
+        return {
+            workerApplicationId,
+            applicationId: appId.toNumber(),
+            member: await this.memberProfileById(workerApplication.member_id),
+            roleAccout: workerApplication.role_account,
+            stakes: {
+                application: appStakingId.isSome ? (await this.stakeValue(appStakingId.unwrap())).toNumber() : 0,
+                role: roleStakingId.isSome ? (await this.stakeValue(roleStakingId.unwrap())).toNumber() : 0
+            },
+            humanReadableText: application.human_readable_text.toString(),
+            stage: application.stage.type as ApplicationStageKeys
+        };
+    }
+
+    async groupApplication(group: WorkingGroups, workerApplicationId: number): Promise<GroupApplication> {
+        const workerApplication = await this.workerApplicationById(group, workerApplicationId);
+        return await this.parseApplication(workerApplicationId, workerApplication);
+    }
+
+    protected async groupOpeningApplications(group: WorkingGroups, workerOpeningId: number): Promise<GroupApplication[]> {
+        const applications: GroupApplication[] = [];
+
+        const nextAppId = await this.workingGroupApiQuery(group).nextWorkerApplicationId() as WorkerApplicationId;
+        for (let i = 0; i < nextAppId.toNumber(); i++) {
+            const workerApplication = await this.workerApplicationById(group, i);
+            if (workerApplication.worker_opening_id.toNumber() !== workerOpeningId) {
+                continue;
+            }
+            applications.push(await this.parseApplication(i, workerApplication));
+        }
+
+
+        return applications;
+    }
+
+    async groupOpening(group: WorkingGroups, workerOpeningId: number): Promise<GroupOpening> {
+        const nextId = ((await this.workingGroupApiQuery(group).nextWorkerOpeningId()) as WorkerOpeningId).toNumber();
+
+        if (workerOpeningId < 0 || workerOpeningId >= nextId) {
+            throw new CLIError('Invalid group opening ID!');
+        }
+
+        const groupOpening = this.singleLinkageResult<WorkerOpening>(
+            await this.workingGroupApiQuery(group).workerOpeningById(workerOpeningId) as LinkageResult
+        );
+
+        const openingId = groupOpening.opening_id.toNumber();
+        const opening = await this.hiringOpeningById(openingId);
+        const applications = await this.groupOpeningApplications(group, workerOpeningId);
+        const stage = await this.parseOpeningStage(opening.stage);
+        const stakes = {
+            application: opening.application_staking_policy.unwrapOr(undefined),
+            role: opening.role_staking_policy.unwrapOr(undefined)
+        }
+
+        return ({
+            workerOpeningId,
+            openingId,
+            opening,
+            stage,
+            stakes,
+            applications
+        });
+    }
+
+    async parseOpeningStage(stage: OpeningStage): Promise<GroupOpeningStage> {
+        let
+            status: OpeningStatus | undefined,
+            stageBlock: number | undefined,
+            stageDate: Date | undefined;
+
+        if (stage.type === OpeningStageKeys.WaitingToBegin) {
+            const stageData = (stage.value as WaitingToBeingOpeningStageVariant);
+            const currentBlockNumber = (await this._api.derive.chain.bestNumber()).toNumber();
+            const expectedBlockTime = (this._api.consts.babe.expectedBlockTime as Moment).toNumber();
+            status = OpeningStatus.WaitingToBegin;
+            stageBlock = stageData.begins_at_block.toNumber();
+            stageDate = new Date(Date.now() + (stageBlock - currentBlockNumber) * expectedBlockTime);
+        }
+
+        if (stage.type === OpeningStageKeys.Active) {
+            const stageData = (stage.value as ActiveOpeningStageVariant);
+            const substage = stageData.stage;
+            if (substage.type === ActiveOpeningStageKeys.AcceptingApplications) {
+                status = OpeningStatus.AcceptingApplications;
+                stageBlock = (substage.value as AcceptingApplications).started_accepting_applicants_at_block.toNumber();
+            }
+            if (substage.type === ActiveOpeningStageKeys.ReviewPeriod) {
+                status = OpeningStatus.InReview;
+                stageBlock = (substage.value as ReviewPeriod).started_review_period_at_block.toNumber();
+            }
+            if (substage.type === ActiveOpeningStageKeys.Deactivated) {
+                status = (substage.value as Deactivated).cause.type === OpeningDeactivationCauseKeys.Filled
+                    ? OpeningStatus.Complete
+                    : OpeningStatus.Cancelled;
+                stageBlock = (substage.value as Deactivated).deactivated_at_block.toNumber();
+            }
+            if (stageBlock) stageDate = new Date(await this.blockTimestamp(stageBlock));
+        }
+
+        return {
+            status: status || OpeningStatus.Unknown,
+            block: stageBlock,
+            date: stageDate
+        }
+    }
 }

+ 43 - 0
cli/src/Types.ts

@@ -23,6 +23,7 @@ import {
     CreatorDetails
 } from '@joystream/types/lib/hiring/schemas/role.schema.typings';
 import ajv from 'ajv';
+import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/lib/hiring';
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -106,6 +107,48 @@ export type GroupMember = {
     earned: Balance;
 }
 
+export type GroupApplication = {
+    workerApplicationId: number;
+    applicationId: number;
+    member: Profile | null;
+    roleAccout: AccountId;
+    stakes: {
+        application: number;
+        role: number;
+    },
+    humanReadableText: string;
+    stage: ApplicationStageKeys;
+}
+
+export enum OpeningStatus {
+    WaitingToBegin = 'WaitingToBegin',
+    AcceptingApplications = 'AcceptingApplications',
+    InReview = 'InReview',
+    Complete = 'Complete',
+    Cancelled = 'Cancelled',
+    Unknown = 'Unknown'
+}
+
+export type GroupOpeningStage = {
+    status: OpeningStatus;
+    block?: number;
+    date?: Date;
+}
+
+export type GroupOpeningStakes = {
+    application?: StakingPolicy;
+    role?: StakingPolicy;
+}
+
+export type GroupOpening = {
+    workerOpeningId: number;
+    openingId: number;
+    stage: GroupOpeningStage;
+    opening: Opening;
+    stakes: GroupOpeningStakes;
+    applications: GroupApplication[];
+}
+
 // Some helper structs for generating human_readable_text in worker opening extrinsic
 // Note those types are not part of the runtime etc., we just use them to simplify prompting for values
 // (since there exists functionality that handles that for substrate types like: Struct, Vec etc.)

+ 56 - 0
cli/src/base/DefaultCommandBase.ts

@@ -1,6 +1,7 @@
 import ExitCodes from '../ExitCodes';
 import Command from '@oclif/command';
 import inquirer, { DistinctQuestion } from 'inquirer';
+import chalk from 'chalk';
 
 /**
  * Abstract base class for pretty much all commands
@@ -8,6 +9,7 @@ import inquirer, { DistinctQuestion } from 'inquirer';
  */
 export default abstract class DefaultCommandBase extends Command {
     protected indentGroupsOpened = 0;
+    protected jsonPrettyIdent = '';
 
     openIndentGroup() {
         console.group();
@@ -30,6 +32,60 @@ export default abstract class DefaultCommandBase extends Command {
         return result;
     }
 
+    private jsonPrettyIndented(line:string) {
+        return `${this.jsonPrettyIdent}${ line }`;
+    }
+
+    private jsonPrettyOpen(char: '{' | '[') {
+        this.jsonPrettyIdent += '    ';
+        return chalk.gray(char)+"\n";
+    }
+
+    private jsonPrettyClose(char: '}' | ']') {
+        this.jsonPrettyIdent = this.jsonPrettyIdent.slice(0, -4);
+        return this.jsonPrettyIndented(chalk.gray(char));
+    }
+
+    private jsonPrettyKeyVal(key:string, val:any): string {
+        return this.jsonPrettyIndented(chalk.white(`${key}: ${this.jsonPrettyAny(val)}`));
+    }
+
+    private jsonPrettyObj(obj: { [key: string]: any }): string {
+        return this.jsonPrettyOpen('{')
+            + Object.keys(obj).map(k => this.jsonPrettyKeyVal(k, obj[k])).join(',\n') + "\n"
+            + this.jsonPrettyClose('}');
+    }
+
+    private jsonPrettyArr(arr: any[]): string {
+        return this.jsonPrettyOpen('[')
+            + arr.map(v => this.jsonPrettyIndented(this.jsonPrettyAny(v))).join(',\n') + "\n"
+            + this.jsonPrettyClose(']');
+    }
+
+    private jsonPrettyAny(val: any): string {
+        if (Array.isArray(val)) {
+            return this.jsonPrettyArr(val);
+        }
+        else if (typeof val === 'object' && val !== null) {
+            return this.jsonPrettyObj(val);
+        }
+        else if (typeof val === 'string') {
+            return chalk.green(`"${val}"`);
+        }
+
+        // Number, boolean etc.
+        return chalk.cyan(val);
+    }
+
+    jsonPrettyPrint(json: string) {
+        try {
+            const parsed = JSON.parse(json);
+            console.log(this.jsonPrettyAny(parsed));
+        } catch(e) {
+            console.log(this.jsonPrettyAny(json));
+        }
+    }
+
     async finally(err: any) {
         // called after run and catch regardless of whether or not the command errored
         // We'll force exit here, in case there is no error, to prevent console.log from hanging the process

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

@@ -168,7 +168,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
         }
         const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase);
         if (!AvailableGroups.includes(flags.group as any)) {
-            throw new CLIError('Invalid group!', { exit: ExitCodes.InvalidInput });
+            throw new CLIError(`Invalid group! Available values are: ${AvailableGroups.join(', ')}`, { exit: ExitCodes.InvalidInput });
         }
         this.group = flags.group as WorkingGroups;
     }

+ 40 - 0
cli/src/commands/working-groups/application.ts

@@ -0,0 +1,40 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayCollapsedRow, displayHeader } from '../../helpers/display';
+import _ from 'lodash';
+import chalk from 'chalk';
+
+export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given application by Worker Application ID';
+    static args = [
+        {
+            name: 'workerApplicationId',
+            required: true,
+            description: 'Worker Application ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsApplication);
+
+        const application = await this.getApi().groupApplication(this.group, parseInt(args.workerApplicationId));
+
+        displayHeader('Human readable text');
+        this.jsonPrettyPrint(application.humanReadableText);
+
+        displayHeader(`Details`);
+        const applicationRow = {
+            'Worker application ID': application.workerApplicationId,
+            'Application ID': application.applicationId,
+            'Member handle': application.member?.handle.toString() || chalk.red('NONE'),
+            'Role account': application.roleAccout.toString(),
+            'Stage': application.stage,
+            'Application stake': application.stakes.application,
+            'Role stake': application.stakes.role,
+            'Total stake': Object.values(application.stakes).reduce((a, b) => a + b)
+        };
+        displayCollapsedRow(applicationRow);
+    }
+}

+ 78 - 0
cli/src/commands/working-groups/opening.ts

@@ -0,0 +1,78 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayTable, displayCollapsedRow, displayHeader } from '../../helpers/display';
+import _ from 'lodash';
+import { OpeningStatus, GroupOpeningStage, GroupOpeningStakes } from '../../Types';
+import { StakingAmountLimitModeKeys, StakingPolicy } from '@joystream/types/lib/hiring';
+import { formatBalance } from '@polkadot/util';
+import chalk from 'chalk';
+
+export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given working group opening by Worker Opening ID';
+    static args = [
+        {
+            name: 'workerOpeningId',
+            required: true,
+            description: 'Worker Opening ID'
+        },
+    ]
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    stageColumns(stage: GroupOpeningStage) {
+        const { status, date, block } = stage;
+        const statusTimeHeader = status === OpeningStatus.WaitingToBegin ? 'Starts at' : 'Last status change';
+        return {
+            'Stage': _.startCase(status),
+            [statusTimeHeader]: (date && block)
+                ? `~ ${date.toLocaleTimeString()} ${ date.toLocaleDateString()} (#${block})`
+                : (block && `#${block}` || '?')
+        };
+    }
+
+    formatStake(stake: StakingPolicy | undefined) {
+        if (!stake) return 'NONE';
+        const { amount, amount_mode } = stake;
+        return amount_mode.type === StakingAmountLimitModeKeys.AtLeast
+            ? `>= ${ formatBalance(amount) }`
+            : `== ${ formatBalance(amount) }`;
+    }
+
+    stakeColumns(stakes: GroupOpeningStakes) {
+        const { role, application } = stakes;
+        return {
+            'Application stake': this.formatStake(application),
+            'Role stake': this.formatStake(role),
+        }
+    }
+
+    async run() {
+        const { args } = this.parse(WorkingGroupsOpening);
+
+        const opening = await this.getApi().groupOpening(this.group, parseInt(args.workerOpeningId));
+
+        displayHeader('Human readable text');
+        this.jsonPrettyPrint(opening.opening.human_readable_text.toString());
+
+        displayHeader('Opening details');
+        const openingRow = {
+            'Worker Opening ID': opening.workerOpeningId,
+            'Opening ID': opening.openingId,
+            ...this.stageColumns(opening.stage),
+            ...this.stakeColumns(opening.stakes)
+        };
+        displayCollapsedRow(openingRow);
+
+        displayHeader(`Applications (${opening.applications.length})`);
+        const applicationsRows = opening.applications.map(a => ({
+            'Worker appl. ID': a.workerApplicationId,
+            'Appl. ID': a.applicationId,
+            'Member': a.member?.handle.toString() || chalk.red('NONE'),
+            'Stage': a.stage,
+            'Appl. stake': a.stakes.application,
+            'Role stake': a.stakes.role,
+            'Total stake': Object.values(a.stakes).reduce((a, b) => a + b)
+        }));
+        displayTable(applicationsRows, 5);
+    }
+  }

+ 22 - 0
cli/src/commands/working-groups/openings.ts

@@ -0,0 +1,22 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase';
+import { displayTable } from '../../helpers/display';
+import _ from 'lodash';
+
+export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
+    static description = 'Shows an overview of given working group openings';
+    static flags = {
+        ...WorkingGroupsCommandBase.flags,
+    };
+
+    async run() {
+        const openings = await this.getApi().openingsByGroup(this.group);
+
+        const openingsRows = openings.map(o => ({
+            'Worker Opening ID': o.workerOpeningId,
+            'Opening ID': o.openingId,
+            'Stage': `${_.startCase(o.stage.status)}${o.stage.block ? ` (#${o.stage.block})` : ''}`,
+            'Applications': o.applications.length
+        }));
+        displayTable(openingsRows, 5);
+    }
+}

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

@@ -33,6 +33,6 @@ export default class WorkingGroupsOverview extends WorkingGroupsCommandBase {
             'Stake': formatBalance(m.stake),
             'Earned': formatBalance(m.earned)
         }));
-        displayTable(membersRows, 20);
+        displayTable(membersRows, 5);
     }
   }

+ 24 - 3
cli/src/helpers/display.ts

@@ -23,13 +23,34 @@ export function displayNameValueTable(rows: NameValueObj[]) {
     );
 }
 
-export function displayTable(rows: { [k: string]: string }[], minColumnWidth = 0) {
+export function displayCollapsedRow(row: { [k: string]: string | number }) {
+    const collapsedRow: NameValueObj[] = Object.keys(row).map(name => ({
+        name,
+        value: typeof row[name] === 'string' ? row[name] as string : row[name].toString()
+    }));
+
+    displayNameValueTable(collapsedRow);
+}
+
+export function displayCollapsedTable(rows: { [k: string]: string | number }[]) {
+    for (const row of rows) displayCollapsedRow(row);
+}
+
+export function displayTable(rows: { [k: string]: string | number }[], cellHorizontalPadding = 0) {
     if (!rows.length) {
         return;
     }
+    const maxLength = (columnName: string) => rows.reduce(
+        (maxLength, row) => {
+            const val = row[columnName];
+            const valLength = typeof val === 'string' ? val.length : val.toString().length;
+            return Math.max(maxLength, valLength);
+        },
+        columnName.length
+    )
     const columnDef = (columnName: string) => ({
-        get: (row: typeof rows[number])  => chalk.white(row[columnName]),
-        minWidth: minColumnWidth
+        get: (row: typeof rows[number])  => chalk.white(`${row[columnName]}`),
+        minWidth: maxLength(columnName) + cellHorizontalPadding
     });
     let columns: Table.table.Columns<{ [k: string]: string }> = {};
     Object.keys(rows[0]).forEach(columnName => columns[columnName] = columnDef(columnName))

+ 4 - 0
types/src/hiring/index.ts

@@ -416,6 +416,10 @@ export class Opening extends JoyStruct<IOpening> {
     return this.getField<Option<StakingPolicy>>('role_staking_policy')
   }
 
+  get human_readable_text(): Text {
+    return this.getField<Text>('human_readable_text');
+  }
+
   get max_applicants(): number {
     const appPolicy = this.application_rationing_policy
     if (appPolicy.isNone) {