Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
01d9619
Add persistent per-view layouts
Rello Mar 18, 2026
19fa7e5
Fix layout review feedback
Rello Mar 18, 2026
23edbaf
Refactor value assignment for JsonSerializable
Rello Mar 19, 2026
aea4c66
Refactor layout options to use a table format
Rello Mar 19, 2026
0cb66de
Add dynamic class binding for card layout
Rello Mar 19, 2026
38accd4
Refactor CustomTable layout and URL generation
Rello Mar 19, 2026
7a8eec8
Update styles for custom table in NcTable.vue
Rello Mar 19, 2026
76a0cdb
Merge branch 'main' into codex/implement-persistent-layout-modes-in-n…
Rello Mar 19, 2026
28fbeed
Delete tests/unit/Db/ViewLayoutTest.php
Rello Mar 19, 2026
76fc76d
Delete cypress/e2e/view.cy.js
Rello Apr 13, 2026
eb8dc8d
Merge branch 'main' into codex/implement-persistent-layout-modes-in-n…
Rello Apr 13, 2026
966c79f
update doc
Rello Apr 13, 2026
99e0bc7
Add layout property with enum options to openapi.ts
Rello Apr 13, 2026
8bb97f0
Add 'layout' property to OpenAPI schema
Rello Apr 13, 2026
e962941
Change layout type to string or null
Rello Apr 13, 2026
de58e69
Add files via upload
Rello Apr 13, 2026
8df9e93
Enhance layout property documentation
Rello Apr 13, 2026
97f12ed
Add files via upload
Rello Apr 13, 2026
24339c8
Add files via upload
Rello Apr 13, 2026
5c9000d
Add files via upload
Rello Apr 13, 2026
3ec1e87
Add files via upload
Rello Apr 13, 2026
4887356
Add files via upload
Rello Apr 13, 2026
9403893
Add files via upload
Rello Apr 13, 2026
0ccebd1
Add files via upload
Rello Apr 13, 2026
2ad4739
Add files via upload
Rello Apr 13, 2026
d57edf7
Add files via upload
Rello Apr 13, 2026
55ed950
Add files via upload
Rello Apr 13, 2026
0bf453d
Add files via upload
Rello Apr 14, 2026
c079712
Add files via upload
Rello Apr 14, 2026
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
3 changes: 3 additions & 0 deletions lib/Constants/ViewUpdatableParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ enum ViewUpdatableParameters: string {
case SORT = 'sort';
case FILTER = 'filter';
case COLUMN_SETTINGS = 'columns';
case LAYOUT = 'layout';
case CARD_BACKGROUND_SOURCE = 'cardBackgroundSource';
case CARD_TITLE_SOURCE = 'cardTitleSource';
}
8 changes: 6 additions & 2 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ public function indexViews(int $tableId): DataResponse {
* @param int $tableId Table ID that will hold the view
* @param string $title Title for the view
* @param string|null $emoji Emoji for the view
* @param string|null $layout Layout for the view with 'table', 'tiles', 'gallery' or null
*
* @return DataResponse<Http::STATUS_OK, TablesView, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
Expand All @@ -346,9 +347,9 @@ public function indexViews(int $tableId): DataResponse {
#[CORS]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function createView(int $tableId, string $title, ?string $emoji): DataResponse {
public function createView(int $tableId, string $title, ?string $emoji, ?string $layout = null): DataResponse {
try {
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId))->jsonSerialize());
return new DataResponse($this->viewService->create($title, $emoji, $this->tableService->find($tableId), null, $layout)->jsonSerialize());
} catch (PermissionError $e) {
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
$message = ['message' => $e->getMessage()];
Expand Down Expand Up @@ -404,6 +405,9 @@ public function getView(int $viewId): DataResponse {
* columns?: list<int>,
* columnSettings?: list<array{columnId?: int, order?: int, readonly?: bool, mandatory?: bool}>,
* sort?: list<array{columnId: int, mode: 'ASC'|'DESC'}>,
* layout?: 'table'|'tiles'|'gallery'|null,
* cardBackgroundSource?: int|null,
* cardTitleSource?: int|null,
* filter?: list<list<array{columnId: int, operator: 'begins-with'|'ends-with'|'contains'|'does-not-contain'|'is-equal'|'is-not-equal'|'is-greater-than'|'is-greater-than-or-equal'|'is-lower-than'|'is-lower-than-or-equal'|'is-empty', value: string|int|float}>>
* } $data fields of the view with their new values
* @return DataResponse<Http::STATUS_OK, TablesView, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
Expand Down
2 changes: 2 additions & 0 deletions lib/Controller/ApiTablesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti
$view['emoji'],
$table,
$this->userId,
$view['layout'] ?? null,
);

