Skip to content

Commit 89f1bdb

Browse files
committed
Feat: Add technicalName to views
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent 04724f0 commit 89f1bdb

14 files changed

Lines changed: 457 additions & 44 deletions

File tree

‎appinfo/info.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud.
2626
Have a good time and manage whatever you want.
2727
2828
]]></description>
29-
<version>2.2.0</version>
29+
<version>2.2.1</version>
3030
<licence>AGPL-3.0-or-later</licence>
3131
<author>Nextcloud GmbH and Nextcloud contributors</author>
3232
<namespace>Tables</namespace>

‎lib/Constants/ViewUpdatableParameters.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ enum ViewUpdatableParameters: string {
1616
case SORT = 'sort';
1717
case FILTER = 'filter';
1818
case COLUMN_SETTINGS = 'columns';
19+
case TECHNICAL_NAME = 'technicalName';
1920
}

‎lib/Controller/Api1Controller.php‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,12 @@ public function indexViews(int $tableId): DataResponse {
348348
* @param int $tableId Table ID that will hold the view
349349
* @param string $title Title for the view
350350
* @param string|null $emoji Emoji for the view
351+
* @param string|null $technicalName Technical name for the view
351352
*
352-
* @return DataResponse<Http::STATUS_OK, TablesView, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
353+
* @return DataResponse<Http::STATUS_OK, TablesView, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
353354
*
354355
* 200: View created
356+
* 400: Bad request
355357
* 403: No permissions
356358
* 404: Not found
357359
*/
@@ -360,13 +362,17 @@ public function indexViews(int $tableId): DataResponse {
360362
#[CORS]
361363
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
362364
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
363-
public function createView(int $tableId, string $title, ?string $emoji): DataResponse {
365+
public function createView(int $tableId, string $title, ?string $emoji, ?string $technicalName = null): DataResponse {
364366
try {
365-
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId))->jsonSerialize());
367+
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $technicalName)->jsonSerialize());
366368
} catch (PermissionError $e) {
367369
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
368370
$message = ['message' => $e->getMessage()];
369371
return new DataResponse($message, Http::STATUS_FORBIDDEN);
372+
} catch (BadRequestError $e) {
373+
$this->logger->warning('A bad request error occurred: ' . $e->getMessage(), ['exception' => $e]);
374+
$message = ['message' => $e->getMessage()];
375+
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
370376
} catch (InternalError|Exception $e) {
371377
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
372378
$message = ['message' => $e->getMessage()];
@@ -444,6 +450,10 @@ public function updateView(int $viewId, array $data): DataResponse {
444450
$this->logger->warning('An invalid request occurred: ' . $e->getMessage(), ['exception' => $e]);
445451
$message = ['message' => $e->getMessage()];
446452
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
453+
} catch (BadRequestError $e) {
454+
$this->logger->warning('A bad request error occurred: ' . $e->getMessage(), ['exception' => $e]);
455+
$message = ['message' => $e->getMessage()];
456+
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
447457
} catch (InternalError|Exception $e) {
448458
$this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]);
449459
$message = ['message' => $e->getMessage()];

