Skip to content

Commit 5968e48

Browse files
committed
feat: Notion-style view tabs, chart views, and relation columns
View Tabs: - Horizontal tab bar above table content for switching between views - "+" button to create new views directly from tab bar - Smart sidebar: parent table highlights when collapsed with active child view Chart Views: - New "chart" view type with bar, line, and pie/donut charts - Inline config bar for chart type, X-axis, Y-axis column selection - View type selector (Table/Chart) in view creation modal Relation Columns: - New "relation" column type for linking rows between tables - Proper join table (tables_row_relations) instead of JSON blobs - Relation types: one-to-one, one-to-many, many-to-many - Bidirectional: auto-creates reverse column on target table - Display column config to show meaningful values from linked rows - TableCellRelation renders linked rows as clickable chips - RelationService handles link CRUD with cascade cleanup Signed-off-by: Erlend Ryan <erlendryan@pm.me>
1 parent 06af332 commit 5968e48

42 files changed

Lines changed: 1882 additions & 69 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
['name' => 'api1#deleteRowByView', 'url' => '/api/1/views/{viewId}/rows/{rowId}', 'verb' => 'DELETE'],
6363
['name' => 'api1#updateRow', 'url' => '/api/1/rows/{rowId}', 'verb' => 'PUT'],
6464
['name' => 'api1#deleteRow', 'url' => '/api/1/rows/{rowId}', 'verb' => 'DELETE'],
65+
// -> relations
66+
['name' => 'api1#getRelations', 'url' => '/api/1/relations/{columnId}/{rowId}', 'verb' => 'GET'],
67+
['name' => 'api1#setRelations', 'url' => '/api/1/relations/{columnId}/{rowId}', 'verb' => 'PUT'],
6568
// -> import
6669
['name' => 'api1#importInTable', 'url' => '/api/1/import/table/{tableId}', 'verb' => 'POST'],
6770
['name' => 'api1#importInView', 'url' => '/api/1/import/views/{viewId}', 'verb' => 'POST'],
@@ -135,6 +138,7 @@
135138
['name' => 'ApiColumns#createSelectionColumn', 'url' => '/api/2/columns/selection', 'verb' => 'POST'],
136139
['name' => 'ApiColumns#createDatetimeColumn', 'url' => '/api/2/columns/datetime', 'verb' => 'POST'],
137140
['name' => 'ApiColumns#createUsergroupColumn', 'url' => '/api/2/columns/usergroup', 'verb' => 'POST'],
141+
['name' => 'ApiColumns#createRelationColumn', 'url' => '/api/2/columns/relation', 'verb' => 'POST'],
138142

139143
['name' => 'ApiFavorite#create', 'url' => '/api/2/favorites/{nodeType}/{nodeId}', 'verb' => 'POST', 'requirements' => ['nodeType' => '(\d+)', 'nodeId' => '(\d+)']],
140144
['name' => 'ApiFavorite#destroy', 'url' => '/api/2/favorites/{nodeType}/{nodeId}', 'verb' => 'DELETE', 'requirements' => ['nodeType' => '(\d+)', 'nodeId' => '(\d+)']],

lib/Constants/ColumnType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ enum ColumnType: string {
1515
case SELECTION = 'selection';
1616
case DATETIME = 'datetime';
1717
case PEOPLE = 'usergroup';
18+
case RELATION = 'relation';
1819
}