$inputColumnsArray = [];
Expand Down Expand Up @@ -218,6 +219,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti
array_merge($inputColumnsArray, [
'sort' => $newSort,
'filter' => $newFilter,
'layout' => $view['layout'] ?? null,
])
));
}
Expand Down
6 changes: 3 additions & 3 deletions lib/Controller/ViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ public function show(int $id): DataResponse {

#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
public function create(int $tableId, string $title, ?string $emoji): DataResponse {
return $this->handleError(function () use ($tableId, $title, $emoji) {
return $this->service->create($title, $emoji, $this->getTable($tableId, true));
public function create(int $tableId, string $title, ?string $emoji, ?string $layout = null): DataResponse {
return $this->handleError(function () use ($tableId, $title, $emoji, $layout) {
return $this->service->create($title, $emoji, $this->getTable($tableId, true), null, $layout);
});
}

Expand Down
18 changes: 18 additions & 0 deletions lib/Db/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
* @method setEmoji(string $emoji)
* @method getDescription(): string
* @method setDescription(string $description)
* @method getLayout(): ?string
* @method setLayout(?string $layout)
* @method getCardBackgroundSource(): ?int
* @method setCardBackgroundSource(?int $cardBackgroundSource)
* @method getCardTitleSource(): ?int
* @method setCardTitleSource(?int $cardTitleSource)
* @method getIsShared(): bool
* @method setIsShared(bool $isShared)
* @method getOnSharePermissions(): ?Permissions
Expand Down Expand Up @@ -74,6 +80,9 @@ class View extends EntitySuper implements JsonSerializable {
protected ?string $columns = null; // json
protected ?string $sort = null; // json
protected ?string $filter = null; // json
protected ?string $layout = null;
protected ?int $cardBackgroundSource = null;
protected ?int $cardTitleSource = null;

// virtual properties
protected ?bool $isShared = null;
Expand All @@ -89,6 +98,8 @@ class View extends EntitySuper implements JsonSerializable {
public function __construct() {
$this->addType('id', 'integer');
$this->addType('tableId', 'integer');
$this->addType('cardBackgroundSource', 'integer');
$this->addType('cardTitleSource', 'integer');
}

/**
Expand Down Expand Up @@ -171,6 +182,10 @@ public function setFilterArray(array $array):void {
$this->setFilter(\json_encode($array));
}

public function getLayoutNormalized(): string {
return in_array($this->layout, ['tiles', 'gallery'], true) ? $this->layout : 'table';
}

private function getSharePermissions(): ?Permissions {
return $this->getOnSharePermissions();
}
Expand Down Expand Up @@ -199,6 +214,9 @@ public function jsonSerialize(): array {
'hasShares' => (bool)$this->hasShares,
'rowsCount' => $this->rowsCount ?: 0,
'ownerDisplayName' => $this->ownerDisplayName,
'layout' => $this->getLayoutNormalized(),
'cardBackgroundSource' => $this->cardBackgroundSource,
'cardTitleSource' => $this->cardTitleSource,
];
$serialisedJson['filter'] = $this->getFilterArray();

Expand Down
52 changes: 52 additions & 0 deletions lib/Migration/Version1000Date20260318000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

class Version1000Date20260318000000 extends SimpleMigrationStep {

#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$tableName = 'tables_views';
if (!$schema->hasTable($tableName)) {
return null;
}

$table = $schema->getTable($tableName);
if (!$table->hasColumn('layout')) {
$table->addColumn('layout', Types::STRING, [
'notnull' => false,
'length' => 16,
]);
}
if (!$table->hasColumn('card_background_source')) {
$table->addColumn('card_background_source', Types::INTEGER, [
'notnull' => false,
'unsigned' => false,
]);
}
if (!$table->hasColumn('card_title_source')) {
$table->addColumn('card_title_source', Types::INTEGER, [
'notnull' => false,
'unsigned' => false,
]);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@Rello
I’m planning to introduce a Kanban layout, which will also require additional columns. This led me to consider an alternative approach: instead of continuously adding new dedicated columns for each feature, we could introduce a single settings column (stored as JSON text).

This settings field would encapsulate various configuration attributes, allowing us to store extensible metadata without inflating the schema.

I see this as a more scalable and maintainable solution, as it avoids excessive schema growth and simplifies future migrations.

Example:

settings =
{
layout: ...,
card_background_source: ...,
card_title_source: ...,
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hi,

thats a good idea. But I would keep the dedicated layout column for performance. there, your kanban would then also go in. but to derive the pure layout of a view (where table might be the standard) it would be overhead to parse the settings json every time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi.
Are you planning to replace the card_background_source and card_title_source columns with a single settings (JSON) column, or should I try to push that change into your PR?


return $schema;
}
}
37 changes: 37 additions & 0 deletions lib/Model/ViewUpdateInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public function __construct(
protected readonly ?ColumnSettings $columnSettings = null,
protected readonly ?FilterSet $filterSet = null,
protected readonly ?SortRuleSet $sortRuleSet = null,
protected readonly ?string $layout = null,
protected readonly ?int $cardBackgroundSource = null,
protected readonly ?int $cardTitleSource = null,
) {
}

Expand All @@ -51,6 +54,15 @@ public function updateDetail(): Generator {
if ($this->sortRuleSet) {
yield ViewUpdatableParameters::SORT => $this->sortRuleSet;
}
if ($this->layout !== null) {
yield ViewUpdatableParameters::LAYOUT => $this->layout;
}
if ($this->cardBackgroundSource !== null) {
yield ViewUpdatableParameters::CARD_BACKGROUND_SOURCE => $this->cardBackgroundSource;
}
if ($this->cardTitleSource !== null) {
yield ViewUpdatableParameters::CARD_TITLE_SOURCE => $this->cardTitleSource;
}
}

/**
Expand All @@ -61,6 +73,9 @@ public function updateDetail(): Generator {
* columns?: list<int>,
* columnSettings?: list<array{columnId?: int, order?: int, readonly?: bool, mandatory?: bool}>,
* sort?: list<array{columnId: int, mode: 'ASC'|'DESC'}>,
* layout?: 'table'|'tiles'|'gallery'|null,
* cardBackgroundSource?: int|null,
* cardTitleSource?: int|null,
* filter?: list<list<array{columnId: int, operator: 'begins-with'|'ends-with'|'contains'|'does-not-contain'|'is-equal'|'is-not-equal'|'is-greater-than'|'is-greater-than-or-equal'|'is-lower-than'|'is-lower-than-or-equal'|'is-empty', value: string|int|float}>>
* } $data
*/
Expand All @@ -80,16 +95,38 @@ public static function fromInputArray(array $data): self {
$data['columnSettings'] = $value;
}

$layout = self::normalizeLayout($data['layout'] ?? null);

return new self(
title: $data['title'] ? new Title($data['title']) : null,
description: $data['description'] ?? null,
emoji: $data['emoji'] ? new Emoji($data['emoji']) : null,
columnSettings: $data['columnSettings'] ? ColumnSettings::createFromInputArray($data['columnSettings']) : null,
filterSet: $data['filter'] ? FilterSet::createFromInputArray($data['filter']) : null,
sortRuleSet: $data['sort'] ? SortRuleSet::createFromInputArray($data['sort']) : null,
layout: $layout,
cardBackgroundSource: array_key_exists('cardBackgroundSource', $data) && $data['cardBackgroundSource'] !== null ? (int)$data['cardBackgroundSource'] : null,
cardTitleSource: array_key_exists('cardTitleSource', $data) && $data['cardTitleSource'] !== null ? (int)$data['cardTitleSource'] : null,
);
}


private static function normalizeLayout(mixed $layout): ?string {
if ($layout === null || $layout === '') {
return null;
}

if (!is_string($layout)) {
throw new \InvalidArgumentException('Invalid layout value.');
}

if (!in_array($layout, ['table', 'tiles', 'gallery'], true)) {
throw new \InvalidArgumentException('Invalid layout value.');
}

return $layout;
}

protected static function transformJsonToArrayInPayload(array $input, array $keys): array {
$output = $input;
foreach ($keys as $targetKey) {
Expand Down
3 changes: 3 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
* columnSettings:list<array{columnId: int, order: int, readonly: bool}>,
* sort: list<array{columnId: int, mode: 'ASC'|'DESC'}>,
* filter: list<list<array{columnId: int, operator: 'begins-with'|'ends-with'|'contains'|'does-not-contain'|'is-equal'|'is-not-equal'|'is-greater-than'|'is-greater-than-or-equal'|'is-lower-than'|'is-lower-than-or-equal'|'is-empty', value: string|int|float}>>,
* layout: 'table'|'tiles'|'gallery',
* cardBackgroundSource: int|null,
* cardTitleSource: int|null,
* isShared: bool,
* favorite: bool,
* onSharePermissions: ?array{
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/TableTemplateService.php
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@ private function createRow(Table $table, array $values): void {
private function createView(Table $table, array $data): void {
try {
$inputData = ViewUpdateInput::fromInputArray($data);
$view = $this->viewService->create($data['title'], $data['emoji'], $table);
$view = $this->viewService->create($data['title'], $data['emoji'], $table, null, $data['layout'] ?? null);
$this->viewService->update($view->getId(), $inputData);
} catch (PermissionError $e) {
$this->logger->warning('Cannot create view, permission denied: ' . $e->getMessage());
Expand Down
14 changes: 9 additions & 5 deletions lib/Service/ViewService.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ public function findSharedViewsWithMe(?string $userId = null): array {
* @throws InternalError
* @throws PermissionError
*/
public function create(string $title, ?string $emoji, Table $table, ?string $userId = null): View {
public function create(string $title, ?string $emoji, Table $table, ?string $userId = null, ?string $layout = null): View {
/** @var string $userId */
$userId = $this->permissionsService->preCheckUserId($userId, false); // $userId is set

Expand All @@ -209,6 +209,7 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use
$item->setEmoji($emoji);
}
$item->setDescription('');
$item->setLayout(in_array($layout, ['tiles', 'gallery'], true) ? $layout : null);
$item->setTableId($table->getId());
$item->setCreatedBy($userId);
$item->setLastEditBy($userId);
Expand Down Expand Up @@ -251,12 +252,12 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b
$this->assertInputColumnsAreValid($view, $userId, $value);
}

if ($value instanceof JsonSerializable) {
$insertableValue = json_encode($value);
}
$insertableValue = $value instanceof JsonSerializable
? json_encode($value)
: $value;

$setterMethod = 'set' . ucfirst($parameter->value);
$view->$setterMethod($insertableValue ?? $value);
$view->$setterMethod($insertableValue);
}

$time = new DateTime();
Expand Down Expand Up @@ -613,6 +614,9 @@ public function importView(int $tableId, array $view, string $userId): void {
$item->setColumns(json_encode($view['columnSettings']));
$item->setSort(json_encode($view['sort']));
$item->setFilter(json_encode($view['filter']));
$item->setLayout(in_array($view['layout'] ?? null, ['tiles', 'gallery'], true) ? $view['layout'] : null);
$item->setCardBackgroundSource(isset($view['cardBackgroundSource']) ? (int)$view['cardBackgroundSource'] : null);
$item->setCardTitleSource(isset($view['cardTitleSource']) ? (int)$view['cardTitleSource'] : null);
try {
$this->mapper->insert($item);
} catch (\Exception $e) {
Expand Down
46 changes: 46 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,9 @@
"columnSettings",
"sort",
"filter",
"layout",
"cardBackgroundSource",
"cardTitleSource",
"isShared",
"favorite",
"onSharePermissions",
Expand Down Expand Up @@ -1013,6 +1016,24 @@
}
}
},
"layout": {
"type": "string",
"enum": [
"table",
"tiles",
"gallery"
]
},
"cardBackgroundSource": {
"type": "integer",
"format": "int64",
"nullable": true
},
"cardTitleSource": {
"type": "integer",
"format": "int64",
"nullable": true
},
"isShared": {
"type": "boolean"
},
Expand Down Expand Up @@ -1834,6 +1855,12 @@
"type": "string",
"nullable": true,
"description": "Emoji for the view"
},
"layout": {
"type": "string",
"nullable": true,
"default": null,
"description": "Layout for the view with 'table', 'tiles', 'gallery' or null"
}
}
}
Expand Down Expand Up @@ -2135,6 +2162,25 @@
}
}
},
"layout": {
"type": "string",
"nullable": true,
"enum": [
"table",
"tiles",
"gallery"
]
},
"cardBackgroundSource": {
"type": "integer",
"format": "int64",
"nullable": true
},
"cardTitleSource": {
"type": "integer",
"format": "int64",
"nullable": true
},
"filter": {
"type": "array",
"items": {
Expand Down
Loading
Loading