Browse Source

Merge pull request #1624 from Lezek123/cli-working-groups-rework

CLI - working-groups:createOpening rework
Mokhtar Naamani 4 years ago
parent
commit
aefe785f4d

+ 4 - 2
cli/package.json

@@ -53,7 +53,8 @@
     "mocha": "^5.2.0",
     "nyc": "^14.1.1",
     "ts-node": "^8.8.2",
-    "typescript": "^3.8.3"
+    "typescript": "^3.8.3",
+    "json-schema-to-typescript": "^9.1.1"
   },
   "engines": {
     "node": ">=12.18.0",
@@ -120,7 +121,8 @@
     "version": "oclif-dev readme && git add README.md",
     "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
-    "format": "prettier ./ --write"
+    "format": "prettier ./ --write",
+    "generate:schema-typings": "rm -rf ./src/json-schemas/typings && json2ts -i ./src/json-schemas/ -o ./src/json-schemas/typings/"
   },
   "types": "lib/index.d.ts"
 }

+ 2 - 200
cli/src/Types.ts

@@ -1,31 +1,14 @@
 import BN from 'bn.js'
 import { ElectionStage, Seat } from '@joystream/types/council'
-import { Option, Text } from '@polkadot/types'
-import { Constructor, Codec } from '@polkadot/types/types'
-import { Struct, Vec } from '@polkadot/types/codec'
-import { u32 } from '@polkadot/types/primitive'
+import { Option } from '@polkadot/types'
+import { Codec } from '@polkadot/types/types'
 import { BlockNumber, Balance, AccountId } from '@polkadot/types/interfaces'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { WorkerId, OpeningType } from '@joystream/types/working-group'
 import { Membership, MemberId } from '@joystream/types/members'
-import {
-  GenericJoyStreamRoleSchema,
-  JobSpecifics,
-  ApplicationDetails,
-  QuestionSections,
-  QuestionSection,
-  QuestionsFields,
-  QuestionField,
-  EntryInMembershipModuke,
-  HiringProcess,
-  AdditionalRolehiringProcessDetails,
-  CreatorDetails,
-} from '@joystream/types/hiring/schemas/role.schema.typings'
-import ajv from 'ajv'
 import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring'
 import { Validator } from 'inquirer'
-import { JoyStructCustom } from '@joystream/types/common'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -187,183 +170,6 @@ export type GroupOpening = {
   unstakingPeriods: UnstakingPeriods
 }
 
-// Some helper structs for generating human_readable_text in working group 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.)
-interface WithJSONable<T> {
-  toJSONObj: () => T
-}
-export class HRTJobSpecificsStruct
-  extends JoyStructCustom({
-    title: Text,
-    description: Text,
-  })
-  implements WithJSONable<JobSpecifics> {
-  get title(): string {
-    return this.getField('title').toString()
-  }
-
-  get description(): string {
-    return this.getField('description').toString()
-  }
-
-  toJSONObj(): JobSpecifics {
-    const { title, description } = this
-    return { title, description }
-  }
-}
-export class HRTEntryInMembershipModukeStruct
-  extends JoyStructCustom({
-    handle: Text,
-  })
-  implements WithJSONable<EntryInMembershipModuke> {
-  get handle(): string {
-    return this.getField('handle').toString()
-  }
-
-  toJSONObj(): EntryInMembershipModuke {
-    const { handle } = this
-    return { handle }
-  }
-}
-export class HRTCreatorDetailsStruct
-  extends JoyStructCustom({
-    membership: HRTEntryInMembershipModukeStruct,
-  })
-  implements WithJSONable<CreatorDetails> {
-  get membership(): EntryInMembershipModuke {
-    return this.getField('membership').toJSONObj()
-  }
-
-  toJSONObj(): CreatorDetails {
-    const { membership } = this
-    return { membership }
-  }
-}
-export class HRTHiringProcessStruct
-  extends JoyStructCustom({
-    details: Vec.with(Text),
-  })
-  implements WithJSONable<HiringProcess> {
-  get details(): AdditionalRolehiringProcessDetails {
-    return this.getField('details')
-      .toArray()
-      .map((v) => v.toString())
-  }
-
-  toJSONObj(): HiringProcess {
-    const { details } = this
-    return { details }
-  }
-}
-export class HRTQuestionFieldStruct
-  extends JoyStructCustom({
-    title: Text,
-    type: Text,
-  })
-  implements WithJSONable<QuestionField> {
-  get title(): string {
-    return this.getField('title').toString()
-  }
-
-  get type(): string {
-    return this.getField('type').toString()
-  }
-
-  toJSONObj(): QuestionField {
-    const { title, type } = this
-    return { title, type }
-  }
-}
-class HRTQuestionsFieldsVec extends Vec.with(HRTQuestionFieldStruct) implements WithJSONable<QuestionsFields> {
-  toJSONObj(): QuestionsFields {
-    return this.toArray().map((v) => v.toJSONObj())
-  }
-}
-export class HRTQuestionSectionStruct
-  extends JoyStructCustom({
-    title: Text,
-    questions: HRTQuestionsFieldsVec,
-  })
-  implements WithJSONable<QuestionSection> {
-  get title(): string {
-    return this.getField('title').toString()
-  }
-
-  get questions(): QuestionsFields {
-    return this.getField('questions').toJSONObj()
-  }
-
-  toJSONObj(): QuestionSection {
-    const { title, questions } = this
-    return { title, questions }
-  }
-}
-export class HRTQuestionSectionsVec extends Vec.with(HRTQuestionSectionStruct)
-  implements WithJSONable<QuestionSections> {
-  toJSONObj(): QuestionSections {
-    return this.toArray().map((v) => v.toJSONObj())
-  }
-}
-export class HRTApplicationDetailsStruct
-  extends JoyStructCustom({
-    sections: HRTQuestionSectionsVec,
-  })
-  implements WithJSONable<ApplicationDetails> {
-  get sections(): QuestionSections {
-    return this.getField('sections').toJSONObj()
-  }
-
-  toJSONObj(): ApplicationDetails {
-    const { sections } = this
-    return { sections }
-  }
-}
-export class HRTStruct
-  extends JoyStructCustom({
-    version: u32,
-    headline: Text,
-    job: HRTJobSpecificsStruct,
-    application: HRTApplicationDetailsStruct,
-    reward: Text,
-    creator: HRTCreatorDetailsStruct,
-    process: HRTHiringProcessStruct,
-  })
-  implements WithJSONable<GenericJoyStreamRoleSchema> {
-  get version(): number {
-    return this.getField('version').toNumber()
-  }
-
-  get headline(): string {
-    return this.getField('headline').toString()
-  }
-
-  get job(): JobSpecifics {
-    return this.getField('job').toJSONObj()
-  }
-
-  get application(): ApplicationDetails {
-    return this.getField('application').toJSONObj()
-  }
-
-  get reward(): string {
-    return this.getField('reward').toString()
-  }
-
-  get creator(): CreatorDetails {
-    return this.getField('creator').toJSONObj()
-  }
-
-  get process(): HiringProcess {
-    return this.getField('process').toJSONObj()
-  }
-
-  toJSONObj(): GenericJoyStreamRoleSchema {
-    const { version, headline, job, application, reward, creator, process } = this
-    return { version, headline, job, application, reward, creator, process }
-  }
-}
-
 // Api-related
 
 // Additional options that can be passed to ApiCommandBase.promptForParam in order to override