‎lib/Db/View.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
* @method setId(int $id)
2626
* @method getTitle(): string
2727
* @method setTitle(string $title)
28+
* @method getTechnicalName(): string
29+
* @method setTechnicalName(?string $technicalName)
2830
* @method getTableId(): int
2931
* @method setTableId(int $tableId)
3032
* @method getColumns(): string
@@ -64,6 +66,7 @@
6466
*/
6567
class View extends EntitySuper implements JsonSerializable {
6668
protected ?string $title = null;
69+
protected ?string $technicalName = null;
6770
protected ?int $tableId = null;
6871
protected ?string $createdBy = null;
6972
protected ?string $createdAt = null;
@@ -183,6 +186,7 @@ public function jsonSerialize(): array {
183186
'id' => $this->id,
184187
'tableId' => ($this->tableId || $this->tableId === 0) ? $this->tableId : -1,
185188
'title' => $this->title ?: '',
189+
'technicalName' => $this->technicalName,
186190
'description' => $this->description,
187191
'emoji' => $this->emoji,
188192
'ownership' => $this->ownership ?: '',
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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\QueryBuilder\IQueryBuilder;
15+
use OCP\DB\Types;
16+
use OCP\IDBConnection;
17+
use OCP\Migration\IOutput;
18+
use OCP\Migration\SimpleMigrationStep;
19+
use Override;
20+
21+
class Version2011Date20260428000000 extends SimpleMigrationStep {
22+
private IDBConnection $connection;
23+
24+
public function __construct(IDBConnection $connection) {
25+
$this->connection = $connection;
26+
}
27+
28+
#[Override]
29+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
30+
/** @var ISchemaWrapper $schema */
31+
$schema = $schemaClosure();
32+
33+
if (!$schema->hasTable('tables_views')) {
34+
return null;
35+
}
36+
37+
$table = $schema->getTable('tables_views');
38+
if (!$table->hasColumn('technical_name')) {
39+
$table->addColumn('technical_name', Types::STRING, [
40+
'notnull' => false,
41+
'length' => 200,
42+
]);
43+
}
44+
45+
if (!$table->hasIndex('tables_views_table_tech_name_uq')) {
46+
$table->addUniqueIndex(['table_id', 'technical_name'], 'tables_views_table_tech_name_uq');
47+
}
48+
49+
return $schema;
50+
}
51+
52+
#[Override]
53+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
54+
$selectQb = $this->connection->getQueryBuilder();
55+
$selectQb->select('id')
56+
->from('tables_views')
57+
->where(
58+
$selectQb->expr()->orX(
59+
$selectQb->expr()->isNull('technical_name'),
60+
$selectQb->expr()->eq('technical_name', $selectQb->createNamedParameter('')),
61+
)
62+
);
63+
64+
$result = $selectQb->executeQuery();
65+
$updatedCount = 0;
66+
67+
try {
68+
while ($row = $result->fetchAssociative()) {
69+
$viewId = (int)$row['id'];
70+
71+
$updateQb = $this->connection->getQueryBuilder();
72+
$updateQb->update('tables_views')
73+
->set('technical_name', $updateQb->createNamedParameter('view_' . $viewId, IQueryBuilder::PARAM_STR))
74+
->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($viewId, IQueryBuilder::PARAM_INT)));
75+
76+
$updatedCount += $updateQb->executeStatement();
77+
}
78+
} finally {
79+
$result->closeCursor();
80+
}
81+
82+
$output->info('Version2011Date20260428000000: backfilled technical_name for ' . $updatedCount . ' views.');
83+
}
84+
}

