From 8cf00a308da07d02fda427051c260c1c52fd953b Mon Sep 17 00:00:00 2001 From: ruby10127130 Date: Wed, 18 Dec 2024 03:27:09 +0800 Subject: [PATCH] feat: add validate rules and related styles - block next step if there are errors exist --- components/Marathon/SignUp/Edit.styled.jsx | 19 ++ components/Marathon/SignUp/MarathonForm.jsx | 238 +++++++++++++++++- components/Marathon/SignUp/MilestoneGroup.jsx | 12 +- utils/storage.js | 1 + 4 files changed, 255 insertions(+), 15 deletions(-) diff --git a/components/Marathon/SignUp/Edit.styled.jsx b/components/Marathon/SignUp/Edit.styled.jsx index c80bf94..338ac25 100644 --- a/components/Marathon/SignUp/Edit.styled.jsx +++ b/components/Marathon/SignUp/Edit.styled.jsx @@ -80,6 +80,9 @@ export const StyledSection = styled(Box)` border-radius: 16px; border: 1px solid #DBDBDB; + &.error { + border-color: #EF5364; + } @media (max-width: 767px) { padding: 32px 16px; @@ -187,6 +190,12 @@ export const StyledInputBase = styled(InputBase)` border-width: 1px; padding: 12px 16px; } + + &.error { + border-color: #EF5364; + outline-color: #EF5364; + position: relative; + } `; export const StyledTextareaAutosize = styled(TextareaAutosize)` width: 100%; @@ -200,10 +209,20 @@ export const StyledTextareaAutosize = styled(TextareaAutosize)` border: 2px solid #16B9B3; padding: 11px 15px; outline-color: #16B9B3; + + &.error { + border-color: #EF5364; + outline-color: #EF5364; + } } .MuiInputBase-input { padding: 0; line-height: 140%; } + + &.error { + border-color: #EF5364; + outline-color: #16B9B3; + } `; diff --git a/components/Marathon/SignUp/MarathonForm.jsx b/components/Marathon/SignUp/MarathonForm.jsx index 73be443..9a7e43e 100644 --- a/components/Marathon/SignUp/MarathonForm.jsx +++ b/components/Marathon/SignUp/MarathonForm.jsx @@ -1,9 +1,11 @@ import { useState, useEffect, useReducer } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; import { updateNewMarathon } from '@/redux/actions/marathon'; import { initialState as reduxInitMarathonState } from '@/redux/reducers/marathon'; +import { getMarathonErrorsStorage } from '@/utils/storage'; import { Box, @@ -11,6 +13,7 @@ import { FormControlLabel, Checkbox, } from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; import MilestoneGroup from './MilestoneGroup'; import { @@ -72,7 +75,8 @@ export default function MarathonForm({ }) { const reduxDispatch = useDispatch(); const [hasLoaded, setHasLoaded] = useState(false); - + const [errors, setErrors] = useState({}); + const [hasErrors, setHasErrors] = useState(false); const marathonState = useSelector((state) => { return state.marathon; }); const localStorgeStored = window.localStorage.getItem('newMarathon'); const editingMarathon = localStorgeStored ? JSON.parse(localStorgeStored) : null; @@ -84,17 +88,95 @@ export default function MarathonForm({ const [newMarathon, setNewMarathon] = useReducer(marathonFormReducer, initialState()); const onNextStep = () => { - reduxDispatch(updateNewMarathon(newMarathon)); - setCurrentStep(currentStep + 1); + if (hasErrors) { + toast.error('請修正錯誤'); + } else { + reduxDispatch(updateNewMarathon(newMarathon)); + setCurrentStep(currentStep + 1); + } }; const onPrevStep = () => { reduxDispatch(updateNewMarathon(newMarathon)); setCurrentStep(currentStep - 1); }; + const handleValidate = (name, input, errorMessage) => { + let validate = false; + switch (name) { + case 'title': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'description': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'motivationDescription': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'milestonesName': + validate = (value) => { + const names = value.filter((milestone, _i) => { + return (milestone.name.trim().length > 0); + }); + return names.length === newMarathon.milestones?.length; + }; + break; + case 'goals': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'content': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'strategiesDescription': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'outcomesDescription': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + case 'resources': + validate = (value) => { + return (value.trim().length > 0); + }; + break; + default: + break; + } + + if (validate(input)) { + setErrors((prevErrors) => { + const { [name]: _, ...remainingErrors } = prevErrors; + return remainingErrors; + }); + } else { + setErrors({ + ...errors, + [name]: { + message: errorMessage || null + } + }); + } + return validate(input); + }; useEffect(() => { setHasLoaded(true); + const storagedErrors = getMarathonErrorsStorage().get(); + if (storagedErrors) { + setErrors(storagedErrors); + } }, []); useEffect(() => { @@ -103,9 +185,21 @@ export default function MarathonForm({ } }, [newMarathon]); + useEffect(() => { + getMarathonErrorsStorage().set(errors); + if (Object.keys(errors).length) { + setHasErrors(true); + } else { + setHasErrors(false); + } + }, [errors]); + return ( <> - + : null} placeholder="範例:成為一位Youtuber、半世紀以來的氣候變遷紀錄研究、開一間線上甜點店" /> + {errors.title && ( + + {errors.title?.message} + + )} 計畫簡述 * @@ -168,9 +276,22 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('description', e.target.value, '請填寫計畫簡述'); }} placeholder="範例:因為對剪影片和當 Youtuber 有興趣,我預計會研究搞笑型 Youtuber 的影片腳本與剪輯方式、拍攝我日常生活及練習剪輯,並建立 Youtube 頻道上傳影片。希望能藉此了解如何當一位 Youtuber。" + className={errors.description ? 'error' : ''} /> + {errors.description && ( + + {errors.description?.message} + + )} @@ -215,10 +336,23 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('motivationDescription', e.target.value, '請填寫學習動機'); }} + className={errors.motivationDescription ? 'error' : ''} value={newMarathon?.motivation?.description || ''} placeholder="範例:因為同學常常說我很好笑,很適合把生活日常做成影片,我也發現自己對做影片、當Youtuber有興趣,所以想要嘗試累積作品,並開一個 Youtuber 頻道。" /> + {errors.motivationDescription && ( + + {errors.motivationDescription?.message} + + )} @@ -238,12 +372,25 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('goals', e.target.value, '請填寫學習目標'); }} value={newMarathon.goals || ''} placeholder="範例: - 能收集並分析搞笑風格的 Youtuber - 能拍攝畫面穩定、清晰且具專業感的影片" + className={errors.goals ? 'error' : ''} /> + {errors.goals && ( + + {errors.goals?.message} + + )} @@ -263,13 +410,26 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('content', e.target.value, '請填寫學習內容'); }} value={newMarathon.content || ''} placeholder="範例: - 內容規劃與創意發想(定位、主題、腳本) - 基礎拍攝技術(攝影設備、燈光、語音) - 影片剪輯與後製(剪輯軟體、配樂)" + className={errors.content ? 'error' : ''} /> + {errors.content && ( + + {errors.content?.message} + + )} @@ -316,10 +476,23 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('strategiesDescription', e.target.value, '請填寫學習方法與策略'); }} value={newMarathon?.strategies?.description || ''} placeholder="範例:我預計會研究影片腳本、拍攝與剪輯方式,接著了解拍攝、剪輯與Youtube頻道經營,並同時練習拍攝與剪輯,開始經營頻道。我會用notion整理我收集到的資料以及筆記。" + className={errors.strategiesDescription ? 'error' : ''} /> + {errors.strategiesDescription && ( + + {errors.strategiesDescription?.message} + + )} @@ -335,28 +508,52 @@ export default function MarathonForm({ sx={{ width: '100%' }} placeholder="範例:YouTube 創作者的實用資源" value={newMarathon.resources || ''} - onChange={(e) => setNewMarathon({ - type: 'UPDATE_FIELD', - payload: { - key: 'resources', - value: e.target.value - } - })} + onChange={(e) => { + setNewMarathon({ + type: 'UPDATE_FIELD', + payload: { + key: 'resources', + value: e.target.value + } + }); + handleValidate('resources', e.target.value, '請填寫學習資源'); + }} + className={errors.resources ? 'error' : 'warning'} + endAdornment={errors.resources ? : null} /> + {errors.resources && ( + + {errors.resources?.message} + + )} - + - + 學習成果及呈現方式 * @@ -395,10 +592,25 @@ export default function MarathonForm({ value: e.target.value } }); + handleValidate('outcomesDescription', e.target.value, '請填寫學習成果'); }} value={newMarathon?.outcomes?.description || ''} placeholder="範例:我預計會架設一個Youtube頻道,並上傳至少5支影片,並整理觀眾回饋與相關數據。" + className={errors.outcomesDescription ? 'error' : ''} /> + {errors.outcomesDescription && ( + + {errors.outcomesDescription?.message} + + )} { const changedMilestones = milestones.map((item, _i) => { return (item._tempId === newMilestone._tempId ? newMilestone : item); }); - + // check if milestone name exist + onValidate('milestonesName', changedMilestones, '請填寫每週 / 隔週里程碑目標'); onChange({ type: 'UPDATE_FIELD', payload: { @@ -246,6 +251,9 @@ export default function MilestoneGroup({ })} + ); diff --git a/utils/storage.js b/utils/storage.js index 9a6fba7..a6fdd1f 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -20,3 +20,4 @@ export const getTokenStorage = () => createStorage('_token'); export const getRedirectionStorage = () => createStorage('_r'); export const getTrustWebsitesStorage = () => createStorage('_trustWeb'); export const getReminderStorage = () => createStorage('_reminder'); +export const getMarathonErrorsStorage = () => createStorage('_marathonFormErrors');