diff --git a/packages/ui/src/app/GlobalModals.tsx b/packages/ui/src/app/GlobalModals.tsx index 7029d7e97d..760aac41c3 100644 --- a/packages/ui/src/app/GlobalModals.tsx +++ b/packages/ui/src/app/GlobalModals.tsx @@ -68,6 +68,7 @@ import { VoteRationale } from '@/proposals/modals/VoteRationale/VoteRationale' import { ApplicationDetailsModal, ApplicationDetailsModalCall } from '@/working-groups/modals/ApplicationDetailsModal' import { ApplyForRoleModal, ApplyForRoleModalCall } from '@/working-groups/modals/ApplyForRoleModal' import { ChangeAccountModal, ChangeAccountModalCall } from '@/working-groups/modals/ChangeAccountModal' +import { CreateOpeningModal, CreateOpeningModalCall } from '@/working-groups/modals/CreateOpening' import { IncreaseWorkerStakeModal, IncreaseWorkerStakeModalCall, @@ -87,6 +88,7 @@ export type ModalNames = | ModalName | ModalName | ModalName + | ModalName | ModalName | ModalName | ModalName @@ -131,6 +133,7 @@ const modals: Record = { ApplyForRoleModal: , ApplicationDetails: , SwitchMember: , + CreateOpening: , LeaveRole: , ChangeAccountModal: , MoveFundsModal: , @@ -193,6 +196,7 @@ const GUEST_ACCESSIBLE_MODALS: ModalNames[] = [ export const MODAL_WITH_CLOSE_CONFIRMATION: ModalNames[] = [ 'AddNewProposalModal', 'AnnounceCandidateModal', + 'CreateOpening', 'CreatePost', 'CreateThreadModal', 'ApplyForRoleModal', diff --git a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx index 0cc75609da..0865f2b456 100644 --- a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx +++ b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx @@ -1,8 +1,13 @@ +import { OpeningMetadata } from '@joystream/metadata-protobuf' +import { expect } from '@storybook/jest' import { Meta, StoryContext, StoryObj } from '@storybook/react' +import { userEvent, waitFor, within } from '@storybook/testing-library' +import BN from 'bn.js' import { FC } from 'react' +import { metadataFromBytes } from '@/common/model/JoystreamNode' import { member } from '@/mocks/data/members' -import { joy } from '@/mocks/helpers' +import { getButtonByText, getEditorByLabel, joy, withinModal } from '@/mocks/helpers' import { MocksParameters } from '@/mocks/providers' import { GetWorkersDocument, GetWorkingGroupDocument } from '@/working-groups/queries' @@ -10,7 +15,7 @@ import { WorkingGroup } from './WorkingGroup' type Args = { isLead: boolean - isLoggedIn: boolean + onCreateOpening: jest.Mock } type Story = StoryObj> @@ -20,12 +25,41 @@ const WG_DATA = { name: 'membership', } +const WG_OPENING_METADATA = { + title: 'Membership worker role', + shortDescription: 'Lorem Ipsum...', + description: 'Bigger Lorem ipsum...', + applicationDetails: 'Application process default', + applicationFormQuestions: [ + { question: '🐁?', type: OpeningMetadata.ApplicationFormQuestion.InputType.TEXT }, + { question: '🐘?', type: OpeningMetadata.ApplicationFormQuestion.InputType.TEXTAREA }, + ], + hiringLimit: 5, + expectedEndingTimestamp: 2000, +} + +const WG_JSON_OPENING = { + ...WG_OPENING_METADATA, + applicationFormQuestions: [ + { question: '🐁?', type: 'TEXT' }, + { question: '🐘?', type: 'TEXTAREA' }, + ], + rewardPerBlock: 20_0000000000, + stakingPolicy: { + unstakingPeriod: 200, + amount: 200_0000000000, + }, +} + export default { title: 'Pages/Working Group/WorkingGroup', component: WorkingGroup, + argTypes: { + onCreateOpening: { action: 'MembershipWorkingGroup.OpeningCreated' }, + }, + args: { - isLoggedIn: true, isLead: true, }, @@ -47,9 +81,25 @@ export default { ], }) return { - accounts: parameters.isLoggedIn ? { active: { member: alice } } : { list: [{ member: alice }] }, + accounts: { active: { member: alice } }, - // chain: undefined, + chain: { + tx: { + membershipWorkingGroup: { + addOpening: { + event: 'OpeningCreated', + onSend: args.onCreateOpening, + failure: parameters.createOpeningFailure, + }, + }, + }, + consts: { + membershipWorkingGroup: { + minimumApplicationStake: joy(10), + minUnstakingPeriodLimit: 100, + }, + }, + }, queryNode: [ { @@ -60,7 +110,7 @@ export default { name: WG_DATA.name, budget: joy(200), workers: [], - leader: { membershipId: alice.id, isActive: true }, + leader: { membershipId: alice.id, isActive: args.isLead }, }, }, }, @@ -96,3 +146,144 @@ export default { } satisfies Meta export const Default: Story = {} + +export const CreateOpening: Story = { + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(screen.getByText('Add opening')) + expect(modal.getByText('Create Opening')) + const nextButton = getButtonByText(modal, 'Next step') + + await step('Working group & description', async () => { + const openingTitleField = await modal.findByLabelText('Opening title') + const shortDescriptionField = modal.getByLabelText('Short description') + + await waitFor(() => expect(nextButton).toBeDisabled()) + + await userEvent.type(openingTitleField, 'Membership worker role') + await userEvent.type(shortDescriptionField, 'Lorem Ipsum...') + ;(await getEditorByLabel(modal, 'Description')).setData('Bigger Lorem ipsum...') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + await step('Duration & Process', async () => { + await waitFor(() => expect(nextButton).toBeDisabled()) + ;(await getEditorByLabel(modal, 'Application process')).setData('Application process default') + await waitFor(() => expect(nextButton).toBeEnabled()) + const hiringTargetField = modal.getByLabelText('Hiring Target') + await userEvent.clear(hiringTargetField) + await userEvent.type(hiringTargetField, '5') + await userEvent.click(modal.getByText('Limited')) + const expectedLengthField = modal.getByLabelText('Expected length of the application period') + await userEvent.clear(expectedLengthField) + await userEvent.type(expectedLengthField, '2000') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + await step('Application Form', async () => { + await waitFor(() => expect(nextButton).toBeDisabled()) + await userEvent.type(modal.getByRole('textbox'), '🐁?') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(modal.getByText('Add new question')) + await waitFor(() => expect(nextButton).toBeDisabled()) + await userEvent.click(modal.getAllByText('Long answer')[1]) + await userEvent.type(modal.getAllByRole('textbox')[1], '🐘?') + await waitFor(() => expect(nextButton).toBeEnabled()) + await userEvent.click(nextButton) + }) + await step('Staking Policy & Reward', async () => { + const createButton = getButtonByText(modal, 'Create Opening') + expect(createButton).toBeDisabled() + await userEvent.type(modal.getByLabelText('Staking amount *'), '100') + await userEvent.clear(modal.getByLabelText('Role cooldown period')) + await userEvent.type(modal.getByLabelText('Role cooldown period'), '1000') + await userEvent.type(modal.getByLabelText('Reward amount per Block'), '0.1') + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + }) + + await step('Sign transaction and Create', async () => { + expect(await modal.findByText('You intend to create an Opening.')) + await userEvent.click(modal.getByText('Sign transaction and Create')) + }) + + step('Transaction parameters', () => { + const [description, openingType, stakePolicy, rewardPerBlock] = args.onCreateOpening.mock.calls.at(-1) + + expect(stakePolicy.toJSON()).toEqual({ + stakeAmount: 100_0000000000, + leavingUnstakingPeriod: 1000, + }) + expect(new BN(rewardPerBlock).toNumber()).toEqual(1000000000) + + expect(openingType).toEqual('Regular') + expect(metadataFromBytes(OpeningMetadata, description)).toEqual(WG_OPENING_METADATA) + }) + }, +} +export const CreateOpeningImport: Story = { + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(screen.getByText('Add opening')) + expect(modal.getByText('Create Opening')) + const nextButton = getButtonByText(modal, 'Next step') + + await step('Import File', async () => { + const importButton = getButtonByText(modal, 'Import') + + await userEvent.click(importButton) + + const uploadField = await modal.findByText(/Browse for file/) + expect(getButtonByText(modal, 'Preview Import')).toBeDisabled() + + await userEvent.upload( + uploadField, + new File([JSON.stringify(WG_JSON_OPENING)], 'file.json', { type: 'application/json' }) + ) + }) + await step('Check imported data', async () => { + expect(await modal.findByText(/File imported successfully, preview your input/)) + + const previewImportButton = getButtonByText(modal, 'Preview Import') + await waitFor(() => expect(previewImportButton).toBeEnabled()) + await userEvent.click(previewImportButton) + + expect(await modal.findByLabelText('Opening title')) + expect(nextButton).toBeEnabled() + await userEvent.click(nextButton) + + expect(await modal.findByText('Opening Duration')) + await userEvent.click(modal.getByText('Limited')) + expect(nextButton).toBeEnabled() + await userEvent.click(nextButton) + + expect(await modal.findByText('Application form')) + expect(nextButton).toBeEnabled() + await userEvent.click(nextButton) + + expect(await modal.getByLabelText('Staking amount *')) + const createButton = getButtonByText(modal, 'Create Opening') + await userEvent.click(createButton) + }) + await step('Sign transaction and Create', async () => { + expect(await modal.findByText('You intend to create an Opening.')) + await userEvent.click(modal.getByText('Sign transaction and Create')) + }) + step('Transaction parameters', () => { + const [description, openingType, stakePolicy, rewardPerBlock] = args.onCreateOpening.mock.calls.at(-1) + + expect(stakePolicy.toJSON()).toEqual({ + stakeAmount: 200_0000000000, + leavingUnstakingPeriod: 200, + }) + expect(new BN(rewardPerBlock).toNumber()).toEqual(200000000000) + + expect(openingType).toEqual('Regular') + expect(metadataFromBytes(OpeningMetadata, description)).toEqual(WG_OPENING_METADATA) + }) + }, +} diff --git a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.tsx b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.tsx index 672974ef5a..22616437e1 100644 --- a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.tsx +++ b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.tsx @@ -1,12 +1,15 @@ -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import { useParams } from 'react-router-dom' -import { PageLayout, PageHeaderWrapper } from '@/app/components/PageLayout' +import { PageLayout, PageHeaderWrapper, PageHeaderRow } from '@/app/components/PageLayout' +import { ButtonsGroup } from '@/common/components/buttons' import { Loading } from '@/common/components/Loading' import { PageTitle } from '@/common/components/page/PageTitle' import { PreviousPage } from '@/common/components/page/PreviousPage' import { Tabs } from '@/common/components/Tabs' import { nameMapping } from '@/common/helpers' +import { CreateOpeningButton } from '@/working-groups/components/CreateOpeningButton' +import { useMyWorkers } from '@/working-groups/hooks/useMyWorkers' import { useWorkingGroup } from '@/working-groups/hooks/useWorkingGroup' import { urlParamToWorkingGroupId } from '@/working-groups/model/workingGroupName' @@ -22,6 +25,11 @@ export function WorkingGroup() { const [currentTab, setCurrentTab] = useState('OPENINGS') const { name } = useParams<{ name: string }>() const { isLoading, group } = useWorkingGroup({ name: urlParamToWorkingGroupId(name) }) + const { workers } = useMyWorkers() + const isLead = useMemo( + () => group?.isActive && workers.find((w) => w.membership.id === group?.leadId), + [workers, group?.isActive, group?.leadId] + ) const tabs = [ { title: 'Openings', active: currentTab === 'OPENINGS', onClick: () => setCurrentTab('OPENINGS') }, @@ -58,14 +66,22 @@ export function WorkingGroup() { - - {nameMapping(group?.name ?? name)} - {group?.status && ( - - {group?.status} - + + + {nameMapping(group?.name ?? name)} + {group?.status && ( + + {group?.status} + + )} + + {group && isLead && currentTab === 'OPENINGS' && ( + + + )} - + + } diff --git a/packages/ui/src/common/components/buttons/DownloadButtons.tsx b/packages/ui/src/common/components/buttons/DownloadButtons.tsx index 59e2a29721..04f9843d42 100644 --- a/packages/ui/src/common/components/buttons/DownloadButtons.tsx +++ b/packages/ui/src/common/components/buttons/DownloadButtons.tsx @@ -10,20 +10,22 @@ import { export interface DownloadLinkProps { name: string - parts: BlobPart[] + parts?: BlobPart[] + content?: string type?: string className?: string disabled?: boolean } -export const DownloadLink: FC = ({ name, parts, type, className, children }) => { +export const DownloadLink: FC = ({ name, parts, content, type, className, children }) => { const [href, setHref] = useState('') useEffect(() => { - const url = URL.createObjectURL(new Blob(parts, { type: type ?? getType(name) ?? undefined })) + const blobParts = content ? [content] : parts + const url = URL.createObjectURL(new Blob(blobParts, { type: type ?? getType(name) ?? undefined })) setHref(url) return () => URL.revokeObjectURL(url) - }, []) + }, [content]) return ( diff --git a/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx b/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx index c1fe25bee3..c640fb3ac5 100644 --- a/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx +++ b/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx @@ -381,7 +381,7 @@ export const StepperProposalWrapper = styled(StepperModalWrapper)` grid-template-columns: 220px 336px 1fr; ` -const StyledStepperBody = styled(StepperBody)` +export const StyledStepperBody = styled(StepperBody)` flex-direction: column; row-gap: 20px; ` diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/ApplicationForm.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/ApplicationForm.tsx index ee04befebe..ee480cc1aa 100644 --- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/ApplicationForm.tsx +++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/ApplicationForm.tsx @@ -1,13 +1,11 @@ import React from 'react' -import { useFormContext } from 'react-hook-form' -import EditableInputList from '@/common/components/EditableInputList/EditableInputList' import { Row } from '@/common/components/Modal' import { RowGapBlock } from '@/common/components/page/PageContent' import { TextMedium } from '@/common/components/typography' +import { ApplicationForm as ApplicationForm_ } from '@/working-groups/components/CreateOpening/ApplicationForm' export const ApplicationForm = () => { - const { watch, setValue } = useFormContext() return ( @@ -16,13 +14,9 @@ export const ApplicationForm = () => { Create Working Group Lead Opening + - setValue('applicationForm.questions', questions, { shouldValidate: true })} - /> + ) diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/DurationAndProcess.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/DurationAndProcess.tsx index 42cbe44361..4939273dc2 100644 --- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/DurationAndProcess.tsx +++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/DurationAndProcess.tsx @@ -1,11 +1,9 @@ import React from 'react' -import { CKEditor } from '@/common/components/CKEditor' -import { InputComponent } from '@/common/components/forms' import { Row } from '@/common/components/Modal' -import { OpeningDuration } from '@/common/components/OpeningDuration/OpeningDuration' import { RowGapBlock } from '@/common/components/page/PageContent' -import { TextBig, TextMedium } from '@/common/components/typography' +import { TextMedium } from '@/common/components/typography' +import { DurationAndProcess as DurationAndProcess_ } from '@/working-groups/components/CreateOpening/DurationAndProcess' export const DurationAndProcess = () => { return ( @@ -16,23 +14,9 @@ export const DurationAndProcess = () => { Create Working Group Lead Opening - - - - Opening Duration - - - - - - + + ) diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/StakingPolicyAndReward.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/StakingPolicyAndReward.tsx index 31f76c5796..0a1528643a 100644 --- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/StakingPolicyAndReward.tsx +++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/StakingPolicyAndReward.tsx @@ -1,17 +1,11 @@ import React from 'react' -import { useFormContext } from 'react-hook-form' -import { CurrencyName } from '@/app/constants/currency' -import { InputComponent, InputNumber, TokenInput } from '@/common/components/forms' import { Row } from '@/common/components/Modal' import { RowGapBlock } from '@/common/components/page/PageContent' import { TextMedium } from '@/common/components/typography' -import { formatBlocksToDuration } from '@/common/model/formatters' +import { StakingPolicyAndReward as StakingPolicyAndReward_ } from '@/working-groups/components/CreateOpening/StakingPolicyAndReward' export const StakingPolicyAndReward = () => { - const { watch } = useFormContext() - const leavingUnstakingPeriod = watch('stakingPolicyAndReward.leavingUnstakingPeriod') - return ( @@ -21,44 +15,7 @@ export const StakingPolicyAndReward = () => { - - - - - - - - - - - + ) diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/WorkingGroupAndDescription.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/WorkingGroupAndDescription.tsx index 79ecd3c308..69aed39e23 100644 --- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/WorkingGroupAndDescription.tsx +++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/WorkingGroupAndDescription.tsx @@ -1,15 +1,16 @@ import React from 'react' -import { useFormContext } from 'react-hook-form' -import { CKEditor } from '@/common/components/CKEditor' -import { InputComponent, InputText, InputTextarea } from '@/common/components/forms' import { Row } from '@/common/components/Modal' import { RowGapBlock } from '@/common/components/page/PageContent' import { TextMedium } from '@/common/components/typography' -import { SelectWorkingGroup } from '@/working-groups/components/SelectWorkingGroup' +import { WorkingGroupAndDescription as WorkingGroupAndDescription_ } from '@/working-groups/components/CreateOpening/WorkingGroupAndDescription' +import { GroupIdName } from '@/working-groups/types' -export const WorkingGroupAndDescription = () => { - const { watch, setValue } = useFormContext() +interface Props { + groupId?: GroupIdName +} + +export const WorkingGroupAndDescription = ({ groupId }: Props) => { return ( @@ -19,51 +20,7 @@ export const WorkingGroupAndDescription = () => { - - - { - setValue('workingGroupAndDescription.groupId', selected.id, { shouldValidate: true }) - setValue('groupId', selected.id) - }} - /> - - - - - - - - - - - + ) diff --git a/packages/ui/src/working-groups/components/CreateOpening/ApplicationForm.tsx b/packages/ui/src/working-groups/components/CreateOpening/ApplicationForm.tsx new file mode 100644 index 0000000000..5a75ae9d2d --- /dev/null +++ b/packages/ui/src/working-groups/components/CreateOpening/ApplicationForm.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { useFormContext } from 'react-hook-form' + +import EditableInputList from '@/common/components/EditableInputList/EditableInputList' + +export const ApplicationForm = () => { + const { watch, setValue } = useFormContext() + return ( + setValue('applicationForm.questions', questions, { shouldValidate: true })} + /> + ) +} diff --git a/packages/ui/src/working-groups/components/CreateOpening/DurationAndProcess.tsx b/packages/ui/src/working-groups/components/CreateOpening/DurationAndProcess.tsx new file mode 100644 index 0000000000..2bd1b42c3d --- /dev/null +++ b/packages/ui/src/working-groups/components/CreateOpening/DurationAndProcess.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +import { CKEditor } from '@/common/components/CKEditor' +import { InputComponent, InputNumber } from '@/common/components/forms' +import { Row } from '@/common/components/Modal' +import { OpeningDuration } from '@/common/components/OpeningDuration/OpeningDuration' +import { RowGapBlock } from '@/common/components/page/PageContent' +import { TextBig } from '@/common/components/typography' + +interface Props { + hasHiringTarget?: boolean +} + +export const DurationAndProcess = ({ hasHiringTarget }: Props) => { + return ( + + + + + Opening Duration + + + + {hasHiringTarget && ( + + + + )} + + + + + + + + ) +} diff --git a/packages/ui/src/working-groups/components/CreateOpening/StakingPolicyAndReward.tsx b/packages/ui/src/working-groups/components/CreateOpening/StakingPolicyAndReward.tsx new file mode 100644 index 0000000000..17e1654fa9 --- /dev/null +++ b/packages/ui/src/working-groups/components/CreateOpening/StakingPolicyAndReward.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { useFormContext } from 'react-hook-form' + +import { CurrencyName } from '@/app/constants/currency' +import { InputComponent, InputNumber, TokenInput } from '@/common/components/forms' +import { RowGapBlock } from '@/common/components/page/PageContent' +import { formatBlocksToDuration } from '@/common/model/formatters' + +export const StakingPolicyAndReward = () => { + const { watch } = useFormContext() + const leavingUnstakingPeriod = watch('stakingPolicyAndReward.leavingUnstakingPeriod') + + return ( + + + + + + + + + + + + ) +} diff --git a/packages/ui/src/working-groups/components/CreateOpening/WorkingGroupAndDescription.tsx b/packages/ui/src/working-groups/components/CreateOpening/WorkingGroupAndDescription.tsx new file mode 100644 index 0000000000..42abaa96fe --- /dev/null +++ b/packages/ui/src/working-groups/components/CreateOpening/WorkingGroupAndDescription.tsx @@ -0,0 +1,83 @@ +import React, { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' + +import { CKEditor } from '@/common/components/CKEditor' +import { InputComponent, InputText, InputTextarea } from '@/common/components/forms' +import { Row } from '@/common/components/Modal' +import { RowGapBlock } from '@/common/components/page/PageContent' +import { SelectWorkingGroup } from '@/working-groups/components/SelectWorkingGroup' +import { GroupIdToGroupParam } from '@/working-groups/constants' +import { GroupIdName } from '@/working-groups/types' + +interface Props { + groupId?: GroupIdName +} + +export const WorkingGroupAndDescription = ({ groupId }: Props) => { + const { watch, setValue } = useFormContext() + const MappedGroupedParams = { + ...GroupIdToGroupParam, + operationsWorkingGroupAlpha: 'Builders', + operationsWorkingGroupBeta: 'HR', + operationsWorkingGroupGamma: 'Marketing', + } + + useEffect(() => { + setValue('workingGroupAndDescription.groupId', groupId, { shouldValidate: true }) + setValue('groupId', groupId) + }, [groupId]) + + return ( + + + {groupId ? ( +
Group: {MappedGroupedParams[groupId]}
+ ) : ( + + { + setValue('workingGroupAndDescription.groupId', selected.id, { shouldValidate: true }) + setValue('groupId', selected.id) + }} + /> + + )} + + + + + + + + + +
+
+ ) +} diff --git a/packages/ui/src/working-groups/components/CreateOpeningButton.tsx b/packages/ui/src/working-groups/components/CreateOpeningButton.tsx new file mode 100644 index 0000000000..fe11aa10eb --- /dev/null +++ b/packages/ui/src/working-groups/components/CreateOpeningButton.tsx @@ -0,0 +1,26 @@ +import React, { useCallback } from 'react' + +import { TransactionButton } from '@/common/components/buttons/TransactionButton' +import { PlusIcon } from '@/common/components/icons/PlusIcon' +import { useModal } from '@/common/hooks/useModal' +import { CreateOpeningModalCall } from '@/working-groups/modals/CreateOpening/types' +import { GroupIdName } from '@/working-groups/types' + +export interface CreateOpeningButtonProps { + group: GroupIdName +} + +export const CreateOpeningButton = ({ group }: CreateOpeningButtonProps) => { + const { showModal } = useModal() + const createOpening = useCallback( + () => showModal({ modal: 'CreateOpening', data: { group } }), + [group] + ) + + return ( + + + Add opening + + ) +} diff --git a/packages/ui/src/working-groups/modals/CreateOpening/CreateOpening.tsx b/packages/ui/src/working-groups/modals/CreateOpening/CreateOpening.tsx new file mode 100644 index 0000000000..d18860a977 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/CreateOpening.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import styled from 'styled-components' + +import { useTransactionFee } from '@/accounts/hooks/useTransactionFee' +import { useApi } from '@/api/hooks/useApi' +import { ButtonPrimary, ButtonsGroup } from '@/common/components/buttons' +import { DownloadButtonGhost } from '@/common/components/buttons/DownloadButtons' +import { Modal, ModalHeader, ModalTransactionFooter } from '@/common/components/Modal' +import { Stepper, StepperModalBody, StepperModalWrapper } from '@/common/components/StepperModal' +import { TextMedium } from '@/common/components/typography' +import { useMachine } from '@/common/hooks/useMachine' +import { useModal } from '@/common/hooks/useModal' +import { SignTransactionModal } from '@/common/modals/SignTransactionModal/SignTransactionModal' +import { isLastStepActive } from '@/common/modals/utils' +import { getSteps } from '@/common/model/machines/getSteps' +import { useYupValidationResolver } from '@/common/utils/validation' +import { machineStateConverter } from '@/council/modals/AnnounceCandidacy/helpers' +import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' +import { StyledStepperBody } from '@/proposals/modals/AddNewProposal' + +import { SuccessModal, CreateOpeningSteps as Steps, ImportOpening } from './components' +import { createOpeningMachine, CreateOpeningMachineState } from './machine' +import { OpeningConditions, CreateOpeningForm, CreateOpeningModalCall, OpeningSchema, defaultValues } from './types' +import { getTxParams } from './utils' + +export const CreateOpeningModal = () => { + const [showImport, setShowImport] = useState(false) + + const { api } = useApi() + const { active: activeMember } = useMyMemberships() + const [state, send, service] = useMachine(createOpeningMachine) + const { hideModal, modalData } = useModal() + + const { group } = modalData + const workingGroupConsts = api?.consts[group] + + const context = { + group, + minUnstakingPeriodLimit: workingGroupConsts?.minUnstakingPeriodLimit, + minimumApplicationStake: workingGroupConsts?.minimumApplicationStake, + } as OpeningConditions + const path = useMemo(() => machineStateConverter(state.value), [state.value]) + const resolver = useYupValidationResolver(OpeningSchema, path) + const form = useForm({ resolver, mode: 'onChange', defaultValues, context }) + useEffect(() => { + form.trigger(machineStateConverter(state.value) as keyof CreateOpeningForm) + }, [machineStateConverter(state.value)]) + + const { transaction, feeInfo } = useTransactionFee( + activeMember?.controllerAccount, + () => { + if (api && group) { + const { ...specifics } = form.getValues() as CreateOpeningForm + const { description, stakePolicy, rewardPerBlock } = getTxParams(group, specifics) + return api.tx[group].addOpening(description, 'Regular', stakePolicy, String(rewardPerBlock)) + } + }, + [api?.isConnected, activeMember?.id, group, form.formState.isValidating] + ) + const exportedJsonValue = useMemo(() => { + const { ...specifics } = form.getValues() as CreateOpeningForm + const exportValue = { + applicationDetails: specifics.durationAndProcess.details, + title: specifics.workingGroupAndDescription.title, + shortDescription: specifics.workingGroupAndDescription.shortDescription, + description: specifics.workingGroupAndDescription.description, + applicationFormQuestions: specifics.applicationForm?.questions?.map((question) => { + return { question: question.questionField } + }), + stakingPolicy: { + amount: specifics.stakingPolicyAndReward.stakingAmount?.toNumber(), + unstakingPeriod: specifics.stakingPolicyAndReward.leavingUnstakingPeriod, + }, + rewardPerBlock: specifics.stakingPolicyAndReward.rewardPerBlock?.toNumber(), + expectedEndingTimestamp: specifics?.durationAndProcess?.isLimited + ? specifics.durationAndProcess?.duration + : undefined, + } + return JSON.stringify(exportValue) + }, [form.getValues()]) + + const goToPrevious = useCallback(() => { + send('BACK') + }, [send]) + + useEffect((): any => { + if (state.matches('requirementsVerification')) { + return feeInfo && send(feeInfo.canAfford ? 'NEXT' : 'FAIL') + } + if (state.matches('beforeTransaction')) { + return feeInfo?.canAfford ? send('NEXT') : send('FAIL') + } + }, [state, activeMember?.id, feeInfo]) + + if (!activeMember || state.matches('requirementsFailed')) { + return null + } + + if (state.matches('transaction') && transaction && group) { + return ( + + You intend to create an Opening. + + ) + } + + if (state.matches('success') && group) { + return + } + + return ( + + + + + + + + + {showImport ? ( + + ) : ( + + )} + + + + + + send('NEXT'), + }} + extraButtons={ + + setShowImport(!showImport)} + disabled={showImport && !form.formState.isValid} + > + {showImport ? 'Preview Import' : 'Import'} + + {isLastStepActive(getSteps(service)) && ( + + Export + + )} + + } + /> + + ) +} + +export const StepperOpeningWrapper = styled(StepperModalWrapper)` + grid-template-columns: 220px 1fr; +` diff --git a/packages/ui/src/working-groups/modals/CreateOpening/components/Import.tsx b/packages/ui/src/working-groups/modals/CreateOpening/components/Import.tsx new file mode 100644 index 0000000000..1ac85ce1a8 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/components/Import.tsx @@ -0,0 +1,112 @@ +import BN from 'bn.js' +import React, { useEffect, useCallback, useReducer } from 'react' +import { useFormContext } from 'react-hook-form' +import * as Yup from 'yup' + +import { FileEntry, FileInput } from '@/common/components/forms/FileInput' +import { GroupIdName } from '@/working-groups/types' + +import { CreateOpeningForm, OpeningSchema } from '../types' + +type Value = FileEntry & { formContent?: CreateOpeningForm } +type Action = { type: 'set-file'; value: File } | { type: 'set-content'; value: string; groupId?: GroupIdName } + +interface Props { + groupId?: GroupIdName +} + +const parseContent = (contentJson: any, groupId?: GroupIdName): Pick => { + try { + const fileContent = JSON.parse(contentJson) + const formContent: CreateOpeningForm = { + applicationForm: { + questions: fileContent.applicationFormQuestions?.map((qValue: any) => { + return { questionField: qValue.question, shortValue: qValue.type === 'TEXT' } + }), + }, + durationAndProcess: { + duration: fileContent.expectedEndingTimestamp, + details: fileContent.applicationDetails, + isLimited: fileContent.expectedEndingTimestamp ? true : false, + target: fileContent.hiringLimit, + }, + stakingPolicyAndReward: { + stakingAmount: new BN(fileContent.stakingPolicy?.amount), + leavingUnstakingPeriod: fileContent.stakingPolicy?.unstakingPeriod, + rewardPerBlock: new BN(fileContent.rewardPerBlock), + }, + workingGroupAndDescription: { + groupId: groupId, + title: fileContent.title, + description: fileContent.description, + shortDescription: fileContent.shortDescription, + }, + } + OpeningSchema.validateSync(formContent) + return { formContent, errors: [] } + } catch (error) { + if (error instanceof SyntaxError) { + return { errors: [new Yup.ValidationError(error.message)] } + } else if (error instanceof Yup.ValidationError) { + return { errors: [error] } + } else throw error + } +} + +const valueReducer = (value: undefined | Value, action: Action): undefined | Value => { + switch (action.type) { + case 'set-file': + return { file: action.value } + + case 'set-content': + if (value) { + return { file: value.file, ...parseContent(action.value, action.groupId) } + } + } +} + +export const ImportOpening = ({ groupId }: Props) => { + const [value, dispatch] = useReducer(valueReducer, undefined) + const form = useFormContext() + + const onUpload = useCallback(async ([file]: File[]) => { + if (!file) return + dispatch({ type: 'set-file', value: file }) + const contentJson = await file.text() + dispatch({ type: 'set-content', value: contentJson, groupId }) + }, []) + + useEffect(() => { + if (value?.formContent) { + form.setValue('durationAndProcess.target', value.formContent.durationAndProcess.target) + form.setValue('applicationForm.questions', value.formContent.applicationForm.questions) + form.setValue('durationAndProcess.isLimited', value.formContent.durationAndProcess.isLimited) + form.setValue('durationAndProcess.duration', value.formContent.durationAndProcess.duration) + form.setValue('durationAndProcess.details', value.formContent.durationAndProcess.details) + form.setValue('stakingPolicyAndReward.stakingAmount', value.formContent.stakingPolicyAndReward.stakingAmount) + form.setValue( + 'stakingPolicyAndReward.leavingUnstakingPeriod', + value.formContent.stakingPolicyAndReward.leavingUnstakingPeriod + ) + form.setValue('stakingPolicyAndReward.rewardPerBlock', value.formContent.stakingPolicyAndReward.rewardPerBlock) + form.setValue('workingGroupAndDescription.groupId', value.formContent.workingGroupAndDescription.groupId) + form.setValue('workingGroupAndDescription.title', value.formContent.workingGroupAndDescription.title) + form.setValue('workingGroupAndDescription.description', value.formContent.workingGroupAndDescription.description) + form.setValue( + 'workingGroupAndDescription.shortDescription', + value.formContent.workingGroupAndDescription.shortDescription + ) + form.trigger() + } + }, [value]) + return ( + <> + Note: This will override current form input. + + {value?.errors?.length && value.errors.length > 0 + ? value.errors.map((error, index) =>
{new String(error)}
) + : ''} + {value?.formContent && 'File imported successfully, preview your input'} + + ) +} diff --git a/packages/ui/src/working-groups/modals/CreateOpening/components/Steps.tsx b/packages/ui/src/working-groups/modals/CreateOpening/components/Steps.tsx new file mode 100644 index 0000000000..bae11d5383 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/components/Steps.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import { ApplicationForm } from '@/working-groups/components/CreateOpening/ApplicationForm' +import { DurationAndProcess } from '@/working-groups/components/CreateOpening/DurationAndProcess' +import { StakingPolicyAndReward } from '@/working-groups/components/CreateOpening/StakingPolicyAndReward' +import { WorkingGroupAndDescription } from '@/working-groups/components/CreateOpening/WorkingGroupAndDescription' +import { GroupIdName } from '@/working-groups/types' + +import { CreateOpeningMachineState } from '../machine' + +export interface CreateOpeningStepProps { + matches: CreateOpeningMachineState['matches'] + groupId?: GroupIdName +} + +export const CreateOpeningSteps = ({ matches, groupId }: CreateOpeningStepProps) => { + switch (true) { + case matches('workingGroupAndDescription'): + return + + case matches('durationAndProcess'): + return + + case matches('applicationForm'): + return + + case matches('stakingPolicyAndReward'): + return + + default: + return <> + } +} diff --git a/packages/ui/src/working-groups/modals/CreateOpening/components/Success.tsx b/packages/ui/src/working-groups/modals/CreateOpening/components/Success.tsx new file mode 100644 index 0000000000..c2de543f41 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/components/Success.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { useHistory } from 'react-router-dom' + +import { ButtonGhost } from '@/common/components/buttons' +import { SuccessSymbol } from '@/common/components/icons/symbols' +import { Info } from '@/common/components/Info' +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/common/components/Modal' +import { TextMedium } from '@/common/components/typography' +import { GroupIdToGroupParam } from '@/working-groups/constants' +import { GroupIdName } from '@/working-groups/types' + +interface SuccessModalProps { + onClose: () => void + groupId: GroupIdName +} + +export const SuccessModal = ({ onClose, groupId }: SuccessModalProps) => { + const history = useHistory() + + const redirect = () => { + onClose() + history.push(`/working-groups/${GroupIdToGroupParam[groupId].toLowerCase()}`) + } + + return ( + + } /> + + + You have just successfully created an opening. + + + + + Back to Working Group + + + + ) +} diff --git a/packages/ui/src/working-groups/modals/CreateOpening/components/index.ts b/packages/ui/src/working-groups/modals/CreateOpening/components/index.ts new file mode 100644 index 0000000000..68b33a7a13 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/components/index.ts @@ -0,0 +1,3 @@ +export * from './Steps' +export * from './Import' +export * from './Success' diff --git a/packages/ui/src/working-groups/modals/CreateOpening/index.ts b/packages/ui/src/working-groups/modals/CreateOpening/index.ts new file mode 100644 index 0000000000..0552229791 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/index.ts @@ -0,0 +1,4 @@ +export * from './CreateOpening' +export * from './components' +export * from './machine' +export * from './types' diff --git a/packages/ui/src/working-groups/modals/CreateOpening/machine.ts b/packages/ui/src/working-groups/modals/CreateOpening/machine.ts new file mode 100644 index 0000000000..8a94cbdc2f --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/machine.ts @@ -0,0 +1,117 @@ +import { assign, createMachine, State, Typestate } from 'xstate' +import { StateSchema } from 'xstate/lib/types' + +import { transactionModalFinalStatusesFactory } from '@/common/modals/utils' +import { getDataFromEvent } from '@/common/model/JoystreamNode' +import { + isTransactionCanceled, + isTransactionError, + isTransactionSuccess, + transactionMachine, +} from '@/common/model/machines' +import { EmptyObject } from '@/common/types' + +import { CreateOpeningForm, TransactionContext } from './types' + +export type CreateOpeningState = + | { value: 'requirementsVerification'; context: EmptyObject } + | { value: 'requirementsFailed'; context: EmptyObject } + | { value: 'workingGroupAndDescription'; context: Required } + | { value: 'durationAndProcess'; context: Required } + | { value: 'applicationForm'; context: Required } + | { value: 'stakingPolicyAndReward'; context: Required } + | { value: 'beforeTransaction'; context: Required } + | { value: 'transaction'; context: Required } + | { value: 'success'; context: Required } + | { value: 'error'; context: TransactionContext } + +export type CreateOpeningEvent = { type: 'FAIL' } | { type: 'BACK' } | { type: 'NEXT' } + +export type CreateOpeningMachineState = State< + Partial, + CreateOpeningEvent, + StateSchema>, + Typestate> +> + +type Context = CreateOpeningForm & TransactionContext + +export const createOpeningMachine = createMachine, CreateOpeningEvent, CreateOpeningState>({ + initial: 'requirementsVerification', + states: { + requirementsVerification: { on: { FAIL: 'requirementsFailed', NEXT: 'workingGroupAndDescription' } }, + requirementsFailed: { type: 'final' }, + workingGroupAndDescription: { + meta: { + isStep: true, + stepTitle: 'Working group & Description', + }, + on: { + NEXT: 'durationAndProcess', + }, + }, + durationAndProcess: { + meta: { + isStep: true, + stepTitle: 'Duration & Process', + }, + on: { + BACK: 'workingGroupAndDescription', + NEXT: 'applicationForm', + }, + }, + applicationForm: { + meta: { + isStep: true, + stepTitle: 'Application Form', + }, + on: { + BACK: 'durationAndProcess', + NEXT: 'stakingPolicyAndReward', + }, + }, + stakingPolicyAndReward: { + meta: { + isStep: true, + stepTitle: 'Staking Policy & Reward', + }, + on: { + BACK: 'applicationForm', + NEXT: 'beforeTransaction', + }, + }, + beforeTransaction: { + id: 'beforeTransaction', + on: { + NEXT: 'transaction', + FAIL: 'requirementsFailed', + }, + }, + transaction: { + invoke: { + id: 'transaction', + src: transactionMachine, + onDone: [ + { + target: 'success', + actions: assign({ + openingId: (_, event) => + Number(getDataFromEvent(event.data.events, event.data.section, 'OpeningCreated') ?? -1), + }), + cond: (context, event) => isTransactionSuccess(context, event), + }, + { + target: 'error', + actions: assign({ transactionEvents: (context, event) => event.data.events }), + cond: isTransactionError, + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + ...transactionModalFinalStatusesFactory(), + }, +}) diff --git a/packages/ui/src/working-groups/modals/CreateOpening/types.tsx b/packages/ui/src/working-groups/modals/CreateOpening/types.tsx new file mode 100644 index 0000000000..a60c0d3ee3 --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/types.tsx @@ -0,0 +1,110 @@ +import { EventRecord } from '@polkadot/types/interfaces/system' +import BN from 'bn.js' +import * as Yup from 'yup' + +import { QuestionValueProps } from '@/common/components/EditableInputList/EditableInputList' +import { ModalWithDataCall } from '@/common/providers/modal/types' +import { BNSchema, minContext, minMixed } from '@/common/utils/validation' +import { GroupIdName } from '@/working-groups/types' + +export interface OpeningModalData { + group: GroupIdName +} + +export type CreateOpeningModalCall = ModalWithDataCall<'CreateOpening', OpeningModalData> + +// TODO research runtime constraints +export interface OpeningConditions { + group: GroupIdName + minUnstakingPeriodLimit?: BN + minimumApplicationStake?: BN +} + +export interface TransactionContext extends OpeningConditions { + transactionEvents?: EventRecord[] + openingId?: number +} + +export const OpeningSchema = Yup.object().shape({ + group: Yup.number().optional(), + applicationForm: Yup.object().shape({ + questions: Yup.array() + .of( + Yup.object({ + questionField: Yup.string().required('Question is required'), + shortValue: Yup.boolean(), + }) + ) + .min(1) + .required('Questions is required'), + }), + durationAndProcess: Yup.object().shape({ + details: Yup.string().required('Details is required'), + isLimited: Yup.boolean(), + duration: Yup.number().when('isLimited', { + is: true, + then: Yup.number().required('Duration is required'), + }), + target: Yup.number() + .min(1, 'Minimum hiring target must be greater than zero') + .required('Hiring target is required'), + }), + stakingPolicyAndReward: Yup.object().shape({ + stakingAmount: BNSchema.test( + minContext('Input must be at least ${min} for proposal to execute', 'minimumApplicationStake', true, 'execution') + ).required('Staking amount is required'), + leavingUnstakingPeriod: BNSchema.test( + minContext('Input must be at least ${min} for proposal to execute', 'minUnstakingPeriodLimit', false, 'execution') + ).required('Leaving unstaking period is required'), + rewardPerBlock: BNSchema.test(minMixed(0, 'Amount must be greater than or equal zero')).required( + 'Reward per block is required' + ), + }), + workingGroupAndDescription: Yup.object().shape({ + title: Yup.string().required('Title is required').max(55, 'Max length is 55 characters'), + description: Yup.string().required('Description is required'), + shortDescription: Yup.string().required('Short Description is required'), + groupId: Yup.string().required('Group Id is required'), + }), +}) + +export const defaultValues = { + durationAndProcess: { + details: '', + duration: 100000, + isLimited: false, + target: 1, + }, + stakingPolicyAndReward: { + stakingAmount: undefined, + leavingUnstakingPeriod: 14400, + rewardPerBlock: undefined, + }, + workingGroupAndDescription: { + title: '', + description: '', + shortDescription: '', + }, +} + +export interface CreateOpeningForm { + group?: GroupIdName + applicationForm: { questions?: QuestionValueProps[] } + durationAndProcess: { + details?: string + duration?: number + isLimited: boolean + target: number + } + stakingPolicyAndReward: { + stakingAmount: BN + leavingUnstakingPeriod: number + rewardPerBlock: BN + } + workingGroupAndDescription: { + title?: string + description?: string + shortDescription?: string + groupId?: GroupIdName + } +} diff --git a/packages/ui/src/working-groups/modals/CreateOpening/utils.ts b/packages/ui/src/working-groups/modals/CreateOpening/utils.ts new file mode 100644 index 0000000000..10971ba3aa --- /dev/null +++ b/packages/ui/src/working-groups/modals/CreateOpening/utils.ts @@ -0,0 +1,32 @@ +import { OpeningMetadata } from '@joystream/metadata-protobuf' +import BN from 'bn.js' + +import { createType } from '@/common/model/createType' +import { metadataToBytes } from '@/common/model/JoystreamNode' +import { GroupIdName } from '@/working-groups/types' + +import { CreateOpeningForm } from './types' + +export const getTxParams = (group: GroupIdName, specifics: CreateOpeningForm) => ({ + description: metadataToBytes(OpeningMetadata, { + title: specifics?.workingGroupAndDescription?.title, + shortDescription: specifics?.workingGroupAndDescription?.shortDescription, + description: specifics?.workingGroupAndDescription?.description, + hiringLimit: specifics?.durationAndProcess?.target, + expectedEndingTimestamp: specifics?.durationAndProcess?.isLimited + ? specifics.durationAndProcess?.duration + : undefined, + applicationDetails: specifics?.durationAndProcess?.details, + applicationFormQuestions: specifics?.applicationForm?.questions?.map(({ questionField, shortValue }) => ({ + question: questionField, + type: OpeningMetadata.ApplicationFormQuestion.InputType[shortValue ? 'TEXT' : 'TEXTAREA'], + })), + }), + openingType: 'Regular', + stakePolicy: createType('PalletWorkingGroupStakePolicy', { + stakeAmount: specifics?.stakingPolicyAndReward?.stakingAmount, + leavingUnstakingPeriod: specifics?.stakingPolicyAndReward?.leavingUnstakingPeriod, + }), + rewardPerBlock: new BN(specifics?.stakingPolicyAndReward?.rewardPerBlock).toNumber(), + group, +})