diff --git a/frontend/packages/client/package.json b/frontend/packages/client/package.json index cbc1ef387..92b76bed1 100644 --- a/frontend/packages/client/package.json +++ b/frontend/packages/client/package.json @@ -19,6 +19,7 @@ "date-fns": "^2.28.0", "draft-js": "^0.11.7", "draft-js-export-html": "^1.4.1", + "draft-js-import-html": "^1.4.1", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "immutable": "^4.0.0", diff --git a/frontend/packages/client/src/App.sass b/frontend/packages/client/src/App.sass index 81f00b40f..d580b45d9 100644 --- a/frontend/packages/client/src/App.sass +++ b/frontend/packages/client/src/App.sass @@ -695,9 +695,12 @@ span[data-tooltip] background-size: contain border-color: transparent background-repeat: no-repeat - + @keyframes star - from - transform: scale(0) + from + transform: scale(0) to - transform: scale(1) \ No newline at end of file + transform: scale(1) + +.rdw-editor-toolbar + border-radius: 8px !important \ No newline at end of file diff --git a/frontend/packages/client/src/components/CommunityCreate/StepThree.js b/frontend/packages/client/src/components/CommunityCreate/StepThree.js index bb5a026a4..6923c1010 100644 --- a/frontend/packages/client/src/components/CommunityCreate/StepThree.js +++ b/frontend/packages/client/src/components/CommunityCreate/StepThree.js @@ -1,5 +1,5 @@ -import React from 'react'; -import { useForm } from 'react-hook-form'; +import React, { useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; import { useWebContext } from 'contexts/Web3'; import { ActionButton } from 'components'; import { ThresholdForm } from 'components/Community/ProposalThresholdEditor'; @@ -24,7 +24,7 @@ export default function StepThree({ const { isValidFlowAddress } = useWebContext(); - const { control, register, handleSubmit, formState } = useForm({ + const { control, register, handleSubmit, formState, setFocus } = useForm({ resolver: yupResolver(Schema(isValidFlowAddress)), defaultValues: { proposalThreshold, @@ -42,6 +42,15 @@ export default function StepThree({ const { isDirty, isSubmitting, errors, isValid } = formState; + const dropdownValue = useWatch({ control, name: 'contractType' }); + const contractAddressValue = useWatch({ control, name: 'contractAddress' }); + + useEffect(() => { + if (dropdownValue && contractAddressValue === '') { + setFocus('contractAddress'); + } + }, [dropdownValue, contractAddressValue, setFocus]); + return ( { + const openCloseDropdown = (e) => { + e.preventDefault(); + e.stopPropagation(); setIsOpen((status) => !status); }; diff --git a/frontend/packages/client/src/components/ProposalCreate/FormConfig.js b/frontend/packages/client/src/components/ProposalCreate/FormConfig.js new file mode 100644 index 000000000..02a34c265 --- /dev/null +++ b/frontend/packages/client/src/components/ProposalCreate/FormConfig.js @@ -0,0 +1,76 @@ +import yup from 'helpers/validation'; + +const formFieldsStepOne = ['name', 'strategy', 'body', 'choices', 'tabOption']; +const formFieldsStepTwo = ['startDate', 'endDate', 'startTime', 'endTime']; + +const StepOneSchema = yup.object().shape({ + name: yup + .string() + .trim() + .required('Please enter a proposal title') + .max(150, 'The maximum length for title is 128 characters'), + strategy: yup.string().required('Please select a strategy'), + body: yup.string().required('Please enter a proposal description'), + tabOption: yup.string().oneOf(['text-based', 'visual']), + choices: yup + .array() + .of( + yup.object({ + value: yup.string().required('Please enter option value'), + choiceImgUrl: yup.string().nullable(), + }) + ) + .when('tabOption', { + is: 'visual', + then: yup.array().of( + yup.object({ + value: yup.string().required('Please enter option value'), + choiceImgUrl: yup + .string() + .trim() + .url('Image option is not valid') + .required('Please upload an image'), + }) + ), + }) + .min(2, 'Please add a choice, minimun amout is two') + .unique('value', 'Invalid duplicated option'), + maxWeight: yup + .string() + .trim() + .matches( + /\s+$|^$|(^[0-9]+$)/, + 'Proposal maximun weight must be a valid number' + ), + minBalance: yup + .string() + .trim() + .matches( + /\s+$|^$|(^[0-9]+$)/, + 'Proposal minimun balance must be a valid number' + ), +}); + +const StepTwoSchema = yup.object().shape({ + startDate: yup.date().required('Please provide a start date'), + startTime: yup.date().required('Please provide a start time'), + endDate: yup.date().required('Please provide an end date'), + endTime: yup.date().required('Please provide an end time'), +}); + +const initialValues = (fields = []) => + Object.assign({}, ...fields.map((key) => ({ [key]: undefined }))); + +const stepOne = { + Schema: StepOneSchema, + initialValues: initialValues(formFieldsStepOne), + formFields: formFieldsStepOne, +}; + +const stepTwo = { + Schema: StepTwoSchema, + initialValues: initialValues(formFieldsStepTwo), + formFields: formFieldsStepTwo, +}; + +export { stepOne, stepTwo }; diff --git a/frontend/packages/client/src/components/ProposalCreate/StepOne/ChoiceOptionCreator.js b/frontend/packages/client/src/components/ProposalCreate/StepOne/ChoiceOptionCreator.js new file mode 100644 index 000000000..c9cc39b41 --- /dev/null +++ b/frontend/packages/client/src/components/ProposalCreate/StepOne/ChoiceOptionCreator.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import ImageChoices from './ImageChoices'; +import TextBasedChoices from './TextBasedChoices'; + +export default function ChoiceOptionCreator({ + setValue = () => {}, + error = [], + register, + fieldName, + control, + clearErrors, +} = {}) { + const tabOption = useWatch({ control, name: 'tabOption' }); + + // tabOption value is saved on form + const setTab = (option) => (e) => { + e.preventDefault(); + e.stopPropagation(); + setValue('tabOption', option); + }; + + return ( + <> +
+ +
+ {tabOption === 'text-based' && ( + + )} + {tabOption === 'visual' && ( + + )} + + ); +} diff --git a/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoiceUploader.js b/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoiceUploader.js index 95c13f15b..8c66c0a3c 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoiceUploader.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoiceUploader.js @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useDropzone } from 'react-dropzone'; -import { Loader } from 'components'; +import { FadeIn, Loader } from 'components'; import { Bin, Upload } from 'components/Svg'; import { useFileUploader } from 'hooks'; import { MAX_FILE_SIZE } from 'const'; @@ -13,6 +13,13 @@ const IMAGE_STATUS = { toBeDeleted: 'to-be-deleted', }; +const initialState = { + imageUrl: null, + uploadStatus: null, + file: null, + text: '', +}; + const UploadArea = ({ getRootProps, getInputProps, errorMessage }) => { return ( <> @@ -46,32 +53,23 @@ const UploadArea = ({ getRootProps, getInputProps, errorMessage }) => { ); }; -// initial state when no image has been uploaded -const initialState = { - imageUrl: null, - uploadStatus: null, - file: null, - text: '', -}; - export default function ImageChoiceUploader({ onImageUpdate, image: imageParam, letterLabel, + error: errorParam, } = {}) { const [errorMessage, setErrorMessage] = useState(null); // existing image and component receives props + const { imageUrl, text } = imageParam; - const existingImage = { - imageUrl, - uploadStatus: IMAGE_STATUS.uploaded, + + const [image, setImage] = useState({ + imageUrl: imageUrl === '' ? null : imageUrl, + uploadStatus: imageUrl === '' ? null : IMAGE_STATUS.uploaded, file: null, text, - }; - - const [image, setImage] = useState( - imageParam.imageUrl === '' ? initialState : existingImage - ); + }); const { uploadFile, loading, error } = useFileUploader({ useModalNotifications: false, @@ -183,11 +181,22 @@ export default function ImageChoiceUploader({ return (
{!image.imageUrl && ( - + <> + + {errorParam?.choiceImgUrl?.message && ( + +
+

+ {errorParam.choiceImgUrl.message} +

+
+
+ )} + )} {(image?.uploadStatus === IMAGE_STATUS.notStarted || image?.uploadStatus === IMAGE_STATUS.uploading) && ( @@ -253,6 +262,15 @@ export default function ImageChoiceUploader({ } style={{ width: '100%' }} /> + {errorParam?.value?.message && ( + +
+

+ {errorParam.value.message} +

+
+
+ )}
); } diff --git a/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoices.js b/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoices.js index b2a1dac84..e98423571 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoices.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepOne/ImageChoices.js @@ -1,28 +1,30 @@ import React, { useEffect } from 'react'; -import { getProposalType } from 'utils'; +import { useFieldArray } from 'react-hook-form'; import ImageChoiceUploader from './ImageChoiceUploader'; -const ImageChoices = ({ choices = [], onChoiceChange, initChoices } = {}) => { +const ImageChoices = ({ error, control } = {}) => { + const [errorOptOne, errorOptTwo] = Array.isArray(error) ? error : []; + + const { + fields: choices, + update, + append, + } = useFieldArray({ + control, + name: 'choices', + focusAppend: true, + }); + useEffect(() => { - if (getProposalType(choices) !== 'image') { - initChoices([ - { - id: 1, - value: '', - choiceImgUrl: '', - }, - { - id: 2, - value: '', - choiceImgUrl: '', - }, - ]); + if (choices.length < 2) { + const size = 2 - choices.length; + const toAdd = new Array(size).fill({ value: '', choiceImgUrl: '' }); + append(toAdd); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [choices, append]); const onImageUpdate = (index) => (image) => { - onChoiceChange({ value: image.text, choiceImgUrl: image.imageUrl }, index); + update(index, { value: image.text, choiceImgUrl: image.imageUrl }); }; const [choiceA, choiceB] = choices; @@ -44,6 +46,7 @@ const ImageChoices = ({ choices = [], onChoiceChange, initChoices } = {}) => { }} letterLabel="A" onImageUpdate={onImageUpdate(0)} + error={errorOptOne} />
@@ -54,6 +57,7 @@ const ImageChoices = ({ choices = [], onChoiceChange, initChoices } = {}) => { }} letterLabel="B" onImageUpdate={onImageUpdate(1)} + error={errorOptTwo} />
diff --git a/frontend/packages/client/src/components/ProposalCreate/StepOne/TextBasedChoices.js b/frontend/packages/client/src/components/ProposalCreate/StepOne/TextBasedChoices.js index 36558afef..3ac67662e 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepOne/TextBasedChoices.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepOne/TextBasedChoices.js @@ -1,53 +1,100 @@ import React, { useEffect } from 'react'; +import { useFieldArray } from 'react-hook-form'; import AddButton from 'components/AddButton'; +import FadeIn from 'components/FadeIn'; import { Bin } from 'components/Svg'; -import { getProposalType } from 'utils'; const TextBasedChoices = ({ - choices = [], - onChoiceChange, - onDestroyChoice, - onCreateChoice, - initChoices, + fieldName = 'choices', + register, + error, + control, + clearErrors, } = {}) => { + const { + fields: choices, + append, + remove, + } = useFieldArray({ + control, + name: 'choices', + focusAppend: true, + }); + + const onCreateChoice = (e) => { + e.preventDefault(); + e.stopPropagation(); + append({ + value: '', + }); + }; + + // when choices.length === 2 error for min(2) on schema is not removed: this causes the error to be listed + // without cleaning the error message user is able to submit (which is expected, and the error is removed) + // This useEffect handles cleaning the error message useEffect(() => { - if (getProposalType(choices) !== 'text-based') { - initChoices([]); + if ( + choices.length === 2 && + choices[1].value === '' && + error?.message === 'Please add a choice, minimun amout is two' + ) { + clearErrors('choices'); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [choices, clearErrors, error]); + return ( <> - {choices?.map((choice, i) => ( -
- - onChoiceChange({ value: event.target.value }, i) - } - autoFocus - /> -
onDestroyChoice(i)} - > - + {choices?.map((choice, index) => { + const errorInField = Array.isArray(error) + ? error?.[index]?.value + : null; + return ( + +
+ +
remove(index)} + > + +
+
+ {errorInField && ( + +
+

+ {errorInField.message} +

+
+
+ )} +
+ ); + })} + {error?.message && ( + +
+

{error.message}

-
- ))} + + )}
text?.length <= 128; - -// using a React component to render custom blocks -const ImageCaptionCustomBlock = (props) => { - return
{props.children}
; -}; -const blockRenderMap = Map({ - 'image-caption-block': { - // element is used during paste or html conversion to auto match your component; - // it is also retained as part of this.props.children and not stripped out. Example: - // element: "section", - wrapper: , - }, -}); - -// keep support for other draft default block types and add our image-caption type -const extendedBlockRenderMap = DefaultDraftBlockRenderMap.merge(blockRenderMap); - -function AddImageOption({ addImage }) { - return ( - <> - - - ); -} +import { yupResolver } from '@hookform/resolvers/yup'; +import pick from 'lodash/pick'; +import { stepOne } from '../FormConfig'; +import ChoiceOptionCreator from './ChoiceOptionCreator'; const StepOne = ({ stepData, setStepValid, onDataChange, - setPreCheckStepAdvance, + formId, + moveToNextStep, }) => { - const dropDownRef = useRef(); - const { communityId } = useParams(); const { data: community } = useCommunityDetails(communityId); @@ -83,315 +34,44 @@ const StepOne = ({ [strategies] ); - const { openModal, closeModal } = useModalContext(); - - const tabOption = useMemo( - () => stepData?.proposalType || 'text-based', - [stepData?.proposalType] - ); - const [localEditorState, setLocalEditorState] = useState( - stepData?.description || EditorState.createEmpty() - ); - - const [showUploadImagesModal, setShowUploadImagesModal] = useState(false); - - useEffect(() => { - const requiredFields = { - title: (text) => text?.trim().length > 0 && checkValidTitleLength(text), - description: (body) => body?.getCurrentContent().hasText(), - choices: (opts) => { - const getLabel = (o) => o?.value?.trim(); - const getImageUrl = (o) => o?.choiceImgUrl?.trim(); - const moreThanOne = Array.isArray(opts) && opts.length > 1; - - const optLabels = (opts || []).map((opt) => getLabel(opt)); - - const haveLabels = - moreThanOne && optLabels.every((opt) => opt.length > 0); - - const eachUnique = - moreThanOne && - optLabels.every((opt, idx) => optLabels.indexOf(opt) === idx); - - if (tabOption === 'text-based') return haveLabels && eachUnique; - - const imagesUrl = (opts || []).map((opt) => getImageUrl(opt)); - - const validImageOpts = imagesUrl.every( - (imgUrl) => imgUrl && imgUrl.length > 0 - ); - - return haveLabels && eachUnique && validImageOpts; - }, - }; - const isValid = Object.keys(requiredFields).every( - (field) => stepData && requiredFields[field](stepData[field]) - ); - setStepValid(isValid); - }, [stepData, setStepValid, onDataChange, tabOption]); - - useEffect(() => { - onDataChange({ description: localEditorState }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localEditorState]); - - const setTab = (option) => () => { - onDataChange({ - proposalType: option, - }); - }; - - const onEditorChange = (changes) => { - setLocalEditorState(changes); - }; - - const options = ['blockType', 'inline', 'list', 'link', 'emoji']; - const inline = { - options: ['bold', 'italic', 'underline'], - }; - const list = { - options: ['unordered'], - }; - const link = { - options: ['link'], - defaultTargetOption: '_blank', - }; - - const styleMap = { - IMAGE_CAPTION: { - fontFamily: 'Arimo', - fontStyle: 'normal', - fontWeight: 400, - fontSize: '12px', - }, - }; - - const { strategy } = stepData ?? {}; - - useEffect(() => { - setPreCheckStepAdvance(() => { - if (!strategy) { - openModal( - React.createElement(Error, { - error: ( -
- -
- ), - errorTitle: 'Please select a strategy.', - }), - { classNameModalContent: 'rounded-sm' } - ); - return false; - } - return true; - }); - }, [strategy, setPreCheckStepAdvance, openModal, closeModal]); - - const choices = useMemo(() => stepData?.choices || [], [stepData?.choices]); - - const onCreateChoice = useCallback(() => { - onDataChange({ - choices: choices.concat([ - { - id: choices.length + 1, - value: '', - }, - ]), - }); - }, [onDataChange, choices]); - - const onDestroyChoice = useCallback( - (choiceIdx) => { - const newChoices = choices.slice(0); - newChoices.splice(choiceIdx, 1); - onDataChange({ choices: newChoices }); - }, - [choices, onDataChange] - ); - - const onChoiceChange = useCallback( - (choiceUpdate, choiceIdx) => { - const newChoices = choices.map((choice, idx) => { - if (idx === choiceIdx) { - return { - ...choice, - ...choiceUpdate, - }; - } - - return choice; - }); - - onDataChange({ choices: newChoices }); - }, - [choices, onDataChange] - ); - - const initChoices = useCallback( - (choices) => { - onDataChange({ - choices, - }); + const fieldsObj = Object.assign( + {}, + stepOne.initialValues, + { + choices: [], + tabOption: 'text-based', }, - [onDataChange] + pick(stepData || {}, stepOne.formFields) ); - const onSelectStrategy = (strategy) => { - const strategySelected = votingStrategies?.find( - (vs) => vs.key === strategy - ); - onDataChange({ - strategy: { label: strategySelected.name, value: strategySelected.key }, + const { register, handleSubmit, formState, control, setValue, clearErrors } = + useForm({ + reValidateMode: 'onChange', + defaultValues: fieldsObj, + resolver: yupResolver(stepOne.Schema), }); - }; - - const addImage = () => { - setShowUploadImagesModal(true); - }; - const onDismissModal = () => { - setShowUploadImagesModal(false); - }; - - // function to update editor state - // used to insert more than one image at the time - function updateEditorState( - editorState, - { src, height, width, alt }, - caption - ) { - const entityKey = editorState - .getCurrentContent() - .createEntity('IMAGE', 'MUTABLE', { - src, - height, - width, - alt, - }) - .getLastCreatedEntityKey(); - - const selection = editorState.getSelection(); - - const currentFocusKey = selection.getFocusKey(); - const newESWidthImageAndExtraBlock = AtomicBlockUtils.insertAtomicBlock( - editorState, - entityKey, - ' ' - ); - // user did not add caption text - if (caption.length === 0) { - return newESWidthImageAndExtraBlock; + const onSubmit = (data) => { + let choices; + if (data.tabOption === 'visual') { + choices = data.choices.slice(0, 2); + } else { + choices = data.choices.map((e) => ({ value: e.value })); } - // using cs: content state - const contentState = newESWidthImageAndExtraBlock.getCurrentContent(); - - const atomicBlockInserted = contentState.getBlockAfter(currentFocusKey); - - // AtomicBlockUtils.insertAtomicBlock inserts an empty block right after the cursor position - const emptyBlockInserted = contentState.getBlockAfter( - atomicBlockInserted.getKey() - ); - - const lastBlockAddedKey = emptyBlockInserted.getKey(); - - // get existing blocks and - // filter and remove the last block added - // bc it's not necessary and caption block goes right after it - const blockMapArray = contentState - .getBlocksAsArray() - .filter((block) => block.getKey() !== lastBlockAddedKey); - - // create new temporal content state to extract block with text - const tempCSWithCaption = ContentState.createFromText(caption); - // get the block with the text from temp content - const [tempBlockArray] = tempCSWithCaption.getBlocksAsArray(); - - // update block type so it's a custom type: image-caption - const csWithUpdatedBlock = Modifier.setBlockType( - ContentState.createFromBlockArray([tempBlockArray]), - SelectionState.createEmpty(tempBlockArray.key), - 'image-caption-block' - ); - // get the block with custom type and with text - const [updatedBlock] = csWithUpdatedBlock.getBlocksAsArray(); - - const newBlockMapArray = blockMapArray.reduce( - (accumulator, currentValue) => { - if (currentValue.getKey() === atomicBlockInserted.getKey()) { - return [ - ...accumulator, - currentValue, - updatedBlock, - emptyBlockInserted, - ]; - } - return [...accumulator, currentValue]; - }, - [] - ); - // add block updated and concat empty block at the end - const newContentState = ContentState.createFromBlockArray( - newBlockMapArray, - contentState.getEntityMap() - ); - - // this keeps the history of the action - const editorStateWithImageAndCaption = EditorState.push( - newESWidthImageAndExtraBlock, - newContentState, - 'insert-fragment' - ); - - // move cursor to the end - const newState = EditorState.moveSelectionToEnd( - editorStateWithImageAndCaption - ); - return newState; - } - - const addImagesToEditor = (images, captionValues) => { - // captionValue - let tempEditorState = localEditorState; - - for (let index = 0; index < images.length; index++) { - const image = images[index]; - const caption = captionValues[index]; - tempEditorState = updateEditorState( - tempEditorState, - { - src: image.imageUrl, - height: 'auto', - width: '100%', - alt: caption, - }, - caption - ); - } - setLocalEditorState(tempEditorState); - setShowUploadImagesModal(false); + onDataChange({ ...data, choices }); + moveToNextStep(); }; - const defaultValueStrategy = stepData?.strategy; + const defaultValueStrategy = useWatch({ control, name: 'strategy' }); - const showTitleInputError = !checkValidTitleLength(stepData?.title ?? ''); + const { isDirty, isSubmitting, isValid, errors } = formState; + + useEffect(() => { + setStepValid((isDirty || isValid) && !isSubmitting); + }, [isDirty, isValid, isSubmitting, setStepValid]); return ( - <> - {showUploadImagesModal && ( - - )} +

@@ -401,23 +81,12 @@ const StepOne = ({ Give your proposal a title based on the decision or initiative being voted on. Best to keep it simple and specific.

- - onDataChange({ - title: event.target.value, - }) - } + - {showTitleInputError && ( -
-

- The maximum length for Title is 128 characters -

-
- )}

@@ -429,17 +98,7 @@ const StepOne = ({ context; the expected costs and benefits of this collective decision.

- ]} - customStyleMap={styleMap} - blockRenderMap={extendedBlockRenderMap} - /> +

Voting Strategy

@@ -448,41 +107,35 @@ const StepOne = ({ strategies are set by community admins.

({ label: vs.name, value: vs.key, })) ?? [] } - disabled={votingStrategies.length === 0} - onSelectValue={onSelectStrategy} - ref={dropDownRef} + disabled={isSubmitting || votingStrategies.length === 0} + control={control} /> {defaultValueStrategy && ( <> - - onDataChange({ - minBalance: event.target.value, - }) - } + classNames="rounded-sm border-light p-3 column is-full" + conatinerClassNames="mt-4 mb-4" + register={register} + error={errors['minBalance']} + name="minBalance" /> - - onDataChange({ - maxWeight: event.target.value, - }) - } + classNames="rounded-sm border-light p-3 column is-full" + conatinerClassNames="mb-4" + register={register} + error={errors['maxWeight']} + name="maxWeight" /> )} @@ -496,49 +149,17 @@ const StepOne = ({ Text-based presentation for choices that described in words. Use Visual for side-by-side visual options represented by images.

-
-
    -
  • - -
  • -
  • - -
  • -
-
- {tabOption === 'text-based' && ( - - )} - {tabOption === 'visual' && ( - - )} +
- +
); }; diff --git a/frontend/packages/client/src/components/ProposalCreate/StepThree.js b/frontend/packages/client/src/components/ProposalCreate/StepThree.js index 3dfbcb299..1683ff141 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepThree.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepThree.js @@ -1,6 +1,5 @@ import React, { useCallback, useEffect } from 'react'; import { parseDateToServer } from 'utils'; -import { customDraftToHTML } from 'utils'; import { ProposalStatus, VoteOptions } from '../Proposal'; const StepThree = ({ stepsData, setStepValid }) => { @@ -27,9 +26,7 @@ const StepThree = ({ stepsData, setStepValid }) => { })), }; - const currentContent = stepsData[0]?.description?.getCurrentContent(); - - const htmlBody = customDraftToHTML(currentContent); + const htmlBody = stepsData[0]?.body; return (
diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo.js deleted file mode 100644 index 815193998..000000000 --- a/frontend/packages/client/src/components/ProposalCreate/StepTwo.js +++ /dev/null @@ -1,316 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import DatePicker from 'react-datepicker'; -import { Calendar, CaretDown } from 'components/Svg'; -import { useMediaQuery } from 'hooks'; -import { HAS_DELAY_ON_START_TIME } from 'const'; - -const detectTimeZone = () => - new window.Intl.DateTimeFormat().resolvedOptions().timeZone; - -const getTimeIntervals = (cutOffDate = 0) => { - const timeIntervals = []; - for (let i = 0; i < 24; i++) { - for (let j = 0; j < 4; j++) { - let time = new Date(); - time.setHours(i); - time.setMinutes(j * 15); - time.setSeconds(0); - if (time.getTime() >= cutOffDate) { - timeIntervals.push(time); - } - } - } - return timeIntervals; -}; - -const addDays = (date, days) => { - date.setDate(date.getDate() + days); - return date; -}; - -const subtractDays = (date, days) => { - date.setDate(date.getDate() - days); - return date; -}; - -const formatTime = (date) => { - let hours = date.getHours(); - let minutes = date.getMinutes(); - const ampm = hours >= 12 ? 'pm' : 'am'; - hours = hours % 12; - hours = hours ? hours : 12; // the hour '0' should be '12' - minutes = minutes < 10 ? '0' + minutes : minutes; - return hours + ':' + minutes + ' ' + ampm; -}; - -const isToday = (date) => { - return date?.setHours(0, 0, 0, 0) === new Date().setHours(0, 0, 0, 0); -}; - -const TimeIntervals = ({ date, time, setTime, type } = {}) => { - const startDateIsToday = date ? isToday(date) : false; - - const startTimeInterval = startDateIsToday - ? HAS_DELAY_ON_START_TIME - ? new Date(Date.now() + 60 * 60 * 1000) - : Date.now() - : 0; - - const timeIntervals = getTimeIntervals(startTimeInterval); - - // this enables setting start time inmediatly - if (startDateIsToday && !HAS_DELAY_ON_START_TIME) { - // push date now to the top of timeIntervals - timeIntervals[0] !== new Date() && timeIntervals.unshift(new Date()); - } - return ( - <> - {timeIntervals.map((itemValue, index) => ( - - ))} - - ); -}; - -const StepTwo = ({ stepData, setStepValid, onDataChange }) => { - const [isStartTimeOpen, setStartTimeOpen] = useState(false); - const [isEndTimeOpen, setEndTimeOpen] = useState(false); - - const notMobile = useMediaQuery(); - - useEffect(() => { - const isDate = (d) => Object.prototype.toString.call(d) === '[object Date]'; - const requiredFields = { - startDate: isDate, - endDate: isDate, - startTime: isDate, - endTime: isDate, - }; - - const isValid = Object.keys(requiredFields).every( - (field) => stepData && requiredFields[field](stepData[field]) - ); - - setStepValid(isValid); - }, [stepData, setStepValid, onDataChange]); - - const closeStartOnBlur = () => { - setStartTimeOpen(false); - }; - - const closeEndOnBlur = () => { - setEndTimeOpen(false); - }; - - const timeZone = detectTimeZone(); - - const onSetStartTimeOpen = () => setStartTimeOpen(true); - - const setStartTime = useCallback( - (itemValue) => () => { - onDataChange({ - startTime: itemValue, - }); - setStartTimeOpen(false); - }, - [onDataChange] - ); - - const setEndTime = useCallback( - (itemValue) => () => { - onDataChange({ - endTime: itemValue, - }); - setEndTimeOpen(false); - }, - [onDataChange] - ); - - const minDateForStartDate = new Date( - HAS_DELAY_ON_START_TIME ? Date.now() + 60 * 60 * 1000 : Date.now() - ); - - const maxDateForStartDate = stepData?.endDate - ? subtractDays(new Date(stepData?.endDate), 1) - : undefined; - - return ( -
-
-

- Start date and time * -

-
-
- !notMobile && e.target.blur()} - onChange={(date) => { - onDataChange({ - startDate: date, - // resets time in case user has selected a future date and comes back to present with a non valid hour - startTime: isToday(date) ? null : stepData?.startTime, - }); - }} - className="border-light rounded-sm column is-full is-full-mobile p-3" - /> -
- -
-
-
-
-
- -
- -
-
-
-
-
-

- End date and time * -

-
-
- !notMobile && e.target.blur()} - onChange={(date) => { - onDataChange({ endDate: date }); - }} - className="border-light rounded-sm column is-full is-full-mobile p-3" - /> -
- -
-
-
-
-
- -
- -
-
-
-
- {timeZone && ( -
- 🌐 We've detected your time - zone as: {timeZone} -
- )} -
- ); -}; - -export default StepTwo; diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/StepTwo.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/StepTwo.js new file mode 100644 index 000000000..6e71c6764 --- /dev/null +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/StepTwo.js @@ -0,0 +1,271 @@ +import React, { useEffect, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { FadeIn } from 'components'; +import { CaretDown } from 'components/Svg'; +import CustomDatePicker from 'components/common/CustomDatePicker'; +import Form from 'components/common/Form'; +import { useMediaQuery } from 'hooks'; +import { HAS_DELAY_ON_START_TIME } from 'const'; +import { formatTime } from 'utils'; +import { yupResolver } from '@hookform/resolvers/yup'; +import pick from 'lodash/pick'; +import { stepTwo } from '../FormConfig'; +import TimeIntervals from './TimeIntervals'; + +const detectTimeZone = () => + new window.Intl.DateTimeFormat().resolvedOptions().timeZone; + +const addDays = (date, days) => { + date.setDate(date.getDate() + days); + return date; +}; + +const subtractDays = (date, days) => { + date.setDate(date.getDate() - days); + return date; +}; + +const isToday = (date) => { + return date?.setHours(0, 0, 0, 0) === new Date().setHours(0, 0, 0, 0); +}; + +const StepTwo = ({ + stepData, + setStepValid, + onDataChange, + formId, + moveToNextStep, +}) => { + const [isStartTimeOpen, setStartTimeOpen] = useState(false); + const [isEndTimeOpen, setEndTimeOpen] = useState(false); + + const notMobile = useMediaQuery(); + + const closeStartOnBlur = () => { + setStartTimeOpen(false); + }; + + const closeEndOnBlur = () => { + setEndTimeOpen(false); + }; + + const timeZone = detectTimeZone(); + + const fieldsObj = Object.assign( + {}, + stepTwo.initialValues, + pick(stepData || {}, stepTwo.formFields) + ); + + const { handleSubmit, formState, control, setValue, clearErrors } = useForm({ + defaultValues: fieldsObj, + resolver: yupResolver(stepTwo.Schema), + }); + + const setTime = (field) => (itemValue) => (e) => { + e.preventDefault(); + e.stopPropagation(); + setValue(field, itemValue); + field === 'startTime' ? setStartTimeOpen(false) : setEndTimeOpen(false); + }; + + const { errors, isValid, isDirty, isSubmitting } = formState; + + const onSubmit = (data) => { + onDataChange(data); + moveToNextStep(); + }; + + const onSetStartTimeOpen = (e) => { + e.preventDefault(); + e.stopPropagation(); + clearErrors('startTime'); + setStartTimeOpen(true); + }; + + const onSetEndTimeOpen = (e) => { + e.preventDefault(); + e.stopPropagation(); + clearErrors('endTime'); + setEndTimeOpen(true); + }; + + const startDate = useWatch({ control, name: 'startDate' }); + const startTime = useWatch({ control, name: 'startTime' }); + const endDate = useWatch({ control, name: 'endDate' }); + const endTime = useWatch({ control, name: 'endTime' }); + + useEffect(() => { + setStepValid((isValid || isDirty) && !isSubmitting); + }, [isValid, isDirty, isSubmitting, setStepValid]); + + useEffect(() => { + if ( + isToday(startDate) && + startTime && + new Date().setHours(startTime.getHours(), startTime.getMinutes(), 0, 0) < + new Date().setHours(1, 0, 0, 0) + ) { + setValue('startTime', ''); + } + }, [startDate, startTime, setValue]); + + const minDateForStartDate = new Date( + HAS_DELAY_ON_START_TIME ? Date.now() + 60 * 60 * 1000 : Date.now() + ); + + const maxDateForStartDate = endDate + ? subtractDays(new Date(endDate), 1) + : undefined; + + return ( +
+
+

+ Start date and time * +

+
+ +
+
+
+
+ +
+ {errors?.startTime?.message && ( +
+ +
+

+ {errors?.startTime?.message} +

+
+
+
+ )} +
+ +
+
+
+
+
+

+ End date and time * +

+
+ +
+
+
+
+ +
+ {errors?.endTime?.message && ( +
+ +
+

+ {errors?.endTime?.message} +

+
+
+
+ )} +
+ +
+
+
+
+ {timeZone && ( +
+ 🌐 We've detected your time + zone as: {timeZone} +
+ )} +
+ ); +}; + +export default StepTwo; diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/TimeIntervals.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/TimeIntervals.js new file mode 100644 index 000000000..c2648ea4a --- /dev/null +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/TimeIntervals.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { HAS_DELAY_ON_START_TIME } from 'const'; +import { formatTime } from 'utils'; + +const getTimeIntervals = (cutOffDate = 0) => { + const timeIntervals = []; + for (let i = 0; i < 24; i++) { + for (let j = 0; j < 4; j++) { + let time = new Date(); + time.setHours(i); + time.setMinutes(j * 15); + time.setSeconds(0); + if (time.getTime() >= cutOffDate) { + timeIntervals.push(time); + } + } + } + return timeIntervals; +}; + +const isToday = (date) => { + return date?.setHours(0, 0, 0, 0) === new Date().setHours(0, 0, 0, 0); +}; + +export default function TimeIntervals({ date, time, setTime, type } = {}) { + const startDateIsToday = date ? isToday(date) : false; + + const startTimeInterval = startDateIsToday + ? HAS_DELAY_ON_START_TIME + ? new Date(Date.now() + 60 * 60 * 1000) + : Date.now() + : 0; + + const timeIntervals = getTimeIntervals(startTimeInterval); + + // this enables setting start time inmediatly + if (startDateIsToday && !HAS_DELAY_ON_START_TIME) { + // push date now to the top of timeIntervals + timeIntervals[0] !== new Date() && timeIntervals.unshift(new Date()); + } + return ( + <> + {timeIntervals.map((itemValue, index) => ( + + ))} + + ); +} diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/index.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/index.js new file mode 100644 index 000000000..5bf146194 --- /dev/null +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/index.js @@ -0,0 +1 @@ +export { default as StepTwo } from './StepTwo'; diff --git a/frontend/packages/client/src/components/ProposalCreate/index.js b/frontend/packages/client/src/components/ProposalCreate/index.js index 97a706f51..e1f8aa522 100644 --- a/frontend/packages/client/src/components/ProposalCreate/index.js +++ b/frontend/packages/client/src/components/ProposalCreate/index.js @@ -1,3 +1,3 @@ export { default as PropCreateStepOne } from './StepOne/index'; -export { default as PropCreateStepTwo } from './StepTwo'; +export { StepTwo as PropCreateStepTwo } from './StepTwo'; export { default as PropCreateStepThree } from './StepThree'; diff --git a/frontend/packages/client/src/components/StepByStep/NexStepButton.js b/frontend/packages/client/src/components/StepByStep/NexStepButton.js new file mode 100644 index 000000000..694efd959 --- /dev/null +++ b/frontend/packages/client/src/components/StepByStep/NexStepButton.js @@ -0,0 +1,29 @@ +import React from 'react'; +import classnames from 'classnames'; + +const NextButton = ({ + formId: formIdParam, + moveToNextStep, + disabled = false, +} = {}) => { + const classNames = classnames( + 'button is-block has-background-yellow rounded-sm py-2 px-4 has-text-centered', + { 'is-disabled': disabled }, + { 'is-fullwidth': !!formIdParam } + ); + return ( +
+ {formIdParam ? ( + + ) : ( +
+ Next +
+ )} +
+ ); +}; + +export default NextButton; diff --git a/frontend/packages/client/src/components/StepByStep/SubmitButton.js b/frontend/packages/client/src/components/StepByStep/SubmitButton.js new file mode 100644 index 000000000..2827eff19 --- /dev/null +++ b/frontend/packages/client/src/components/StepByStep/SubmitButton.js @@ -0,0 +1,28 @@ +import React from 'react'; +import classnames from 'classnames'; + +export default function SubmitButton({ + formId: formIdParam, + label = '', + disabled = false, + onSubmit = () => {}, +} = {}) { + const classNames = classnames( + 'button is-block has-background-yellow rounded-sm py-2 px-4 has-text-centered', + { 'is-disabled': disabled }, + { 'is-fullwidth': !!formIdParam } + ); + return ( +
+ {formIdParam ? ( + + ) : ( +
+ {label} +
+ )} +
+ ); +} diff --git a/frontend/packages/client/src/components/StepByStep/index.js b/frontend/packages/client/src/components/StepByStep/index.js index 8741b74cf..c1a3e757e 100644 --- a/frontend/packages/client/src/components/StepByStep/index.js +++ b/frontend/packages/client/src/components/StepByStep/index.js @@ -2,6 +2,8 @@ import React, { useCallback, useState } from 'react'; import { Prompt } from 'react-router-dom'; import Loader from '../Loader'; import { ArrowLeft, CheckMark } from '../Svg'; +import NextButton from './NexStepButton'; +import SubmitButton from './SubmitButton'; function StepByStep({ finalLabel, @@ -11,6 +13,7 @@ function StepByStep({ isSubmitting, submittingMessage, passNextToComp = false, + showActionButtonLeftPannel = false, passSubmitToComp = false, blockNavigationOut = false, blockNavigationText, @@ -122,6 +125,10 @@ function StepByStep({ const child = showPreStep ? preStep : steps[currentStep].component; + const { useHookForms = false } = steps[currentStep]; + + const formId = useHookForms ? `form-Id-${currentStep}` : undefined; + const getBackLabel = (isSubmitting) => (
( -
-
- Next -
-
- ); - - const getSubmitButton = (isSubmitting) => ( -
-
- {finalLabel} -
-
- ); + const showNextButton = !passNextToComp || showActionButtonLeftPannel; + const showSubmitButton = !passSubmitToComp || showActionButtonLeftPannel; return ( <> @@ -201,12 +185,22 @@ function StepByStep({ {currentStep > 0 && getBackLabel(isSubmitting)}
{steps.map((step, i) => getStepIcon(i, step.label))}
- {currentStep < steps.length - 1 && - !passNextToComp && - getNextButton()} - {currentStep === steps.length - 1 && - !passSubmitToComp && - getSubmitButton(isSubmitting)} + {currentStep < steps.length - 1 && showNextButton && ( + + )} + {currentStep === steps.length - 1 && showSubmitButton && ( + + )}
{/* left panel mobile */}
- {currentStep < steps.length - 1 && - !passNextToComp && - getNextButton()} - {currentStep === steps.length - 1 && - !passSubmitToComp && - getSubmitButton(isSubmitting)} + {currentStep < steps.length - 1 && showNextButton && ( + + )} + {currentStep === steps.length - 1 && showSubmitButton && ( + + )}
diff --git a/frontend/packages/client/src/components/common/Checkbox.js b/frontend/packages/client/src/components/common/Checkbox.js index 247a6a7fb..c769f4aa2 100644 --- a/frontend/packages/client/src/components/common/Checkbox.js +++ b/frontend/packages/client/src/components/common/Checkbox.js @@ -8,7 +8,6 @@ export default function Checkbox({ label, disabled, error, - type = 'text', } = {}) { return (
diff --git a/frontend/packages/client/src/components/common/CustomDatePicker.js b/frontend/packages/client/src/components/common/CustomDatePicker.js new file mode 100644 index 000000000..486e4a646 --- /dev/null +++ b/frontend/packages/client/src/components/common/CustomDatePicker.js @@ -0,0 +1,61 @@ +import React from 'react'; +import DatePicker from 'react-datepicker'; +import { Controller } from 'react-hook-form'; +import FadeIn from 'components/FadeIn'; +import { Calendar } from 'components/Svg'; + +export default function CustomDatePicker({ + control, + fieldName, + notMobile, + minDate, + maxDate, + disabled = false, + placeholderText, + errorMessage, +} = {}) { + return ( +
+
+ ( + !notMobile && e.target.blur()} + onChange={(date) => field.onChange(date)} + className="border-light rounded-sm column is-full is-full-mobile p-3" + disabled={disabled} + /> + )} + /> +
+ +
+
+ {errorMessage && ( +
+ +
+

{errorMessage}

+
+
+
+ )} +
+ ); +} diff --git a/frontend/packages/client/src/components/common/Dropdown.js b/frontend/packages/client/src/components/common/Dropdown.js index fa0d12f35..b7d3c3fdc 100644 --- a/frontend/packages/client/src/components/common/Dropdown.js +++ b/frontend/packages/client/src/components/common/Dropdown.js @@ -1,123 +1,132 @@ -import React, { useEffect, useState } from 'react'; +import React, { forwardRef, useEffect, useState } from 'react'; import { Controller } from 'react-hook-form'; import FadeIn from 'components/FadeIn'; import { CaretDown } from 'components/Svg'; import classnames from 'classnames'; -const Dropdown = ({ - defaultValue, - options = [], - onSelectValue = () => {}, - disabled = false, - label = 'Select option', - dropdownFull = true, - isRight = false, - padding = '', - margin = '', -} = {}) => { - const [isOpen, setIsOpen] = useState(false); - const [innerValue, setInnerValue] = useState( - defaultValue ? { value: defaultValue } : undefined - ); +const Dropdown = forwardRef( + ( + { + defaultValue, + options = [], + onSelectValue = () => {}, + disabled = false, + label = 'Select option', + dropdownFull = true, + isRight = false, + padding = '', + margin = '', + name, + } = {}, + ref + ) => { + const [isOpen, setIsOpen] = useState(false); + const [innerValue, setInnerValue] = useState( + defaultValue ? { value: defaultValue } : undefined + ); - useEffect(() => { - if (!innerValue?.label && innerValue?.value && options.length) { - const defaultSelection = options.find( - (op) => op.value === innerValue?.value - ); - if (defaultSelection) { - setInnerValue(defaultSelection); + useEffect(() => { + if (!innerValue?.label && innerValue?.value && options.length) { + const defaultSelection = options.find( + (op) => op.value === innerValue?.value + ); + if (defaultSelection) { + setInnerValue(defaultSelection); + } } - } - }, [options, innerValue]); + }, [options, innerValue]); - const openCloseDropdown = (e) => { - e.preventDefault(); - e.stopPropagation(); - setIsOpen((status) => !status); - }; + const openCloseDropdown = (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsOpen((status) => !status); + }; + + const setValue = + ({ label, value }) => + (e) => { + e.target.value = value; + setInnerValue({ label, value }); + onSelectValue(e); + setIsOpen(false); + }; - const setValue = - ({ label, value }) => - (e) => { - e.target.value = value; - setInnerValue({ label, value }); - onSelectValue(e); + // use for click out on dropdown + const closeOnBlur = () => { setIsOpen(false); }; - // use for click out on dropdown - const closeOnBlur = () => { - setIsOpen(false); - }; - - const classNames = classnames( - `dropdown is-flex is-flex-grow-1`, - { 'is-right': isRight }, - { 'is-active': isOpen }, - { [padding]: !!padding }, - { [margin]: !!margin } - ); - return ( -
+ const classNames = classnames( + `dropdown is-flex is-flex-grow-1`, + { 'is-right': isRight }, + { 'is-active': isOpen }, + { [padding]: !!padding }, + { [margin]: !!margin } + ); + return (
- +
+ - -
- ); -}; + ); + } +); + export default function DropdownWrapper({ control, name, @@ -134,13 +143,13 @@ export default function DropdownWrapper({ control={control} name={name} render={({ - field: { onChange, onBlur, value, name, ref }, - fieldState: { invalid, isTouched, isDirty, error }, - formState, + field: { onChange, value, name, ref }, + fieldState: { error }, }) => { return (
{error && ( diff --git a/frontend/packages/client/src/components/common/Editor/DraftjsEditor.js b/frontend/packages/client/src/components/common/Editor/DraftjsEditor.js new file mode 100644 index 000000000..7ff002524 --- /dev/null +++ b/frontend/packages/client/src/components/common/Editor/DraftjsEditor.js @@ -0,0 +1,230 @@ +import React, { forwardRef, useEffect, useState } from 'react'; +import { Editor } from 'react-draft-wysiwyg'; +import { UploadImageModal } from 'components'; +import { customDraftToHTML, customHTMLtoDraft } from 'utils'; +import { + AtomicBlockUtils, + ContentState, + DefaultDraftBlockRenderMap, + EditorState, + Modifier, + SelectionState, +} from 'draft-js'; +import { Map } from 'immutable'; +import AddImageOption from './ImageOption'; + +const options = ['blockType', 'inline', 'list', 'link', 'emoji']; +const inline = { + options: ['bold', 'italic', 'underline'], +}; +const list = { + options: ['unordered'], +}; +const link = { + options: ['link'], + defaultTargetOption: '_blank', +}; + +const styleMap = { + IMAGE_CAPTION: { + fontFamily: 'Arimo', + fontStyle: 'normal', + fontWeight: 400, + fontSize: '12px', + }, +}; + +// using a React component to render custom blocks +const ImageCaptionCustomBlock = (props) => { + return
{props.children}
; +}; +const blockRenderMap = Map({ + 'image-caption-block': { + // element is used during paste or html conversion to auto match your component; + // it is also retained as part of this.props.children and not stripped out. Example: + // element: "section", + wrapper: , + }, +}); + +// keep support for other draft default block types and add our image-caption type +const extendedBlockRenderMap = DefaultDraftBlockRenderMap.merge(blockRenderMap); + +const CustomEditor = forwardRef(({ onChange, value }, ref) => { + const [localEditorState, setLocalEditorState] = useState( + EditorState.createEmpty() + ); + const [updated, setUpdated] = useState(false); + + // this effect runs on initial component load to create editor from html + useEffect(() => { + if (!updated) { + const defaultValue = value ? value : ''; + const htmlToState = customHTMLtoDraft(defaultValue); + const newEditorState = EditorState.createWithContent(htmlToState); + setLocalEditorState(newEditorState); + } + }, [value, updated]); + + const onEditorStateChange = (editorState) => { + !updated && setUpdated(true); + setLocalEditorState(editorState); + const pureHtml = customDraftToHTML(editorState.getCurrentContent()); + // when editor es empty but has been changed it will return


+ const textOnly = pureHtml.replace(/<[^>]+>/g, ''); + return onChange('' === textOnly ? '' : pureHtml); + }; + + const [showUploadImagesModal, setShowUploadImagesModal] = useState(false); + + const addImage = () => { + setShowUploadImagesModal(true); + }; + + // function to update editor state + // used to insert more than one image at the time + function updateEditorState( + editorState, + { src, height, width, alt }, + caption + ) { + const entityKey = editorState + .getCurrentContent() + .createEntity('IMAGE', 'MUTABLE', { + src, + height, + width, + alt, + }) + .getLastCreatedEntityKey(); + + const selection = editorState.getSelection(); + + const currentFocusKey = selection.getFocusKey(); + + const newESWidthImageAndExtraBlock = AtomicBlockUtils.insertAtomicBlock( + editorState, + entityKey, + ' ' + ); + // user did not add caption text + if (caption.length === 0) { + return newESWidthImageAndExtraBlock; + } + // using cs: content state + const contentState = newESWidthImageAndExtraBlock.getCurrentContent(); + + const atomicBlockInserted = contentState.getBlockAfter(currentFocusKey); + + // AtomicBlockUtils.insertAtomicBlock inserts an empty block right after the cursor position + const emptyBlockInserted = contentState.getBlockAfter( + atomicBlockInserted.getKey() + ); + + const lastBlockAddedKey = emptyBlockInserted.getKey(); + + // get existing blocks and + // filter and remove the last block added + // bc it's not necessary and caption block goes right after it + const blockMapArray = contentState + .getBlocksAsArray() + .filter((block) => block.getKey() !== lastBlockAddedKey); + + // create new temporal content state to extract block with text + const tempCSWithCaption = ContentState.createFromText(caption); + // get the block with the text from temp content + const [tempBlockArray] = tempCSWithCaption.getBlocksAsArray(); + + // update block type so it's a custom type: image-caption + const csWithUpdatedBlock = Modifier.setBlockType( + ContentState.createFromBlockArray([tempBlockArray]), + SelectionState.createEmpty(tempBlockArray.key), + 'image-caption-block' + ); + // get the block with custom type and with text + const [updatedBlock] = csWithUpdatedBlock.getBlocksAsArray(); + + const newBlockMapArray = blockMapArray.reduce( + (accumulator, currentValue) => { + if (currentValue.getKey() === atomicBlockInserted.getKey()) { + return [ + ...accumulator, + currentValue, + updatedBlock, + emptyBlockInserted, + ]; + } + return [...accumulator, currentValue]; + }, + [] + ); + // add block updated and concat empty block at the end + const newContentState = ContentState.createFromBlockArray( + newBlockMapArray, + contentState.getEntityMap() + ); + + // this keeps the history of the action + const editorStateWithImageAndCaption = EditorState.push( + newESWidthImageAndExtraBlock, + newContentState, + 'insert-fragment' + ); + + // move cursor to the end + const newState = EditorState.moveSelectionToEnd( + editorStateWithImageAndCaption + ); + return newState; + } + + const addImagesToEditor = (images, captionValues) => { + // captionValue + let tempEditorState = localEditorState; + + for (let index = 0; index < images.length; index++) { + const image = images[index]; + const caption = captionValues[index]; + tempEditorState = updateEditorState( + tempEditorState, + { + src: image.imageUrl, + height: 'auto', + width: '100%', + alt: caption, + }, + caption + ); + } + onEditorStateChange(tempEditorState); + setShowUploadImagesModal(false); + }; + + const onDismissModal = () => { + setShowUploadImagesModal(false); + }; + return ( + <> + {showUploadImagesModal && ( + + )} + ]} + customStyleMap={styleMap} + blockRenderMap={extendedBlockRenderMap} + ref={ref} + /> + + ); +}); + +export default CustomEditor; diff --git a/frontend/packages/client/src/components/common/Editor/Editor.js b/frontend/packages/client/src/components/common/Editor/Editor.js new file mode 100644 index 000000000..66ea5b395 --- /dev/null +++ b/frontend/packages/client/src/components/common/Editor/Editor.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { Controller } from 'react-hook-form'; +import FadeIn from 'components/FadeIn'; +import DraftjsEditor from './DraftjsEditor'; + +export default function Editor({ control, error, name } = {}) { + return ( + <> + { + return ; + }} + /> + {error && ( + +
+

{error?.message}

+
+
+ )} + + ); +} diff --git a/frontend/packages/client/src/components/common/Editor/ImageOption.js b/frontend/packages/client/src/components/common/Editor/ImageOption.js new file mode 100644 index 000000000..1f235bdf2 --- /dev/null +++ b/frontend/packages/client/src/components/common/Editor/ImageOption.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Image } from 'components/Svg'; + +export default function ImageOption({ addImage = () => {} } = {}) { + return ( + <> + + + ); +} diff --git a/frontend/packages/client/src/components/common/Editor/index.js b/frontend/packages/client/src/components/common/Editor/index.js new file mode 100644 index 000000000..366882009 --- /dev/null +++ b/frontend/packages/client/src/components/common/Editor/index.js @@ -0,0 +1 @@ +export { default as Editor } from './Editor'; diff --git a/frontend/packages/client/src/components/common/Form.js b/frontend/packages/client/src/components/common/Form.js index 825635e0e..519e9fa42 100644 --- a/frontend/packages/client/src/components/common/Form.js +++ b/frontend/packages/client/src/components/common/Form.js @@ -1,9 +1,17 @@ import React from 'react'; -export default function Form({ removeInnerForm, children, onSubmit }) { +const Form = ({ removeInnerForm = false, children, onSubmit, formId } = {}) => { + // TODO: make enter to jump to next input field on form + const checkKeyDown = (e) => { + if (e.code === 'Enter') e.preventDefault(); + }; return removeInnerForm ? ( <>{children} ) : ( -
{children}
+
checkKeyDown(e)}> + {children} +
); -} +}; + +export default Form; diff --git a/frontend/packages/client/src/components/common/Input.js b/frontend/packages/client/src/components/common/Input.js index 8c349dbec..9533dfb73 100644 --- a/frontend/packages/client/src/components/common/Input.js +++ b/frontend/packages/client/src/components/common/Input.js @@ -10,9 +10,12 @@ export default function Input({ disabled, error, type = 'text', + conatinerClassNames = '', } = {}) { return ( -
+
row[field]?.toLowerCase())) + ); + const isUnique = array.length === uniqueData.length; + if (isUnique) { + return true; + } + const index = array.findIndex( + (row, i) => row[field]?.toLowerCase() !== uniqueData[i] + ); + if (array[index][field] === '') { + return true; + } + return this.createError({ + path: `${this.path}.${index}.${field}`, + message, + }); + }); +}); + +export default yup; diff --git a/frontend/packages/client/src/pages/CommunityCreate.js b/frontend/packages/client/src/pages/CommunityCreate.js index 942f6d118..7abba95b1 100644 --- a/frontend/packages/client/src/pages/CommunityCreate.js +++ b/frontend/packages/client/src/pages/CommunityCreate.js @@ -161,6 +161,7 @@ export default function CommunityCreate() { submittingMessage: 'Creating community...', passNextToComp: true, passSubmitToComp: true, + showActionButtonLeftPannel: true, preStep: , blockNavigationOut: true && !data, blockNavigationText: diff --git a/frontend/packages/client/src/pages/ProposalCreate.js b/frontend/packages/client/src/pages/ProposalCreate.js index 6598d2213..6307e8791 100644 --- a/frontend/packages/client/src/pages/ProposalCreate.js +++ b/frontend/packages/client/src/pages/ProposalCreate.js @@ -11,7 +11,7 @@ import { PropCreateStepTwo, } from 'components/ProposalCreate'; import { useProposal } from 'hooks'; -import { customDraftToHTML, isStartTimeValid, parseDateToServer } from 'utils'; +import { isStartTimeValid, parseDateToServer } from 'utils'; export default function ProposalCreatePage() { const { createProposal, data, loading, error } = useProposal(); @@ -69,11 +69,8 @@ export default function ProposalCreatePage() { }); return; } - const name = stepsData[0].title; - const currentContent = stepsData[0]?.description?.getCurrentContent(); - - const body = customDraftToHTML(currentContent); + const { strategy, minBalance, maxWeight, name, body } = stepsData[0]; const hasValidStartTime = isStartTimeValid( stepsData[1].startTime, @@ -103,8 +100,6 @@ export default function ProposalCreatePage() { choiceImgUrl: c?.choiceImgUrl ?? null, })); - const { strategy, minBalance, maxWeight } = stepsData[0]; - const proposalData = { name, body, @@ -112,9 +107,11 @@ export default function ProposalCreatePage() { creatorAddr, endTime, startTime, - strategy: strategy?.value, - minBalance: minBalance && parseFloat(minBalance), - maxWeight: maxWeight && parseFloat(maxWeight), + strategy: strategy, + ...(minBalance !== '' + ? { minBalance: parseFloat(minBalance) } + : undefined), + ...(maxWeight !== '' ? { maxWeight: parseFloat(maxWeight) } : undefined), status: 'published', communityId, achievementsDone: false, @@ -132,24 +129,29 @@ export default function ProposalCreatePage() { blockNavigationOut: true && !data, blockNavigationText: 'Proposal creation is not complete yet, are you sure you want to leave?', + passNextToComp: true, + passSubmitToComp: true, + showActionButtonLeftPannel: true, steps: [ { label: 'Draft Proposal', description: 'Some description of what you can write here that is useful.', component: , + useHookForms: true, }, { label: 'Set Date & Time', description: 'Some description of what you can write here that is useful.', - component: , + component: , + useHookForms: true, }, { label: 'Preview Proposal', description: 'Some description of what you can write here that is useful.', - component: , + component: , }, ], }; diff --git a/frontend/packages/client/src/utils.js b/frontend/packages/client/src/utils.js index 2bb3a4c24..8ec619fab 100644 --- a/frontend/packages/client/src/utils.js +++ b/frontend/packages/client/src/utils.js @@ -1,6 +1,7 @@ import { HAS_DELAY_ON_START_TIME } from 'const'; import { formatDistance } from 'date-fns'; import { stateToHTML } from 'draft-js-export-html'; +import { stateFromHTML } from 'draft-js-import-html'; import { customAlphabet } from 'nanoid'; const nanoid = customAlphabet('1234567890abcdef', 10); @@ -181,6 +182,16 @@ export const customDraftToHTML = (content) => { return stateToHTML(content, options); }; +export const customHTMLtoDraft = (html) => { + const options = { + customBlockFn: (element) => { + if (element.tagName === 'P' && element.className === 'image-caption') { + return { type: 'image-caption-block' }; + } + }, + }; + return stateFromHTML(html, options); +}; export const isValidAddress = (addr) => /0[x,X][a-zA-Z0-9]{16}$/gim.test(addr); export const wait = async (milliSeconds = 5000) => @@ -227,3 +238,13 @@ export const isStartTimeValid = (startTime, startDate) => { return HAS_DELAY_ON_START_TIME ? dif > 1 : true; }; + +export const formatTime = (date) => { + let hours = date.getHours(); + let minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'pm' : 'am'; + hours = hours % 12; + hours = hours ? hours : 12; // the hour '0' should be '12' + minutes = minutes < 10 ? '0' + minutes : minutes; + return hours + ':' + minutes + ' ' + ampm; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 3911f821a..99698a69c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4603,6 +4603,21 @@ draft-js-export-html@^1.4.1: dependencies: draft-js-utils "^1.4.0" +draft-js-import-element@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/draft-js-import-element/-/draft-js-import-element-1.4.0.tgz#8760acbfeb60ed824a1c8319ec049f702681df66" + integrity sha512-WmYT5PrCm47lGL5FkH6sRO3TTAcn7qNHsD3igiPqLG/RXrqyKrqN4+wBgbcT2lhna/yfWTRtgzAbQsSJoS1Meg== + dependencies: + draft-js-utils "^1.4.0" + synthetic-dom "^1.4.0" + +draft-js-import-html@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/draft-js-import-html/-/draft-js-import-html-1.4.1.tgz#c222a3a40ab27dee5874fcf78526b64734fe6ea4" + integrity sha512-KOZmtgxZriCDgg5Smr3Y09TjubvXe7rHPy/2fuLSsL+aSzwUDwH/aHDA/k47U+WfpmL4qgyg4oZhqx9TYJV0tg== + dependencies: + draft-js-import-element "^1.4.0" + draft-js-utils@^1.4.0: version "1.4.1" resolved "https://registry.yarnpkg.com/draft-js-utils/-/draft-js-utils-1.4.1.tgz#a59c792ad621f7050292031a237d524708a6d509" @@ -11210,6 +11225,11 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +synthetic-dom@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/synthetic-dom/-/synthetic-dom-1.4.0.tgz#d988d7a4652458e2fc8706a875417af913e4dd34" + integrity sha512-mHv51ZsmZ+ShT/4s5kg+MGUIhY7Ltq4v03xpN1c8T1Krb5pScsh/lzEjyhrVD0soVDbThbd2e+4dD9vnDG4rhg== + table@^6.0.9: version "6.8.0" resolved "https://registry.yarnpkg.com/table/-/table-6.8.0.tgz#87e28f14fa4321c3377ba286f07b79b281a3b3ca"