Skip to content

Commit 2b330ef

Browse files
Merge pull request #2481 from nextcloud/feat/607/reorder-and-sort-table-columns
🔢 Reorder and sort table columns
2 parents 4a1fd18 + e26fd70 commit 2b330ef

30 files changed

Lines changed: 1400 additions & 74 deletions

cypress/e2e/tables-table.cy.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ describe('Manage a table', () => {
7171
// Updating the title is flaky, so skipping it for now
7272
// cy.get('[data-cy="editTableModal"] [data-cy="editTableTitleInput"]').should('be.visible').should('be.enabled')
7373
// cy.get('[data-cy="editTableModal"] [data-cy="editTableTitleInput"]').clear().type('ToDo list')
74-
cy.get('.modal__content #description-editor .tiptap.ProseMirror').type('Updated ToDo List description')
74+
cy.get('[data-cy="editTableModal"] #description-editor .tiptap.ProseMirror').type('Updated ToDo List description')
7575
cy.get('[data-cy="editTableSaveBtn"]').should('be.enabled').click()
7676

7777
cy.wait(10).get('.toastify.toast-success').should('be.visible')

lib/Controller/ApiTablesController.php

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,14 @@ public function showScheme(int $id): DataResponse {
130130
* @param string $description description
131131
* @param list<TablesColumn> $columns columns
132132
* @param list<TablesView> $views views
133+
* @param list<array{columnId: int, order: int, readonly: bool}> $columnOrder Default column order settings
134+
* @param list<array{columnId: int, mode: 'ASC'|'DESC'}> $sort Default sort rules
133135
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
134136
*
135137
* 200: Tables returned
136138
*/
137139
#[NoAdminRequired]
138-
public function createFromScheme(string $title, string $emoji, string $description, array $columns, array $views): DataResponse {
140+
public function createFromScheme(string $title, string $emoji, string $description, array $columns, array $views, array $columnOrder = [], array $sort = []): DataResponse {
139141
try {
140142
$this->db->beginTransaction();
141143
$table = $this->service->create($title, 'custom', $emoji, $description);
@@ -175,6 +177,21 @@ public function createFromScheme(string $title, string $emoji, string $descripti
175177
);
176178
$colMap[$column['id']] = $col->getId();
177179
}
180+
if (!empty($columnOrder) || !empty($sort)) {
181+
$remappedColumnOrder = !empty($columnOrder) ? array_map(static function (array $entry) use ($colMap): array {
182+
if (isset($entry['columnId']) && $entry['columnId'] > 0) {
183+
$entry['columnId'] = $colMap[$entry['columnId']] ?? $entry['columnId'];
184+
}
185+
return $entry;
186+
}, $columnOrder) : null;
187+
$remappedSort = !empty($sort) ? array_map(static function (array $entry) use ($colMap): array {
188+
if (isset($entry['columnId']) && $entry['columnId'] > 0) {
189+
$entry['columnId'] = $colMap[$entry['columnId']] ?? $entry['columnId'];
190+
}
191+
return $entry;
192+
}, $sort) : null;
193+
$table = $this->service->update($table->getId(), null, null, null, null, $this->userId, $remappedColumnOrder, $remappedSort);
194+
}
178195
foreach ($views as $view) {
179196
$newView = $this->viewService->create(
180197
$view['title'],
@@ -262,6 +279,8 @@ public function create(string $title, ?string $emoji, ?string $description, stri
262279
* @param string|null $emoji New table emoji
263280
* @param bool $archived whether the table is archived
264281
* @param string $description the tables description
282+
* @param list<array{columnId: int, order: int, readonly: bool}>|string|null $columnSettings Default column order settings (array or JSON string)
283+
* @param list<array{columnId: int, mode: 'ASC'|'DESC'}>|string|null $sort Default sort rules (array or JSON string)
265284
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
266285
*
267286
* 200: Tables returned
@@ -270,9 +289,15 @@ public function create(string $title, ?string $emoji, ?string $description, stri
270289
*/
271290
#[NoAdminRequired]
272291
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'id')]
273-
public function update(int $id, ?string $title = null, ?string $emoji = null, ?string $description = null, ?bool $archived = null): DataResponse {
292+
public function update(int $id, ?string $title = null, ?string $emoji = null, ?string $description = null, ?bool $archived = null, null|array|string $columnSettings = null, null|array|string $sort = null): DataResponse {
293+
if (is_string($columnSettings)) {
294+
$columnSettings = json_decode($columnSettings, true) ?? null;
295+
}
296+
if (is_string($sort)) {
297+
$sort = json_decode($sort, true) ?? null;
298+
}
274299
try {
275-
return new DataResponse($this->service->update($id, $title, $emoji, $description, $archived, $this->userId)->jsonSerialize());
300+
return new DataResponse($this->service->update($id, $title, $emoji, $description, $archived, $this->userId, $columnSettings, $sort)->jsonSerialize());
276301
} catch (PermissionError $e) {
277302
return $this->handlePermissionError($e);
278303
} catch (InternalError $e) {

lib/Controller/TableController.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,15 @@ public function destroy(int $id): DataResponse {
7070

7171
#[NoAdminRequired]
7272
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'id')]
73-
public function update(int $id, ?string $title = null, ?string $emoji = null, ?bool $archived = null): DataResponse {
74-
return $this->handleError(function () use ($id, $title, $emoji, $archived) {
75-
return $this->service->update($id, $title, $emoji, null, $archived, $this->userId);
73+
public function update(int $id, ?string $title = null, ?string $emoji = null, ?bool $archived = null, null|array|string $columnSettings = null, null|array|string $sort = null): DataResponse {
74+
if (is_string($columnSettings)) {
75+
$columnSettings = json_decode($columnSettings, true) ?? null;
76+
}
77+
if (is_string($sort)) {
78+
$sort = json_decode($sort, true) ?? null;
79+
}
80+
return $this->handleError(function () use ($id, $title, $emoji, $archived, $columnSettings, $sort) {
81+
return $this->service->update($id, $title, $emoji, null, $archived, $this->userId, $columnSettings, $sort);
7682
});
7783
}
7884
}

lib/Db/Table.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
use JsonSerializable;
1111
use OCA\Tables\Model\Permissions;
12+
use OCA\Tables\Model\SortRuleSet;
1213
use OCA\Tables\ResponseDefinitions;
14+
use OCA\Tables\Service\ValueObject\ViewColumnInformation;
1315

1416
/**
1517
* @psalm-suppress PropertyNotSetInConstructor
@@ -46,6 +48,10 @@
4648
* @method setViews(array $views)
4749
* @method getColumns(): array
4850
* @method setColumns(array $columns)
51+
* @method getColumnOrder(): ?string
52+
* @method setColumnOrder(?string $columnOrder)
53+
* @method getSort(): ?string
54+
* @method setSort(?string $sort)
4955
* @method getCreatedBy(): string
5056
* @method setCreatedBy(string $createdBy)
5157
* @method getCreatedAt(): string
@@ -66,6 +72,9 @@ class Table extends EntitySuper implements JsonSerializable {
6672
protected bool $archived = false;
6773
protected ?string $description = null;
6874

75+
protected ?string $columnOrder = null; // json
76+
protected ?string $sort = null; // json
77+
6978
// virtual properties
7079
protected ?bool $isShared = null;
7180
protected ?Permissions $onSharePermissions = null;
@@ -107,6 +116,8 @@ public function jsonSerialize(): array {
107116
'columnsCount' => $this->columnsCount ?: 0,
108117
'views' => $this->getViewsArray(),
109118
'description' => $this->description ?:'',
119+
'columnOrder' => $this->getColumnOrderSettingsArray(),
120+
'sort' => $this->getSortArray(),
110121
];
111122
}
112123

@@ -121,4 +132,53 @@ private function getSharePermissions(): ?Permissions {
121132
private function getViewsArray(): array {
122133
return $this->getViews() ?: [];
123134
}
135+
136+
/**
137+
* @psalm-suppress MismatchingDocblockReturnType
138+
* @return int[]
139+
*/
140+
public function getColumnOrderArray(): array {
141+
$columnSettings = $this->getColumnOrderSettingsArray();
142+
usort($columnSettings, static function (ViewColumnInformation $a, ViewColumnInformation $b) {
143+
return $a->getOrder() - $b->getOrder();
144+
});
145+
return array_map(static fn (ViewColumnInformation $vci): int => $vci->getId(), $columnSettings);
146+
}
147+
148+
/**
149+
* @return array<ViewColumnInformation>
150+
*/
151+
public function getColumnOrderSettingsArray(): array {
152+
$columns = $this->getArray($this->getColumnOrder());
153+
if (empty($columns)) {
154+
return [];
155+
}
156+
157+
if (is_array(reset($columns))) {
158+
return array_values(array_map(static fn (array $a): ViewColumnInformation => ViewColumnInformation::fromArray($a), $columns));
159+
}
160+
161+
$result = [];
162+
foreach ($columns as $index => $columnId) {
163+
$result[] = new ViewColumnInformation($columnId, order: (int)$index + 1);
164+
}
165+
return $result;
166+
}
167+
168+
/**
169+
* @psalm-suppress MismatchingDocblockReturnType
170+
* @return list<array{columnId: int, mode: 'ASC'|'DESC'}>
171+
*/
172+
public function getSortArray(): array {
173+
$rawSortRules = $this->getArray($this->getSort());
174+
return SortRuleSet::createFromInputArray($rawSortRules)->jsonSerialize();
175+
}
176+
177+
private function getArray(?string $json): array {
178+
if ($json !== '' && $json !== null && $json !== 'null') {
179+
return \json_decode($json, true);
180+
} else {
181+
return [];
182+
}
183+
}
124184
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Tables\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
use Override;
18+
19+
class Version2010Date20260414000000 extends SimpleMigrationStep {
20+
21+
#[Override]
22+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
23+
/** @var ISchemaWrapper $schema */
24+
$schema = $schemaClosure();
25+
26+
if (!$schema->hasTable('tables_tables')) {
27+
return null;
28+
}
29+
30+
$tablesTables = $schema->getTable('tables_tables');
31+
32+
if (!$tablesTables->hasColumn('column_order')) {
33+
$tablesTables->addColumn('column_order', Types::TEXT, [
34+
'notnull' => false,
35+
'default' => null,
36+
'comment' => 'JSON array of ViewColumnInformation — default column order for the table',
37+
]);
38+
}
39+
40+
if (!$tablesTables->hasColumn('sort')) {
41+
$tablesTables->addColumn('sort', Types::TEXT, [
42+
'notnull' => false,
43+
'default' => null,
44+
'comment' => 'JSON array of sort rules — default row sort for the table',
45+
]);
46+
}
47+
48+
return $schema;
49+
}
50+
}

lib/Model/TableScheme.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ class TableScheme implements JsonSerializable {
2323
protected ?array $views = null;
2424
protected ?string $description = null;
2525
protected ?string $tablesVersion = null;
26+
protected array $columnOrder = [];
27+
protected array $sort = [];
2628

27-
public function __construct(string $title, string $emoji, array $columns, array $view, string $description, string $tablesVersion) {
29+
public function __construct(string $title, string $emoji, array $columns, array $view, string $description, string $tablesVersion, array $columnOrder = [], array $sort = []) {
2830
$this->tablesVersion = $tablesVersion;
2931
$this->title = $title;
3032
$this->emoji = $emoji;
3133
$this->columns = $columns;
3234
$this->description = $description;
3335
$this->views = $view;
36+
$this->columnOrder = $columnOrder;
37+
$this->sort = $sort;
3438
}
3539

3640
public function getTitle():string {
@@ -43,8 +47,10 @@ public function jsonSerialize(): array {
4347
'emoji' => $this->emoji,
4448
'columns' => $this->columns,
4549
'views' => $this->views,
46-
'description' => $this->description ?:'',
50+
'description' => $this->description ?: '',
4751
'tablesVersion' => $this->tablesVersion,
52+
'columnOrder' => $this->columnOrder,
53+
'sort' => $this->sort,
4854
];
4955
}
5056

lib/Model/ViewUpdateInput.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@ public static function fromInputArray(array $data): self {
8181
}
8282

8383
return new self(
84-
title: $data['title'] ? new Title($data['title']) : null,
84+
title: ($data['title'] ?? null) ? new Title($data['title']) : null,
8585
description: $data['description'] ?? null,
86-
emoji: $data['emoji'] ? new Emoji($data['emoji']) : null,
87-
columnSettings: $data['columnSettings'] ? ColumnSettings::createFromInputArray($data['columnSettings']) : null,
88-
filterSet: $data['filter'] ? FilterSet::createFromInputArray($data['filter']) : null,
89-
sortRuleSet: $data['sort'] ? SortRuleSet::createFromInputArray($data['sort']) : null,
86+
emoji: ($data['emoji'] ?? null) ? new Emoji($data['emoji']) : null,
87+
columnSettings: ($data['columnSettings'] ?? null) ? ColumnSettings::createFromInputArray($data['columnSettings']) : null,
88+
filterSet: ($data['filter'] ?? null) ? FilterSet::createFromInputArray($data['filter']) : null,
89+
sortRuleSet: ($data['sort'] ?? null) ? SortRuleSet::createFromInputArray($data['sort']) : null,
9090
);
9191
}
9292

lib/ResponseDefinitions.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
* rowsCount: int,
6767
* views: list<TablesView>,
6868
* columnsCount: int,
69+
* columnOrder: list<array{columnId: int, order: int, readonly: bool}>,
70+
* sort: list<array{columnId: int, mode: 'ASC'|'DESC'}>,
6971
* }
7072
*
7173
* @psalm-type TablesIndex = array{

lib/Service/ColumnService.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ class ColumnService extends SuperService {
4747

4848
private ColumnDtoValidator $columnDtoValidator;
4949

50+
/** @var array<int, int[]> Per-request cache of sorted column-id order, keyed by tableId. */
51+
private array $columnOrderCache = [];
52+
5053
public function __construct(
5154
PermissionsService $permissionsService,
5255
LoggerInterface $logger,
@@ -74,10 +77,40 @@ public function __construct(
7477
* @throws InternalError
7578
* @throws PermissionError
7679
*/
77-
public function findAllByTable(int $tableId, ?string $userId = null): array {
80+
public function findAllByTable(int $tableId, ?string $userId = null, ?Table $table = null): array {
7881
if ($this->permissionsService->canReadColumnsByTableId($tableId, $userId)) {
7982
try {
80-
return $this->enhanceColumns($this->mapper->findAllByTable($tableId));
83+
$columns = $this->enhanceColumns($this->mapper->findAllByTable($tableId));
84+
85+
// Apply table-level default column order when set.
86+
// Use a per-request cache to avoid a redundant DB fetch when the
87+
// caller did not pass the Table entity and the TableMapper cache
88+
// is cold (e.g. CLI/migration paths with $userId = '').
89+
if (!array_key_exists($tableId, $this->columnOrderCache)) {
90+
$entity = $table ?? $this->tableMapper->find($tableId);
91+
$this->columnOrderCache[$tableId] = $entity->getColumnOrderArray();
92+
}
93+
$columnOrder = $this->columnOrderCache[$tableId];
94+
if (!empty($columnOrder)) {
95+
$indexed = [];
96+
foreach ($columns as $column) {
97+
$indexed[$column->getId()] = $column;
98+
}
99+
$ordered = [];
100+
foreach ($columnOrder as $id) {
101+
if (isset($indexed[$id])) {
102+
$ordered[] = $indexed[$id];
103+
unset($indexed[$id]);
104+
}
105+
}
106+
// append any columns not listed in the order (e.g. newly added)
107+
foreach ($indexed as $column) {
108+
$ordered[] = $column;
109+
}
110+
return $ordered;
111+
}
112+
113+
return $columns;
81114
} catch (\OCP\DB\Exception $e) {
82115
$this->logger->error($e->getMessage(), ['exception' => $e]);
83116
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());

lib/Service/RowService.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ public function findAllByTable(int $tableId, string $userId, ?int $limit = null,
9797
$tableColumns = $this->columnMapper->findAllByTable($tableId);
9898
$showColumnIds = array_map(fn (Column $column) => $column->getId(), $tableColumns);
9999

100-
return $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, null, $userId);
100+
$table = $this->tableMapper->find($tableId);
101+
$sort = $table->getSortArray() ?: null;
102+
103+
return $this->row2Mapper->findAll($showColumnIds, $tableId, $limit, $offset, null, $sort, $userId);
101104
} else {
102105
throw new PermissionError('no read access to table id = ' . $tableId);
103106
}

0 commit comments

Comments
 (0)