lib/Constants/ViewUpdatableParameters.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ enum ViewUpdatableParameters: string {
1313
case TITLE = 'title';
1414
case EMOJI = 'emoji';
1515
case DESCRIPTION = 'description';
16+
case TYPE = 'type';
1617
case SORT = 'sort';
1718
case FILTER = 'filter';
1819
case COLUMN_SETTINGS = 'columns';

lib/Controller/Api1Controller.php

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use OCA\Tables\ResponseDefinitions;
2626
use OCA\Tables\Service\ColumnService;
2727
use OCA\Tables\Service\ImportService;
28+
use OCA\Tables\Service\RelationService;
2829
use OCA\Tables\Service\RowService;
2930
use OCA\Tables\Service\ShareService;
3031
use OCA\Tables\Service\TableService;
@@ -69,6 +70,8 @@ class Api1Controller extends ApiController {
6970

7071
protected LoggerInterface $logger;
7172

73+
private RelationService $relationService;
74+
7275
use Errors;
7376

7477

@@ -85,6 +88,7 @@ public function __construct(
8588
LoggerInterface $logger,
8689
IL10N $l10N,
8790
?string $userId,
91+
RelationService $relationService,
8892
) {
8993
parent::__construct(Application::APP_ID, $request);
9094
$this->tableService = $service;
@@ -98,6 +102,7 @@ public function __construct(
98102
$this->v1Api = $v1Api;
99103
$this->logger = $logger;
100104
$this->l10N = $l10N;
105+
$this->relationService = $relationService;
101106
}
102107

103108
// Tables
@@ -346,9 +351,9 @@ public function indexViews(int $tableId): DataResponse {
346351
#[CORS]
347352
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
348353
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
349-
public function createView(int $tableId, string $title, ?string $emoji): DataResponse {
354+
public function createView(int $tableId, string $title, ?string $emoji, ?string $type = 'table'): DataResponse {
350355
try {
351-
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId))->jsonSerialize());
356+
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $type)->jsonSerialize());
352357
} catch (PermissionError $e) {
353358
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
354359
$message = ['message' => $e->getMessage()];
@@ -1729,4 +1734,39 @@ public function createTableColumn(
17291734
return new DataResponse($message, Http::STATUS_BAD_REQUEST);
17301735
}
17311736
}
1737+
1738+
// Relations
1739+
1740+
/**
1741+
* @NoAdminRequired
1742+
*/
1743+
#[NoAdminRequired]
1744+
#[NoCSRFRequired]
1745+
#[CORS]
1746+
public function getRelations(int $columnId, int $rowId): DataResponse {
1747+
try {
1748+
$linkedIds = $this->relationService->getLinkedRowIds($rowId, $columnId);
1749+
return new DataResponse($linkedIds);
1750+
} catch (\Exception $e) {
1751+
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
1752+
}
1753+
}
1754+
1755+
/**
1756+
* @NoAdminRequired
1757+
*/
1758+
#[NoAdminRequired]
1759+
#[NoCSRFRequired]
1760+
#[CORS]
1761+
public function setRelations(int $columnId, int $rowId, array $targetRowIds = []): DataResponse {
1762+
try {
1763+
$column = $this->columnService->find($columnId);
1764+
$userId = $this->userId;
1765+
$this->relationService->setLinks($columnId, $rowId, $targetRowIds, $userId, $column);
1766+
$linkedIds = $this->relationService->getLinkedRowIds($rowId, $columnId);
1767+
return new DataResponse($linkedIds);
1768+
} catch (\Exception $e) {
1769+
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
1770+
}
1771+
}
17321772
}

lib/Controller/ApiColumnsController.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,4 +412,62 @@ public function createUsergroupColumn(int $baseNodeId, string $title, ?string $u
412412
);
413413
return new DataResponse($column->jsonSerialize());
414414
}
415+
416+
/**
417+
* [api v2] Create new relation column
418+
*
419+
* Links rows from one table to rows in another table
420+
*
421+
* @param int $baseNodeId Context of the column creation
422+
* @param string $title Title
423+
* @param int $relationTableId ID of the related table
424+
* @param boolean $relationMultiple Whether multiple rows can be linked
425+
* @param string $relationType Relation type: 'one-to-one', 'one-to-many', 'many-to-many'
426+
* @param int|null $relationDisplayColumnId Column ID from target table to display
427+
* @param string|null $description Description
428+
* @param list<int>|null $selectedViewIds View IDs where this columns
429+
* should be added
430+
* @param boolean $mandatory Is mandatory
431+
* @param 'table'|'view' $baseNodeType Context type of the column creation
432+
* @param array<string, mixed> $customSettings Custom settings for the
433+
* column
434+
*
435+
*
436+
* @return DataResponse<Http::STATUS_OK, TablesColumn,
437+
* array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND,
438+
* array{message: string}, array{}>
439+
*
440+
*
441+
* 200: Column created
442+
* 403: No permission
443+
* 404: Not found
444+
* @throws InternalError
445+
* @throws NotFoundError
446+
* @throws PermissionError
447+
* @throws BadRequestError
448+
*/
449+
#[NoAdminRequired]
450+
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'baseNodeType', idParam: 'baseNodeId')]
451+
public function createRelationColumn(int $baseNodeId, string $title, int $relationTableId, ?bool $relationMultiple = false, string $relationType = 'many-to-many', ?int $relationDisplayColumnId = null, ?string $description = null, ?array $selectedViewIds = [], bool $mandatory = false, string $baseNodeType = 'table', array $customSettings = []): DataResponse {
452+
$tableId = $baseNodeType === 'table' ? $baseNodeId : null;
453+
$viewId = $baseNodeType === 'view' ? $baseNodeId : null;
454+
$column = $this->service->create(
455+
$this->userId,
456+
$tableId,
457+
$viewId,
458+
new ColumnDto(
459+
title: $title,
460+
type: ColumnType::RELATION->value,
461+
mandatory: $mandatory,
462+
description: $description,
463+
customSettings: json_encode($customSettings),
464+
relationTableId: $relationTableId,
465+
relationMultiple: $relationMultiple,
466+
relationType: $relationType,
467+
relationDisplayColumnId: $relationDisplayColumnId,
468+
),
469+
$selectedViewIds
470+
);
471+
return new DataResponse($column->jsonSerialize());
472+
}
415473
}

