Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Showcasing combined sort-dir.
  • Loading branch information
dereuromark committed Sep 10, 2025
commit 2a5410571343fd9583862778c8bc576627bd61e3
17 changes: 16 additions & 1 deletion src/Datasource/Paging/NumericPaginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,13 @@ protected function getDefaults(string $alias, array $settings): array
* also be sanitized. Lastly sort + direction keys will be converted into
* the model friendly order key.
*
* Supports two formats for sort parameters:
* - Traditional: `?sort=title&direction=asc`
* - Combined: `?sort=title-asc` or `?sort=title-desc`
*
* The combined format merges the field and direction into a single parameter,
* making URLs cleaner and more RESTful. Both formats work with sortMap.
*
* You can use the allowedParameters option to control which columns/fields are
* available for sorting via URL parameters. This helps prevent users from ordering large
* result sets on un-indexed values.
Expand All @@ -558,9 +565,17 @@ protected function validateSort(RepositoryInterface $object, array $options): ar
{
if (isset($options['sort'])) {
$direction = null;
if (isset($options['direction'])) {
$sortField = $options['sort'];

// Check for combined sort-direction format (e.g., 'title-asc' or 'title-desc')
if (preg_match('/^(.+)-(asc|desc)$/i', $sortField, $matches)) {
$sortField = $matches[1];
$direction = strtolower($matches[2]);
$options['sort'] = $sortField;
} elseif (isset($options['direction'])) {
$direction = strtolower($options['direction']);
}

if (!in_array($direction, ['asc', 'desc'], true)) {
$direction = 'asc';
}
Expand Down
42 changes: 35 additions & 7 deletions src/View/Helper/PaginatorHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,18 @@ class PaginatorHelper extends Helper
* - `routePlaceholders` An array specifying which paging params should be
* passed as route placeholders instead of query string parameters. The array
* can have values `'sort'`, `'direction'`, `'page'`.
* - `sortFormat` Format for sort parameters. Can be 'separate' (default) for
* `?sort=field&direction=asc` or 'combined' for `?sort=field-asc`.
*
* Templates: the templates used by this class
*
* @var array<string, mixed>
*/
protected array $_defaultConfig = [
'params' => [],
'options' => [],
'options' => [
'sortFormat' => 'separate',
],
'templates' => [
'nextActive' => '<li class="next"><a rel="next" href="{{url}}">{{text}}</a></li>',
'nextDisabled' => '<li class="next disabled"><a>{{text}}</a></li>',
Expand Down Expand Up @@ -433,7 +437,15 @@ public function sort(string $key, array|string|null $title = null, array $option
$title = $title[$dir];
}

$paging = ['sort' => $key, 'direction' => $dir, 'page' => 1];
// Check if we should use combined format
$sortFormat = $this->getConfig('options.sortFormat', 'separate');
if ($sortFormat === 'combined') {
// Use combined format: field-asc or field-desc
$paging = ['sort' => $key . '-' . $dir, 'page' => 1];
} else {
// Use traditional separate format
$paging = ['sort' => $key, 'direction' => $dir, 'page' => 1];
}
Comment on lines +440 to +448
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this if we're introducing SortField?


$vars = [
'text' => $options['escape'] ? h($title) : $title,
Expand Down Expand Up @@ -501,17 +513,33 @@ public function generateUrlParams(array $options = [], array $url = []): array
$paging['sortDefault'] = $this->_removeAlias($paging['sortDefault'], $this->param('alias'));
}

$options += array_intersect_key(
$paging,
['page' => null, 'limit' => null, 'sort' => null, 'direction' => null],
);
// Check if we're using combined format in the options
$sortFormat = $this->getConfig('options.sortFormat', 'separate');
$hasCombinedSort = isset($options['sort']) && preg_match('/-(asc|desc)$/i', $options['sort']);

if ($sortFormat === 'combined' || $hasCombinedSort) {
// Don't include separate direction parameter when using combined format
$options += array_intersect_key(
$paging,
['page' => null, 'limit' => null, 'sort' => null],
);
// Remove any separate direction parameter
unset($options['direction']);
} else {
$options += array_intersect_key(
$paging,
['page' => null, 'limit' => null, 'sort' => null, 'direction' => null],
);
}

if (!empty($options['page']) && $options['page'] === 1) {
$options['page'] = null;
}

if (
isset($paging['sortDefault'], $paging['directionDefault'], $options['sort'], $options['direction'])
isset($paging['sortDefault'], $paging['directionDefault'], $options['sort'])
&& !$hasCombinedSort
&& isset($options['direction'])
&& $options['sort'] === $paging['sortDefault']
&& strtolower($options['direction']) === strtolower($paging['directionDefault'])
) {
Expand Down
88 changes: 88 additions & 0 deletions tests/TestCase/Datasource/Paging/NumericPaginatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,94 @@ public function testBackwardCompatibilityWithoutSortMap(): void
$this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']);
}

/**
* Test combined sort-direction parameter format (e.g., 'title-asc')
*/
public function testCombinedSortDirectionFormat(): void
{
$table = $this->getTableLocator()->get('PaginatorPosts');

// Test ascending with combined format
$params = ['sort' => 'title-asc'];
$result = $this->Paginator->paginate($table, $params);
$pagingParams = $result->pagingParams();

$this->assertEquals('title', $pagingParams['sort']);
$this->assertEquals('asc', $pagingParams['direction']);
$this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']);

// Test descending with combined format
$params = ['sort' => 'body-desc'];
$result = $this->Paginator->paginate($table, $params);
$pagingParams = $result->pagingParams();

$this->assertEquals('body', $pagingParams['sort']);
$this->assertEquals('desc', $pagingParams['direction']);
$this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']);

// Test that traditional format still works
$params = ['sort' => 'title', 'direction' => 'desc'];
$result = $this->Paginator->paginate($table, $params);
$pagingParams = $result->pagingParams();

$this->assertEquals('title', $pagingParams['sort']);
$this->assertEquals('desc', $pagingParams['direction']);
$this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']);

// Test combined format with hyphenated field names
$params = ['sort' => 'author_id-desc'];
$result = $this->Paginator->paginate($table, $params);
$pagingParams = $result->pagingParams();

$this->assertEquals('author_id', $pagingParams['sort']);
$this->assertEquals('desc', $pagingParams['direction']);
$this->assertEquals(['PaginatorPosts.author_id' => 'desc'], $pagingParams['completeSort']);
}

/**
* Test combined sort format with sortMap
*/
public function testCombinedSortFormatWithSortMap(): void
{
$table = $this->getTableLocator()->get('PaginatorPosts');
$settings = [
'sortMap' => [
'name' => 'PaginatorPosts.title',
'content' => 'PaginatorPosts.body',
'newest' => ['PaginatorPosts.id' => 'desc', 'PaginatorPosts.title'],
],
];

// Test simple mapping with combined format
$params = ['sort' => 'name-desc'];
$result = $this->Paginator->paginate($table, $params, $settings);
$pagingParams = $result->pagingParams();

$this->assertEquals('name', $pagingParams['sort']);
$this->assertEquals('desc', $pagingParams['direction']);
$this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']);

// Test that unmapped fields with combined format are still rejected
$params = ['sort' => 'unmapped-asc'];
$result = $this->Paginator->paginate($table, $params, $settings);
$pagingParams = $result->pagingParams();

$this->assertNull($pagingParams['sort']);
$this->assertNull($pagingParams['direction']);
$this->assertEquals([], $pagingParams['completeSort']);

// Test multi-field mapping with combined format
$params = ['sort' => 'newest-asc']; // Direction should apply to non-fixed fields
$result = $this->Paginator->paginate($table, $params, $settings);
$pagingParams = $result->pagingParams();

$this->assertEquals('newest', $pagingParams['sort']);
$this->assertEquals([
'PaginatorPosts.id' => 'desc', // Fixed direction
'PaginatorPosts.title' => 'asc', // Uses combined format direction
], $pagingParams['completeSort']);
}

/**
* Test sortMap with association sorting
*/
Expand Down
76 changes: 76 additions & 0 deletions tests/TestCase/View/Helper/PaginatorHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,82 @@ public function testSortAdminLinks(): void
$this->assertHtml($expected, $result);
}

/**
* Test sort links with combined format (field-direction)
*/
public function testSortLinksCombinedFormat(): void
{
$request = new ServerRequest([
'url' => '/accounts/',
'params' => [
'plugin' => null, 'controller' => 'Accounts', 'action' => 'index', 'pass' => [],
],
'base' => '',
'webroot' => '/',
]);
Router::setRequest($request);

// Configure to use combined format
$this->Paginator->setConfig('options.sortFormat', 'combined');

$this->setPaginatedResult([
'currentPage' => 1,
'count' => 9,
'totalCount' => 62,
'hasPrevPage' => false,
'hasNextPage' => true,
'pageCount' => 7,
'sort' => 'date',
'direction' => 'asc',
], false);

// Test generating sort link with combined format
$result = $this->Paginator->sort('title');
$expected = [
'a' => ['href' => '/Accounts/index?sort=title-asc'],
'Title',
'/a',
];
$this->assertHtml($expected, $result);

// Test with current sort field - should toggle direction
$result = $this->Paginator->sort('date');
$expected = [
'a' => ['href' => '/Accounts/index?sort=date-desc', 'class' => 'asc'],
'Date',
'/a',
];
$this->assertHtml($expected, $result);

// Test with custom title
$result = $this->Paginator->sort('title', 'Custom Title');
$expected = [
'a' => ['href' => '/Accounts/index?sort=title-asc'],
'Custom Title',
'/a',
];
$this->assertHtml($expected, $result);

// Test with locked direction
$result = $this->Paginator->sort('price', null, ['direction' => 'desc', 'lock' => true]);
$expected = [
'a' => ['href' => '/Accounts/index?sort=price-desc'],
'Price',
'/a',
];
$this->assertHtml($expected, $result);

// Reset to separate format and verify it still works
$this->Paginator->setConfig('options.sortFormat', 'separate');
$result = $this->Paginator->sort('name');
$expected = [
'a' => ['href' => '/Accounts/index?sort=name&amp;direction=asc'],
'Name',
'/a',
];
$this->assertHtml($expected, $result);
}

/**
* Test that generated URLs work without sort defined within the request
*/
Expand Down
Loading