‎lib/Model/ViewUpdateInput.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ViewUpdateInput {
2424

2525
public function __construct(
2626
protected readonly ?Title $title = null,
27+
protected readonly ?string $technicalName = null,
2728
protected readonly ?string $description = null,
2829
protected readonly ?Emoji $emoji = null,
2930
protected readonly ?ColumnSettings $columnSettings = null,
@@ -36,6 +37,9 @@ public function updateDetail(): Generator {
3637
if ($this->title) {
3738
yield ViewUpdatableParameters::TITLE => $this->title;
3839
}
40+
if ($this->technicalName !== null) {
41+
yield ViewUpdatableParameters::TECHNICAL_NAME => $this->technicalName;
42+
}
3943
if ($this->description) {
4044
yield ViewUpdatableParameters::DESCRIPTION => $this->description;
4145
}
@@ -58,6 +62,7 @@ public function updateDetail(): Generator {
5862
* title?: string,
5963
* emoji?: string,
6064
* description?: string,
65+
* technicalName?: string,
6166
* columns?: list<int>,
6267
* columnSettings?: list<array{columnId?: int, order?: int, readonly?: bool, mandatory?: bool}>,
6368
* sort?: list<array{columnId: int, mode: 'ASC'|'DESC'}>,
@@ -82,6 +87,7 @@ public static function fromInputArray(array $data): self {
8287

8388
return new self(
8489
title: ($data['title'] ?? null) ? new Title($data['title']) : null,
90+
technicalName: $data['technicalName'] ?? null,
8591
description: $data['description'] ?? null,
8692
emoji: ($data['emoji'] ?? null) ? new Emoji($data['emoji']) : null,
8793
columnSettings: ($data['columnSettings'] ?? null) ? ColumnSettings::createViewSettingsFromInputArray($data['columnSettings']) : null,

‎lib/ResponseDefinitions.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* @psalm-type TablesView = array{
1717
* id: int,
1818
* title: string,
19+
* technicalName: string|null,
1920
* emoji: string|null,
2021
* tableId: int,
2122
* ownership: string,

‎lib/Service/ViewService.php‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OCA\Tables\Db\Table;
2020
use OCA\Tables\Db\View;
2121
use OCA\Tables\Db\ViewMapper;
22+
use OCA\Tables\Errors\BadRequestError;
2223
use OCA\Tables\Errors\InternalError;
2324
use OCA\Tables\Errors\NotFoundError;
2425
use OCA\Tables\Errors\PermissionError;
@@ -189,11 +190,13 @@ public function findSharedViewsWithMe(?string $userId = null): array {
189190
* @param string|null $emoji
190191
* @param Table $table
191192
* @param string|null $userId
193+
* @param string|null $technicalName
192194
* @return View
193195
* @throws InternalError
194196
* @throws PermissionError
197+
* @throws BadRequestError
195198
*/
196-
public function create(string $title, ?string $emoji, Table $table, ?string $userId = null): View {
199+
public function create(string $title, ?string $emoji, Table $table, ?string $userId = null, ?string $technicalName = null): View {
197200
/** @var string $userId */
198201
$userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set
199202

@@ -214,6 +217,11 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use
214217
$item->setLastEditBy($userId);
215218
$item->setCreatedAt($time->format('Y-m-d H:i:s'));
216219
$item->setLastEditAt($time->format('Y-m-d H:i:s'));
220+
if ($technicalName !== null) {
221+
$this->assertTechnicalNameValid($technicalName);
222+
$this->assertTechnicalNameUnique($table->getId(), $technicalName);
223+
$item->setTechnicalName($technicalName);
224+
}
217225
// ownership is not stored with the record, but it might be necessary upon
218226
// further interaction with the view in the running process, as the instance
219227
// is cached now. The ownership is always inherited from the table.
@@ -224,6 +232,15 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use
224232
$this->logger->error($e->getMessage());
225233
throw new InternalError($e->getMessage());
226234
}
235+
if ($newItem->getTechnicalName() === null || $newItem->getTechnicalName() === '') {
236+
$newItem->setTechnicalName($this->buildDefaultTechnicalName($newItem->getId()));
237+
try {
238+
$newItem = $this->mapper->update($newItem);
239+
} catch (\OCP\DB\Exception $e) {
240+
$this->logger->error($e->getMessage());
241+
throw new InternalError($e->getMessage(), previous: $e);
242+
}
243+
}
227244

228245
return $newItem;
229246
}
@@ -251,6 +268,11 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b
251268
$this->assertInputColumnsAreValid($view, $userId, $value);
252269
}
253270

271+
if ($parameter === ViewUpdatableParameters::TECHNICAL_NAME) {
272+
$this->assertTechnicalNameValid($value);
273+
$this->assertTechnicalNameUnique($view->getTableId(), $value, $view->getId());
274+
}
275+
254276
if ($value instanceof JsonSerializable) {
255277
$insertableValue = json_encode($value);
256278
}
@@ -267,7 +289,7 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b
267289
$this->enhanceView($view, $userId);
268290
}
269291
return $view;
270-
} catch (InvalidArgumentException $e) {
292+
} catch (BadRequestError|InvalidArgumentException $e) {
271293
throw $e;
272294
} catch (Exception $e) {
273295
$this->logger->error($e->getMessage(), ['exception' => $e]);
@@ -610,6 +632,7 @@ public function importView(int $tableId, array $view, string $userId): void {
610632
$item->setTableId($tableId);
611633
$item->setTitle($view['title']);
612634
$item->setEmoji($view['emoji']);
635+
$item->setTechnicalName($view['technicalName'] ?? null);
613636
$item->setCreatedBy($userId);
614637
$item->setCreatedAt($view['createdAt']);
615638
$item->setLastEditBy($userId);
@@ -620,9 +643,51 @@ public function importView(int $tableId, array $view, string $userId): void {
620643
$item->setFilter(json_encode($view['filter']));
621644
try {
622645
$this->mapper->insert($item);
646+
if ($item->getTechnicalName() === null || $item->getTechnicalName() === '') {
647+
$item->setTechnicalName($this->buildDefaultTechnicalName($item->getId()));
648+
$this->mapper->update($item);
649+
}
650+
} catch (BadRequestError $e) {
651+
throw $e;
623652
} catch (\Exception $e) {
624653
$this->logger->error('userMigrationImport insert error: ' . $e->getMessage());
625654
throw new InternalError('userMigrationImport insert error: ' . $e->getMessage());
626655
}
627656
}
657+
658+
private function buildDefaultTechnicalName(int $viewId): string {
659+
return 'view_' . $viewId;
660+
}
661+
662+
/**
663+
* @throws BadRequestError
664+
*/
665+
private function assertTechnicalNameValid(string $technicalName): void {
666+
if (!preg_match('/^[a-z][a-z0-9_]*$/', $technicalName)) {
667+
throw new BadRequestError('Technical name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.');
668+
}
669+
}
670+
671+
/**
672+
* @throws BadRequestError
673+
* @throws InternalError
674+
*/
675+
private function assertTechnicalNameUnique(int $tableId, string $technicalName, ?int $excludeCurrentViewId = null): void {
676+
try {
677+
$views = $this->mapper->findAll($tableId);
678+
} catch (\OCP\DB\Exception $e) {
679+
$this->logger->error($e->getMessage(), ['exception' => $e]);
680+
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage(), previous: $e);
681+
}
682+
683+
foreach ($views as $view) {
684+
if ($excludeCurrentViewId !== null && $view->getId() === $excludeCurrentViewId) {
685+
continue;
686+
}
687+
688+
if ($view->getTechnicalName() === $technicalName) {
689+
throw new BadRequestError('Technical name must be unique in the table.');
690+
}
691+
}
692+
}
628693
}

0 commit comments

Comments
 (0)