@@ -374,10 +180,6 @@ export type ApiParamOptions<ParamType = Codec> = {
     default: ParamType
     locked?: boolean
   }
-  jsonSchema?: {
-    struct: Constructor<Struct>
-    schemaValidator: ajv.ValidateFunction
-  }
   validator?: Validator
   nestedOptions?: ApiParamsOptions // For more complex params, like structs
 }

+ 8 - 60
cli/src/base/ApiCommandBase.ts

@@ -2,15 +2,14 @@ import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import StateAwareCommandBase from './StateAwareCommandBase'
 import Api from '../Api'
-import { getTypeDef, Option, Tuple, Bytes, TypeRegistry } from '@polkadot/types'
-import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types'
+import { getTypeDef, Option, Tuple, TypeRegistry } from '@polkadot/types'
+import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo } from '@polkadot/types/types'
 
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { KeyringPair } from '@polkadot/keyring/types'
 import chalk from 'chalk'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
-import ajv from 'ajv'
 import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
@@ -18,7 +17,7 @@ import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 
-class ExtrinsicFailedError extends Error {}
+export class ExtrinsicFailedError extends Error {}
 
 /**
  * Abstract base class for commands that require access to the API.
@@ -308,16 +307,6 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
       return paramOptions.value.default
     }
 
-    if (paramOptions?.jsonSchema) {
-      const { struct, schemaValidator } = paramOptions.jsonSchema
-      return await this.promptForJsonBytes(
-        struct,
-        typeDef.name,
-        paramOptions.value?.default as Bytes | undefined,
-        schemaValidator
-      )
-    }
-
     if (rawTypeDef.info === TypeDefInfo.Option) {
       return await this.promptForOption(typeDef, paramOptions)
     } else if (rawTypeDef.info === TypeDefInfo.Tuple) {
@@ -338,49 +327,6 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return await this.promptForParam(type, options)
   }
 
-  async promptForJsonBytes(
-    jsonStruct: Constructor<Struct>,
-    argName?: string,
-    defaultValue?: Bytes,
-    schemaValidator?: ajv.ValidateFunction
-  ) {
-    const JsonStructObject = jsonStruct
-    const rawType = new JsonStructObject(this.getTypesRegistry()).toRawType()
-    const typeDef = getTypeDef(rawType)
-
-    const defaultStruct =
-      defaultValue &&
-      new JsonStructObject(
-        this.getTypesRegistry(),
-        JSON.parse(Buffer.from(defaultValue.toHex().replace('0x', ''), 'hex').toString())
-      )
-
-    if (argName) {
-      typeDef.name = argName
-    }
-
-    let isValid = true
-    let jsonText: string
-    do {
-      const structVal = await this.promptForStruct(typeDef, createParamOptions(typeDef.name, defaultStruct))
-      jsonText = JSON.stringify(structVal.toJSON())
-      if (schemaValidator) {
-        isValid = Boolean(schemaValidator(JSON.parse(jsonText)))
-        if (!isValid) {
-          this.log('\n')
-          this.warn(
-            'Schema validation failed with:\n' +
-              schemaValidator.errors?.map((e) => chalk.red(`${chalk.bold(e.dataPath)}: ${e.message}`)).join('\n') +
-              '\nTry again...'
-          )
-          this.log('\n')
-        }
-      }
-    } while (!isValid)
-
-    return this.createType('Bytes', '0x' + Buffer.from(jsonText, 'ascii').toString('hex'))
-  }
-
   async promptForExtrinsicParams(
     module: string,
     method: string,
@@ -454,13 +400,15 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     account: KeyringPair,
     tx: SubmittableExtrinsic<'promise'>,
     warnOnly = false // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
-  ): Promise<void> {
+  ): Promise<boolean> {
     try {
       await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
+      return true
     } catch (e) {
       if (e instanceof ExtrinsicFailedError && warnOnly) {
         this.warn(`Extrinsic failed! ${e.message}`)
+        return false
       } else if (e instanceof ExtrinsicFailedError) {
         throw new CLIError(`Extrinsic failed! ${e.message}`, { exit: ExitCodes.ApiError })
       } else {
@@ -475,10 +423,10 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     method: string,
     params: CodecArg[],
     warnOnly = false
-  ): Promise<void> {
+  ): Promise<boolean> {
     this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
     const tx = await this.getOriginalApi().tx[module][method](...params)
-    await this.sendAndFollowTx(account, tx, warnOnly)
+    return await this.sendAndFollowTx(account, tx, warnOnly)
   }
 
   async buildAndSendExtrinsic(

+ 0 - 93
cli/src/base/WorkingGroupsCommandBase.ts

@@ -7,22 +7,14 @@ import {
   NamedKeyringPair,
   GroupMember,
   GroupOpening,
-  ApiMethodArg,
-  ApiMethodNamedArgs,
   OpeningStatus,
   GroupApplication,
 } from '../Types'
-import { apiModuleByGroup } from '../Api'
-import { CLIError } from '@oclif/errors'
-import fs from 'fs'
-import path from 'path'
 import _ from 'lodash'
 import { ApplicationStageKeys } from '@joystream/types/hiring'
 import chalk from 'chalk'
 import { IConfig } from '@oclif/config'
 
-const DRAFTS_FOLDER = 'opening-drafts'
-
 /**
  * Abstract base class for commands that need to use gates based on user's roles
  */
