github.com/oinume/lekcije@v0.0.0-20231017100347-5b4c5eb6ab24/frontend/src/pages/MePage.tsx (about) 1 import React, {useState} from 'react'; 2 import {toast} from 'react-toastify'; 3 import {useQueryClient} from '@tanstack/react-query'; 4 import {PageTitle} from '../components/PageTitle'; 5 import {Loader} from '../components/Loader'; 6 import type {Teacher} from '../models/Teacher'; 7 import {ToastContainer} from '../components/ToastContainer'; 8 import type {GetViewerWithFollowingTeachersQuery} from '../graphql/generated'; 9 import { 10 useCreateFollowingTeacherMutation, useDeleteFollowingTeachersMutation, 11 useGetViewerWithFollowingTeachersQuery, useGetViewerWithNotificationTimeSpansQuery, 12 } from '../graphql/generated'; 13 import type {GraphQLError} from '../http/graphql'; 14 import {createGraphQLClient, toMessage} from '../http/graphql'; 15 import type {FollowingTeacher} from '../models/FollowingTeacher'; 16 import {SubmitButton} from '../components/SubmitButton'; 17 18 const graphqlClient = createGraphQLClient(); 19 20 export const MePage = () => { 21 const getViewerResult = useGetViewerWithFollowingTeachersQuery<GetViewerWithFollowingTeachersQuery, GraphQLError>(graphqlClient, {}, { 22 onError(error) { 23 toast.error(toMessage(error, 'データの取得に失敗しました')); 24 }, 25 }); 26 const showTutorial = getViewerResult.data ? getViewerResult.data.viewer.showTutorial : false; 27 const followingTeachers: FollowingTeacher[] = getViewerResult.data ? getViewerResult.data.viewer.followingTeachers.nodes.map(node => ({ 28 teacher: { 29 id: node.teacher.id, 30 name: node.teacher.name, 31 }, 32 })) : []; 33 34 return ( 35 <div id="followingForm"> 36 <ToastContainer 37 closeOnClick={false} 38 /> 39 <PageTitle>フォローしている講師</PageTitle> 40 { 41 getViewerResult.isLoading 42 ? <Loader isLoading={getViewerResult.isLoading}/> 43 : <MeContent followingTeachers={followingTeachers} showTutorial={showTutorial}/> 44 } 45 </div> 46 ); 47 }; 48 49 type MeContentProps = { 50 readonly followingTeachers: FollowingTeacher[]; 51 readonly showTutorial: boolean; // eslint-disable-line react/boolean-prop-naming 52 }; 53 54 // Help URL 55 // https://lekcije.amebaownd.com/posts/{{ if .IsUserAgentPC }}2044879{{ end }}{{ if .IsUserAgentSP }}1577091{{ end }}{{ if .IsUserAgentTablet }}1577091{{ end }} 56 57 const MeContent = ({followingTeachers, showTutorial}: MeContentProps) => ( 58 <> 59 {showTutorial ? <Tutorial/> : <div/>} 60 <CreateForm/> 61 <TeacherList followingTeachers={followingTeachers}/> 62 </> 63 ); 64 65 const Tutorial = () => ( 66 <div className="alert alert-success alert-dismissible" role="alert"> 67 <button type="button" className="btn-close" data-bs-dismiss="alert" aria-label="Close"/> 68 <h4><i className="bi bi-info-square-fill"/> 講師をフォローするには</h4> 69 <ol> 70 <li><a href="https://eikaiwa.dmm.com/list/" className="alert-link" target="_blank" rel="noreferrer">DMM英会話</a>でお気に入りの講師のページにアクセスしましょう</li> 71 <li>講師のURLをコピーしましょう(<a href="https://lekcije.amebaownd.com/posts/1577091" className="alert-link" target="_blank" rel="noreferrer">ヘルプ</a>)</li> 72 <li>URLを下の入力欄にペーストしてフォローしましょう</li> 73 <li>フォローすると、その講師の空きレッスンがあった時にメールでお知らせします</li> 74 </ol> 75 </div> 76 ); 77 78 const CreateForm = () => { 79 const [teacherIdOrUrl, setTeacherIdOrUrl] = useState(''); 80 const [submitDisabled, setSubmitDisabled] = useState(true); 81 const [submitLoading, setSubmitLoading] = useState(false); 82 83 const queryClient = useQueryClient(); 84 85 const createFollowingTeacherMutation = useCreateFollowingTeacherMutation<GraphQLError>(graphqlClient, { 86 async onSuccess() { 87 await queryClient.invalidateQueries(useGetViewerWithFollowingTeachersQuery.getKey()); 88 setTeacherIdOrUrl(''); 89 setSubmitDisabled(true); 90 setSubmitLoading(false); 91 toast.success('講師をフォローしました!'); 92 }, 93 onError(error) { 94 // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions 95 console.error(`useCreateFollowingTeacherMutation.onError: err=${error}`); 96 setSubmitDisabled(false); 97 setSubmitLoading(false); 98 toast.error(toMessage(error, '講師のフォローに失敗しました')); 99 }, 100 }); 101 102 return ( 103 <form 104 onSubmit={event => { 105 event.preventDefault(); 106 setSubmitDisabled(true); 107 setSubmitLoading(true); 108 createFollowingTeacherMutation.mutate({input: {teacherIdOrUrl}}); 109 }} 110 > 111 <p> 112 講師のURLまたはIDを入力してフォローします<a href="https://lekcije.amebaownd.com/posts/2044879" rel="noreferrer" target="_blank"><i className="fas fa-question-circle button-help" aria-hidden="true"/></a><br/> 113 <small><a href="https://eikaiwa.dmm.com/list/" rel="noreferrer" target="_blank">DMM英会話で講師を検索</a></small> 114 </p> 115 <div className="input-group mb-3"> 116 <input 117 required 118 autoFocus 119 id="teacherIdsOrUrl" 120 type="text" 121 className="form-control" 122 name="teacherIdsOrUrl" 123 placeholder="https://eikaiwa.dmm.com/teacher/index/492/" 124 value={teacherIdOrUrl} 125 onChange={event => { 126 event.preventDefault(); 127 setSubmitDisabled(event.currentTarget.value === ''); 128 setTeacherIdOrUrl(event.currentTarget.value); 129 }} 130 /> 131 <span className="px-2"/> 132 <SubmitButton 133 disabled={submitDisabled} 134 loading={submitLoading} 135 > 136 送信 137 </SubmitButton> 138 </div> 139 </form> 140 ); 141 }; 142 143 type TeacherListProps = { 144 readonly followingTeachers: FollowingTeacher[]; 145 }; 146 147 const TeacherList = ({followingTeachers}: TeacherListProps) => { 148 const [checkedIds, setCheckedIds] = useState<string[]>([]); 149 const [deleteSubmitDisabled, setDeleteSubmitDisabled] = useState(true); 150 const [deleteSubmitLoading, setDeleteSubmitLoading] = useState(false); 151 152 const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => { 153 const targetId = event.target.value; 154 if (event.target.checked) { 155 setCheckedIds([...checkedIds, targetId]); 156 setDeleteSubmitDisabled(false); 157 } else { 158 const restIds = checkedIds.filter(id => id !== targetId); 159 setCheckedIds(restIds); 160 setDeleteSubmitDisabled(restIds.length === 0); 161 } 162 }; 163 164 const queryClient = useQueryClient(); 165 const deleteFollowingTeacherMutation = useDeleteFollowingTeachersMutation<GraphQLError>(graphqlClient, { 166 async onSuccess() { 167 await queryClient.invalidateQueries(useGetViewerWithFollowingTeachersQuery.getKey()); 168 toast.success('講師のフォローを解除しました'); 169 setDeleteSubmitDisabled(true); 170 setDeleteSubmitLoading(false); 171 }, 172 onError(error) { 173 // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions 174 console.error(`deleteFollowingTeacherMutation.onError: err=${error}`); 175 setDeleteSubmitDisabled(false); 176 setDeleteSubmitLoading(false); 177 // toast.error(`講師のフォロー解除に失敗しました: ${error.message}`); 178 toast.error(toMessage(error, '講師のフォロー解除に失敗しました')); 179 }, 180 }, 181 ); 182 183 return ( 184 <div id="followingTeachers"> 185 <form 186 onSubmit={event => { 187 event.preventDefault(); 188 setDeleteSubmitDisabled(true); 189 setDeleteSubmitLoading(true); 190 deleteFollowingTeacherMutation.mutate({input: {teacherIds: checkedIds}}); 191 }} 192 > 193 <table className="table table-striped table-hover"> 194 <thead> 195 <tr> 196 <th scope="col" className="col-md-1"> 197 <SubmitButton 198 disabled={deleteSubmitDisabled} 199 loading={deleteSubmitLoading} 200 > 201 削除 202 </SubmitButton> 203 </th> 204 <th scope="col" className="col-md-11"> 205 講師 206 </th> 207 </tr> 208 </thead> 209 <tbody> 210 {followingTeachers.map(ft => <TeacherRow key={ft.teacher.id} teacher={ft.teacher} handleOnChange={handleCheckboxChange}/>)} 211 </tbody> 212 </table> 213 </form> 214 </div> 215 ); 216 }; 217 218 type TeacherRowProps = { 219 readonly teacher: Teacher; 220 readonly handleOnChange: (event: React.ChangeEvent<HTMLInputElement>) => void; 221 }; 222 223 const TeacherRow = ({teacher, handleOnChange}: TeacherRowProps) => ( 224 <tr> 225 <td className="col-md-1"><input type="checkbox" name="teacherIds" value={teacher.id} onChange={handleOnChange}/></td> 226 <td className="col-md-8"><a href={`https://eikaiwa.dmm.com/teacher/index/${teacher.id}`} target="_blank" rel="noreferrer">{ teacher.name }</a></td> 227 </tr> 228 );