From 462bd8c7b61e329b0243fe60407caa4c83235dc8 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 8 Nov 2024 12:52:19 -0300 Subject: [PATCH] Feature: add merge campaigns UI (#7612) --- .../ListTable/CampaignsListTable.php | 6 +- .../{NameColumn.php => TitleColumn.php} | 6 +- .../components/CampaignsListTable/index.tsx | 21 ++ .../MergeCampaign/Form/Form.module.scss | 96 ++++++ .../components/MergeCampaign/Form/index.tsx | 300 ++++++++++++++++++ .../components/MergeCampaign/Form/types.ts | 14 + .../MergeCampaign/Modal/Modal.module.scss | 0 .../components/MergeCampaign/Modal/index.tsx | 90 ++++++ .../ListTable/ListTablePage/index.tsx | 22 +- 9 files changed, 545 insertions(+), 10 deletions(-) rename src/Campaigns/ListTable/Columns/{NameColumn.php => TitleColumn.php} (88%) create mode 100644 src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss create mode 100644 src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx create mode 100644 src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts create mode 100644 src/Campaigns/resources/admin/components/MergeCampaign/Modal/Modal.module.scss create mode 100644 src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx diff --git a/src/Campaigns/ListTable/CampaignsListTable.php b/src/Campaigns/ListTable/CampaignsListTable.php index 539914dac2..27ef0ea570 100644 --- a/src/Campaigns/ListTable/CampaignsListTable.php +++ b/src/Campaigns/ListTable/CampaignsListTable.php @@ -5,9 +5,9 @@ use Give\Campaigns\ListTable\Columns\DonationsCountColumn; use Give\Campaigns\ListTable\Columns\GoalColumn; use Give\Campaigns\ListTable\Columns\IdColumn; -use Give\Campaigns\ListTable\Columns\NameColumn; use Give\Campaigns\ListTable\Columns\RevenueColumn; use Give\Campaigns\ListTable\Columns\StatusColumn; +use Give\Campaigns\ListTable\Columns\TitleColumn; use Give\Framework\ListTable\ListTable; use Give\Framework\ListTable\ModelColumn; @@ -34,7 +34,7 @@ protected function getDefaultColumns(): array // TODO We need to decide which columns should be displayed return [ new IdColumn(), - new NameColumn(), + new TitleColumn(), new GoalColumn(), new DonationsCountColumn(), new RevenueColumn(), @@ -55,7 +55,7 @@ protected function getDefaultVisibleColumns(): array { return [ IdColumn::getId(), - NameColumn::getId(), + TitleColumn::getId(), GoalColumn::getId(), DonationsCountColumn::getId(), RevenueColumn::getId(), diff --git a/src/Campaigns/ListTable/Columns/NameColumn.php b/src/Campaigns/ListTable/Columns/TitleColumn.php similarity index 88% rename from src/Campaigns/ListTable/Columns/NameColumn.php rename to src/Campaigns/ListTable/Columns/TitleColumn.php index 3647d2837f..09b513841e 100644 --- a/src/Campaigns/ListTable/Columns/NameColumn.php +++ b/src/Campaigns/ListTable/Columns/TitleColumn.php @@ -8,16 +8,16 @@ /** * @unreleased */ -class NameColumn extends ModelColumn +class TitleColumn extends ModelColumn { - protected $sortColumn = 'name'; + protected $sortColumn = 'title'; /** * @unreleased */ public static function getId(): string { - return 'name'; + return 'title'; } /** diff --git a/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx b/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx index 2d70f0fc13..5a2ddcec57 100644 --- a/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx +++ b/src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx @@ -8,6 +8,7 @@ import {CampaignsRowActions} from './CampaignsRowActions'; import styles from './CampaignsListTable.module.scss'; import {GiveCampaignsListTable} from './types'; import CreateCampaignModal from '../CreateCampaignModal'; +import MergeCampaignModal from '../MergeCampaign/Modal'; declare const window: { GiveCampaignsListTable: GiveCampaignsListTable; @@ -90,6 +91,25 @@ const bulkActions: Array = [ ), }, + { + label: __('Merge', 'give'), + value: 'merge', + type: 'urlAction', + action: async (selected) => { + return await new Promise((resolve) => setTimeout(resolve, 0)); + }, + confirm: (selected, names) => { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set('action', 'merge'); + window.history.replaceState( + {selected: selected, names: names}, + __('Merge Campaings', 'give'), + `${window.location.pathname}?${urlParams.toString()}` + ); + + return null; + }, + }, ]; /** @@ -134,6 +154,7 @@ export default function CampaignsListTable() { listTableBlankSlate={ListTableBlankSlate()} > + ); diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss b/src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss new file mode 100644 index 0000000000..be03695b62 --- /dev/null +++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/Form.module.scss @@ -0,0 +1,96 @@ +.campaignForm { + + .intro { + font-size: 1rem; + color: #4b5563; + align-self: stretch; + padding: 0 1.25rem 0 0.625rem; + margin: unset; + } + + .submitButton { + border-radius: 0; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.43; + padding: var(--givewp-spacing-2) var(--givewp-spacing-4); + text-align: center; + } + + label { + font-size: 1rem; + } + + input, + select, + textarea { + font-size: 1rem; + line-height: 2; + display: block; + width: 100%; + border: 1px solid #9ca0af; + border-radius: var(--givewp-spacing-1); + padding: var(--givewp-spacing-2); + } + + select { + max-width: 100%; + } + + .description { + font-size: 0.875rem; + margin-bottom: 0.4rem; + } + + .notice { + display: flex; + margin: 1rem 0 -0.2rem 0; + gap: 0.3rem; + padding: 0 0.5rem 0 0.5rem; + background-color: var(--givewp-blue-25); + border-radius: 4px; + border: 1px solid var(--givewp-blue-400); + border-left-width: 4px; + font-size: 0.875rem; + font-weight: 500; + color: #1a0f00; + + svg { + margin: 0.7rem 0.3rem; + height: 1.25rem; + width: 1.25rem; + } + } + + .returnMessage { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + gap: 1.5rem; + + span { + margin: -1rem 3rem 1rem 3rem; + } + } +} + +.fieldRequired { + color: var(--givewp-red-500); +} + +.button:is(:global(.button)) { + border-radius: var(--givewp-rounded-8) +} + +.previousButton:is(:global(.button)) { + background-color: transparent; + border: solid 1px #9ca0af; + color: #060c1a; + + &:hover { + border: solid 1px #9ca0af; + color: #060c1a; + } +} diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx b/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx new file mode 100644 index 0000000000..0828eceb65 --- /dev/null +++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx @@ -0,0 +1,300 @@ +import {FormProvider, SubmitHandler, useForm} from 'react-hook-form'; +import {__} from '@wordpress/i18n'; +import styles from './Form.module.scss'; +import FormModal from '../../FormModal'; +import {MergeCampaignFormInputs, MergeCampaignFormProps} from './types'; +import {useState} from 'react'; +import apiFetch from '@wordpress/api-fetch'; +import {addQueryArgs} from '@wordpress/url'; +import {getGiveCampaignsListTableWindowData} from '../../CampaignsListTable'; + +/** + * Campaign Form Modal component + * + * @unreleased + */ +export default function MergeCampaignsForm({isOpen, handleClose, title, campaigns}: MergeCampaignFormProps) { + if (!campaigns) { + return null; + } + + const [step, setStep] = useState(1); + + const methods = useForm({ + defaultValues: { + destinationCampaignId: '', + }, + }); + + const { + register, + handleSubmit, + formState: {isDirty, isSubmitting}, + watch, + } = methods; + + const destinationCampaignId = watch('destinationCampaignId'); + + const requiredAsterisk = *; + + const onSubmit: SubmitHandler = async (inputs, event) => { + event.preventDefault(); + + if (step !== 2) { + return; + } + + const campaignsToMergeIds = campaigns.selected.filter((id) => id != inputs.destinationCampaignId); + + try { + const response = await apiFetch({ + path: addQueryArgs('/give-api/v2/campaigns/' + destinationCampaignId + '/merge', { + campaignsToMergeIds: campaignsToMergeIds, + }), + method: 'PATCH', + }); + + console.log('Merge campaigns response: ', response); + + // Go to success page + setStep(3); + + //Reset bulk actions selector + const selects = document.querySelectorAll('#give-admin-campaigns-root select'); + selects.forEach((select) => { + const selectElement = select as HTMLSelectElement; + selectElement.selectedIndex = 0; + }); + + // Uncheck all checkboxes + const checkboxes = document.querySelectorAll(".giveListTable input[type='checkbox']"); + checkboxes.forEach((checkbox) => { + const input = checkbox as HTMLInputElement; + input.checked = false; + }); + // @ts-ignore + document.querySelector('.giveListTable #giveListTableSelectAll').checked = false; + + //Remove campaignsToMergeIds from the list table. + const adminFormsListViewItems = document.querySelectorAll('tr'); + if (adminFormsListViewItems.length > 0) { + adminFormsListViewItems.forEach((itemElement) => { + const select = itemElement.querySelector('.giveListTableSelect'); + + if (!select) { + return; + } + + const campaignId = select.getAttribute('data-id'); + if (campaignsToMergeIds.includes(campaignId)) { + itemElement.remove(); + } + }); + } + //handleClose(response); + } catch (error) { + // Go to error page + setStep(4); + console.error('Error merging campaigns: ', error); + } + }; + + const extractTextFromLink = (link) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(link, 'text/html'); + return doc.querySelector('a')?.textContent || link; + }; + + return ( + + + {step === 1 && ( + <> +
+

+ {__( + 'All selected campaigns will be merged into the destination campaign. This means that forms, donors, donations, and all related data will be added to the destination campaign, and the merged campaigns will cease to exist.', + 'give' + )} +

+
+ + + )} + {step === 2 && ( + <> +
+ + + {__('All selected campaigns will be merged into this campaign.', 'give')} + + +
+ + {isDirty && ( +
+ + + + +

{__('Once completed, this action is irreversible.', 'give')}

+
+ )} + + )} + {step === 3 && ( + <> +
+
+ + + + + + + {__( + 'All donations, donors, and forms from selected campaigns now belong to your destination campaign.', + 'give' + )} + +
+
+
+ + + +
+ + )} + {step === 4 && ( + <> +
+
+ + + + + + {__( + 'An error occurred during the merging process. Please try again, or contact our support team if the issue persists.', + 'give' + )} + +
+
+
+ + + +
+ + )} +
+
+ ); +} diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts b/src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts new file mode 100644 index 0000000000..be57539ae9 --- /dev/null +++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/types.ts @@ -0,0 +1,14 @@ +export interface MergeCampaignFormProps { + isOpen: boolean; + handleClose: (response?: any) => void; + title: string; + campaigns: { + selected: string[]; + names: string[]; + }; +} + +export type MergeCampaignFormInputs = { + title: string; + destinationCampaignId: string; +}; diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Modal/Modal.module.scss b/src/Campaigns/resources/admin/components/MergeCampaign/Modal/Modal.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx b/src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx new file mode 100644 index 0000000000..fb8e4ce475 --- /dev/null +++ b/src/Campaigns/resources/admin/components/MergeCampaign/Modal/index.tsx @@ -0,0 +1,90 @@ +import {useEffect, useState} from 'react'; +import {__} from '@wordpress/i18n'; +import MergeCampaignsForm from './../Form'; + +/** + * Remove the "action" query parameter from the current URL + * + * @unreleased + */ +const removeActionParam = () => { + const queryParams = new URLSearchParams(window.location.search); + const actionParam = queryParams.get('action'); + + if (actionParam) { + queryParams.delete('action'); + window.history.replaceState(null, '', `${window.location.pathname}?${queryParams.toString()}`); + } +}; + +/** + * Auto open modal if the URL has the query parameter id as new + * + * @unreleased + */ +const autoOpenModal = () => { + const queryParams = new URLSearchParams(window.location.search); + const actionParam = queryParams.get('action'); + + if (actionParam && !window.history.state) { + removeActionParam(); + return false; + } + + return actionParam === 'merge'; +}; + +/** + * Create Campaign Modal component + * + * @unreleased + */ +export default function MergeCampaignModal() { + const [isOpen, setOpen] = useState(autoOpenModal()); + const closeModal = () => { + removeActionParam(); + setOpen(false); + }; + + useEffect(() => { + // Override pushState and replaceState to trigger a custom event + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + window.history.pushState = function (...args) { + originalPushState.apply(window.history, args); + window.dispatchEvent(new Event('urlChange')); + }; + + window.history.replaceState = function (...args) { + originalReplaceState.apply(window.history, args); + window.dispatchEvent(new Event('urlChange')); + }; + + // Add listeners for "popstate" and the custom "urlChange" event + const handleQueryParamsChange = () => setOpen(autoOpenModal()); + window.addEventListener('popstate', handleQueryParamsChange); + window.addEventListener('urlChange', handleQueryParamsChange); + + // Remove listeners when the component unmounts + return () => { + window.removeEventListener('popstate', handleQueryParamsChange); + window.removeEventListener('urlChange', handleQueryParamsChange); + + // Restore the original pushState and replaceState functions + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }; + }, []); + + return ( + <> + + + ); +} diff --git a/src/Views/Components/ListTable/ListTablePage/index.tsx b/src/Views/Components/ListTable/ListTablePage/index.tsx index 6ca40f4eb1..0f8e250efd 100644 --- a/src/Views/Components/ListTable/ListTablePage/index.tsx +++ b/src/Views/Components/ListTable/ListTablePage/index.tsx @@ -61,7 +61,7 @@ export interface BulkActionsConfig { //optional isVisible?: (data, parameters) => Boolean; - type?: 'normal' | 'warning' | 'danger'; + type?: 'normal' | 'warning' | 'danger' | 'urlAction'; } export const ShowConfirmModalContext = createContext((label, confirm, action, type = null) => {}); @@ -87,7 +87,12 @@ export default function ListTablePage({ const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(30); const [filters, setFilters] = useState(getInitialFilterState(filterSettings)); - const [modalContent, setModalContent] = useState<{confirm; action; label; type?: 'normal' | 'warning' | 'danger'}>({ + const [modalContent, setModalContent] = useState<{ + confirm; + action; + label; + type?: 'normal' | 'warning' | 'danger' | 'urlAction'; + }>({ confirm: (selected) => {}, action: (selected) => {}, label: '', @@ -129,7 +134,12 @@ export default function ListTablePage({ const handleDebouncedFilterChange = useDebounce(handleFilterChange); - const showConfirmActionModal = (label, confirm, action, type: 'normal' | 'warning' | 'danger' | null = null) => { + const showConfirmActionModal = ( + label, + confirm, + action, + type: 'normal' | 'warning' | 'danger' | 'urlAction' | null + ) => { setModalContent({confirm, action, label, type}); dialog.current.show(); }; @@ -157,7 +167,11 @@ export default function ListTablePage({ setSelectedNames(names); if (selected.length) { setModalContent({...bulkActions[actionIndex]}); - dialog.current.show(); + if ('urlAction' === bulkActions[actionIndex].type) { + modalContent?.confirm(selected, names); + } else { + dialog.current.show(); + } } };