@@ -135,51 +127,6 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     return acceptedApplications
   }
 
-  async promptForNewOpeningDraftName() {
-    let draftName = ''
-    let fileExists = false
-    let overrideConfirmed = false
-
-    do {
-      draftName = await this.simplePrompt({
-        type: 'input',
-        message: 'Provide the draft name',
-        validate: (val) => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!',
-      })
-
-      fileExists = fs.existsSync(this.getOpeningDraftPath(draftName))
-      if (fileExists) {
-        overrideConfirmed = await this.simplePrompt({
-          type: 'confirm',
-          message: 'Such draft already exists. Do you wish to override it?',
-          default: false,
-        })
-      }
-    } while (fileExists && !overrideConfirmed)
-
-    return draftName
-  }
-
-  async promptForOpeningDraft() {
-    let draftFiles: string[] = []
-    try {
-      draftFiles = fs.readdirSync(this.getOpeingDraftsPath())
-    } catch (e) {
-      throw this.createDataReadError(DRAFTS_FOLDER)
-    }
-    if (!draftFiles.length) {
-      throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound })
-    }
-    const draftNames = draftFiles.map((fileName) => _.startCase(fileName.replace('.json', '')))
-    const selectedDraftName = await this.simplePrompt({
-      message: 'Select a draft',
-      type: 'list',
-      choices: draftNames,
-    })
-
-    return selectedDraftName
-  }
-
   async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
     const opening = await this.getApi().groupOpening(this.group, id)
 
@@ -241,48 +188,8 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     return (await this.getWorkerForLeadAction(id, true)) as GroupMember & Required<Pick<GroupMember, 'stake'>>
   }
 
-  loadOpeningDraftParams(draftName: string): ApiMethodNamedArgs {
-    const draftFilePath = this.getOpeningDraftPath(draftName)
-    const params = this.extrinsicArgsFromDraft(apiModuleByGroup[this.group], 'addOpening', draftFilePath)
-
-    return params
-  }
-
-  getOpeingDraftsPath() {
-    return path.join(this.getAppDataPath(), DRAFTS_FOLDER)
-  }
-
-  getOpeningDraftPath(draftName: string) {
-    return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName) + '.json')
-  }
-
-  saveOpeningDraft(draftName: string, params: ApiMethodArg[]) {
-    const paramsJson = JSON.stringify(
-      params.map((p) => p.toJSON()),
-      null,
-      2
-    )
-
-    try {
-      fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson)
-    } catch (e) {
-      throw this.createDataWriteError(DRAFTS_FOLDER)
-    }
-  }
-
-  private initOpeningDraftsDir(): void {
-    if (!fs.existsSync(this.getOpeingDraftsPath())) {
-      fs.mkdirSync(this.getOpeingDraftsPath())
-    }
-  }
-
   async init() {
     await super.init()
-    try {
-      this.initOpeningDraftsDir()
-    } catch (e) {
-      throw this.createDataDirInitError()
-    }
     const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
     if (flags.group) {
       this.group = flags.group

+ 2 - 2
cli/src/commands/media/createChannel.ts

@@ -27,12 +27,12 @@ export default class CreateChannelCommand extends ContentDirectoryCommandBase {
     if (!inputJson) {
       const customPrompts: JsonSchemaCustomPrompts = [
         ['language', () => this.promptForEntityId('Choose channel language', 'Language', 'name')],
-        ['isCensored', async () => undefined],
+        ['isCensored', 'skip'],
       ]
 
       const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, undefined, customPrompts)
 
-      inputJson = await prompter.promptAll()
+      inputJson = await prompter.promptAll(true)
     }
 
     this.jsonPrettyPrint(JSON.stringify(inputJson))

+ 2 - 2
cli/src/commands/media/updateChannel.ts

@@ -75,12 +75,12 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
 
       if (!asCurator) {
         // Skip isCensored is it's not updated by the curator
-        customPrompts.push(['isCensored', async () => undefined])
+        customPrompts.push(['isCensored', 'skip'])
       }
 
       const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, currentValues, customPrompts)
 
-      inputJson = await prompter.promptAll()
+      inputJson = await prompter.promptAll(true)
     }
 
     this.jsonPrettyPrint(JSON.stringify(inputJson))

