Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions cypress/e2e/view.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const firstTitle = 'Test view'
const secondTitle = 'Test view 2'
const thirdTitle = 'Test view 3'
const fourthTitle = 'Test view 4'
const fifthTitle = 'Gallery view'

describe('Interact with views', () => {

Expand Down Expand Up @@ -59,6 +60,7 @@ describe('Interact with views', () => {
cy.get('[data-cy="navigationTableItem"]').contains(secondTitle).should('not.exist')
cy.get('[data-cy="navigationTableItem"]').contains(thirdTitle).should('not.exist')
cy.get('[data-cy="navigationTableItem"]').contains(fourthTitle).should('not.exist')
cy.get('[data-cy="navigationTableItem"]').contains(fifthTitle).should('not.exist')
})

it('Create view and insert rows in the view', () => {
Expand Down Expand Up @@ -156,6 +158,34 @@ describe('Interact with views', () => {
// cy.get('[data-cy="editRowSaveButton"]').contains('Save').click()
})



it('Persists the configured gallery layout for a view', () => {
cy.loadTable('View test table')
cy.get('[data-cy="customTableAction"] button').click()
cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true })
cy.get('[data-cy="viewSettingsDialogTitleInput"]').type(fifthTitle)
cy.get('[data-cy="viewLayoutGallery"]').click({ force: true })

cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView')
cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView')
cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click()
cy.wait('@createView').its('request.body').should('include', { layout: 'gallery' })
cy.wait('@updateView').its('request.body.data').should('include', { layout: 'gallery' })

cy.get('[data-cy="customTableRow"]').should('not.exist')
cy.get('[data-cy="galleryLayoutBody"]').should('have.length.at.least', 1)
cy.get('[data-cy="galleryMetadataItem"]').contains('title').should('not.exist')
cy.get('[data-cy="galleryMetadataItem"]').contains('selection').should('exist')
cy.get('[data-cy="viewSettingsDialog"]').should('not.exist')
cy.contains('.options', 'Gallery').should('not.exist')

cy.reload()
cy.loadView(fifthTitle)
cy.get('[data-cy="galleryLayoutBody"]').should('have.length.at.least', 1)
cy.get('[data-cy="customTableRow"]').should('not.exist')
})

it('Create view and delete rows in the view', () => {
cy.loadTable('View test table')

Expand Down
1 change: 1 addition & 0 deletions lib/Constants/ViewUpdatableParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ enum ViewUpdatableParameters: string {
case SORT = 'sort';
case FILTER = 'filter';
case COLUMN_SETTINGS = 'columns';
case LAYOUT = 'layout';
}
5 changes: 3 additions & 2 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,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 +404,7 @@ 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,
* 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
8 changes: 8 additions & 0 deletions lib/Db/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
* @method setEmoji(string $emoji)
* @method getDescription(): string
* @method setDescription(string $description)
* @method getLayout(): ?string
* @method setLayout(?string $layout)
* @method getIsShared(): bool
* @method setIsShared(bool $isShared)
* @method getOnSharePermissions(): ?Permissions
Expand Down Expand Up @@ -74,6 +76,7 @@ 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;

// virtual properties
protected ?bool $isShared = null;
Expand Down Expand Up @@ -171,6 +174,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 +206,7 @@ public function jsonSerialize(): array {
'hasShares' => (bool)$this->hasShares,
'rowsCount' => $this->rowsCount ?: 0,
'ownerDisplayName' => $this->ownerDisplayName,
'layout' => $this->getLayoutNormalized(),
];
$serialisedJson['filter'] = $this->getFilterArray();

Expand Down
40 changes: 40 additions & 0 deletions lib/Migration/Version1000Date20260318000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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,
]);
}

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

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

/**
Expand All @@ -61,6 +65,7 @@ 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,
* 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 +85,36 @@ 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,
);
}


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
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
* 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',
* 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
12 changes: 7 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 @@ -610,6 +611,7 @@ 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);
try {
$this->mapper->insert($item);
} catch (\Exception $e) {
Expand Down
4 changes: 3 additions & 1 deletion src/modules/main/sections/MainWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ export default {
// Since we show one page at a time, no need keep other tables in the store
this.clearState()

this.viewSetting = {}
this.viewSetting = {
layout: this.isView ? (this.element?.layout ?? 'table') : 'table',
}
if (this.isView && this.element?.sort?.length) {
this.viewSetting.presetSorting = [...this.element.sort]
}
Expand Down
Loading
Loading