diff --git a/composer.json b/composer.json index 7c96db9c58e..e08bcc769e1 100644 --- a/composer.json +++ b/composer.json @@ -112,6 +112,11 @@ "/Tests/" ] }, + "exclude_from_prefix": { + "namespaces": [ + "session" + ] + }, "delete_vendor_packages": true, "override_autoload": { "symfony/polyfill-ctype": {}, diff --git a/src/Donations/Endpoints/ListDonations.php b/src/Donations/Endpoints/ListDonations.php index 7645340d12f..5d779421111 100644 --- a/src/Donations/Endpoints/ListDonations.php +++ b/src/Donations/Endpoints/ListDonations.php @@ -23,14 +23,16 @@ class ListDonations extends Endpoint protected $endpoint = 'admin/donations'; /** + * @unreleased becomes public to be usable in hooks * @var WP_REST_Request */ - protected $request; + public $request; /** + * @unreleased becomes public to be usable in hooks * @var DonationsListTable */ - protected $listTable; + public $listTable; /** * @since 3.4.0 @@ -46,6 +48,77 @@ public function __construct(DonationsListTable $listTable) */ public function registerRoute() { + $args = [ + 'page' => [ + 'type' => 'integer', + 'required' => false, + 'default' => 1, + 'minimum' => 1 + ], + 'perPage' => [ + 'type' => 'integer', + 'required' => false, + 'default' => 30, + 'minimum' => 1 + ], + 'form' => [ + 'type' => 'integer', + 'required' => false, + 'default' => 0 + ], + 'search' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'start' => [ + 'type' => 'string', + 'required' => false, + 'validate_callback' => [$this, 'validateDate'] + ], + 'end' => [ + 'type' => 'string', + 'required' => false, + 'validate_callback' => [$this, 'validateDate'] + ], + 'donor' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'sortColumn' => [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + 'sortDirection' => [ + 'type' => 'string', + 'required' => false, + 'enum' => [ + 'asc', + 'desc', + ], + ], + 'locale' => [ + 'type' => 'string', + 'required' => false, + 'default' => get_locale(), + ], + 'testMode' => [ + 'type' => 'boolean', + 'required' => false, + 'default' => give_is_test_mode(), + ], + 'return' => [ + 'type' => 'string', + 'required' => false, + 'default' => 'columns', + 'enum' => [ + 'model', + 'columns', + ], + ], + ]; register_rest_route( 'give-api/v2', $this->endpoint, @@ -55,77 +128,22 @@ public function registerRoute() 'callback' => [$this, 'handleRequest'], 'permission_callback' => [$this, 'permissionsCheck'], ], - 'args' => [ - 'page' => [ - 'type' => 'integer', - 'required' => false, - 'default' => 1, - 'minimum' => 1 - ], - 'perPage' => [ - 'type' => 'integer', - 'required' => false, - 'default' => 30, - 'minimum' => 1 - ], - 'form' => [ - 'type' => 'integer', - 'required' => false, - 'default' => 0 - ], - 'search' => [ - 'type' => 'string', - 'required' => false, - 'sanitize_callback' => 'sanitize_text_field', - ], - 'start' => [ - 'type' => 'string', - 'required' => false, - 'validate_callback' => [$this, 'validateDate'] - ], - 'end' => [ - 'type' => 'string', - 'required' => false, - 'validate_callback' => [$this, 'validateDate'] - ], - 'donor' => [ - 'type' => 'string', - 'required' => false, - 'sanitize_callback' => 'sanitize_text_field', - ], - 'sortColumn' => [ - 'type' => 'string', - 'required' => false, - 'sanitize_callback' => 'sanitize_text_field', - ], - 'sortDirection' => [ - 'type' => 'string', - 'required' => false, - 'enum' => [ - 'asc', - 'desc', - ], - ], - 'locale' => [ - 'type' => 'string', - 'required' => false, - 'default' => get_locale(), - ], - 'testMode' => [ - 'type' => 'boolean', - 'required' => false, - 'default' => give_is_test_mode(), - ], - 'return' => [ - 'type' => 'string', - 'required' => false, - 'default' => 'columns', - 'enum' => [ - 'model', - 'columns', - ], - ], - ], + /** + * Allow adding API endpoint args + * + * @unreleased + * @param array $args Array of api args { + * Arg details + * + * @type string $type Type of value + * @type boolean $required Is this arg required for each request + * @type mixed $default Optional - Default value for this arg + * @type callable $validate_callback Optional + * @type callable $sanitize_callback Optional + * @type mixed[] $enum Optional - Array of allowed values + * }[] + */ + 'args' => apply_filters('give_list-donation_api_args', $args), ] ); } @@ -171,20 +189,22 @@ public function handleRequest(WP_REST_Request $request): WP_REST_Response */ public function getDonations(): array { + $query = give()->donations->prepareQuery(); + + // Pagination $page = $this->request->get_param('page'); $perPage = $this->request->get_param('perPage'); + $query->limit($perPage)->offset(($page - 1) * $perPage); + + // Sort $sortColumns = $this->listTable->getSortColumnById($this->request->get_param('sortColumn') ?: 'id'); $sortDirection = $this->request->get_param('sortDirection') ?: 'desc'; - - $query = give()->donations->prepareQuery(); - list($query) = $this->getWhereConditions($query); - foreach ($sortColumns as $sortColumn) { $query->orderBy($sortColumn, $sortDirection); } - $query->limit($perPage) - ->offset(($page - 1) * $perPage); + // Where + list($query) = $this->getWhereConditions($query); $donations = $query->getAll(); @@ -203,12 +223,11 @@ public function getDonations(): array */ public function getTotalDonationsCount(): int { - $query = DB::table('posts') - ->where('post_type', 'give_payment') - ->groupBy('mode'); + $query = DB::table('posts')->where('post_type', 'give_payment'); list($query, $dependencies) = $this->getWhereConditions($query); + $dependencies = array_unique($dependencies); $query->attachMeta( 'give_donationmeta', 'ID', @@ -231,35 +250,49 @@ public function getTotalDonationsCount(): int */ protected function getWhereConditions(QueryBuilder $query): array { - $search = $this->request->get_param('search'); - $start = $this->request->get_param('start'); - $end = $this->request->get_param('end'); - $form = $this->request->get_param('form'); - $donor = $this->request->get_param('donor'); - $testMode = $this->request->get_param('testMode'); + $dependencies = []; + list($query, $dependencies) = $this->getSearchWhereCondition($query, $dependencies); + list($query, $dependencies) = $this->getDonorWhereCondition($query, $dependencies); + list($query, $dependencies) = $this->getFormWhereCondition($query, $dependencies); + list($query, $dependencies) = $this->getDateWhereCondition($query, $dependencies); + list($query, $dependencies) = $this->getModeWhereCondition($query, $dependencies); - $dependencies = [ - DonationMetaKeys::MODE(), - ]; - - $hasWhereConditions = $search || $start || $end || $form || $donor; + /** + * Allow adding request clauses + * + * @unreleased + * @param array $value { + * @type ModelQueryBuilder $query Donation query builder + * @type DonationMetaKeys[] $dependencies List of meta dependencies for added where clauses + * } + * @param ListDonations $endpoint API Endpoint instance + */ + return apply_filters('give_list-donation_where_conditions', [$query, $dependencies], $this); + } - if ($search) { - if (ctype_digit($search)) { - $query->where('id', $search); - } elseif (strpos($search, '@') !== false) { - $query - ->whereLike('give_donationmeta_attach_meta_email.meta_value', $search); - $dependencies[] = DonationMetaKeys::EMAIL(); - } else { - $query - ->whereLike('give_donationmeta_attach_meta_firstName.meta_value', $search) - ->orWhereLike('give_donationmeta_attach_meta_lastName.meta_value', $search); - $dependencies[] = DonationMetaKeys::FIRST_NAME(); - $dependencies[] = DonationMetaKeys::LAST_NAME(); - } + private function getSearchWhereCondition (QueryBuilder $query, array $dependencies) + { + $search = $this->request->get_param('search'); + if (!$search) return [$query, $dependencies]; + if (ctype_digit($search)) { + $query->where('id', $search); + } elseif (strpos($search, '@') !== false) { + $query + ->whereLike('give_donationmeta_attach_meta_email.meta_value', $search); + $dependencies[] = DonationMetaKeys::EMAIL(); + } else { + $query + ->whereLike('give_donationmeta_attach_meta_firstName.meta_value', $search) + ->orWhereLike('give_donationmeta_attach_meta_lastName.meta_value', $search); + $dependencies[] = DonationMetaKeys::FIRST_NAME(); + $dependencies[] = DonationMetaKeys::LAST_NAME(); } + return [$query, $dependencies]; + } + private function getDonorWhereCondition (QueryBuilder $query, array $dependencies) + { + $donor = $this->request->get_param('donor'); if ($donor) { if (ctype_digit($donor)) { $query @@ -273,13 +306,24 @@ protected function getWhereConditions(QueryBuilder $query): array $dependencies[] = DonationMetaKeys::LAST_NAME(); } } + return [$query, $dependencies]; + } + private function getFormWhereCondition (QueryBuilder $query, array $dependencies) + { + $form = $this->request->get_param('form'); if ($form) { $query ->where('give_donationmeta_attach_meta_formId.meta_value', $form); $dependencies[] = DonationMetaKeys::FORM_ID(); } + return [$query, $dependencies]; + } + private function getDateWhereCondition (QueryBuilder $query, array $dependencies) + { + $start = $this->request->get_param('start'); + $end = $this->request->get_param('end'); if ($start && $end) { $query->whereBetween('post_date', $start, $end); } elseif ($start) { @@ -287,19 +331,22 @@ protected function getWhereConditions(QueryBuilder $query): array } elseif ($end) { $query->where('post_date', $end, '<='); } + return [$query, $dependencies]; + } - if ($hasWhereConditions) { - $query->havingRaw('HAVING COALESCE(give_donationmeta_attach_meta_mode.meta_value, %s) = %s', DonationMode::LIVE, $testMode ? DonationMode::TEST : DonationMode::LIVE); - } elseif ($testMode) { + private function getModeWhereCondition (QueryBuilder $query, array $dependencies) + { + $dependencies[] = DonationMetaKeys::MODE(); + $testMode = $this->request->get_param('testMode'); + if ($testMode) { $query->where('give_donationmeta_attach_meta_mode.meta_value', DonationMode::TEST); } else { - $query->whereIsNull('give_donationmeta_attach_meta_mode.meta_value') - ->orWhere('give_donationmeta_attach_meta_mode.meta_value', DonationMode::TEST, '<>'); + $query->where(function ($whereBuilder) { + $whereBuilder + ->whereIsNull('give_donationmeta_attach_meta_mode.meta_value') + ->orWhere('give_donationmeta_attach_meta_mode.meta_value', DonationMode::TEST, '<>'); + }); } - - return [ - $query, - $dependencies, - ]; + return [$query, $dependencies]; } } diff --git a/src/Views/Components/ListTable/Filters/index.tsx b/src/Views/Components/ListTable/Filters/index.tsx index d1667f88df4..925ffd00184 100644 --- a/src/Views/Components/ListTable/Filters/index.tsx +++ b/src/Views/Components/ListTable/Filters/index.tsx @@ -51,9 +51,12 @@ export const Filter = ({filter, value = null, onChange, debouncedOnChange}) => { }; // figure out what the initial filter state should be based on the filter configuration -export const getInitialFilterState = (filters) => { +export const getInitialFilterState = (filters, apiSettings) => { const state = {}; const urlParams = new URLSearchParams(window.location.search); + + // Allow third party extends filters + filters = wp.hooks.applyFilters(`give-${apiSettings.table.id}-list-table-filters`, filters); filters.map((filter) => { // if the search parameters contained a value for the filter, use that const filterQuery = decodeURI(urlParams.get(filter.name)); diff --git a/src/Views/Components/ListTable/ListTablePage/index.tsx b/src/Views/Components/ListTable/ListTablePage/index.tsx index e48a6a319ac..49cdb66b7b2 100644 --- a/src/Views/Components/ListTable/ListTablePage/index.tsx +++ b/src/Views/Components/ListTable/ListTablePage/index.tsx @@ -84,7 +84,7 @@ export default function ListTablePage({ }: ListTablePageProps) { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(30); - const [filters, setFilters] = useState(getInitialFilterState(filterSettings)); + const [filters, setFilters] = useState(getInitialFilterState(filterSettings, apiSettings)); const [modalContent, setModalContent] = useState<{confirm; action; label; type?: 'normal' | 'warning' | 'danger'}>({ confirm: (selected) => {}, action: (selected) => {}, diff --git a/tests/Unit/Donations/Endpoints/TestListDonations.php b/tests/Unit/Donations/Endpoints/TestListDonations.php index 161743d897b..f40d04bced7 100644 --- a/tests/Unit/Donations/Endpoints/TestListDonations.php +++ b/tests/Unit/Donations/Endpoints/TestListDonations.php @@ -33,10 +33,11 @@ public function testShouldReturnListWithSameSize() $mockRequest->set_param('testMode', give_is_test_mode()); $listDonations = give(ListDonations::class); + $expectedItems = $this->getMockColumns($donations); $response = $listDonations->handleRequest($mockRequest); - $this->assertSameSize($donations, $response->data['items']); + $this->assertSame($expectedItems, $response->data['items']); } /** @@ -58,7 +59,128 @@ public function testShouldReturnListWithSameData() $mockRequest->set_param('sortDirection', $sortDirection); $mockRequest->set_param('testMode', give_is_test_mode()); - $expectedItems = $this->getMockColumns($donations,$sortDirection); + $expectedItems = $this->getMockColumns($donations, $sortDirection); + + $listDonations = give(ListDonations::class); + + $response = $listDonations->handleRequest($mockRequest); + + $this->assertSame($expectedItems, $response->data['items']); + } + + /** + * @unreleased + * + * @return void + * @throws Exception + */ + public function testShouldReturnFilteredListByDonorId() + { + $donations = Donation::factory()->count(5)->create(); + $donorId = $donations[0]->donorId; + + $mockRequest = $this->getMockRequest(); + // set_params + $mockRequest->set_param('page', 1); + $mockRequest->set_param('perPage', 30); + $mockRequest->set_param('locale', 'us-US'); + $mockRequest->set_param('donor', (string)$donorId); + $mockRequest->set_param('testMode', give_is_test_mode()); + + $expectedItems = $this->getMockColumns( + array_filter($donations, function ($donation) use ($donorId) { + return $donation->donorId === $donorId; + }) + ); + + $listDonations = give(ListDonations::class); + + $response = $listDonations->handleRequest($mockRequest); + + $this->assertSame($expectedItems, $response->data['items']); + } + + /** + * @unreleased + * + * @return void + * @throws Exception + */ + public function testShouldAllowAddingFilters() + { + $donations = Donation::factory()->count(5)->create(); + + $expectedItems = array_slice($donations, 0, 2); + foreach ($expectedItems as $item) { + give_update_payment_meta($item->id, 'my_key', 'on'); + } + $expectedItems = $this->getMockColumns($expectedItems); + + add_filter('give_list-donation_api_args', function ($args) { + $args['my_param'] = [ + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ]; + return $args; + }); + + add_filter('give_list-donation_where_conditions', function ($value, $endpoint) { + list($query, $dependencies) = $value; + + $paramValue = $endpoint->request->get_param('my_param'); + if (!empty($paramValue)) { + $query->attachMeta( + 'give_donationmeta', + 'ID', + 'donation_id', + ['my_key', 'myKey'] + ); + $query->where('give_donationmeta_attach_meta_myKey.meta_value', $paramValue); + } + + return [$query, $dependencies]; + }, 10, 2); + + $mockRequest = $this->getMockRequest(); + // set_params + $mockRequest->set_param('page', 1); + $mockRequest->set_param('perPage', 30); + $mockRequest->set_param('locale', 'us-US'); + $mockRequest->set_param('testMode', give_is_test_mode()); + $mockRequest->set_param('my_param', 'on'); + + $listDonations = give(ListDonations::class); + + $response = $listDonations->handleRequest($mockRequest); + + $this->assertSame($expectedItems, $response->data['items']); + } + + /** + * @unreleased + * + * @return void + * @throws Exception + */ + public function testShouldAllowUsingDependencyTwice() + { + $donations = Donation::factory()->count(5)->create(); + + $firstName = $donations[3]->firstName; + $expectedItems = array_filter($donations, function ($donation) use ($firstName) { + return $donation->firstName == $firstName; + }); + $expectedItems = $this->getMockColumns($expectedItems); + + $mockRequest = $this->getMockRequest(); + // set_params + $mockRequest->set_param('page', 1); + $mockRequest->set_param('perPage', 30); + $mockRequest->set_param('locale', 'us-US'); + $mockRequest->set_param('testMode', give_is_test_mode()); + $mockRequest->set_param('donor', $firstName); + $mockRequest->set_param('search', $firstName); $listDonations = give(ListDonations::class); diff --git a/tests/includes/legacy/tests-give.php b/tests/includes/legacy/tests-give.php index ffe4f40cbb8..1a282b3009d 100644 --- a/tests/includes/legacy/tests-give.php +++ b/tests/includes/legacy/tests-give.php @@ -32,7 +32,10 @@ public function test_constants() { // Plugin Root File $path = str_replace( 'tests/unit-tests/', '', plugin_dir_path( $filePath ) ); - $this->assertSame(GIVE_PLUGIN_FILE, untrailingslashit($path) . DIRECTORY_SEPARATOR . 'give.php'); + // Windows compatibility + $path = str_replace('/', DIRECTORY_SEPARATOR, $path); + $this->assertSame( GIVE_PLUGIN_FILE, $path . 'give.php' ); + $this->assertSame(GIVE_PLUGIN_FILE, untrailingslashit($path) . DIRECTORY_SEPARATOR . 'give.php'); } /**