Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
539a382
Merge origin/main into feature branch, resolve conflicts
Copilot Apr 28, 2026
8f93610
Filter card source options to view-accessible columns; add backend va…
Copilot Apr 28, 2026
556b61b
feat(layout): change to dynamic settings column
Rello Apr 29, 2026
d71645e
Merge branch 'main' into codex/implement-persistent-layout-modes-in-n…
Rello Apr 29, 2026
821a173
feat(layout): add playwright test
Rello Apr 29, 2026
14ff143
feat(layout): add playwright test
Rello Apr 29, 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
2 changes: 2 additions & 0 deletions lib/Constants/ViewUpdatableParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ enum ViewUpdatableParameters: string {
case SORT = 'sort';
case FILTER = 'filter';
case COLUMN_SETTINGS = 'columns';
case LAYOUT = 'layout';
case VIEW_SETTINGS = 'viewSettings';
}
7 changes: 5 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,8 @@ 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,
* viewSettings?: array{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 @@ -213,6 +213,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti
$view['emoji'],
$table,
$this->userId,
$view['layout'] ?? null,
);

$inputColumnsArray = [];
Expand Down Expand Up @@ -250,6 +251,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
17 changes: 17 additions & 0 deletions lib/Db/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCA\Tables\Model\FilterSet;
use OCA\Tables\Model\Permissions;
use OCA\Tables\Model\SortRuleSet;
use OCA\Tables\Model\ViewSettings;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\ValueObject\ViewColumnInformation;

Expand Down Expand Up @@ -45,6 +46,10 @@
* @method setEmoji(string $emoji)
* @method getDescription(): string
* @method setDescription(string $description)
* @method getLayout(): ?string
* @method setLayout(?string $layout)
* @method getViewSettings(): ?string
* @method setViewSettings(?string $viewSettings)
* @method getIsShared(): bool
* @method setIsShared(bool $isShared)
* @method getOnSharePermissions(): ?Permissions
Expand Down Expand Up @@ -74,6 +79,8 @@ 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 ?string $viewSettings = null; // json

// virtual properties
protected ?bool $isShared = null;
Expand Down Expand Up @@ -171,6 +178,14 @@ 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';
}

public function getViewSettingsObject(): ViewSettings {
return ViewSettings::createFromInputArray($this->getArray($this->getViewSettings()));
}

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

Expand Down
45 changes: 45 additions & 0 deletions lib/Migration/Version1000Date20260318000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?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('view_settings')) {
$table->addColumn('view_settings', \Doctrine\DBAL\Types\Types::JSON, [
'notnull' => false,
]);
}
Comment thread
Rello marked this conversation as resolved.

return $schema;
}
}
61 changes: 61 additions & 0 deletions lib/Model/ViewSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Model;

use InvalidArgumentException;
use JsonSerializable;

class ViewSettings implements JsonSerializable {
public function __construct(
protected readonly ?int $cardBackgroundSource = null,
protected readonly ?int $cardTitleSource = null,
) {
}

/**
* @param array{cardBackgroundSource?: int|null, cardTitleSource?: int|null} $data
*/
public static function createFromInputArray(array $data): self {
return new self(
cardBackgroundSource: self::nullableIntFromArray($data, 'cardBackgroundSource'),
cardTitleSource: self::nullableIntFromArray($data, 'cardTitleSource'),
);
}

public function getCardBackgroundSource(): ?int {
return $this->cardBackgroundSource;
}

public function getCardTitleSource(): ?int {
return $this->cardTitleSource;
}

/**
* @return array{cardBackgroundSource: int|null, cardTitleSource: int|null}
*/
public function jsonSerialize(): array {
return [
'cardBackgroundSource' => $this->cardBackgroundSource,
'cardTitleSource' => $this->cardTitleSource,
];
}

private static function nullableIntFromArray(array $data, string $key): ?int {
if (!array_key_exists($key, $data) || $data[$key] === null) {
return null;
}

if (!is_int($data[$key])) {
throw new InvalidArgumentException('Invalid ' . $key . ' value.');
}

return $data[$key];
}
}
62 changes: 61 additions & 1 deletion lib/Model/ViewUpdateInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ 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 ?ViewSettings $viewSettings = null,
) {
}

Expand All @@ -51,6 +53,12 @@ public function updateDetail(): Generator {
if ($this->sortRuleSet) {
yield ViewUpdatableParameters::SORT => $this->sortRuleSet;
}
if ($this->layout !== null) {
yield ViewUpdatableParameters::LAYOUT => $this->layout;
}
if ($this->viewSettings !== null) {
yield ViewUpdatableParameters::VIEW_SETTINGS => $this->viewSettings;
}
}

/**
Expand All @@ -61,11 +69,13 @@ 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,
* viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}|string,
* 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
*/
public static function fromInputArray(array $data): self {
$data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort']);
$data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort', 'viewSettings']);

if (isset($data['columns']) && !isset($data['columnSettings'])) {
$logger = Server::get(LoggerInterface::class);
Expand All @@ -80,16 +90,66 @@ public static function fromInputArray(array $data): self {
$data['columnSettings'] = $value;
}

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

return new self(
title: ($data['title'] ?? null) ? new Title($data['title']) : null,
description: $data['description'] ?? null,
emoji: ($data['emoji'] ?? null) ? new Emoji($data['emoji']) : null,
columnSettings: ($data['columnSettings'] ?? null) ? ColumnSettings::createViewSettingsFromInputArray($data['columnSettings']) : null,
filterSet: ($data['filter'] ?? null) ? FilterSet::createFromInputArray($data['filter']) : null,
sortRuleSet: ($data['sort'] ?? null) ? SortRuleSet::createFromInputArray($data['sort']) : null,
layout: $layout,
viewSettings: $viewSettings,
);
}

private static function createViewSettingsFromInputData(array $data): ?ViewSettings {
if (array_key_exists('viewSettings', $data)) {
if ($data['viewSettings'] === null) {
return new ViewSettings();
}
if (!is_array($data['viewSettings'])) {
throw new \InvalidArgumentException('Invalid viewSettings value.');
}
return ViewSettings::createFromInputArray($data['viewSettings']);
}

$legacyKeys = ['cardBackgroundSource', 'cardTitleSource'];
$hasLegacySettings = false;
foreach ($legacyKeys as $legacyKey) {
if (array_key_exists($legacyKey, $data)) {
$hasLegacySettings = true;
break;
}
}
if (!$hasLegacySettings) {
return null;
}

return ViewSettings::createFromInputArray([
'cardBackgroundSource' => $data['cardBackgroundSource'] ?? null,
'cardTitleSource' => $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
2 changes: 2 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
* 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',
* viewSettings: array{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
Loading
Loading