diff --git a/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index f991af4525..7b1116c026 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -16,4 +16,6 @@ enum ViewUpdatableParameters: string { case SORT = 'sort'; case FILTER = 'filter'; case COLUMN_SETTINGS = 'columns'; + case LAYOUT = 'layout'; + case VIEW_SETTINGS = 'viewSettings'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 0db4b1eb8b..666d6b3944 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -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|DataResponse * @@ -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()]; @@ -404,6 +405,8 @@ public function getView(int $viewId): DataResponse { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|null, + * viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}, * filter?: list> * } $data fields of the view with their new values * @return DataResponse|DataResponse diff --git a/lib/Controller/ApiTablesController.php b/lib/Controller/ApiTablesController.php index 7510929b37..d10cee8f7a 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -213,6 +213,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti $view['emoji'], $table, $this->userId, + $view['layout'] ?? null, ); $inputColumnsArray = []; @@ -250,6 +251,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti array_merge($inputColumnsArray, [ 'sort' => $newSort, 'filter' => $newFilter, + 'layout' => $view['layout'] ?? null, ]) )); } diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 2828dc4021..e8f6a9205e 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -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); }); } diff --git a/lib/Db/View.php b/lib/Db/View.php index 34783f76ef..4c3f1a7a37 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -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; @@ -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 @@ -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; @@ -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(); } @@ -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(); diff --git a/lib/Migration/Version1000Date20260318000000.php b/lib/Migration/Version1000Date20260318000000.php new file mode 100644 index 0000000000..28de2494d3 --- /dev/null +++ b/lib/Migration/Version1000Date20260318000000.php @@ -0,0 +1,45 @@ +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, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/ViewSettings.php b/lib/Model/ViewSettings.php new file mode 100644 index 0000000000..a018a81df6 --- /dev/null +++ b/lib/Model/ViewSettings.php @@ -0,0 +1,61 @@ +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]; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 3e01ef9789..a2979506e8 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -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, ) { } @@ -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; + } } /** @@ -61,11 +69,13 @@ public function updateDetail(): Generator { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|null, + * viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}|string, * filter?: list> * } $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); @@ -80,6 +90,9 @@ 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, @@ -87,9 +100,56 @@ public static function fromInputArray(array $data): self { 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) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 1dd4d5f6a8..4097017fa3 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -29,6 +29,8 @@ * columnSettings:list, * sort: list, * filter: list>, + * layout: 'table'|'tiles'|'gallery', + * viewSettings: array{cardBackgroundSource: int|null, cardTitleSource: int|null}, * isShared: bool, * favorite: bool, * onSharePermissions: ?array{ diff --git a/lib/Service/TableTemplateService.php b/lib/Service/TableTemplateService.php index c11fac37bf..6e2e38c6e3 100644 --- a/lib/Service/TableTemplateService.php +++ b/lib/Service/TableTemplateService.php @@ -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()); diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 9a8b558d68..bdd8e76192 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -28,6 +28,7 @@ use OCA\Tables\Model\FilterSet; use OCA\Tables\Model\Permissions; use OCA\Tables\Model\SortRuleSet; +use OCA\Tables\Model\ViewSettings; use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\ViewColumnInformation; @@ -193,7 +194,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 @@ -209,6 +210,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); @@ -251,14 +253,16 @@ 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); } + $this->assertCardSourceColumnsAreValid($view); + $time = new DateTime(); $view->setLastEditBy($userId); $view->setLastEditAt($time->format('Y-m-d H:i:s')); @@ -293,6 +297,29 @@ protected function assertInputColumnsAreValid(View $view, string $userId, Column } } + /** + * Ensures that card view settings reference columns that are part of the view. + * @throws InvalidArgumentException + */ + protected function assertCardSourceColumnsAreValid(View $view): void { + $viewColumnIds = $view->getColumnIds(); + if (empty($viewColumnIds)) { + return; + } + + $viewSettings = $view->getViewSettingsObject(); + + $backgroundSource = $viewSettings->getCardBackgroundSource(); + if ($backgroundSource !== null && !in_array($backgroundSource, $viewColumnIds, true)) { + throw new InvalidArgumentException('Invalid cardBackgroundSource column ID: ' . $backgroundSource); + } + + $titleSource = $viewSettings->getCardTitleSource(); + if ($titleSource !== null && !in_array($titleSource, $viewColumnIds, true)) { + throw new InvalidArgumentException('Invalid cardTitleSource column ID: ' . $titleSource); + } + } + /** * @param int $id * @param string|null $userId @@ -613,6 +640,8 @@ 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->setViewSettings(json_encode($this->createImportedViewSettings($view))); try { $this->mapper->insert($item); } catch (\Exception $e) { @@ -620,4 +649,15 @@ public function importView(int $tableId, array $view, string $userId): void { throw new InternalError('userMigrationImport insert error: ' . $e->getMessage()); } } + + private function createImportedViewSettings(array $view): ViewSettings { + if (isset($view['viewSettings']) && is_array($view['viewSettings'])) { + return ViewSettings::createFromInputArray($view['viewSettings']); + } + + return ViewSettings::createFromInputArray([ + 'cardBackgroundSource' => $view['cardBackgroundSource'] ?? null, + 'cardTitleSource' => $view['cardTitleSource'] ?? null, + ]); + } } diff --git a/openapi.json b/openapi.json index 6ab031fc0d..5e3f346869 100644 --- a/openapi.json +++ b/openapi.json @@ -912,6 +912,8 @@ "columnSettings", "sort", "filter", + "layout", + "viewSettings", "isShared", "favorite", "onSharePermissions", @@ -1062,6 +1064,33 @@ } } }, + "layout": { + "type": "string", + "enum": [ + "table", + "tiles", + "gallery" + ] + }, + "viewSettings": { + "type": "object", + "required": [ + "cardBackgroundSource", + "cardTitleSource" + ], + "properties": { + "cardBackgroundSource": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "cardTitleSource": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "isShared": { "type": "boolean" }, @@ -1883,6 +1912,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" } } } @@ -2184,6 +2219,30 @@ } } }, + "layout": { + "type": "string", + "nullable": true, + "enum": [ + "table", + "tiles", + "gallery" + ] + }, + "viewSettings": { + "type": "object", + "properties": { + "cardBackgroundSource": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "cardTitleSource": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "filter": { "type": "array", "items": { diff --git a/package-lock.json b/package-lock.json index 95a595a6db..5ddc1ce91f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3208,6 +3208,17 @@ "source-map-js": "^1.2.1" } }, + "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-shared": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz", @@ -3394,6 +3405,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nextcloud/dialogs/node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@nextcloud/dialogs/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -6093,6 +6127,34 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/eslint-config-typescript": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", @@ -13853,6 +13915,14 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -15428,6 +15498,21 @@ } } }, + "node_modules/rollup-plugin-license/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/rollup-plugin-node-externals": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/rollup-plugin-node-externals/-/rollup-plugin-node-externals-8.1.1.tgz", diff --git a/playwright/e2e/view-layout.spec.ts b/playwright/e2e/view-layout.spec.ts new file mode 100644 index 0000000000..3a6c62c2d5 --- /dev/null +++ b/playwright/e2e/view-layout.spec.ts @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import type { Page } from '@playwright/test' +import { + clickOnTableThreeDotMenu, + createTable, + createTextLineColumn, + fillInValueTextLine, + loadTable, + loadView, +} from '../support/commands' + +const tableTitle = 'Layout mode test table' +const defaultViewTitle = 'Layout default table view' +const tilesViewTitle = 'Layout tiles view' +const galleryViewTitle = 'Layout gallery view' + +async function setupLayoutTable(page: Page) { + await createTable(page, tableTitle) + await createTextLineColumn(page, 'preview', '', '', true) + await createTextLineColumn(page, 'title', '', '', false) + await createTextLineColumn(page, 'category', '', '', false) + + const addRow = async (preview: string, title: string, category: string) => { + await page.locator('[data-cy="createRowBtn"]').click() + await fillInValueTextLine(page, 'preview', preview) + await fillInValueTextLine(page, 'title', title) + await fillInValueTextLine(page, 'category', category) + await page.locator('[data-cy="createRowSaveButton"]').click() + } + + await addRow('not-a-preview-url', 'First layout row', 'Catalogue') +} + +async function createLayoutView(page: Page, title: string, layout?: 'tiles' | 'gallery') { + await loadTable(page, tableTitle) + await clickOnTableThreeDotMenu(page, 'Create view') + + const titleInput = page.locator('[data-cy="viewSettingsDialogTitleInput"]') + await titleInput.waitFor({ state: 'visible', timeout: 10000 }) + await titleInput.fill(title) + + if (layout) { + await page.locator(`[data-cy="viewLayout${layout === 'tiles' ? 'Tiles' : 'Gallery'}"]`).click() + } + + const createViewReqPromise = page.waitForResponse( + response => response.url().includes('/apps/tables/view') && response.request().method() === 'POST', + ) + await page.locator('[data-cy="modifyViewBtn"]').click() + await createViewReqPromise + + await expect(page.locator('[data-cy="navigationViewItem"]').filter({ hasText: title }).first()).toBeVisible() +} + +async function expectTableLayout(page: Page) { + await expect(page.locator('[data-cy="customTableRow"]').filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="tilesLayoutCard"]')).toHaveCount(0) + await expect(page.locator('[data-cy="galleryLayoutCard"]')).toHaveCount(0) +} + +async function expectTilesLayout(page: Page) { + const cards = page.locator('[data-cy="tilesLayoutCard"]') + await expect(cards).toHaveCount(1) + await expect(cards.filter({ hasText: 'First layout row' }).first()).toBeVisible() + await expect(page.locator('[data-cy="galleryLayoutBody"]')).toHaveCount(0) + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +async function expectGalleryLayout(page: Page) { + const cards = page.locator('[data-cy="galleryLayoutCard"]') + await expect(cards).toHaveCount(1) + + const firstCard = cards.filter({ hasText: 'First layout row' }).first() + await expect(firstCard).toBeVisible() + await expect(firstCard.locator('[data-cy="galleryMetadataItem"]').filter({ hasText: 'category' })).toContainText('Catalogue') + await expect(page.locator('[data-cy="customTableRow"]')).toHaveCount(0) +} + +test.describe('View layout modes', () => { + test('renders and persists table, tiles, and gallery layouts for views', async ({ userPage: { page } }) => { + test.setTimeout(120000) + await page.goto('/index.php/apps/tables') + await setupLayoutTable(page) + await loadTable(page, tableTitle) + + await createLayoutView(page, defaultViewTitle) + await loadView(page, defaultViewTitle) + await expectTableLayout(page) + + await createLayoutView(page, tilesViewTitle, 'tiles') + await loadView(page, tilesViewTitle) + await expectTilesLayout(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 }) + await expectTilesLayout(page) + + await createLayoutView(page, galleryViewTitle, 'gallery') + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + + await loadTable(page, tableTitle) + await loadView(page, galleryViewTitle) + await expectGalleryLayout(page) + }) +}) diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index b2a7387740..0c7ad2c0da 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -146,7 +146,18 @@ 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.viewSetting = { + ...this.viewSetting, + viewSettings: { + cardBackgroundSource: this.element?.viewSettings?.cardBackgroundSource ?? null, + cardTitleSource: this.element?.viewSettings?.cardTitleSource ?? null, + }, + } + } if (this.element?.sort?.length) { this.viewSetting.presetSorting = [...this.element.sort] } diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index fef14d5f89..5ca675f1ec 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -53,6 +53,48 @@ :generated-filters="viewSetting ? generatedView.filter : null" :columns="allColumns" /> + + + + + + + + + +
+
+
+ {{ t('tables', 'Background source') }} +
+ +
+
+
+ {{ t('tables', 'Title source') }} +
+ +
+
+
@@ -210,6 +393,159 @@ export default { margin-top: 0!important; } +.selected-page{ + padding-inline-start: 5px; + + display:inline-flex; + align-items: center; +} + +.page-number{ + padding-inline: 5px; +} + +.large-width{ + width: 100vw !important; + inset-inline-start: 0 !important; +} + +.pagination-items{ + background-color: var(--color-main-background); + border-radius: var(--border-radius-large); + pointer-events: all; + + display: flex; + align-items: center; +} + +.pagination-footer{ + box-shadow: var(--box-shadow); + filter: drop-shadow(0 1px 6px var(--color-box-shadow)); + padding-bottom: 20px; + width: calc(100vw - 316px); + pointer-events: none; + + display: flex; + justify-content: center; + align-items: center; + + :deep(.v-select) { + min-width: 95px !important; + } +} + +.container { + min-width: 0; +} + +.container--cards { + width: var(--app-content-width, 100%); + max-width: var(--app-content-width, 100%); +} + +.card-layout { + width: 100%; + display: grid; + grid-auto-flow: row; + gap: 16px; + padding-inline: calc(var(--default-grid-baseline) * 2); + padding-top: 8px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); +} + +.layout-card { + width: 100%; + max-width: 100%; + padding: 0; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius-large); + overflow: hidden; + background: var(--color-main-background); + text-align: start; + cursor: pointer; + color: var(--color-main-text); +} + +.layout-card__image-wrapper { + position: relative; + aspect-ratio: 3 / 2; + background: var(--color-background-dark); +} + +.layout-card__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.layout-card__no-image { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-maxcontrast); +} + +.layout-card__title-banner { + position: absolute; + inset-inline: 0; + bottom: 0; + padding: 12px; + background: rgba(0,0,0,0.4); + color: #fff; + text-align: start; + font-weight: 600; + display: flex; + justify-content: flex-start; +} + +.layout-card__body { + padding: 12px; + max-height: 168px; + overflow: hidden; +} + +.layout-card__title { + font-weight: 600; + margin-bottom: 8px; +} + +.layout-card__metadata { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.layout-card__metadata li { + display: block; +} + +.layout-card__metadata li + li { + padding-top: 8px; +} + +.layout-card__metadata-label { + font-size: 12px; + font-weight: 600; + color: var(--color-text-maxcontrast); + margin-bottom: 2px; +} + +.layout-card__metadata-value { + font-weight: 400; + color: var(--color-main-text); + white-space: normal; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + :deep(table) { position: relative; border-collapse: collapse; @@ -221,7 +557,6 @@ export default { * { border: none; } - // white-space: nowrap; td, th { padding-inline-end: 8px; @@ -254,7 +589,7 @@ export default { th { vertical-align: middle; color: var(--color-text-maxcontrast); - box-shadow: inset 0 -1px 0 var(--color-border); // use box-shadow instead of border to be compatible with sticky heads + box-shadow: inset 0 -1px 0 var(--color-border); background-color: var(--color-main-background-translucent); z-index: 5; } @@ -262,7 +597,6 @@ export default { } tbody { - td { text-align: start; vertical-align: middle; @@ -286,7 +620,6 @@ export default { background-color: inherit; } - // viewer integration .editor-wrapper { min-width: 100px; overflow-y: auto; @@ -308,7 +641,6 @@ export default { } } - // inline editing .inline-editing-container { position: relative; width: 100%; @@ -376,15 +708,12 @@ export default { } tr>td.sticky:last-child { - // visibility: hidden; opacity: 0; } tr:hover>td:last-child { - // visibility: visible; opacity: 1; } - } .table-row-leave-active { @@ -400,5 +729,4 @@ export default { margin-bottom: 0 !important; transform: translateX(-1rem); } - diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2478af53c3..b8bf48660c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1184,6 +1184,14 @@ export type components = { readonly 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"; readonly value: string | number; }[])[]; + /** @enum {string} */ + readonly layout: "table" | "tiles" | "gallery"; + readonly viewSettings: { + /** Format: int64 */ + readonly cardBackgroundSource: number | null; + /** Format: int64 */ + readonly cardTitleSource: number | null; + }; readonly isShared: boolean; readonly favorite: boolean; readonly onSharePermissions: { @@ -1670,6 +1678,11 @@ export interface operations { readonly title: string; /** @description Emoji for the view */ readonly emoji?: string | null; + /** + * @description Layout for the view with 'table', 'tiles', 'gallery' or null + * @default null + */ + readonly layout?: string | null; }; }; }; @@ -1827,6 +1840,14 @@ export interface operations { /** @enum {string} */ readonly mode: "ASC" | "DESC"; }[]; + /** @enum {string|null} */ + readonly layout?: "table" | "tiles" | "gallery" | null; + readonly viewSettings?: { + /** Format: int64 */ + readonly cardBackgroundSource?: number | null; + /** Format: int64 */ + readonly cardTitleSource?: number | null; + }; readonly filter?: readonly (readonly { /** Format: int64 */ readonly columnId: number;