From 99d42e5e1d34bb031fc0d96c865718fd4df0386f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 5 Sep 2024 04:26:43 -0300 Subject: [PATCH 1/3] Feature: add list table for campaigns (#7524) --- .../Actions/LoadCampaignsListTableAssets.php | 41 ++++ src/Campaigns/CampaignsAdminPage.php | 6 +- .../ListTable/CampaignsListTable.php | 63 ++++++ .../ListTable/Columns/DescriptionColumn.php | 38 ++++ .../Columns/DonationsCountColumn.php | 38 ++++ .../ListTable/Columns/EndDateColumn.php | 42 ++++ src/Campaigns/ListTable/Columns/IdColumn.php | 40 ++++ .../ListTable/Columns/StartDateColumn.php | 42 ++++ .../ListTable/Columns/StatusColumn.php | 61 ++++++ .../ListTable/Columns/TitleColumn.php | 45 +++++ src/Campaigns/Routes/CreateCampaign.php | 97 ++++++++++ .../Routes/DeleteCampaignListTable.php | 116 +++++++++++ .../Routes/GetCampaignsListTable.php | 182 ++++++++++++++++++ src/Campaigns/ServiceProvider.php | 11 ++ .../resources/admin/campaigns-list-table.tsx | 6 + .../CampaignFormModal.module.scss | 11 ++ .../components/CampaignFormModal/index.tsx | 162 ++++++++++++++++ .../CampaignsListTable.module.scss | 36 ++++ .../CampaignsRowActions.tsx | 53 +++++ .../components/CampaignsListTable/index.tsx | 103 ++++++++++ .../components/CampaignsListTable/types.ts | 7 + .../CreateCampaignModal.module.scss | 10 + .../components/CreateCampaignModal/index.tsx | 59 ++++++ .../components/FormModal/ErrorMessages.tsx | 24 +++ .../FormModal/FormModal.module.scss | 98 ++++++++++ .../admin/components/FormModal/index.tsx | 44 +++++ .../resources/admin/components/api.ts | 38 ++++ .../Components/ListTable/hooks/useDebounce.ts | 2 +- webpack.mix.js | 1 + 29 files changed, 1474 insertions(+), 2 deletions(-) create mode 100644 src/Campaigns/Actions/LoadCampaignsListTableAssets.php create mode 100644 src/Campaigns/ListTable/CampaignsListTable.php create mode 100644 src/Campaigns/ListTable/Columns/DescriptionColumn.php create mode 100644 src/Campaigns/ListTable/Columns/DonationsCountColumn.php create mode 100644 src/Campaigns/ListTable/Columns/EndDateColumn.php create mode 100644 src/Campaigns/ListTable/Columns/IdColumn.php create mode 100644 src/Campaigns/ListTable/Columns/StartDateColumn.php create mode 100644 src/Campaigns/ListTable/Columns/StatusColumn.php create mode 100644 src/Campaigns/ListTable/Columns/TitleColumn.php create mode 100644 src/Campaigns/Routes/CreateCampaign.php create mode 100644 src/Campaigns/Routes/DeleteCampaignListTable.php create mode 100644 src/Campaigns/Routes/GetCampaignsListTable.php create mode 100644 src/Campaigns/resources/admin/campaigns-list-table.tsx create mode 100644 src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss create mode 100644 src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx create mode 100644 src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsListTable.module.scss create mode 100644 src/Campaigns/resources/admin/components/CampaignsListTable/CampaignsRowActions.tsx create mode 100644 src/Campaigns/resources/admin/components/CampaignsListTable/index.tsx create mode 100644 src/Campaigns/resources/admin/components/CampaignsListTable/types.ts create mode 100644 src/Campaigns/resources/admin/components/CreateCampaignModal/CreateCampaignModal.module.scss create mode 100644 src/Campaigns/resources/admin/components/CreateCampaignModal/index.tsx create mode 100644 src/Campaigns/resources/admin/components/FormModal/ErrorMessages.tsx create mode 100644 src/Campaigns/resources/admin/components/FormModal/FormModal.module.scss create mode 100644 src/Campaigns/resources/admin/components/FormModal/index.tsx create mode 100644 src/Campaigns/resources/admin/components/api.ts diff --git a/src/Campaigns/Actions/LoadCampaignsListTableAssets.php b/src/Campaigns/Actions/LoadCampaignsListTableAssets.php new file mode 100644 index 0000000000..14e50a8129 --- /dev/null +++ b/src/Campaigns/Actions/LoadCampaignsListTableAssets.php @@ -0,0 +1,41 @@ + esc_url_raw(rest_url('give-api/v2/campaigns/list-table')), + 'apiNonce' => wp_create_nonce('wp_rest'), + 'table' => give(CampaignsListTable::class)->toArray(), + 'adminUrl' => admin_url(), + 'paymentMode' => give_is_test_mode(), + 'pluginUrl' => GIVE_PLUGIN_URL, + ] + ); + + wp_enqueue_script($handleName); + wp_enqueue_style('givewp-design-system-foundation'); + } +} diff --git a/src/Campaigns/CampaignsAdminPage.php b/src/Campaigns/CampaignsAdminPage.php index 5d4e0cdf52..3b7c954189 100644 --- a/src/Campaigns/CampaignsAdminPage.php +++ b/src/Campaigns/CampaignsAdminPage.php @@ -2,6 +2,8 @@ namespace Give\Campaigns; +use Give\Campaigns\Actions\LoadCampaignsListTableAssets; + /** * @unreleased */ @@ -28,6 +30,8 @@ public function addCampaignsSubmenuPage() */ public function renderCampaignsPage() { - echo '

The campaigns list table will be loaded here...

'; + give(LoadCampaignsListTableAssets::class)(); + + echo '
'; } } diff --git a/src/Campaigns/ListTable/CampaignsListTable.php b/src/Campaigns/ListTable/CampaignsListTable.php new file mode 100644 index 0000000000..b3b713d55b --- /dev/null +++ b/src/Campaigns/ListTable/CampaignsListTable.php @@ -0,0 +1,63 @@ +shortDescription); + } +} diff --git a/src/Campaigns/ListTable/Columns/DonationsCountColumn.php b/src/Campaigns/ListTable/Columns/DonationsCountColumn.php new file mode 100644 index 0000000000..9626752c0f --- /dev/null +++ b/src/Campaigns/ListTable/Columns/DonationsCountColumn.php @@ -0,0 +1,38 @@ +query()->count(); //Temp count + } +} diff --git a/src/Campaigns/ListTable/Columns/EndDateColumn.php b/src/Campaigns/ListTable/Columns/EndDateColumn.php new file mode 100644 index 0000000000..eeaba73645 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/EndDateColumn.php @@ -0,0 +1,42 @@ +endDate->format($format); + } +} diff --git a/src/Campaigns/ListTable/Columns/IdColumn.php b/src/Campaigns/ListTable/Columns/IdColumn.php new file mode 100644 index 0000000000..1a5a616cc5 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/IdColumn.php @@ -0,0 +1,40 @@ +id; + } +} diff --git a/src/Campaigns/ListTable/Columns/StartDateColumn.php b/src/Campaigns/ListTable/Columns/StartDateColumn.php new file mode 100644 index 0000000000..3a420f5191 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/StartDateColumn.php @@ -0,0 +1,42 @@ +startDate->format($format); + } +} diff --git a/src/Campaigns/ListTable/Columns/StatusColumn.php b/src/Campaigns/ListTable/Columns/StatusColumn.php new file mode 100644 index 0000000000..7c19f1f2d4 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/StatusColumn.php @@ -0,0 +1,61 @@ +status->getValue()) { + case 'active': + $status = __('Active', 'give'); + break; + case 'inactive': + $status = __('Inactive', 'give'); + break; + case 'draft': + $status = __('Draft', 'give'); + break; + case 'pending': + $status = __('Pending', 'give'); + break; + case 'processing': + $status = __('Processing', 'give'); + break; + case 'failed': + $status = __('Failed', 'give'); + break; + default: + $status = __('Draft', 'give'); + } + + return $status; + } +} diff --git a/src/Campaigns/ListTable/Columns/TitleColumn.php b/src/Campaigns/ListTable/Columns/TitleColumn.php new file mode 100644 index 0000000000..4c14886542 --- /dev/null +++ b/src/Campaigns/ListTable/Columns/TitleColumn.php @@ -0,0 +1,45 @@ +%s', + admin_url("edit.php?post_type=give_forms&page=give-campaigns&id=$model->id"), + __('Visit campaign page', 'give'), + $model->title + ); + } +} diff --git a/src/Campaigns/Routes/CreateCampaign.php b/src/Campaigns/Routes/CreateCampaign.php new file mode 100644 index 0000000000..6ea2f1c9d9 --- /dev/null +++ b/src/Campaigns/Routes/CreateCampaign.php @@ -0,0 +1,97 @@ +endpoint, + [ + [ + 'methods' => 'POST', + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'title' => [ + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'description' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'startDateTime' => [ + 'type' => 'string', + 'format' => 'date-time', // @link https://datatracker.ietf.org/doc/html/rfc3339#section-5.8 + 'required' => true, + 'validate_callback' => 'rest_parse_date', + 'sanitize_callback' => function ($value) { + return new DateTime($value); + }, + ], + 'endDateTime' => [ + 'type' => 'string', + 'format' => 'date-time', // @link https://datatracker.ietf.org/doc/html/rfc3339#section-5.8 + 'required' => false, + 'validate_callback' => 'rest_parse_date', + 'sanitize_callback' => function ($value) { + return new DateTime($value); + }, + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest(WP_REST_Request $request): WP_REST_Response + { + $campaign = Campaign::create([ + 'pageId' => 0, + 'type' => CampaignType::CORE(), + 'title' => $request->get_param('title'), + 'shortDescription' => $request->get_param('shortDescription'), + 'longDescription' => '', + 'logo' => '', + 'image' => '', + 'primaryColor' => '', + 'secondaryColor' => '', + 'goal' => 0, + 'status' => CampaignStatus::DRAFT(), + 'startDate' => $request->get_param('startDateTime'), + 'endDate' => $request->get_param('endDateTime'), + ]); + + return new WP_REST_Response($campaign->toArray(), 201); + } +} diff --git a/src/Campaigns/Routes/DeleteCampaignListTable.php b/src/Campaigns/Routes/DeleteCampaignListTable.php new file mode 100644 index 0000000000..616f095d80 --- /dev/null +++ b/src/Campaigns/Routes/DeleteCampaignListTable.php @@ -0,0 +1,116 @@ +endpoint, + [ + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => [$this, 'permissionsCheck'], + ], + 'args' => [ + 'ids' => [ + 'type' => 'string', + 'required' => true, + 'validate_callback' => function ($ids) { + foreach ($this->splitString($ids) as $id) { + if ( ! filter_var($id, FILTER_VALIDATE_INT)) { + return false; + } + } + + return true; + }, + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest(WP_REST_Request $request): WP_Rest_Response + { + $ids = $this->splitString($request->get_param('ids')); + $errors = []; + $successes = []; + + foreach ($ids as $id) { + $campaignDeleted = give(CampaignRepository::class)->getById($id)->delete(); + $campaignDeleted ? $successes[] = $id : $errors[] = $id; + } + + return new WP_REST_Response(['errors' => $errors, 'successes' => $successes]); + } + + + /** + * Split string + * + * @unreleased + * + * @return string[] + */ + protected function splitString(string $ids): array + { + if (strpos($ids, ',')) { + return array_map('trim', explode(',', $ids)); + } + + return [trim($ids)]; + } + + /** + * @unreleased + * + * @return bool|WP_Error + */ + public function permissionsCheck() + { + return current_user_can('delete_posts') ?: new WP_Error( + 'rest_forbidden', + esc_html__("You don't have permission to delete Campaigns", 'give'), + ['status' => is_user_logged_in() ? 403 : 401] + ); + } +} diff --git a/src/Campaigns/Routes/GetCampaignsListTable.php b/src/Campaigns/Routes/GetCampaignsListTable.php new file mode 100644 index 0000000000..82d6dc2d08 --- /dev/null +++ b/src/Campaigns/Routes/GetCampaignsListTable.php @@ -0,0 +1,182 @@ +endpoint, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => [$this, 'permissionsCheck'], + ], + 'args' => [ + 'page' => [ + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ], + 'perPage' => [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 1, + ], + 'search' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'sortColumn' => [ + 'type' => 'string', + 'default' => 'id', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'sortDirection' => [ + 'type' => 'string', + 'default' => 'asc', + 'enum' => ['asc', 'desc'], + ], + 'locale' => [ + 'type' => 'string', + 'required' => false, + 'default' => get_locale(), + ], + ], + ] + ); + } + + /** + * @unreleased + */ + public function handleRequest(WP_REST_Request $request): WP_REST_Response + { + $this->request = $request; + $this->listTable = give(CampaignsListTable::class); + + $campaigns = $this->getCampaigns(); + $campaignsCount = $this->getTotalCampaignsCount(); + $pageCount = (int)ceil($campaignsCount / $request->get_param('perPage')); + + $this->listTable->items($campaigns, $this->request->get_param('locale') ?? ''); + $items = $this->listTable->getItems(); + + + return new WP_REST_Response( + [ + 'items' => $items, + 'totalItems' => $campaignsCount, + 'totalPages' => $pageCount, + ] + ); + } + + /** + * @unreleased + */ + public function getCampaigns(): array + { + $page = $this->request->get_param('page'); + $perPage = $this->request->get_param('perPage'); + $sortColumns = $this->listTable->getSortColumnById($this->request->get_param('sortColumn') ?: 'id'); + $sortDirection = $this->request->get_param('sortDirection') ?: 'desc'; + + $query = give(CampaignRepository::class)->prepareQuery(); + $query = $this->getWhereConditions($query); + + foreach ($sortColumns as $sortColumn) { + $query->orderBy($sortColumn, $sortDirection); + } + + $query->limit($perPage) + ->offset(($page - 1) * $perPage); + + $campaigns = $query->getAll(); + + if ( ! $campaigns) { + return []; + } + + return $campaigns; + } + + /** + * @unreleased + */ + public function getTotalCampaignsCount(): int + { + $query = DB::table('give_campaigns'); + $query = $this->getWhereConditions($query); + + return $query->count(); + } + + /** + * @unreleased + */ + private function getWhereConditions(QueryBuilder $query): QueryBuilder + { + $search = $this->request->get_param('search'); + + if ($search) { + if (ctype_digit($search)) { + $query->where('id', $search); + } else { + $query->whereLike('campaign_title', $search); + $query->orWhereLike('short_desc', $search); + } + } + + return $query; + } + + /** + * @unreleased + * + * @return bool|WP_Error + */ + public function permissionsCheck() + { + return current_user_can('edit_posts') ?: new WP_Error( + 'rest_forbidden', + esc_html__("You don't have permission to view Campaigns", 'give'), + ['status' => is_user_logged_in() ? 403 : 401] + ); + } +} diff --git a/src/Campaigns/ServiceProvider.php b/src/Campaigns/ServiceProvider.php index 9ccefd7611..f66874cc74 100644 --- a/src/Campaigns/ServiceProvider.php +++ b/src/Campaigns/ServiceProvider.php @@ -35,6 +35,17 @@ public function boot(): void $this->registerActions(); $this->setupCampaignPages(); $this->registerMigrations(); + $this->registerRoutes(); + } + + /** + * @unreleased + */ + private function registerRoutes() + { + Hooks::addAction('rest_api_init', Routes\CreateCampaign::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\GetCampaignsListTable::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\DeleteCampaignListTable::class, 'registerRoute'); } /** diff --git a/src/Campaigns/resources/admin/campaigns-list-table.tsx b/src/Campaigns/resources/admin/campaigns-list-table.tsx new file mode 100644 index 0000000000..d99f8b1c98 --- /dev/null +++ b/src/Campaigns/resources/admin/campaigns-list-table.tsx @@ -0,0 +1,6 @@ +import {createRoot} from 'react-dom/client'; +import CampaignsListTable from './components/CampaignsListTable'; + +const container = document.getElementById('give-admin-campaigns-root'); +const root = createRoot(container!); +root.render(); diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss b/src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss new file mode 100644 index 0000000000..ed8a49dcc7 --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignFormModal/CampaignFormModal.module.scss @@ -0,0 +1,11 @@ +.campaignForm { + .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; + } +} + diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx b/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx new file mode 100644 index 0000000000..bcff7452ab --- /dev/null +++ b/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx @@ -0,0 +1,162 @@ +import {SubmitHandler, useForm} from 'react-hook-form'; +import {__} from '@wordpress/i18n'; +import styles from './CampaignFormModal.module.scss'; +import FormModal from '../FormModal'; +import CampaignsApi from '../api'; + +type Campaign = { + id?: number; + title: string; + shortDescription: string; + startDateTime: { + date: string; + timezone_type: number; + timezone: string; + }; + endDateTime: { + date: string; + timezone_type: number; + timezone: string; + }; + createdAt: string; + updatedAt: string; +}; + +type Inputs = { + title: string; + shortDescription: string; + startDateTime: string; + endDateTime: string; +}; + +interface CampaignModalProps { + isOpen: boolean; + handleClose: (response?: any) => void; + apiSettings: { + apiRoot: string; + apiNonce: string; + }; + title: string; + campaign?: Campaign; +} + +/** + * Get the next sharp hour + * + * @unreleased + */ +const getNextSharpHour = (hoursToAdd: number) => { + const date = new Date(); + date.setHours(date.getHours() + hoursToAdd, 0, 0, 0); + + return date; +}; + +/** + * Format a given date to be used in datetime inputs + * + * @unreleased + */ +const getDateString = (date: Date) => { + const offsetInMilliseconds = date.getTimezoneOffset() * 60 * 1000; + const dateWithOffset = new Date(date.getTime() - offsetInMilliseconds); + + return removeTimezoneFromDateISOString(dateWithOffset.toISOString()); +}; + +/** + * Remove timezone from date string + * + * @unreleased + */ +const removeTimezoneFromDateISOString = (date: string) => { + return date.slice(0, -5); +}; + +/** + * Campaign Form Modal component + * + * @unreleased + */ +export default function CampaignFormModal({isOpen, handleClose, apiSettings, title, campaign}: CampaignModalProps) { + const API = new CampaignsApi(apiSettings); + + const { + register, + handleSubmit, + formState: {errors, isDirty}, + } = useForm({ + defaultValues: { + title: campaign?.title ?? '', + shortDescription: campaign?.shortDescription ?? '', + startDateTime: getDateString( + campaign?.startDateTime?.date ? new Date(campaign?.startDateTime?.date) : getNextSharpHour(1) + ), + endDateTime: getDateString( + campaign?.endDateTime?.date ? new Date(campaign?.endDateTime?.date) : getNextSharpHour(2) + ), + }, + }); + + const onSubmit: SubmitHandler = async (inputs) => { + try { + inputs.startDateTime = getDateString(new Date(inputs.startDateTime)); + inputs.endDateTime = getDateString(new Date(inputs.endDateTime)); + + const endpoint = campaign?.id ? `/campaign/${campaign.id}` : ''; + const response = await API.fetchWithArgs(endpoint, inputs, 'POST'); + + handleClose(response); + } catch (error) { + console.error('Error submitting campaign campaign', error); + } + }; + + return ( + +
+ + +
+
+ +