PluginProbe ʕ •ᴥ•ʔ
GiveWP – Donation Plugin and Fundraising Platform / 4.13.1
GiveWP – Donation Plugin and Fundraising Platform v4.13.1
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 10 months ago style.module.scss 10 months ago
index.tsx
429 lines
1 import {__} from '@wordpress/i18n';
2 import {addQueryArgs} from '@wordpress/url';
3 import useSWR from 'swr';
4 import {useState} from 'react';
5 import apiFetch from '@wordpress/api-fetch';
6 import {useDispatch} from '@wordpress/data';
7 import {ConfirmationDialogIcon, DeleteIcon, DotsMenuIcon, EditIcon, NotesIcon} from './Icons';
8 import Spinner from '../Spinner';
9 import ModalDialog from '@givewp/components/AdminUI/ModalDialog';
10 import style from './style.module.scss';
11 import cx from 'classnames';
12 import {formatTimestamp} from '@givewp/src/Admin/utils';
13 import Header from '@givewp/src/Admin/components/Header';
14
15 /**
16 * @since 4.5.0
17 */
18 type DonorNote = {
19 id: number;
20 donorId: number;
21 content: string;
22 createdAt: {
23 date: string;
24 };
25 }
26
27 /**
28 * @since 4.8.0 Include loadingId in the state to manage loading states per note.
29 * @since 4.5.0
30 */
31 type NoteState = {
32 notes: DonorNote[];
33 loadingId: number | null;
34 totalItems: number;
35 isAddingNote: boolean;
36 isSavingNote: boolean;
37 note: string;
38 perPage: number;
39 }
40
41 /**
42 * @since 4.6.0
43 */
44 export function DonorNotes({donorId}: {donorId: number}) {
45 return <PrivateNotes endpoint={`/givewp/v3/donors/${donorId}/notes`} />
46 }
47
48 /**
49 * @since 4.6.0
50 */
51 export function DonationNotes({donationId}: {donationId: number}) {
52 return <PrivateNotes endpoint={`/givewp/v3/donations/${donationId}/notes`} />
53 }
54
55 /**
56 * @since 4.8.0
57 */
58 export function SubscriptionNotes({subscriptionId}: {subscriptionId: number}) {
59 return <PrivateNotes endpoint={`/givewp/v3/subscriptions/${subscriptionId}/notes`} />
60 }
61
62 /**
63 * @since 4.8.0 Manage local state to handle loading indicators per note.
64 * @since 4.4.0
65 */
66 function PrivateNotes({endpoint}: {endpoint: string}) {
67 const [state, setNoteState] = useState<NoteState>({
68 notes: [],
69 loadingId: undefined,
70 totalItems: 0,
71 isAddingNote: false,
72 isSavingNote: false,
73 note: '',
74 perPage: 5,
75 });
76
77 const dispatch = useDispatch('givewp/admin-details-page-notifications');
78
79 const {
80 data,
81 isLoading,
82 isValidating,
83 mutate,
84 } = useSWR<{data: DonorNote[]; totalPages: number; totalItems: number}>(endpoint, async (url) => {
85 const response = await apiFetch({
86 path: addQueryArgs(url, {page: 1, per_page: state.perPage}),
87 parse: false,
88 }) as Response;
89 const data = await response.json();
90
91 setState({
92 notes: data,
93 totalItems: Number(response.headers.get('X-WP-Total')),
94 });
95
96 return {
97 data,
98 totalPages: Number(response.headers.get('X-WP-TotalPages')),
99 totalItems: Number(response.headers.get('X-WP-Total')),
100 };
101 }, {revalidateOnFocus: false});
102
103 const initialLoad = (isLoading || isValidating) && state.loadingId === undefined;
104
105 const saveNote = () => {
106 const tempId = Date.now();
107 const tempNote = {
108 id: tempId,
109 content: state.note,
110 createdAt: {date: new Date().toISOString()}
111 };
112
113 // Add temporary note to the UI state.
114 setState({
115 loadingId: tempId,
116 notes: [tempNote, ...state.notes],
117 isAddingNote: false,
118 isSavingNote: true
119 });
120
121 apiFetch({path: endpoint, method: 'POST', data: {content: state.note}})
122 .then(async (response) => {
123 await mutate(response);
124 setState({
125 isAddingNote: false,
126 isSavingNote: false,
127 });
128 dispatch.addSnackbarNotice({
129 id: 'add-note',
130 content: __('You added a private note', 'give'),
131 });
132 });
133 };
134
135 const deleteNote = (id: number) => {
136 setState({loadingId: id});
137 apiFetch({path: `${endpoint}/${id}`, method: 'DELETE', data: {id}})
138 .then(async (response) => {
139 await mutate(response);
140 dispatch.addSnackbarNotice({
141 id: 'delete-note',
142 content: __('Private note deleted successfully', 'give'),
143 });
144 });
145 };
146
147 const editNote = (id: number, content: string) => {
148 setState({loadingId: id});
149 apiFetch({path: `${endpoint}/${id}`, method: 'PATCH', data: {content}})
150 .then(async (response) => {
151 await mutate(response);
152 setState({
153 loadingId: null,
154 notes: state.notes.map((note) => note.id === id ? response : note)
155 });
156 dispatch.addSnackbarNotice({
157 id: 'edit-note',
158 content: __('Private note edited', 'give'),
159 });
160 });
161 };
162
163 const setState = (props) => {
164 setNoteState((prevState) => {
165 return {
166 ...prevState,
167 ...props,
168 };
169 });
170 };
171
172 return (
173 <>
174 <Header
175 title={__('Private Note', 'give')}
176 subtitle={__('This note will be seen by only admins', 'give')}
177 actionOnClick={() => setState({isAddingNote: true})}
178 actionText={__('Add note', 'give')}
179 />
180 {initialLoad && (
181 <div style={{margin: '0 auto'}}>
182 <Spinner />
183 </div>
184 )}
185 {!initialLoad && <div className={style.notesContainer}>
186 {state.isAddingNote && (
187 <div className={style.addNoteContainer}>
188 <textarea
189 className={style.textarea}
190 onChange={(e) => setState({note: e.target.value})}
191 ></textarea>
192
193 <div className={style.textAreaButtons}>
194 <button
195 className={cx(style.button, style.cancelBtn)}
196 onClick={() => setState({isAddingNote: false})}
197 >
198 {__('Cancel', 'give')}
199 </button>
200 <button
201 className={cx(style.button, style.saveBtn)}
202 onClick={(e) => {
203 e.preventDefault();
204 saveNote();
205 }}
206 >
207 {__('Save', 'give')}
208 </button>
209 </div>
210 </div>
211 )}
212
213 {state?.notes?.length > 0 ? (
214 <>
215 {state?.notes?.map((note) => {
216 return (
217 <Note
218 key={note.id}
219 note={note}
220 onDelete={(id: number) => deleteNote(id)}
221 onEdit={(id: number, content: string) => editNote(id, content)}
222 isLoading={note.id === state.loadingId}
223 />
224 );
225 })}
226 </>
227 ) : (
228 <>
229 {!state.isAddingNote && (
230 <div style={{margin: '0 auto', textAlign: 'center'}}>
231 <NotesIcon />
232 <p className={style.noNotesText}>{__('No notes yet', 'give')}</p>
233 </div>
234 )}
235 </>
236 )}
237
238 <div className={style.showMoreContainer}>
239 {state?.notes?.length > 0 && state.totalItems > state.perPage && (
240 <button
241 className={style.showMoreButton}
242 onClick={async (e) => {
243 e.preventDefault();
244 setNoteState((prevState) => {
245 return {
246 ...prevState,
247 perPage: prevState.perPage += 5,
248 };
249 });
250
251 await mutate(endpoint);
252 }}>
253 {__('Show more', 'give')}
254 </button>
255 )}
256 </div>
257 </div>}
258 </>
259 );
260 }
261
262 /**
263 * @since 4.8.0 Improved accessibility with semantic buttons. Added per-note loading state handling.
264 * @since 4.4.0
265 */
266 const Note = ({note, onDelete, onEdit, isLoading}) => {
267 const [showContextMenu, setShowContextMenu] = useState(false);
268 const [currentlyEditing, setCurrentlyEditing] = useState();
269 const [content, setContent] = useState(note.content);
270 const [showDeleteDialog, setShowDeleteDialog] = useState(false);
271
272 return (
273 <>
274 <div
275 onMouseLeave={() => {
276 setShowContextMenu(false);
277 }}
278 >
279 {currentlyEditing ? (
280 <>
281 <div className={style.addNoteContainer}>
282 <textarea
283 className={style.textarea}
284 onChange={(e) => setContent(e.target.value)}
285 value={content}
286 ></textarea>
287
288 <div className={style.textAreaButtons}>
289 <button
290 className={cx(style.button, style.cancelBtn)}
291 onClick={() => {
292 setCurrentlyEditing(null);
293 setShowContextMenu(false);
294 }}
295 >
296 {__('Cancel', 'give')}
297 </button>
298 <button
299 className={cx(style.button, style.saveBtn)}
300 onClick={(e) => {
301 e.preventDefault();
302 setShowContextMenu(false);
303 setCurrentlyEditing(null);
304 onEdit(note.id, content);
305 }}
306 >
307 {__('Save', 'give')}
308 </button>
309 </div>
310 </div>
311 </>
312 ) : (
313 <>
314 <div className={style.noteContainer}>
315 {isLoading ? (
316 <div className={style.noteLoading}>
317 <Spinner />
318 </div>
319 ) : (
320 <>
321 <div className={style.note}>
322 <div className={style.title}>
323 {note.content}
324 </div>
325
326 <button
327 className={style.dotsMenu}
328 onClick={() => setShowContextMenu(true)}
329 aria-haspopup="true"
330 aria-expanded={showContextMenu}
331 aria-controls="contextMenu"
332 >
333 <DotsMenuIcon />
334 {showContextMenu && (
335 <div className={style.menu} role="menu"
336 id="contextMenu" >
337 <button
338 className={style.menuItem}
339 onClick={(e) => {
340 e.preventDefault();
341 setShowContextMenu(false);
342 setCurrentlyEditing(note.id);
343 }}
344 >
345 <EditIcon /> {__('Edit', 'give')}
346 </button>
347 <button
348 className={cx(style.menuItem, style.delete)}
349 onClick={(e) => {
350 e.preventDefault();
351 setShowContextMenu(false);
352 setShowDeleteDialog(true);
353 }}
354 >
355 <DeleteIcon /> {__('Delete', 'give')}
356 </button>
357 </div>
358 )}
359 </button>
360 </div>
361 <div className={style.date}>
362 {formatTimestamp(note.createdAt.date)}
363 </div>
364 </>
365 )}
366 </div>
367 </>
368 )}
369 <ConfirmationDialog
370 title={__('Delete Note', 'give')}
371 isOpen={showDeleteDialog}
372 handleClose={() => setShowDeleteDialog(false)}
373 handleConfirm={() => {
374 setShowDeleteDialog(false)
375 onDelete(note.id);
376 }}
377 />
378 </div>
379 </>
380 );
381 };
382
383
384 /**
385 * @since 4.5.0
386 */
387 function ConfirmationDialog({
388 isOpen,
389 title,
390 handleClose,
391 handleConfirm
392 }: {
393 isOpen: boolean;
394 handleClose: () => void;
395 handleConfirm: () => void;
396 title: string;
397 }) {
398 return (
399 <ModalDialog
400 icon={<ConfirmationDialogIcon />}
401 isOpen={isOpen}
402 showHeader={true}
403 handleClose={handleClose}
404 title={title}
405 >
406 <>
407 <div className={style.dialogContent}>
408 {__('Are you sure you want to delete this note?', 'give')}
409 </div>
410 <div className={style.dialogButtons}>
411 <button
412 className={style.cancelButton}
413 onClick={handleClose}
414 >
415 {__('Cancel', 'give')}
416 </button>
417 <button
418 className={style.confirmButton}
419 onClick={handleConfirm}
420 >
421 {__('Delete note', 'give')}
422 </button>
423 </div>
424 </>
425 </ModalDialog>
426 );
427 }
428
429