lib/Controller/ViewController.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ public function show(int $id): DataResponse {
7878

7979
#[NoAdminRequired]
8080
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
81-
public function create(int $tableId, string $title, ?string $emoji): DataResponse {
82-
return $this->handleError(function () use ($tableId, $title, $emoji) {
83-
return $this->service->create($title, $emoji, $this->getTable($tableId, true));
81+
public function create(int $tableId, string $title, ?string $emoji, ?string $type = 'table'): DataResponse {
82+
return $this->handleError(function () use ($tableId, $title, $emoji, $type) {
83+
return $this->service->create($title, $emoji, $this->getTable($tableId, true), null, $type);
8484
});
8585
}
8686

lib/Db/Column.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class Column extends EntitySuper implements JsonSerializable {
103103
public const TYPE_NUMBER = 'number';
104104
public const TYPE_DATETIME = 'datetime';
105105
public const TYPE_USERGROUP = 'usergroup';
106+
public const TYPE_RELATION = 'relation';
106107

107108
public const SUBTYPE_DATETIME_DATE = 'date';
108109
public const SUBTYPE_DATETIME_TIME = 'time';
@@ -156,6 +157,13 @@ class Column extends EntitySuper implements JsonSerializable {
156157
protected ?bool $showUserStatus = null;
157158
protected ?string $customSettings = null;
158159

160+
// type relation
161+
protected ?int $relationTableId = null;
162+
protected ?bool $relationMultiple = null;
163+
protected ?int $relationTargetColumnId = null;
164+
protected ?string $relationType = null;
165+
protected ?int $relationDisplayColumnId = null;
166+
159167
// virtual properties
160168
protected ?string $createdByDisplayName = null;
161169
protected ?string $lastEditByDisplayName = null;
@@ -186,6 +194,13 @@ public function __construct() {
186194
$this->addType('showUserStatus', 'boolean');
187195

188196
$this->addType('customSettings', 'string');
197+
198+
// type relation
199+
$this->addType('relationTableId', 'integer');
200+
$this->addType('relationMultiple', 'boolean');
201+
$this->addType('relationTargetColumnId', 'integer');
202+
$this->addType('relationType', 'string');
203+
$this->addType('relationDisplayColumnId', 'integer');
189204
}
190205

191206
public static function isValidMetaTypeId(int $metaTypeId): bool {
@@ -225,6 +240,11 @@ public static function fromDto(ColumnDto $data): self {
225240
$column->setUsergroupSelectTeams($data->getUsergroupSelectTeams());
226241
$column->setShowUserStatus($data->getShowUserStatus());
227242
$column->setCustomSettings($data->getCustomSettings());
243+
$column->setRelationTableId($data->getRelationTableId());
244+
$column->setRelationMultiple($data->getRelationMultiple());
245+
$column->setRelationTargetColumnId($data->getRelationTargetColumnId());
246+
$column->setRelationType($data->getRelationType());
247+
$column->setRelationDisplayColumnId($data->getRelationDisplayColumnId());
228248
return $column;
229249
}
230250

@@ -305,6 +325,13 @@ public function jsonSerialize(): array {
305325
'usergroupSelectTeams' => $this->usergroupSelectTeams,
306326
'showUserStatus' => $this->showUserStatus,
307327
'customSettings' => $this->getCustomSettingsArray() ?: new \stdClass(),
328+
329+
// type relation
330+
'relationTableId' => $this->getRelationTableId(),
331+
'relationMultiple' => $this->getRelationMultiple(),
332+
'relationTargetColumnId' => $this->getRelationTargetColumnId(),
333+
'relationType' => $this->getRelationType(),
334+
'relationDisplayColumnId' => $this->getRelationDisplayColumnId(),
308335
];
309336
}
310337

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Tables\Db\ColumnTypes;
6+
7+
use OCP\DB\IResult;
8+
use OCP\DB\QueryBuilder\IQueryBuilder;
9+
10+
class RelationColumnQB extends SuperColumnQB implements IColumnTypeQB {
11+
// Use default implementations from SuperColumnQB
12+
}

lib/Db/RowCellRelation.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Tables\Db;
6+
7+
class RowCellRelation extends RowCellSuper {
8+
protected ?string $value = null;
9+
10+
public function __construct() {
11+
parent::__construct();
12+
$this->addType('value', 'string');
13+
}
14+
15+
public function jsonSerialize(): array {
16+
return parent::jsonSerializePreparation($this->value);
17+
}
18+
}

lib/Db/RowCellRelationMapper.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\Tables\Db;
6+
7+
use OCP\IDBConnection;
8+
9+
/**
10+
* @template-extends RowCellMapperSuper<RowCellRelation, string, string|array>
11+
*/
12+
class RowCellRelationMapper extends RowCellMapperSuper {
13+
use RowCellBulkFetchTrait;
14+
15+
protected string $table = 'tables_row_cells_relation';
16+
17+
public function __construct(IDBConnection $db) {
18+
parent::__construct($db, $this->table, RowCellRelation::class);
19+
}
20+
21+
public function filterValueToQueryParam(Column $column, mixed $value): mixed {
22+
return $value ?? '';
23+
}
24+
25+
public function applyDataToEntity(Column $column, RowCellSuper $cell, $data): void {
26+
if (is_array($data)) {
27+
$cell->setValue(json_encode($data));
28+
} else {
29+
$cell->setValue($data);
30+
}
31+
}
32+
33+
public function formatEntity(Column $column, RowCellSuper $cell) {
34+
return json_decode($cell->getValue());
35+
}
36+
}

0 commit comments

Comments
 (0)