EditForm.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import BN from 'bn.js';
  2. import React from 'react';
  3. import { Link } from 'react-router-dom';
  4. import { Form, Field, withFormik, FormikProps } from 'formik';
  5. import * as Yup from 'yup';
  6. import { Option, Vec } from '@polkadot/types';
  7. import Section from '@polkadot/joy-utils/Section';
  8. import TxButton from '@polkadot/joy-utils/TxButton';
  9. import * as JoyForms from '@polkadot/joy-utils/forms';
  10. import { SubmittableResult } from '@polkadot/api';
  11. import { MemberId, UserInfo, Profile, PaidTermId, PaidMembershipTerms } from '@joystream/types/members';
  12. import { OptionText } from '@joystream/types/common';
  13. import { MyAccountProps, withMyAccount } from '@polkadot/joy-utils/MyAccount';
  14. import { queryMembershipToProp } from './utils';
  15. import { withCalls } from '@polkadot/react-api/index';
  16. import { Button, Message } from 'semantic-ui-react';
  17. import { formatBalance } from '@polkadot/util';
  18. import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
  19. import isEqual from 'lodash/isEqual';
  20. // TODO get next settings from Substrate:
  21. const HANDLE_REGEX = /^[a-z0-9_]+$/;
  22. const buildSchema = (p: ValidationProps) =>
  23. Yup.object().shape({
  24. handle: Yup.string()
  25. .matches(HANDLE_REGEX, 'Handle can have only lowercase letters (a-z), numbers (0-9) and underscores (_).')
  26. .min(p.minHandleLength, `Handle is too short. Minimum length is ${p.minHandleLength} chars.`)
  27. .max(p.maxHandleLength, `Handle is too long. Maximum length is ${p.maxHandleLength} chars.`)
  28. .required('Handle is required'),
  29. avatar: Yup.string()
  30. .url('Avatar must be a valid URL of an image.')
  31. .max(p.maxAvatarUriLength, `Avatar URL is too long. Maximum length is ${p.maxAvatarUriLength} chars.`),
  32. about: Yup.string().max(p.maxAboutTextLength, `Text is too long. Maximum length is ${p.maxAboutTextLength} chars.`)
  33. });
  34. type ValidationProps = {
  35. minHandleLength: number;
  36. maxHandleLength: number;
  37. maxAvatarUriLength: number;
  38. maxAboutTextLength: number;
  39. };
  40. type OuterProps = ValidationProps & {
  41. profile?: Profile;
  42. paidTerms: PaidMembershipTerms;
  43. paidTermId: PaidTermId;
  44. memberId?: MemberId;
  45. };
  46. type FormValues = {
  47. handle: string;
  48. avatar: string;
  49. about: string;
  50. };
  51. type FieldName = keyof FormValues;
  52. type FormProps = OuterProps & FormikProps<FormValues>;
  53. const LabelledField = JoyForms.LabelledField<FormValues>();
  54. const LabelledText = JoyForms.LabelledText<FormValues>();
  55. const InnerForm = (props: FormProps) => {
  56. const {
  57. profile,
  58. paidTerms,
  59. paidTermId,
  60. initialValues,
  61. values,
  62. touched,
  63. dirty,
  64. isValid,
  65. isSubmitting,
  66. setSubmitting,
  67. resetForm,
  68. memberId
  69. } = props;
  70. const onSubmit = (sendTx: () => void) => {
  71. if (isValid) sendTx();
  72. };
  73. const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
  74. setSubmitting(false);
  75. if (txResult == null) {
  76. // Tx cancelled.
  77. }
  78. };
  79. const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
  80. setSubmitting(false);
  81. };
  82. // TODO extract to forms.tsx
  83. const isFieldChanged = (field: FieldName): boolean => {
  84. return dirty && touched[field] === true && !isEqual(values[field], initialValues[field]);
  85. };
  86. // TODO extract to forms.tsx
  87. const fieldToTextOption = (field: FieldName): OptionText => {
  88. return isFieldChanged(field) ? OptionText.some(values[field]) : OptionText.none();
  89. };
  90. const buildTxParams = () => {
  91. if (!isValid) return [];
  92. const userInfo = new UserInfo({
  93. handle: fieldToTextOption('handle'),
  94. avatar_uri: fieldToTextOption('avatar'),
  95. about: fieldToTextOption('about')
  96. });
  97. if (profile) {
  98. // update profile
  99. return [memberId, userInfo];
  100. } else {
  101. // register as new member
  102. return [paidTermId, userInfo];
  103. }
  104. };
  105. // TODO show warning that you don't have enough balance to buy a membership
  106. return (
  107. <Section title="My Membership Profile">
  108. <Form className="ui form JoyForm">
  109. <LabelledText
  110. name="handle"
  111. label="Handle/nickname"
  112. placeholder={'You can use a-z, 0-9 and underscores.'}
  113. style={{ maxWidth: '30rem' }}
  114. {...props}
  115. />
  116. <LabelledText
  117. name="avatar"
  118. label="Avatar URL"
  119. placeholder="Paste here an URL of your avatar image."
  120. {...props}
  121. />
  122. <LabelledField name="about" label="About" {...props}>
  123. <Field
  124. component="textarea"
  125. id="about"
  126. name="about"
  127. disabled={isSubmitting}
  128. rows={3}
  129. placeholder="Write here anything you would like to share about yourself with Joystream community."
  130. />
  131. </LabelledField>
  132. {!profile && paidTerms && (
  133. <Message warning style={{ display: 'block', marginBottom: '0' }}>
  134. <p>
  135. Membership costs <b>{formatBalance(paidTerms.fee)}</b> tokens.
  136. </p>
  137. <p>
  138. <span>{'By clicking the "Register" button you agree to our '}</span>
  139. <Link to={'/pages/tos'}>Terms of Service</Link>
  140. <span> and </span>
  141. <Link to={'/pages/privacy'}>Privacy Policy</Link>.
  142. </p>
  143. </Message>
  144. )}
  145. <LabelledField invisibleLabel {...props}>
  146. <TxButton
  147. type="submit"
  148. size="large"
  149. label={profile ? 'Update my profile' : 'Register'}
  150. isDisabled={!dirty || isSubmitting}
  151. params={buildTxParams()}
  152. tx={profile ? 'members.updateProfile' : 'members.buyMembership'}
  153. onClick={onSubmit}
  154. txFailedCb={onTxFailed}
  155. txSuccessCb={onTxSuccess}
  156. />
  157. <Button
  158. type="button"
  159. size="large"
  160. disabled={!dirty || isSubmitting}
  161. onClick={() => resetForm()}
  162. content="Reset form"
  163. />
  164. </LabelledField>
  165. </Form>
  166. </Section>
  167. );
  168. };
  169. const EditForm = withFormik<OuterProps, FormValues>({
  170. // Transform outer props into form values
  171. mapPropsToValues: props => {
  172. const { profile: p } = props;
  173. return {
  174. handle: p ? p.handle.toString() : '',
  175. avatar: p ? p.avatar_uri.toString() : '',
  176. about: p ? p.about.toString() : ''
  177. };
  178. },
  179. validationSchema: buildSchema,
  180. handleSubmit: values => {
  181. // do submitting things
  182. }
  183. })(InnerForm);
  184. type WithMyProfileProps = {
  185. memberId?: MemberId;
  186. memberProfile?: Option<any>; // TODO refactor to Option<Profile>
  187. paidTermsId: PaidTermId;
  188. paidTerms?: Option<PaidMembershipTerms>;
  189. minHandleLength?: BN;
  190. maxHandleLength?: BN;
  191. maxAvatarUriLength?: BN;
  192. maxAboutTextLength?: BN;
  193. };
  194. function WithMyProfileInner (p: WithMyProfileProps) {
  195. const triedToFindProfile = !p.memberId || p.memberProfile;
  196. if (
  197. triedToFindProfile &&
  198. p.paidTerms &&
  199. p.minHandleLength &&
  200. p.maxHandleLength &&
  201. p.maxAvatarUriLength &&
  202. p.maxAboutTextLength
  203. ) {
  204. const profile = p.memberProfile ? p.memberProfile.unwrapOr(undefined) : undefined;
  205. if (!profile && p.paidTerms.isNone) {
  206. console.error('Could not find active paid membership terms');
  207. }
  208. return (
  209. <EditForm
  210. minHandleLength={p.minHandleLength.toNumber()}
  211. maxHandleLength={p.maxHandleLength.toNumber()}
  212. maxAvatarUriLength={p.maxAvatarUriLength.toNumber()}
  213. maxAboutTextLength={p.maxAboutTextLength.toNumber()}
  214. profile={profile as Profile}
  215. paidTerms={p.paidTerms.unwrap()}
  216. paidTermId={p.paidTermsId}
  217. memberId={p.memberId}
  218. />
  219. );
  220. } else return <em>Loading...</em>;
  221. }
  222. const WithMyProfile = withCalls<WithMyProfileProps>(
  223. queryMembershipToProp('minHandleLength'),
  224. queryMembershipToProp('maxHandleLength'),
  225. queryMembershipToProp('maxAvatarUriLength'),
  226. queryMembershipToProp('maxAboutTextLength'),
  227. queryMembershipToProp('memberProfile', 'memberId'),
  228. queryMembershipToProp('paidMembershipTermsById', { paramName: 'paidTermsId', propName: 'paidTerms' })
  229. )(WithMyProfileInner);
  230. type WithMyMemberIdProps = MyAccountProps & {
  231. memberIdsByRootAccountId?: Vec<MemberId>;
  232. memberIdsByControllerAccountId?: Vec<MemberId>;
  233. paidTermsIds?: Vec<PaidTermId>;
  234. };
  235. function WithMyMemberIdInner (p: WithMyMemberIdProps) {
  236. if (p.allAccounts && !Object.keys(p.allAccounts).length) {
  237. return (
  238. <Message warning className="JoyMainStatus">
  239. <Message.Header>Please create a key to get started.</Message.Header>
  240. <div style={{ marginTop: '1rem' }}>
  241. <Link to={'/accounts'} className="ui button orange">
  242. Create key
  243. </Link>
  244. </div>
  245. </Message>
  246. );
  247. }
  248. if (p.memberIdsByRootAccountId && p.memberIdsByControllerAccountId && p.paidTermsIds) {
  249. if (p.paidTermsIds.length) {
  250. // let member_ids = p.memberIdsByRootAccountId.slice(); // u8a.subarray is not a function!!
  251. p.memberIdsByRootAccountId.concat(p.memberIdsByControllerAccountId);
  252. const memberId = p.memberIdsByRootAccountId.length ? p.memberIdsByRootAccountId[0] : undefined;
  253. return <WithMyProfile memberId={memberId} paidTermsId={p.paidTermsIds[0]} />;
  254. } else {
  255. console.error('Active paid membership terms is empty');
  256. }
  257. }
  258. return <em>Loading...</em>;
  259. }
  260. const WithMyMemberId = withMyAccount(
  261. withCalls<WithMyMemberIdProps>(
  262. queryMembershipToProp('memberIdsByRootAccountId', 'myAddress'),
  263. queryMembershipToProp('memberIdsByControllerAccountId', 'myAddress'),
  264. queryMembershipToProp('activePaidMembershipTerms', { propName: 'paidTermsIds' })
  265. )(WithMyMemberIdInner)
  266. );
  267. export default WithMyMemberId;