+ 197 - 56
cli/src/commands/working-groups/createOpening.ts

@@ -1,87 +1,228 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
-import { ApiMethodArg, ApiMethodNamedArgs } from '../../Types'
+import { GroupMember } from '../../Types'
 import chalk from 'chalk'
-import { flags } from '@oclif/command'
 import { apiModuleByGroup } from '../../Api'
-import WorkerOpeningOptions from '../../promptOptions/addWorkerOpening'
-import { setDefaults } from '../../helpers/promptOptions'
+import HRTSchema from '@joystream/types/hiring/schemas/role.schema.json'
+import { GenericJoyStreamRoleSchema as HRTJson } from '@joystream/types/hiring/schemas/role.schema.typings'
+import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import WGOpeningSchema from '../../json-schemas/WorkingGroupOpening.schema.json'
+import { WorkingGroupOpening as WGOpeningJson } from '../../json-schemas/typings/WorkingGroupOpening.schema'
+import _ from 'lodash'
+import { IOFlags, getInputJson, ensureOutputFileIsWriteable, saveOutputJsonToFile } from '../../helpers/InputOutput'
+import Ajv from 'ajv'
+import ExitCodes from '../../ExitCodes'
+import { flags } from '@oclif/command'
+import { createType } from '@joystream/types'
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
   static description = 'Create working group opening (requires lead access)'
   static flags = {
     ...WorkingGroupsCommandBase.flags,
-    useDraft: flags.boolean({
-      char: 'd',
-      description:
-        'Whether to create the opening from existing draft.\n' +
-        'If provided without --draftName - the list of choices will be displayed.',
-    }),
-    draftName: flags.string({
-      char: 'n',
-      description: 'Name of the draft to create the opening from.',
-      dependsOn: ['useDraft'],
+    input: IOFlags.input,
+    output: flags.string({
+      char: 'o',
+      required: false,
+      description: 'Path to the file where the output JSON should be saved (this output can be then reused as input)',
     }),
-    createDraftOnly: flags.boolean({
-      char: 'c',
+    edit: flags.boolean({
+      char: 'e',
+      required: false,
       description:
-        'If provided - the extrinsic will not be executed. Use this flag if you only want to create a draft.',
+        'If provided along with --input - launches in edit mode allowing to modify the input before sending the exstinsic',
+      dependsOn: ['input'],
     }),
-    skipPrompts: flags.boolean({
-      char: 's',
-      description: 'Whether to skip all prompts when adding from draft (will use all default values)',
-      dependsOn: ['useDraft'],
-      exclusive: ['createDraftOnly'],
+    dryRun: flags.boolean({
+      required: false,
+      description:
+        'If provided along with --output - skips sending the actual extrinsic' +
+        '(can be used to generate a "draft" which can be provided as input later)',
+      dependsOn: ['output'],
     }),
   }
 
+  getHRTDefaults(memberHandle: string): HRTJson {
+    const groupName = _.startCase(this.group)
+    return {
+      version: 1,
+      headline: `Looking for ${groupName}!`,
+      job: {
+        title: groupName,
+        description: `Become part of the ${groupName} Group! This is a great opportunity to support Joystream!`,
+      },
+      application: {
+        sections: [
+          {
+            title: 'About you',
+            questions: [
+              {
+                title: 'Your name',
+                type: 'text',
+              },
+              {
+                title: 'What makes you a good fit for the job?',
+                type: 'text area',
+              },
+            ],
+          },
+        ],
+      },
+      reward: '10k JOY per 3600 blocks',
+      creator: {
+        membership: {
+          handle: memberHandle,
+        },
+      },
+    }
+  }
+
+  createTxParams(wgOpeningJson: WGOpeningJson, hrtJson: HRTJson) {
+    return [
+      wgOpeningJson.activateAt,
+      createType('WorkingGroupOpeningPolicyCommitment', {
+        max_review_period_length: wgOpeningJson.maxReviewPeriodLength,
+        application_rationing_policy: wgOpeningJson.maxActiveApplicants
+          ? { max_active_applicants: wgOpeningJson.maxActiveApplicants }
+          : null,
+        application_staking_policy: wgOpeningJson.applicationStake
+          ? {
+              amount: wgOpeningJson.applicationStake.value,
+              amount_mode: wgOpeningJson.applicationStake.mode,
+            }
+          : null,
+        role_staking_policy: wgOpeningJson.roleStake
+          ? {
+              amount: wgOpeningJson.roleStake.value,
+              amount_mode: wgOpeningJson.roleStake.mode,
+            }
+          : null,
+        terminate_role_stake_unstaking_period: wgOpeningJson.terminateRoleUnstakingPeriod,
+        exit_role_stake_unstaking_period: wgOpeningJson.leaveRoleUnstakingPeriod,
+      }),
+      JSON.stringify(hrtJson),
+      createType('OpeningType', 'Worker'),
+    ]
+  }
+
+  async promptForData(
+    lead: GroupMember,
+    rememberedInput?: [WGOpeningJson, HRTJson]
+  ): Promise<[WGOpeningJson, HRTJson]> {
+    const openingDefaults = rememberedInput?.[0]
+    const openingPrompt = new JsonSchemaPrompter<WGOpeningJson>(
+      (WGOpeningSchema as unknown) as JSONSchema,
+      openingDefaults
+    )
+    const wgOpeningJson = await openingPrompt.promptAll()
+
+    const hrtDefaults = rememberedInput?.[1] || this.getHRTDefaults(lead.profile.handle.toString())
+    this.log(`Values for ${chalk.greenBright('human_readable_text')} json:`)
+    const hrtPropmpt = new JsonSchemaPrompter<HRTJson>((HRTSchema as unknown) as JSONSchema, hrtDefaults)
+    // Prompt only for 'headline', 'job', 'application', 'reward' and 'process', leave the rest default
+    const headline = await hrtPropmpt.promptSingleProp('headline')
+    this.log('General information about the job:')
+    const job = await hrtPropmpt.promptSingleProp('job')
+    this.log('Application form sections and questions:')
+    const application = await hrtPropmpt.promptSingleProp('application')
+    this.log('Reward displayed in the opening box:')
+    const reward = await hrtPropmpt.promptSingleProp('reward')
+    this.log('Hiring process details (additional information)')
+    const process = await hrtPropmpt.promptSingleProp('process')
+
+    const hrtJson = { ...hrtDefaults, job, headline, application, reward, process }
+
+    return [wgOpeningJson, hrtJson]
+  }
+
+  async getInputFromFile(filePath: string): Promise<[WGOpeningJson, HRTJson]> {
+    const ajv = new Ajv({ allErrors: true })
+    const inputParams = await getInputJson<[WGOpeningJson, HRTJson]>(filePath)
+    if (!Array.isArray(inputParams) || inputParams.length !== 2) {
+      this.error('Invalid input file', { exit: ExitCodes.InvalidInput })
+    }
+    const [openingJson, hrtJson] = inputParams
+    if (!ajv.validate(WGOpeningSchema, openingJson)) {
+      this.error(`Invalid input file:\n${ajv.errorsText(undefined, { dataVar: 'openingJson', separator: '\n' })}`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+    if (!ajv.validate(HRTSchema, hrtJson)) {
+      this.error(`Invalid input file:\n${ajv.errorsText(undefined, { dataVar: 'hrtJson', separator: '\n' })}`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+
+    return [openingJson, hrtJson]
+  }
+
   async run() {
     const account = await this.getRequiredSelectedAccount()
     // lead-only gate
-    await this.getRequiredLead()
+    const lead = await this.getRequiredLead()
+    await this.requestAccountDecoding(account) // Prompt for password
 
-    const { flags } = this.parse(WorkingGroupsCreateOpening)
+    const {
+      flags: { input, output, edit, dryRun },
+    } = this.parse(WorkingGroupsCreateOpening)
 
-    const promptOptions = new WorkerOpeningOptions()
-    let defaultValues: ApiMethodNamedArgs | undefined
-    if (flags.useDraft) {
-      const draftName = flags.draftName || (await this.promptForOpeningDraft())
-      defaultValues = await this.loadOpeningDraftParams(draftName)
-      setDefaults(promptOptions, defaultValues)
-    }
+    ensureOutputFileIsWriteable(output)
 
-    if (!flags.skipPrompts) {
-      const module = apiModuleByGroup[this.group]
-      const method = 'addOpening'
+    let tryAgain = false
+    let rememberedInput: [WGOpeningJson, HRTJson] | undefined
+    do {
+      if (edit) {
+        rememberedInput = await this.getInputFromFile(input as string)
+      }
+      // Either prompt for the data or get it from input file
+      const [openingJson, hrtJson] =
+        !input || edit || tryAgain
+          ? await this.promptForData(lead, rememberedInput)
+          : await this.getInputFromFile(input)
 
-      let saveDraft = false
-      let params: ApiMethodArg[]
-      if (flags.createDraftOnly) {
-        params = await this.promptForExtrinsicParams(module, method, promptOptions)
-        saveDraft = true
-      } else {
-        await this.requestAccountDecoding(account) // Prompt for password
-        params = await this.buildAndSendExtrinsic(account, module, method, promptOptions, true)
+      // Remember the provided/fetched data in a variable
+      rememberedInput = [openingJson, hrtJson]
 
-        saveDraft = await this.simplePrompt({
-          message: 'Do you wish to save this opening as draft?',
-          type: 'confirm',
-        })
+      // Generate and ask to confirm tx params
+      const txParams = this.createTxParams(openingJson, hrtJson)
+      this.jsonPrettyPrint(JSON.stringify(txParams))
+      const confirmed = await this.simplePrompt({
+        type: 'confirm',
+        message: 'Do you confirm these extrinsic parameters?',
+      })
+      if (!confirmed) {
+        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        continue
       }
 
-      if (saveDraft) {
-        const draftName = await this.promptForNewOpeningDraftName()
-        this.saveOpeningDraft(draftName, params)
+      // Save output to file
+      if (output) {
+        try {
+          saveOutputJsonToFile(output, rememberedInput)
+          this.log(chalk.green(`Output succesfully saved in: ${chalk.white(output)}!`))
+        } catch (e) {
+          this.warn(`Could not save output to ${output}!`)
+        }
+      }
 
-        this.log(chalk.green(`Opening draft ${chalk.white(draftName)} succesfully saved!`))
+      if (dryRun) {
+        this.exit(ExitCodes.OK)
       }
-    } else {
-      await this.requestAccountDecoding(account) // Prompt for password
+
+      // Send the tx
       this.log(chalk.white('Sending the extrinsic...'))
-      await this.sendExtrinsic(
+      const txSuccess = await this.sendAndFollowTx(
         account,
-        this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...defaultValues!.map((v) => v.value))
+        this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams),
+        true // warnOnly
       )
-      this.log(chalk.green('Opening succesfully created!'))
-    }
+
+      // Display a success message on success or ask to try again on error
+      if (txSuccess) {
+        this.log(chalk.green('Opening succesfully created!'))
+        tryAgain = false
+      } else {
+        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+      }
+    } while (tryAgain)
   }
 }

+ 40 - 5
cli/src/helpers/InputOutput.ts

@@ -61,14 +61,49 @@ export function saveOutputJson(outputPath: string | undefined, fileName: string,
       fileName = fileName.replace(/(_[0-9]+)?\.json/, `_${++postfix}.json`)
       outputFilePath = path.join(outputPath, fileName)
     }
+    saveOutputJsonToFile(outputFilePath, data)
+
+    console.log(`${chalk.green('Output succesfully saved to:')} ${chalk.white(outputFilePath)}`)
+  }
+}
+
+// Output as file:
+
+export function saveOutputJsonToFile(outputFilePath: string, data: any): void {
+  try {
+    fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 4))
+  } catch (e) {
+    throw new CLIError(`Could not save the output to: ${outputFilePath}. Check permissions...`, {
+      exit: ExitCodes.FsOperationFailed,
+    })
+  }
+}
+
+export function ensureOutputFileIsWriteable(outputFilePath: string | undefined): void {
+  if (outputFilePath === undefined) {
+    return
+  }
+
+  if (path.extname(outputFilePath) !== '.json') {
+    throw new CLIError(`Output path ${outputFilePath} is not a JSON file!`, { exit: ExitCodes.InvalidInput })
+  }
+
+  if (fs.existsSync(outputFilePath)) {
+    // File already exists - warn the user and check it it's writeable
+    console.warn(`WARNING: ${outputFilePath} already exists and it will get overriden!`)
     try {
-      fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 4))
+      fs.accessSync(`${outputFilePath}`, fs.constants.W_OK)
     } catch (e) {
-      throw new CLIError(`Could not save the output to: ${outputFilePath}. Check directory permissions`, {
-        exit: ExitCodes.FsOperationFailed,
+      throw new CLIError(`Output path ${outputFilePath} is not writeable!`, { exit: ExitCodes.InvalidInput })
+    }
+  } else {
+    // File does not exist yet - check if the directory is writeable
+    try {
+      fs.accessSync(`${path.dirname(outputFilePath)}`, fs.constants.W_OK)
+    } catch (e) {
+      throw new CLIError(`Output directory ${path.dirname(outputFilePath)} is not writeable!`, {
+        exit: ExitCodes.InvalidInput,
       })
     }
-
-    console.log(`${chalk.green('Output succesfully saved to:')} ${chalk.white(outputFilePath)}`)
   }
 }

+ 68 - 14
cli/src/helpers/JsonSchemaPrompt.ts

@@ -8,7 +8,7 @@ import { getSchemasLocation } from 'cd-schemas'
 import path from 'path'
 
 type CustomPromptMethod = () => Promise<any>
-type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt }
+type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt } | 'skip'
 
 // For the explaination of "string & { x: never }", see: https://github.com/microsoft/TypeScript/issues/29729
 // eslint-disable-next-line @typescript-eslint/ban-types
@@ -39,20 +39,29 @@ export class JsonSchemaPrompter<JsonResult> {
     this.filledObject = defaults || {}
   }
 
-  private oneOfToChoices(oneOf: JSONSchema[]) {
+  private oneOfToOptions(oneOf: JSONSchema[], currentValue: any) {
+    let defaultValue: any
     const choices: { name: string; value: number | string }[] = []
 
     oneOf.forEach((pSchema, index) => {
       if (pSchema.description) {
-        choices.push({ name: pSchema.description, value: index })
+        choices.push({ name: pSchema.description, value: index.toString() })
       } else if (pSchema.type === 'object' && pSchema.properties) {
-        choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index })
+        choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index.toString() })
+        // Supports defaults for enum variants:
+        if (
+          typeof currentValue === 'object' &&
+          currentValue !== null &&
+          Object.keys(currentValue).join(',') === Object.keys(pSchema.properties).join(',')
+        ) {
+          defaultValue = index.toString()
+        }
       } else {
-        choices.push({ name: index.toString(), value: index })
+        choices.push({ name: index.toString(), value: index.toString() })
       }
     })
 
-    return choices
+    return { choices, default: defaultValue }
   }
 
   private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
@@ -67,9 +76,25 @@ export class JsonSchemaPrompter<JsonResult> {
     return chalk.green(propertyPath)
   }
 
-  private async prompt(schema: JSONSchema, propertyPath = '', custom?: CustomPrompt): Promise<any> {
+  private async prompt(
+    schema: JSONSchema,
+    propertyPath = '',
+    custom?: CustomPrompt,
+    allPropsRequired = false
+  ): Promise<any> {
     const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
     const propDisplayName = this.propertyDisplayName(propertyPath)
+    const currentValue = _.get(this.filledObject, propertyPath)
+
+    if (customPrompt === 'skip') {
+      return
+    }
+
+    // Automatically handle "null" values (useful for enum variants)
+    if (schema.type === 'null') {
+      _.set(this.filledObject, propertyPath, null)
+      return null
+    }
 
     // Custom prompt
     if (typeof customPrompt === 'function') {
@@ -79,16 +104,45 @@ export class JsonSchemaPrompter<JsonResult> {
     // oneOf
     if (schema.oneOf) {
       const oneOf = schema.oneOf as JSONSchema[]
-      const choices = this.oneOfToChoices(oneOf)
-      const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', choices })
-      return await this.prompt(oneOf[choosen], propertyPath)
+      const options = this.oneOfToOptions(oneOf, currentValue)
+      const { choosen } = await inquirer.prompt({ name: 'choosen', message: propDisplayName, type: 'list', ...options })
+      if (choosen !== options.default) {
+        _.set(this.filledObject, propertyPath, undefined) // Clear any previous value if different variant selected
+      }
+      return await this.prompt(oneOf[parseInt(choosen)], propertyPath)
     }
 
     // object
     if (schema.type === 'object' && schema.properties) {
       const value: Record<string, any> = {}
       for (const [pName, pSchema] of Object.entries(schema.properties)) {
-        value[pName] = await this.prompt(pSchema, propertyPath ? `${propertyPath}.${pName}` : pName)
+        const objectPropertyPath = propertyPath ? `${propertyPath}.${pName}` : pName
+        const propertyCustomPrompt = this.getCustomPrompt(objectPropertyPath)
+
+        if (propertyCustomPrompt === 'skip') {
+          continue
+        }
+
+        let confirmed = true
+        const required = allPropsRequired || (Array.isArray(schema.required) && schema.required.includes(pName))
+
+        if (!required) {
+          confirmed = (
+            await inquirer.prompt([
+              {
+                message: `Do you want to provide optional ${chalk.greenBright(objectPropertyPath)}?`,
+                type: 'confirm',
+                name: 'confirmed',
+                default: _.get(this.filledObject, objectPropertyPath) !== undefined,
+              },
+            ])
+          ).confirmed
+        }
+        if (confirmed) {
+          value[pName] = await this.prompt(pSchema, objectPropertyPath)
+        } else {
+          _.set(this.filledObject, objectPropertyPath, undefined)
+        }
       }
       return value
     }
@@ -99,7 +153,6 @@ export class JsonSchemaPrompter<JsonResult> {
     }
 
     // "primitive" values:
-    const currentValue = _.get(this.filledObject, propertyPath)
     const basicPromptOptions: DistinctQuestion = {
       message: propDisplayName,
       default: currentValue !== undefined ? currentValue : schema.default,
@@ -156,6 +209,7 @@ export class JsonSchemaPrompter<JsonResult> {
           ...BOOL_PROMPT_OPTIONS,
           name: 'next',
           message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
+          default: _.get(this.filledObject, `${propertyPath}[${currItem}]`) !== undefined,
         },
       ])
       if (!next) {
@@ -210,8 +264,8 @@ export class JsonSchemaPrompter<JsonResult> {
     return await RefParser.dereference(this.schemaPath, this.schema, {})
   }
 
-  async promptAll() {
-    await this.prompt(await this.getMainSchema())
+  async promptAll(allPropsRequired = false) {
+    await this.prompt(await this.getMainSchema(), '', undefined, allPropsRequired)
     return this.filledObject as JsonResult
   }
 

+ 73 - 0
cli/src/json-schemas/WorkingGroupOpening.schema.json

@@ -0,0 +1,73 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "https://joystream.org/WorkingGroupOpening.schema.json",
+  "title": "WorkingGroupOpening",
+  "description": "JSON schema to describe Joystream working group opening",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["activateAt", "maxReviewPeriodLength"],
+  "properties": {
+    "activateAt": {
+      "oneOf": [
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["ExactBlock"],
+          "properties": {
+            "ExactBlock": {
+              "type": "integer",
+              "minimum": 1,
+              "description": "Exact block number"
+            }
+          }
+        },
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["CurrentBlock"],
+          "properties": { "CurrentBlock": { "type": "null" } }
+        }
+      ]
+    },
+    "maxActiveApplicants": {
+      "type": "integer",
+      "description": "Max. number of active applicants",
+      "minimum": 1,
+      "default": 10
+    },
+    "maxReviewPeriodLength": {
+      "type": "integer",
+      "description": "Max. review period length in blocks",
+      "minimum": 1,
+      "default": 432000
+    },
+    "applicationStake": { "$ref": "#/definitions/StakingPolicy", "description": "Application stake properties" },
+    "roleStake": { "$ref": "#/definitions/StakingPolicy", "description": "Role stake properties" },
+    "terminateRoleUnstakingPeriod": { "$ref": "#/definitions/UnstakingPeriod" },
+    "leaveRoleUnstakingPeriod": { "$ref": "#/definitions/UnstakingPeriod" }
+  },
+  "definitions": {
+    "UnstakingPeriod": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 100800
+    },
+    "StakingPolicy": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["value", "mode"],
+      "properties": {
+        "mode": {
+          "type": "string",
+          "description": "Application stake mode (Exact/AtLeast)",
+          "enum": ["Exact", "AtLeast"]
+        },
+        "value": {
+          "type": "integer",
+          "description": "Required stake value in JOY",
+          "minimum": 1
+        }
+      }
+    }
+  }
+}

+ 60 - 0
cli/src/json-schemas/typings/WorkingGroupOpening.schema.d.ts

@@ -0,0 +1,60 @@
+/* tslint:disable */
+/**
+ * This file was automatically generated by json-schema-to-typescript.
+ * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
+ * and run json-schema-to-typescript to regenerate this file.
+ */
+
+export type UnstakingPeriod = number
+
+/**
+ * JSON schema to describe Joystream working group opening
+ */
+export interface WorkingGroupOpening {
+  activateAt:
+    | {
+        /**
+         * Exact block number
+         */
+        ExactBlock: number
+      }
+    | {
+        CurrentBlock: null
+      }
+  /**
+   * Max. number of active applicants
+   */
+  maxActiveApplicants?: number
+  /**
+   * Max. review period length in blocks
+   */
+  maxReviewPeriodLength: number
+  /**
+   * Application stake properties
+   */
+  applicationStake?: {
+    /**
+     * Application stake mode (Exact/AtLeast)
+     */
+    mode: 'Exact' | 'AtLeast'
+    /**
+     * Required stake value in JOY
+     */
+    value: number
+  }
+  /**
+   * Role stake properties
+   */
+  roleStake?: {
+    /**
+     * Application stake mode (Exact/AtLeast)
+     */
+    mode: 'Exact' | 'AtLeast'
+    /**
+     * Required stake value in JOY
+     */
+    value: number
+  }
+  terminateRoleUnstakingPeriod?: UnstakingPeriod
+  leaveRoleUnstakingPeriod?: UnstakingPeriod
+}

+ 0 - 59
cli/src/promptOptions/addWorkerOpening.ts

@@ -1,59 +0,0 @@
-import { ApiParamsOptions, ApiParamOptions, HRTStruct } from '../Types'
-import { OpeningType, WorkingGroupOpeningPolicyCommitment } from '@joystream/types/working-group'
-import { SlashingTerms } from '@joystream/types/common'
-import { Bytes } from '@polkadot/types'
-import { schemaValidator } from '@joystream/types/hiring'
-import { createType } from '@joystream/types'
-
-class OpeningPolicyCommitmentOptions implements ApiParamsOptions {
-  [paramName: string]: ApiParamOptions
-  public role_slashing_terms: ApiParamOptions<SlashingTerms> = {
-    value: {
-      default: createType('SlashingTerms', { Unslashable: null }),
-      locked: true,
-    },
-  }
-
-  // Rename fields containing "curator" (solivg minor UI issue related to flat namespace)
-  public terminate_curator_application_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'terminate_application_stake_unstaking_period',
-  }
-
-  public terminate_curator_role_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'terminate_role_stake_unstaking_period',
-  }
-
-  public exit_curator_role_application_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'exit_role_application_stake_unstaking_period',
-  }
-
-  public exit_curator_role_stake_unstaking_period: ApiParamOptions = {
-    forcedName: 'exit_role_stake_unstaking_period',
-  }
-}
-
-class AddWrokerOpeningOptions implements ApiParamsOptions {
-  [paramName: string]: ApiParamOptions
-  // Lock value for opening_type
-  public opening_type: ApiParamOptions<OpeningType> = {
-    value: {
-      default: createType('OpeningType', { Worker: null }),
-      locked: true,
-    },
-  }
-
-  // Json schema for human_readable_text
-  public human_readable_text: ApiParamOptions<Bytes> = {
-    jsonSchema: {
-      schemaValidator,
-      struct: HRTStruct,
-    },
-  }
-
-  // Lock value for role_slashing_terms
-  public commitment: ApiParamOptions<WorkingGroupOpeningPolicyCommitment> = {
-    nestedOptions: new OpeningPolicyCommitmentOptions(),
-  }
-}
-
-export default AddWrokerOpeningOptions

+ 2 - 0
content-directory-schemas/types/extrinsics/index.d.ts

@@ -0,0 +1,2 @@
+export { AddClassSchema } from './AddClassSchema'
+export { CreateClass } from './CreateClass'