PluginProbe ʕ •ᴥ•ʔ
GiveWP – Donation Plugin and Fundraising Platform / 4.15.4
GiveWP – Donation Plugin and Fundraising Platform v4.15.4
4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 2.3.2 2.30.0 2.31.0 2.31.1 2.32.0 2.33.0 2.33.1 2.33.2 2.33.3 2.33.4 2.33.5 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.4.7 2.5.0 2.5.1 2.5.10 2.5.11 2.5.12 2.5.13 2.5.2 2.5.3 2.5.4 2.5.5 2.5.6 2.5.7 2.5.8 2.5.9 2.6.0 2.6.1 2.6.2 2.6.3 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.8.0 2.8.1 2.9.0 2.9.1 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.10.0 3.11.0 3.12.0 3.12.1 3.12.2 3.12.3 3.13.0 3.14.0 3.14.1 3.14.2 3.15.0 3.15.1 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.16.5 3.17.0 3.17.1 3.17.2 3.18.0 3.19.0 3.19.1 3.19.2 3.19.3 3.19.4 3.2.0 3.2.1 3.2.2 3.20.0 3.21.0 3.21.1 3.22.0 3.22.1 3.22.2 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.6.0 3.6.1 3.6.2 3.7.0 3.8.0 3.9.0 4.0.0 4.1.0 4.1.1 4.10.0 4.10.1 4.11.0 4.12.0 4.13.0 4.13.1 4.13.2 4.14.0 4.14.1 4.14.2 4.14.3 4.14.4 4.14.5 4.14.6 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.1 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 trunk 1.9.0 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.10.0 2.10.1 2.10.2 2.10.3 2.10.4 2.11.0 2.11.1 2.11.2 2.11.3 2.12.0 2.12.1 2.12.2 2.12.3 2.13.0 2.13.1 2.13.2 2.13.3 2.13.4 2.14.0 2.15.0 2.16.0 2.16.1 2.17.0 2.17.1 2.17.3 2.18.0 2.18.1 2.19.1 2.19.2 2.19.3 2.19.4 2.19.5 2.19.6 2.19.7 2.19.8 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.20.0 2.20.1 2.20.2 2.21.0 2.21.1 2.21.2 2.21.3 2.21.4 2.22.0 2.22.1 2.22.2 2.22.3 2.23.0 2.23.1 2.23.2 2.24.0 2.24.1 2.24.2 2.25.0 2.25.1 2.25.2 2.25.3 2.26.0 2.27.0 2.27.1 2.27.2 2.27.3 2.28.0 2.29.0 2.29.1 2.29.2
give / src / Admin / components / PrivateNotes / index.tsx
give / src / Admin / components / PrivateNotes Last commit date
Icons.tsx 1 year ago index.tsx 5 months ago style.module.scss 9 months ago
index.tsx
426 lines
1 import { formatTimestamp } from '@givewp/admin/common';
2 import { Header } from '@givewp/admin/components';
3 import ModalDialog from '@givewp/components/AdminUI/ModalDialog';
4 import apiFetch from '@wordpress/api-fetch';
5 import { useDispatch } from '@wordpress/data';
6 import { __ } from '@wordpress/i18n';
7 import { addQueryArgs } from '@wordpress/url';
8 import cx from 'classnames';
9 import { useState } from 'react';
10 import useSWR from 'swr';
11 import Spinner from '../Spinner';
12 import { ConfirmationDialogIcon, DeleteIcon, DotsMenuIcon, EditIcon, NotesIcon } from './Icons';
13 import style from './style.module.scss';
14
15 /**
16 * @since 4.5.0
17 */
18 type DonorNote = {
19 id: number;
20 donorId: number;
21 content: string;
22 createdAt: string;
23 }
24
25 /**
26 * @since 4.8.0 Include loadingId in the state to manage loading states per note.
27 * @since 4.5.0
28 */
29 type NoteState = {
30 notes: DonorNote[];
31 loadingId: number | null;
32 totalItems: number;
33 isAddingNote: boolean;
34 isSavingNote: boolean;
35 note: string;
36 perPage: number;
37 }
38
39 /**
40 * @since 4.6.0
41 */
42 export function DonorNotes({donorId}: {donorId: number}) {
43 return <PrivateNotes endpoint={`/givewp/v3/donors/${donorId}/notes`} />
44 }
45
46 /**
47 * @since 4.6.0
48 */
49 export function DonationNotes({donationId}: {donationId: number}) {
50 return <PrivateNotes endpoint={`/givewp/v3/donations/${donationId}/notes`} />
51 }
52
53 /**
54 * @since 4.8.0
55 */
56 export function SubscriptionNotes({subscriptionId}: {subscriptionId: number}) {
57 return <PrivateNotes endpoint={`/givewp/v3/subscriptions/${subscriptionId}/notes`} />
58 }
59
60 /**
61 * @since 4.8.0 Manage local state to handle loading indicators per note.
62 * @since 4.4.0
63 */
64 function PrivateNotes({endpoint}: {endpoint: string}) {
65 const [state, setNoteState] = useState<NoteState>({
66 notes: [],
67 loadingId: undefined,
68 totalItems: 0,
69 isAddingNote: false,
70 isSavingNote: false,
71 note: '',
72 perPage: 5,
73 });
74
75 const dispatch = useDispatch('givewp/admin-details-page-notifications');
76
77 const {
78 data,
79 isLoading,
80 isValidating,
81 mutate,
82 } = useSWR<{data: DonorNote[]; totalPages: number; totalItems: number}>(endpoint, async (url) => {
83 const response = await apiFetch({
84 path: addQueryArgs(url, {page: 1, per_page: state.perPage}),
85 parse: false,
86 }) as Response;
87 const data = await response.json();
88
89 setState({
90 notes: data,
91 totalItems: Number(response.headers.get('X-WP-Total')),
92 });
93
94 return {
95 data,
96 totalPages: Number(response.headers.get('X-WP-TotalPages')),
97 totalItems: Number(response.headers.get('X-WP-Total')),
98 };
99 }, {revalidateOnFocus: false});
100
101 const initialLoad = (isLoading || isValidating) && state.loadingId === undefined;
102
103 const saveNote = () => {
104 const tempId = Date.now();
105 const tempNote = {
106 id: tempId,
107 content: state.note,
108 createdAt: new Date().toISOString()
109 };
110
111 // Add temporary note to the UI state.
112 setState({
113 loadingId: tempId,
114 notes: [tempNote, ...state.notes],
115 isAddingNote: false,
116 isSavingNote: true
117 });
118
119 apiFetch({path: endpoint, method: 'POST', data: {content: state.note}})
120 .then(async (response) => {
121 await mutate(response);
122 setState({
123 isAddingNote: false,
124 isSavingNote: false,
125 });
126 dispatch.addSnackbarNotice({
127 id: 'add-note',
128 content: __('You added a private note', 'give'),
129 });
130 });
131 };
132
133 const deleteNote = (id: number) => {
134 setState({loadingId: id});
135 apiFetch({path: `${endpoint}/${id}`, method: 'DELETE', data: {id}})
136 .then(async (response) => {
137 await mutate(response);
138 dispatch.addSnackbarNotice({
139 id: 'delete-note',
140 content: __('Private note deleted successfully', 'give'),
141 });
142 });
143 };
144
145 const editNote = (id: number, content: string) => {
146 setState({loadingId: id});
147 apiFetch({path: `${endpoint}/${id}`, method: 'PATCH', data: {content}})
148 .then(async (response) => {
149 await mutate(response);
150 setState({
151 loadingId: null,
152 notes: state.notes.map((note) => note.id === id ? response : note)
153 });
154 dispatch.addSnackbarNotice({
155 id: 'edit-note',
156 content: __('Private note edited', 'give'),
157 });
158 });
159 };
160
161 const setState = (props) => {
162 setNoteState((prevState) => {
163 return {
164 ...prevState,
165 ...props,
166 };
167 });
168 };
169
170 return (
171 <>
172 <Header
173 title={__('Private Note', 'give')}
174 subtitle={__('This note will be seen by only admins', 'give')}
175 actionOnClick={() => setState({isAddingNote: true})}
176 actionText={__('Add note', 'give')}
177 />
178 {initialLoad && (
179 <div style={{margin: '0 auto'}}>
180 <Spinner />
181 </div>
182 )}
183 {!initialLoad && <div className={style.notesContainer}>
184 {state.isAddingNote && (
185 <div className={style.addNoteContainer}>
186 <textarea
187 className={style.textarea}
188 onChange={(e) => setState({note: e.target.value})}
189 ></textarea>
190
191 <div className={style.textAreaButtons}>
192 <button
193 className={cx(style.button, style.cancelBtn)}
194 onClick={() => setState({isAddingNote: false})}
195 >
196 {__('Cancel', 'give')}
197 </button>
198 <button
199 className={cx(style.button, style.saveBtn)}
200 onClick={(e) => {
201 e.preventDefault();
202 saveNote();
203 }}
204 >
205 {__('Save', 'give')}
206 </button>
207 </div>
208 </div>
209 )}
210
211 {state?.notes?.length > 0 ? (
212 <>
213 {state?.notes?.map((note) => {
214 return (
215 <Note
216 key={note.id}
217 note={note}
218 onDelete={(id: number) => deleteNote(id)}
219 onEdit={(id: number, content: string) => editNote(id, content)}
220 isLoading={note.id === state.loadingId}
221 />
222 );
223 })}
224 </>
225 ) : (
226 <>
227 {!state.isAddingNote && (
228 <div style={{margin: '0 auto', textAlign: 'center'}}>
229 <NotesIcon />
230 <p className={style.noNotesText}>{__('No notes yet', 'give')}</p>
231 </div>
232 )}
233 </>
234 )}
235
236 <div className={style.showMoreContainer}>
237 {state?.notes?.length > 0 && state.totalItems > state.perPage && (
238 <button
239 className={style.showMoreButton}
240 onClick={async (e) => {
241 e.preventDefault();
242 setNoteState((prevState) => {
243 return {
244 ...prevState,
245 perPage: prevState.perPage += 5,
246 };
247 });
248
249 await mutate(endpoint);
250 }}>
251 {__('Show more', 'give')}
252 </button>
253 )}
254 </div>
255 </div>}
256 </>
257 );
258 }
259
260 /**
261 * @since 4.8.0 Improved accessibility with semantic buttons. Added per-note loading state handling.
262 * @since 4.4.0
263 */
264 const Note = ({note, onDelete, onEdit, isLoading}) => {
265 const [showContextMenu, setShowContextMenu] = useState(false);
266 const [currentlyEditing, setCurrentlyEditing] = useState();
267 const [content, setContent] = useState(note.content);
268 const [showDeleteDialog, setShowDeleteDialog] = useState(false);
269
270 return (
271 <>
272 <div
273 onMouseLeave={() => {
274 setShowContextMenu(false);
275 }}
276 >
277 {currentlyEditing ? (
278 <>
279 <div className={style.addNoteContainer}>
280 <textarea
281 className={style.textarea}
282 onChange={(e) => setContent(e.target.value)}
283 value={content}
284 ></textarea>
285
286 <div className={style.textAreaButtons}>
287 <button
288 className={cx(style.button, style.cancelBtn)}
289 onClick={() => {
290 setCurrentlyEditing(null);
291 setShowContextMenu(false);
292 }}
293 >
294 {__('Cancel', 'give')}
295 </button>
296 <button
297 className={cx(style.button, style.saveBtn)}
298 onClick={(e) => {
299 e.preventDefault();
300 setShowContextMenu(false);
301 setCurrentlyEditing(null);
302 onEdit(note.id, content);
303 }}
304 >
305 {__('Save', 'give')}
306 </button>
307 </div>
308 </div>
309 </>
310 ) : (
311 <>
312 <div className={style.noteContainer}>
313 {isLoading ? (
314 <div className={style.noteLoading}>
315 <Spinner />
316 </div>
317 ) : (
318 <>
319 <div className={style.note}>
320 <div className={style.title}>
321 {note.content}
322 </div>
323
324 <button
325 className={style.dotsMenu}
326 onClick={() => setShowContextMenu(true)}
327 aria-haspopup="true"
328 aria-expanded={showContextMenu}
329 aria-controls="contextMenu"
330 >
331 <DotsMenuIcon />
332 {showContextMenu && (
333 <div className={style.menu} role="menu"
334 id="contextMenu" >
335 <button
336 className={style.menuItem}
337 onClick={(e) => {
338 e.preventDefault();
339 setShowContextMenu(false);
340 setCurrentlyEditing(note.id);
341 }}
342 >
343 <EditIcon /> {__('Edit', 'give')}
344 </button>
345 <button
346 className={cx(style.menuItem, style.delete)}
347 onClick={(e) => {
348 e.preventDefault();
349 setShowContextMenu(false);
350 setShowDeleteDialog(true);
351 }}
352 >
353 <DeleteIcon /> {__('Delete', 'give')}
354 </button>
355 </div>
356 )}
357 </button>
358 </div>
359 <div className={style.date}>
360 {formatTimestamp(note.createdAt)}
361 </div>
362 </>
363 )}
364 </div>
365 </>
366 )}
367 <ConfirmationDialog
368 title={__('Delete Note', 'give')}
369 isOpen={showDeleteDialog}
370 handleClose={() => setShowDeleteDialog(false)}
371 handleConfirm={() => {
372 setShowDeleteDialog(false)
373 onDelete(note.id);
374 }}
375 />
376 </div>
377 </>
378 );
379 };
380
381
382 /**
383 * @since 4.5.0
384 */
385 function ConfirmationDialog({
386 isOpen,
387 title,
388 handleClose,
389 handleConfirm
390 }: {
391 isOpen: boolean;
392 handleClose: () => void;
393 handleConfirm: () => void;
394 title: string;
395 }) {
396 return (
397 <ModalDialog
398 icon={<ConfirmationDialogIcon />}
399 isOpen={isOpen}
400 showHeader={true}
401 handleClose={handleClose}
402 title={title}
403 >
404 <>
405 <div className={style.dialogContent}>
406 {__('Are you sure you want to delete this note?', 'give')}
407 </div>
408 <div className={style.dialogButtons}>
409 <button
410 className={style.cancelButton}
411 onClick={handleClose}
412 >
413 {__('Cancel', 'give')}
414 </button>
415 <button
416 className={style.confirmButton}
417 onClick={handleConfirm}
418 >
419 {__('Delete note', 'give')}
420 </button>
421 </div>
422 </>
423 </ModalDialog>
424 );
425 }
426