From bdf7f1bf7fbeb1d4ba3de0040ba75b27189dcaa5 Mon Sep 17 00:00:00 2001 From: Johnson Mao Date: Fri, 1 Mar 2024 22:07:29 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20add=20create=20group=20page=20a?= =?UTF-8?q?nd=20fetch=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/Group/Form/Fields/AreaCheckbox.jsx | 74 +++++++++-- components/Group/Form/Fields/Select.jsx | 11 +- components/Group/Form/Fields/TagsField.jsx | 15 ++- components/Group/Form/Fields/TextField.jsx | 17 +-- components/Group/Form/Fields/Upload.jsx | 14 ++- components/Group/Form/Fields/index.jsx | 58 +++------ .../Group/Form/Fields/useWrapperProps.jsx | 8 -- components/Group/Form/index.jsx | 113 ++++++++--------- components/Group/Form/useGroupForm.jsx | 116 ++++++++++++++++++ hooks/useMutation.jsx | 23 ++++ pages/group/create/index.jsx | 27 +++- redux/actions/group.js | 6 +- 12 files changed, 335 insertions(+), 147 deletions(-) delete mode 100644 components/Group/Form/Fields/useWrapperProps.jsx create mode 100644 components/Group/Form/useGroupForm.jsx create mode 100644 hooks/useMutation.jsx diff --git a/components/Group/Form/Fields/AreaCheckbox.jsx b/components/Group/Form/Fields/AreaCheckbox.jsx index ccfaa3b6..dcedd1a5 100644 --- a/components/Group/Form/Fields/AreaCheckbox.jsx +++ b/components/Group/Form/Fields/AreaCheckbox.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import Box from '@mui/material/Box'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; @@ -9,27 +10,76 @@ export default function AreaCheckbox({ itemValue, name, value, - onChange, + control, }) { + const [isPhysicalArea, setIsPhysicalArea] = useState(false); + + const getPhysicalArea = (data) => + options.find((option) => data.includes(option.name)); + + const handleChange = (val) => + control.onChange({ target: { name, value: val } }); + + const physicalAreaValue = getPhysicalArea(value)?.name || ''; + + const toggleIsPhysicalArea = () => { + const updatedValue = value.filter((v) => !getPhysicalArea([v])); + handleChange(updatedValue); + setIsPhysicalArea((pre) => !pre); + }; + + const handleCheckboxChange = (_value) => { + const updatedValue = value.includes(_value) + ? value.filter((v) => v !== _value) + : [...value, _value]; + handleChange(updatedValue); + }; + + const handlePhysicalAreaChange = ({ target }) => { + const updatedValue = value + .filter((v) => !getPhysicalArea([v])) + .concat(target.value); + handleChange(updatedValue); + }; + + const physicalAreaControl = { + onChange: handlePhysicalAreaChange, + onBlur: handlePhysicalAreaChange, + }; + return ( <> - } label="實體活動" /> - +
- } label="線上" /> + handleCheckboxChange('線上')} />} + label="線上" + checked={value.includes('線上')} + />
- } label="待討論" /> + handleCheckboxChange('待討論')} />} + label="待討論" + checked={value.includes('待討論')} + />
); diff --git a/components/Group/Form/Fields/Select.jsx b/components/Group/Form/Fields/Select.jsx index 2b7c9843..b63a28e3 100644 --- a/components/Group/Form/Fields/Select.jsx +++ b/components/Group/Form/Fields/Select.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import FormControl from '@mui/material/FormControl'; import MuiSelect from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; @@ -5,14 +6,16 @@ import MenuItem from '@mui/material/MenuItem'; export default function Select({ id, name, - value, placeholder, options = [], itemLabel = 'label', fullWidth = true, multiple, - onChange, sx, + disabled, + control, + value, + error, }) { const getValue = (any, key) => (typeof any === 'object' ? any[key] : any); const renderValue = (selected) => { @@ -37,7 +40,8 @@ export default function Select({ ...sx, }} value={value} - onChange={onChange} + disabled={disabled} + {...control} > {placeholder && ( @@ -53,6 +57,7 @@ export default function Select({ ))} + {error} ); } diff --git a/components/Group/Form/Fields/TagsField.jsx b/components/Group/Form/Fields/TagsField.jsx index 052ba148..3f37cbab 100644 --- a/components/Group/Form/Fields/TagsField.jsx +++ b/components/Group/Form/Fields/TagsField.jsx @@ -1,11 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import IconButton from '@mui/material/IconButton'; import FormHelperText from '@mui/material/FormHelperText'; import ClearIcon from '@mui/icons-material/Clear'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import { StyledChip, StyledTagsField } from '../Form.styled'; -function TagsField({ label, helperText, ...props }) { +function TagsField({ name, helperText, control }) { const [tags, setTags] = useState([]); const [input, setInput] = useState(''); const [error, setError] = useState(''); @@ -30,6 +30,16 @@ function TagsField({ label, helperText, ...props }) { setTags((pre) => pre.filter((t) => t !== tag)); }; + useEffect(() => { + const event = { + target: { + name, + value: tags, + }, + }; + control.onChange(event); + }, [tags]); + return ( <> @@ -44,7 +54,6 @@ function TagsField({ label, helperText, ...props }) { ))} {tags.length < 8 && ( { - if (value.length > max) setError(errorMessage); - else setError(''); - }, [max, value]); - return ( <> {error} diff --git a/components/Group/Form/Fields/Upload.jsx b/components/Group/Form/Fields/Upload.jsx index e9baeeea..75309471 100644 --- a/components/Group/Form/Fields/Upload.jsx +++ b/components/Group/Form/Fields/Upload.jsx @@ -5,8 +5,8 @@ import DeleteSvg from '@/public/assets/icons/delete.svg'; import { StyledUpload } from '../Form.styled'; import UploadSvg from './UploadSvg'; -export default function Upload({ name, onChange }) { - const [preview, setPreview] = useState(''); +export default function Upload({ name, value, control }) { + const [preview, setPreview] = useState(value || ''); const [error, setError] = useState(''); const inputRef = useRef(); @@ -17,17 +17,25 @@ export default function Upload({ name, onChange }) { value: file, }, }; - onChange(event); + control.onChange(event); }; const handleFile = (file) => { const imageType = /image.*/; + const maxSize = 500 * 1024; // 500 KB + + setPreview(''); setError(''); if (!file.type.match(imageType)) { setError('僅支援上傳圖片唷!'); return; } + if (file.size > maxSize) { + setError('圖片最大限制 500 KB'); + return; + } + const reader = new FileReader(); reader.onload = (e) => setPreview(e.target.result); reader.readAsDataURL(file); diff --git a/components/Group/Form/Fields/index.jsx b/components/Group/Form/Fields/index.jsx index 202c1f25..f8383205 100644 --- a/components/Group/Form/Fields/index.jsx +++ b/components/Group/Form/Fields/index.jsx @@ -1,56 +1,34 @@ +import { useId } from 'react'; import AreaCheckbox from './AreaCheckbox'; import Select from './Select'; import TagsField from './TagsField'; import TextField from './TextField'; import Upload from './Upload'; import Wrapper from './Wrapper'; -import useWrapperProps from './useWrapperProps'; -const Fields = {}; +const withWrapper = (Component) => (props) => { + const id = useId(); + const formItemId = `form-item-${id}`; + const { required, label, tooltip } = props; -Fields.AreaCheckbox = (props) => { - const wrapperProps = useWrapperProps(props); return ( - - + + ); }; -Fields.Select = (props) => { - const wrapperProps = useWrapperProps(props); - return ( - - - + ` position: relative; diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx index 6e7f50fa..103f6f81 100644 --- a/components/Group/Form/index.jsx +++ b/components/Group/Form/index.jsx @@ -1,4 +1,6 @@ +import { useEffect } from 'react'; import Box from '@mui/material/Box'; +import Switch from '@mui/material/Switch'; import CircularProgress from '@mui/material/CircularProgress'; import Button from '@/shared/components/Button'; import StyledPaper from '../Paper.styled'; @@ -7,6 +9,7 @@ import { StyledDescription, StyledContainer, StyledFooter, + StyledSwitchWrapper, } from './Form.styled'; import Fields from './Fields'; import useGroupForm, { @@ -15,10 +18,20 @@ import useGroupForm, { eduOptions, } from './useGroupForm'; -export default function GroupForm({ mode, isLoading, onSubmit }) { - const { control, values, errors, handleSubmit } = useGroupForm(); +export default function GroupForm({ + mode, + defaultValues, + isLoading, + onSubmit, +}) { + const { control, values, errors, isDirty, setValues, handleSubmit } = + useGroupForm(); const isCreateMode = mode === 'create'; + useEffect(() => { + if (defaultValues) setValues(defaultValues); + }, [defaultValues]); + return ( @@ -114,13 +127,29 @@ export default function GroupForm({ mode, isLoading, onSubmit }) { helperText="標籤填寫完成後,會用 Hashtag 的形式呈現,例如: #一起學日文" /> + {!isCreateMode && ( + + + {values.isGrouping ? '開放揪團中' : '已關閉揪團'} + + control.onChange({ + target: { name: 'isGrouping', value: !values.isGrouping }, + }) + } + /> + + + )} + ) : ( <> @@ -36,7 +36,7 @@ function GroupDetail({ source, isLoading }) { ) : ( 已結束 )} - + {isLoading ? : source?.title} diff --git a/components/Profile/Edit/index.jsx b/components/Profile/Edit/index.jsx index 447bc895..ffbd11b7 100644 --- a/components/Profile/Edit/index.jsx +++ b/components/Profile/Edit/index.jsx @@ -63,7 +63,7 @@ function EditPage() { const user = useSelector((state) => state.user); useEffect(() => { - if (user._id || '65a7e0300604d7c3f4641bf9') { + if (user._id) { Object.entries(user).forEach(([key, value]) => { if (key === 'contactList') { const { instagram, facebook, discord, line } = value; diff --git a/components/Profile/MyGroup/GroupCard.jsx b/components/Profile/MyGroup/GroupCard.jsx index d2a3870d..9bb9ad4b 100644 --- a/components/Profile/MyGroup/GroupCard.jsx +++ b/components/Profile/MyGroup/GroupCard.jsx @@ -1,10 +1,13 @@ import { useState } from 'react'; +import { useRouter } from 'next/router'; import Menu from '@mui/material/Menu'; import IconButton from '@mui/material/IconButton'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined'; import Image from '@/shared/components/Image'; import emptyCoverImg from '@/public/assets/empty-cover.png'; +import useMutation from '@/hooks/useMutation'; +import { GROUP_API_URL } from '@/redux/actions/group'; import { timeDuration } from '@/utils/date'; import { StyledAreas, @@ -28,68 +31,109 @@ function GroupCard({ area, isGrouping, updatedDate, + refetch, }) { + const router = useRouter(); const [anchorEl, setAnchorEl] = useState(null); + const apiGrouping = useMutation( + () => + fetch(`${GROUP_API_URL}/${_id}`, { + method: 'PUT', + body: JSON.stringify({ isGrouping: !isGrouping }), + headers: { + 'Content-Type': 'application/json', + }, + }), + { onSuccess: refetch }, + ); + + const apiDeleteGroup = useMutation( + () => + fetch(`${GROUP_API_URL}/${_id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }), + { onSuccess: refetch }, + ); + const handleMenu = (event) => { + event.preventDefault(); setAnchorEl(event.currentTarget); }; - const handleClose = (event) => { + const handleClose = () => { setAnchorEl(null); }; + const handleGrouping = () => { + handleClose(); + apiGrouping.mutate(); + }; + + const handleDeleteGroup = () => { + handleClose(); + apiDeleteGroup.mutate(); + }; + return ( - - {photoAlt - - {title} - - {description} - - - - {area} - - - {timeDuration(updatedDate)} - - {isGrouping ? ( - 揪團中 - ) : ( - 已結束 - )} - - - - - 編輯 - - {isGrouping ? '結束揪團' : '開放揪團'} - - 刪除 - - - - - + <> + + {photoAlt + + {title} + + {description} + + + + {area} + + + {timeDuration(updatedDate)} + + {isGrouping ? ( + 揪團中 + ) : ( + 已結束 + )} + + + + + + + + + + router.push(`/group/edit?id=${_id}`)}> + 編輯 + + + {isGrouping ? '結束揪團' : '開放揪團'} + + 刪除 + + ); } diff --git a/components/Profile/MyGroup/GroupCard.styled.jsx b/components/Profile/MyGroup/GroupCard.styled.jsx index 1025cee8..839a3961 100644 --- a/components/Profile/MyGroup/GroupCard.styled.jsx +++ b/components/Profile/MyGroup/GroupCard.styled.jsx @@ -1,3 +1,4 @@ +import Link from 'next/link'; import styled from '@emotion/styled'; import Divider from '@mui/material/Divider'; import MenuItem from '@mui/material/MenuItem'; @@ -81,7 +82,7 @@ export const StyledAreas = styled.div` align-items: center; `; -export const StyledGroupCard = styled.div` +export const StyledGroupCard = styled(Link)` display: flex; position: relative; background: #fff; diff --git a/components/Profile/MyGroup/LoadingCard.jsx b/components/Profile/MyGroup/LoadingCard.jsx index 0eb23534..76554a52 100644 --- a/components/Profile/MyGroup/LoadingCard.jsx +++ b/components/Profile/MyGroup/LoadingCard.jsx @@ -16,7 +16,7 @@ import { function LoadingCard() { return ( - + diff --git a/components/Profile/MyGroup/index.jsx b/components/Profile/MyGroup/index.jsx index 8c30e621..903ea46c 100644 --- a/components/Profile/MyGroup/index.jsx +++ b/components/Profile/MyGroup/index.jsx @@ -1,8 +1,7 @@ -import { useState, useEffect, Fragment } from 'react'; +import { Fragment } from 'react'; import { Box, Typography } from '@mui/material'; import { useRouter } from 'next/router'; import { useDispatch, useSelector } from 'react-redux'; -import { updateUser, userLogout } from '@/redux/actions/user'; import { GROUP_API_URL } from '@/redux/actions/group'; import useFetch from '@/hooks/useFetch'; import GroupCard from './GroupCard'; @@ -10,33 +9,9 @@ import LoadingCard from './LoadingCard'; import { StyledDivider } from './GroupCard.styled'; const MyGroup = () => { - const { data, isError, isFetching } = useFetch( + const { data, isFetching, refetch } = useFetch( `${GROUP_API_URL}/user/${'65a7e0300604d7c3f4641bf9'}`, ); - console.log(data); - const dispatch = useDispatch(); - const router = useRouter(); - - const [isSubscribeEmail, setIsSubscribeEmail] = useState(false); - const user = useSelector((state) => state.user); - - const onUpdateUser = (status) => { - const payload = { - id: user._id, - email: user.email, - isSubscribeEmail: status, - }; - dispatch(updateUser(payload)); - }; - - const logout = () => { - dispatch(userLogout()); - router.push('/'); - }; - - useEffect(() => { - setIsSubscribeEmail(user?.isSubscribeEmail || false); - }, [user.isSubscribeEmail]); return ( { 我的揪團 - {isFetching && ( - <> - - - - - - - )} - {Array.isArray(data?.data) && + {isFetching ? ( + + ) : ( + Array.isArray(data?.data) && data.data.map((item, index) => ( {index > 0 && } - + - ))} + )) + )} ); diff --git a/hooks/useFetch.jsx b/hooks/useFetch.jsx index 295c79bc..6ac43122 100644 --- a/hooks/useFetch.jsx +++ b/hooks/useFetch.jsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useReducer, useState } from 'react'; const useFetch = (url, { initialValue } = {}) => { + const [render, refetch] = useReducer((pre) => !pre, true); const [data, setData] = useState(initialValue); const [isFetching, setIsFetching] = useState(true); const [isError, setIsError] = useState(false); @@ -8,7 +9,7 @@ const useFetch = (url, { initialValue } = {}) => { useEffect(() => { let pass = true; - if (url.includes('undefined')) return; + if (url.includes('undefined')) return undefined; setIsFetching(true); setIsError(false); @@ -19,10 +20,12 @@ const useFetch = (url, { initialValue } = {}) => { .catch(() => setIsError(true)) .finally(() => setIsFetching(false)); - return () => pass = false; - }, [url]); + return () => { + pass = false; + }; + }, [url, render]); - return { data, isFetching, isError }; + return { data, isFetching, isError, refetch }; }; export default useFetch; diff --git a/pages/group/create/index.jsx b/pages/group/create/index.jsx index d3660038..c36d0e39 100644 --- a/pages/group/create/index.jsx +++ b/pages/group/create/index.jsx @@ -7,7 +7,7 @@ import Navigation from '@/shared/components/Navigation_v2'; import Footer from '@/shared/components/Footer_v2'; import { GROUP_API_URL } from '@/redux/actions/group'; -function GroupPage() { +function CreateGroupPage() { const router = useRouter(); const SEOData = useMemo( @@ -25,23 +25,16 @@ function GroupPage() { ); const { mutate, isLoading } = useMutation( - (values) => { - // const formData = new FormData(); - - // Object.keys(values).forEach((key) => { - // formData.append(key, values[key]); - // }); - - return fetch(GROUP_API_URL, { + (values) => + fetch(GROUP_API_URL, { method: 'POST', body: JSON.stringify(values), headers: { 'Content-Type': 'application/json', }, - }); - }, + }), { - onSuccess: console.log, + onSuccess: router.replace('/profile'), }, ); @@ -55,4 +48,4 @@ function GroupPage() { ); } -export default GroupPage; +export default CreateGroupPage; diff --git a/pages/group/detail/index.jsx b/pages/group/detail/index.jsx index 72f9c29c..06625afb 100644 --- a/pages/group/detail/index.jsx +++ b/pages/group/detail/index.jsx @@ -33,7 +33,7 @@ function GroupPage() { {(id || isFetching) && !isError ? ( - + ) : ( )} diff --git a/pages/group/edit/index.jsx b/pages/group/edit/index.jsx new file mode 100644 index 00000000..fee4ccb7 --- /dev/null +++ b/pages/group/edit/index.jsx @@ -0,0 +1,85 @@ +import React, { useEffect, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useRouter } from 'next/router'; +import { Box, CircularProgress } from '@mui/material'; +import GroupForm from '@/components/Group/Form'; +import useFetch from '@/hooks/useFetch'; +import useMutation from '@/hooks/useMutation'; +import SEOConfig from '@/shared/components/SEO'; +import Navigation from '@/shared/components/Navigation_v2'; +import Footer from '@/shared/components/Footer_v2'; +import { GROUP_API_URL } from '@/redux/actions/group'; + +function EditGroupPage() { + const router = useRouter(); + const me = useSelector((state) => state.user); + const { id } = router.query; + const { data, isFetching } = useFetch( + `https://daodao-server.vercel.app/activity/${id}`, + ); + const source = data?.data?.[0]; + + const SEOData = useMemo( + () => ({ + title: '編輯揪團|島島阿學', + description: + '「島島阿學」揪團專區,結交志同道合的學習夥伴!發起各種豐富多彩的揪團活動,共同探索學習的樂趣。一同參與,共同成長,打造學習的共好社群。加入我們,一起開啟學習的冒險旅程!', + keywords: '島島阿學', + author: '島島阿學', + copyright: '島島阿學', + imgLink: 'https://www.daoedu.tw/preview.webp', + link: `${process.env.HOSTNAME}${router?.asPath}`, + }), + [router?.asPath], + ); + + const goToDetail = () => router.replace(`/group/detail?id=${id}`); + + const { mutate, isLoading } = useMutation( + (values) => + fetch(`${GROUP_API_URL}/${id}`, { + method: 'PUT', + body: JSON.stringify(values), + headers: { + 'Content-Type': 'application/json', + }, + }), + { onSuccess: goToDetail }, + ); + + useEffect(() => { + if (!me?._id) router.push('/login'); + if (isFetching) return; + if (source?.userId !== me._id) goToDetail(); + }, [me, source, isFetching, id]); + + return ( + <> + + + {isFetching && ( + + + + )} + +