diff --git a/cypress/e2e/view.cy.js b/cypress/e2e/view.cy.js index 9e267a1507..13b855dba1 100644 --- a/cypress/e2e/view.cy.js +++ b/cypress/e2e/view.cy.js @@ -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', () => { @@ -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', () => { @@ -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') diff --git a/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index f991af4525..56bedd065a 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -16,4 +16,5 @@ enum ViewUpdatableParameters: string { case SORT = 'sort'; case FILTER = 'filter'; case COLUMN_SETTINGS = 'columns'; + case LAYOUT = 'layout'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 0db4b1eb8b..8663b74a26 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -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()]; @@ -404,6 +404,7 @@ public function getView(int $viewId): DataResponse { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|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 3ccc8115f3..4d9e309301 100644 --- a/lib/Controller/ApiTablesController.php +++ b/lib/Controller/ApiTablesController.php @@ -181,6 +181,7 @@ public function createFromScheme(string $title, string $emoji, string $descripti $view['emoji'], $table, $this->userId, + $view['layout'] ?? null, ); $inputColumnsArray = []; @@ -218,6 +219,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 e3d87a9e63..93ed00b05e 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -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 @@ -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; @@ -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(); } @@ -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(); diff --git a/lib/Migration/Version1000Date20260318000000.php b/lib/Migration/Version1000Date20260318000000.php new file mode 100644 index 0000000000..82dec0be4e --- /dev/null +++ b/lib/Migration/Version1000Date20260318000000.php @@ -0,0 +1,40 @@ +hasTable($tableName)) { + return null; + } + + $table = $schema->getTable($tableName); + if (!$table->hasColumn('layout')) { + $table->addColumn('layout', Types::STRING, [ + 'notnull' => false, + 'length' => 16, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 5c1972f1d5..cfbf116e33 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -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, ) { } @@ -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; + } } /** @@ -61,6 +65,7 @@ public function updateDetail(): Generator { * columns?: list, * columnSettings?: list, * sort?: list, + * layout?: 'table'|'tiles'|'gallery'|null, * filter?: list> * } $data */ @@ -80,6 +85,8 @@ 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, @@ -87,9 +94,27 @@ public static function fromInputArray(array $data): self { 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) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4ab08bdcd0..38eaeda627 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -29,6 +29,7 @@ * columnSettings:list, * sort: list, * filter: list>, + * layout: 'table'|'tiles'|'gallery', * 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 cc05fc5b81..bb0535be3d 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -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 @@ -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); @@ -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(); @@ -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) { diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index bbd8099484..b0b53593aa 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -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] } diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index fef14d5f89..804aa2c6de 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -53,6 +53,29 @@ :generated-filters="viewSetting ? generatedView.filter : null" :columns="allColumns" /> + + + + + + + + + + + + + + + + + @@ -461,6 +553,108 @@ export default { } } +.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; +} + +.layout-card__image-wrapper { + position: relative; + aspect-ratio: 1 / 1; + 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: center; + font-weight: 600; +} + +.layout-card__body { + padding: 12px; +} + +.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: flex; + flex-direction: column; + gap: 2px; +} + +.layout-card__metadata li + li { + padding-top: 8px; +} + +.layout-card__metadata-label { + font-size: 12px; + color: var(--color-text-maxcontrast); +} + +.layout-card__metadata-value { + font-weight: 400; + white-space: normal; + overflow-wrap: anywhere; +} + :deep(table) { position: relative; border-collapse: collapse; @@ -472,7 +666,6 @@ export default { * { border: none; } - // white-space: nowrap; td, th { padding-inline-end: 8px; @@ -505,7 +698,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; } @@ -513,7 +706,6 @@ export default { } tbody { - td { text-align: start; vertical-align: middle; @@ -537,7 +729,6 @@ export default { background-color: inherit; } - // viewer integration .editor-wrapper { min-width: 100px; overflow-y: auto; @@ -559,7 +750,6 @@ export default { } } - // inline editing .inline-editing-container { position: relative; width: 100%; @@ -603,15 +793,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 { @@ -627,5 +814,4 @@ export default { margin-bottom: 0 !important; transform: translateX(-1rem); } -