import {useState} from 'react'; import {__, sprintf} from '@wordpress/i18n'; import {createInterpolateElement} from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import ModalDialog from '@givewp/components/AdminUI/ModalDialog'; import RowAction from '@givewp/components/ListTable/RowAction'; import {CheckCircle} from '@givewp/components/AdminUI/Icons'; import {AsyncPaginate} from 'react-select-async-paginate'; import { AsyncSelectEntity, asyncSelectEntities, useCampaignAsyncSelect, } from '@givewp/components/ListTable/CustomFilter/useAsyncCampaigns'; import {CampaignOption} from '@givewp/components/ListTable/CustomFilter/utils'; import {Interweave} from 'interweave'; import selectStyles from '@givewp/components/ListTable/CustomFilter/styles.module.scss'; import styles from './AssignCampaignModal.module.scss'; type AssignCampaignProps = { formId: number; formTitle: string; /** The campaign the form belongs to now, or 0 for a standalone form. */ campaignId: number; campaignTitle: string; /** The campaign's default form cannot be moved or removed. */ isDefaultCampaignForm: boolean; /** Called when the modal closes after a change, so the list can refresh. */ onChanged: () => Promise; }; type AssignCampaignModalProps = AssignCampaignProps & { isOpen: boolean; handleClose: () => void; }; type Step = 'pick' | 'confirmRemove' | 'moved' | 'removed'; /* A form cannot be assigned to an archived campaign, so the picker leaves those out. */ const assignableCampaigns: AsyncSelectEntity = { ...asyncSelectEntities.campaign, listQuery: {status: 'active,draft'}, }; /** * Row action that owns the modal state. It is a real component so the parent row-actions * function stays hook-free and the row count can change without breaking React's hook order. * A standalone form gets "Assign campaign"; a form that already has one gets "Change campaign". * * @since 4.19.0 */ export default function AssignCampaignRowAction(props: AssignCampaignProps) { const [isOpen, setOpen] = useState(false); return ( <> setOpen(true)} actionId={props.formId} displayText={props.campaignId ? __('Change campaign', 'give') : __('Assign campaign', 'give')} hiddenText={props.formTitle} /> {isOpen && setOpen(false)} />} ); } /** * One modal for everything about a form's campaign: assign a standalone form, move a linked form * to another campaign, or remove it from its campaign. The picker only offers campaigns that are * not archived and never the current one. The server refuses to move or remove a campaign's * default form and explains why. * * @since 4.19.0 */ function AssignCampaignModal({ isOpen, handleClose, formId, formTitle, campaignId: currentCampaignId, campaignTitle: currentCampaignTitle, isDefaultCampaignForm, onChanged, }: AssignCampaignModalProps) { const hasCampaign = currentCampaignId > 0; const [step, setStep] = useState('pick'); const [selected, setSelected] = useState(null); const [isSaving, setSaving] = useState(false); const [error, setError] = useState(''); const {loadOptions, mapOptionsForMenu} = useCampaignAsyncSelect(null, assignableCampaigns); const campaignId = selected?.value ?? 0; const title = hasCampaign ? __('Change campaign', 'give') : __('Assign campaign', 'give'); const campaignUrl = `edit.php?post_type=give_forms&page=give-campaigns&id=${campaignId}&tab=overview&action=edit`; /* * The list refresh re-renders the rows and unmounts this modal, so it waits until the * modal closes. Otherwise the success view would vanish the moment it appeared. */ const close = async () => { handleClose(); if (step === 'moved' || step === 'removed') { await onChanged(); } }; const loadOtherCampaigns = async (search: string) => { const result = await loadOptions(search); return {...result, options: result.options.filter((option) => option.value !== currentCampaignId)}; }; const request = async (path: string, data: object, nextStep: Step) => { setSaving(true); setError(''); try { await apiFetch({path, method: 'POST', data}); setStep(nextStep); } catch (e) { setError(e?.message ?? __('Something went wrong. Please try again.', 'give')); } finally { setSaving(false); } }; const assign = () => request('/givewp/v3/associate-forms-with-campaign', {campaignId, formIDs: [formId]}, 'moved'); const remove = () => request('/givewp/v3/detach-forms-from-campaign', {formIDs: [formId]}, 'removed'); const strong = (content: string) => ( ); return ( {(step === 'moved' || step === 'removed') && (

{step === 'moved' ? hasCampaign ? createInterpolateElement( sprintf( /* translators: 1: form title, 2: new campaign title, 3: previous campaign title */ __('

%1$s
now belongs to %2$s. Its past donations stay with %3$s.', 'give'), formTitle, selected?.label ?? '', currentCampaignTitle ), {form: strong(formTitle), campaign: strong(selected?.label ?? ''), previous: strong(currentCampaignTitle)} ) : createInterpolateElement( /* translators: 1: form title, 2: campaign title */ sprintf(__('
%1$s
now belongs to %2$s.', 'give'), formTitle, selected?.label ?? ''), {form: strong(formTitle), campaign: strong(selected?.label ?? '')} ) : createInterpolateElement( /* translators: 1: form title, 2: campaign title */ sprintf(__('
%1$s
is no longer part of %2$s.', 'give'), formTitle, currentCampaignTitle), {form: strong(formTitle), campaign: strong(currentCampaignTitle)} )}

{step === 'moved' && ( {__('Go to campaign', 'give')} )}
)} {step === 'confirmRemove' && ( <>

{createInterpolateElement( /* translators: 1: form title, 2: campaign title */ sprintf( __('Remove

%1$s
from %2$s? The form keeps working on its own, and its past donations stay with the campaign.', 'give'), formTitle, currentCampaignTitle ), {form: strong(formTitle), campaign: strong(currentCampaignTitle)} )}

{error &&

{error}

}
)} {step === 'pick' && ( <>
{__('Form', 'give')}
{__('Current campaign', 'give')}
{hasCampaign ? : __('No campaign', 'give')}
setSelected(option)} value={selected} isSearchable isClearable debounceTimeout={600} className={`${selectStyles.searchableSelect} ${styles.select}`} classNamePrefix="searchableSelect" /> {error &&

{error}

}
{hasCampaign && !isDefaultCampaignForm && ( )}
)}
); }