diff --git a/cypress/e2e/ToDo list.json b/cypress/e2e/ToDo list.json index dca16f3c9b..31f3d4de50 100644 --- a/cypress/e2e/ToDo list.json +++ b/cypress/e2e/ToDo list.json @@ -6,6 +6,7 @@ "id": 91, "tableId": 19, "title": "Task", + "viewColumnInformation": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -40,6 +41,7 @@ "id": 92, "tableId": 19, "title": "Description", + "viewColumnInformation": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -74,6 +76,7 @@ "id": 93, "tableId": 19, "title": "Target", + "viewColumnInformation": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -108,6 +111,7 @@ "id": 94, "tableId": 19, "title": "Progress", + "viewColumnInformation": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -142,6 +146,7 @@ "id": 95, "tableId": 19, "title": "Comments", + "viewColumnInformation": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -176,6 +181,7 @@ "id": 96, "tableId": 19, "title": "Proofed", + "viewColumnInformation": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", diff --git a/cypress/e2e/tables-import-export-scheme.cy.js b/cypress/e2e/tables-import-export-scheme.cy.js index ee73689d2f..aecfb4ec11 100644 --- a/cypress/e2e/tables-import-export-scheme.cy.js +++ b/cypress/e2e/tables-import-export-scheme.cy.js @@ -41,7 +41,7 @@ describe('Import Export Scheme', () => { cy.get('.action-button').contains('Edit view').click() const columns = ['Target', 'Description', 'Progress', 'Proofed', 'Comments', 'Task'] for (let i = 0; i < columns.length; i++) { - cy.get('#settings-section_columns-and-order .column-entry > .row-elements:not(.move').eq(i).should('contain', columns[i]).find('input.checkbox-radio-switch__input').should('be.checked') + cy.get('[data-cy="selectedViewColumnEl"]').eq(i).should('contain', columns[i]).find('input.checkbox-radio-switch__input').should('be.checked') } cy.get('#settings-section_filter .v-select').eq(0).should('contain', 'Progress') cy.get('#settings-section_filter .v-select').eq(1).should('contain', 'Is lower than') diff --git a/cypress/e2e/view.cy.js b/cypress/e2e/view.cy.js index 6333c5ddc1..b1f567ebfc 100644 --- a/cypress/e2e/view.cy.js +++ b/cypress/e2e/view.cy.js @@ -52,8 +52,8 @@ describe('Interact with views', () => { cy.get('[data-cy="customTableAction"] button').click() cy.get('[data-cy="dataTableEditTableBtn"]').contains('Edit table').click() cy.get('[data-cy="editTableModal"]').should('be.visible') - cy.get('[data-cy="editTableModal"] button').contains('Delete').click() - cy.get('[data-cy="editTableModal"] button').contains('I really want to delete this table!').click() + cy.get('[data-cy="editTableModal"] [data-cy="editTableDeleteBtn"]').click() + cy.get('[data-cy="editTableModal"] [data-cy="editTableConfirmDeleteBtn"]').click() cy.wait(10).get('.toastify.toast-success').should('be.visible') cy.get('[data-cy="navigationTableItem"]').contains('View test table').should('not.exist') cy.get('[data-cy="navigationTableItem"]').contains(title).should('not.exist') @@ -113,6 +113,26 @@ describe('Interact with views', () => { cy.get('[data-cy="customTableRow"]').contains('Changed row').should('exist') }) + it('Create view and make column readonly in the view', () => { + // trigger three dot menu and select readonly + cy.contains('.column-entry', 'title').find('[data-cy="customColumnAction"] button').click({ force: true }) + cy.get('[data-cy="columnReadonlyCheckbox"]').contains('Read only').click() + + // ## save view + 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') + cy.wait('@updateView') + + cy.get('[data-cy="navigationViewItem"]').contains(title).should('exist') + + // TODO: Make sure that column is readonly during edit + // cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() + // cy.get('[data-cy="editRowModal"]').contains('.row.space-T', 'title').find('input').should('have.attr', 'readonly') + // cy.get('[data-cy="editRowSaveButton"]').contains('Save').click() + }) + it('Create view and delete rows in the view', () => { // ## save view @@ -122,13 +142,14 @@ describe('Interact with views', () => { cy.wait('@createView') cy.wait('@updateView') cy.get('[data-cy="navigationViewItem"]').contains(title).should('exist') + cy.get('.icon-loading').should('not.exist') // Delete rows in the view cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() - cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteButton"]').contains('Delete').click() - cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteConfirmButton"]').contains('I really want to delete this row!').click() + cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteButton"]').click() + cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteConfirmButton"]').click() cy.get('[data-cy="editRowModal"]').should('not.exist') cy.get('[data-cy="customTableRow"]').contains('first row').should('not.exist') }) -}) \ No newline at end of file +}) diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 64303b0c9a..f5e9049fe3 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -729,19 +729,14 @@ public function updateShareDisplayMode(int $shareId, int $displayMode, string $t #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexTableColumns(int $tableId, ?int $viewId): DataResponse { try { - try { - // permission check in service class - // they might be missing legally when only the view was shared - $columns = $this->columnService->findAllByTable($tableId); - } catch (PermissionError $e) { - if (!$viewId) { - throw $e; - } + if ($viewId) { $view = $this->viewService->find($viewId, false, $this->userId); if ($tableId !== $view->getTableId()) { throw new PermissionError('Given table is not a parent of the given view.'); } $columns = $this->columnService->findAllByManagedView($view, $this->userId); + } else { + $columns = $this->columnService->findAllByTable($tableId); } return new DataResponse($this->columnService->formatColumns($columns)); } catch (PermissionError $e) { diff --git a/lib/Db/Column.php b/lib/Db/Column.php index d7bb5dc51c..8d4623fe6c 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -11,6 +11,7 @@ use OCA\Tables\Dto\Column as ColumnDto; use OCA\Tables\ResponseDefinitions; +use OCA\Tables\Service\ValueObject\ViewColumnInformation; /** * @psalm-suppress PropertyNotSetInConstructor @@ -79,6 +80,8 @@ * @method setUsergroupSelectTeams(?bool $usergroupSelectTeams) * @method getShowUserStatus(): bool * @method setShowUserStatus(?bool $showUserStatus) + * @method getViewColumnInformation(): ViewColumnInformation + * @method setViewColumnInformation(ViewColumnInformation $viewColumnInformation) */ class Column extends EntitySuper implements JsonSerializable { // Meta column types @@ -147,8 +150,9 @@ class Column extends EntitySuper implements JsonSerializable { // virtual properties protected ?string $createdByDisplayName = null; protected ?string $lastEditByDisplayName = null; + protected ?ViewColumnInformation $viewColumnInformation = null; - protected const VIRTUAL_PROPERTIES = ['createdByDisplayName', 'lastEditByDisplayName']; + protected const VIRTUAL_PROPERTIES = ['createdByDisplayName', 'lastEditByDisplayName', 'viewColumnInformation']; public function __construct() { $this->addType('id', 'integer'); @@ -254,6 +258,7 @@ public function jsonSerialize(): array { 'lastEditBy' => $this->lastEditBy, 'lastEditByDisplayName' => $this->lastEditByDisplayName, 'lastEditAt' => $this->lastEditAt, + 'viewColumnInformation' => $this->viewColumnInformation?->jsonSerialize(), 'type' => $this->type, 'subtype' => $this->subtype, 'mandatory' => $this->mandatory, diff --git a/lib/Db/View.php b/lib/Db/View.php index e8b4de1676..05982396b5 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -122,6 +122,15 @@ public function getColumnsSettingsArray(): array { return $result; } + public function findColumnSettingsForColumn(int $columnId): ?ViewColumnInformation { + foreach ($this->getColumnsSettingsArray() as $columnSettings) { + if ($columnSettings->getId() === $columnId) { + return $columnSettings; + } + } + return null; + } + /** * @psalm-suppress MismatchingDocblockReturnType * @return list diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index b89ba437c8..1d2e64947e 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -26,7 +26,7 @@ * lastEditAt: string, * description: string|null, * columns: list, - * columnSettings:list, + * columnSettings:list, * sort: list, * filter: list>, * isShared: bool, @@ -132,6 +132,11 @@ * usergroupSelectGroups: bool, * usergroupSelectTeams: bool, * showUserStatus: bool, + * viewColumnInformation: ?array{ + * columnId: int, + * order: int, + * readonly: bool, + * }, * } * * @psalm-type TablesImportState = array{ diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 5fd2ef1606..3001d2d6d8 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -19,6 +19,7 @@ use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\UserHelper; use OCA\Tables\ResponseDefinitions; +use OCA\Tables\Service\ValueObject\ViewColumnInformation; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\IL10N; @@ -86,7 +87,7 @@ public function findAllByTable(int $tableId, ?string $userId = null): array { public function findAllByManagedView(View $view, string $userId): array { if ($this->permissionsService->canManageView($view, $userId)) { try { - return $this->enhanceColumns($this->mapper->findAllByTable($view->getTableId())); + return $this->enhanceColumns($this->mapper->findAllByTable($view->getTableId()), $view); } catch (\OCP\DB\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -120,7 +121,7 @@ public function findAllByView(int $viewId, ?string $userId = null): array { } $viewColumns = $this->mapper->findAll($view->getColumnIds()); - return $this->enhanceColumns($viewColumns); + return $this->enhanceColumns($viewColumns, $view); } /** @@ -516,21 +517,26 @@ public function getColumnsCount(int $tableId): int { * * @return Column */ - private function enhanceColumn(Column $column): Column { + private function enhanceColumn(Column $column, ?ViewColumnInformation $viewColumnInformation = null): Column { // add created by display name for UI usage $column->setCreatedByDisplayName($this->userHelper->getUserDisplayName($column->getCreatedBy())); $column->setLastEditByDisplayName($this->userHelper->getUserDisplayName($column->getLastEditBy())); + + if ($viewColumnInformation) { + $column->setViewColumnInformation($viewColumnInformation); + } + return $column; } - private function enhanceColumns(?array $columns): array { + private function enhanceColumns(?array $columns, ?View $view = null): array { if ($columns === null) { return []; } foreach ($columns as $column) { if ($column instanceof Column) { - $this->enhanceColumn($column); + $this->enhanceColumn($column, $view?->findColumnSettingsForColumn($column->getId())); } } return $columns; diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index c105fdcb46..885cf03893 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -25,6 +25,7 @@ use OCA\Tables\Model\RowDataInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnTypes\IColumnTypeBusiness; +use OCA\Tables\Service\ValueObject\ViewColumnInformation; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception; @@ -211,6 +212,7 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): R $data = $this->cleanupAndValidateData($data, $columns, $tableId, $viewId); $data = $this->enhanceWithViewDefaults($view, $data); + $tableId = $tableId ?? $view->getTableId(); $row2 = new Row2(); $row2->setTableId($tableId); $row2->setData($data); @@ -277,11 +279,24 @@ private function enhanceWithViewDefaults(?View $view, RowDataInput $data): RowDa return $data; } + /** + * @return array + */ + private function extractReadOnlyColumns(View $view): array { + $columnSettings = $view->getColumnsSettingsArray(); + return array_reduce($columnSettings, static function (array $carry, ViewColumnInformation $column) { + $carry[$column->getId()] = $column->isReadonly(); + return $carry; + }, []); + } + /** * @throws InternalError * @throws BadRequestError */ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $tableId, ?int $viewId, ?int $rowId = null): RowDataInput { + $readOnlyColumns = $viewId ? $this->extractReadOnlyColumns($this->viewMapper->find($viewId)) : []; + $out = new RowDataInput(); foreach ($data as $entry) { $column = $this->getColumnFromColumnsArray((int)$entry['columnId'], $columns); @@ -304,6 +319,10 @@ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if (!empty($readOnlyColumns[$entry['columnId']])) { + continue; + } + // parse given value to respect the column type value format $out->add((int)$entry['columnId'], $this->parseValueByColumnType($column, $entry['value'])); } diff --git a/lib/Service/ValueObject/ViewColumnInformation.php b/lib/Service/ValueObject/ViewColumnInformation.php index 403877daff..61b4ccf24b 100644 --- a/lib/Service/ValueObject/ViewColumnInformation.php +++ b/lib/Service/ValueObject/ViewColumnInformation.php @@ -17,31 +17,45 @@ class ViewColumnInformation implements ArrayAccess, JsonSerializable { public const KEY_ID = 'columnId'; public const KEY_ORDER = 'order'; + public const KEY_READONLY = 'readonly'; - /** @var array{columndId?: int, order?: int} */ + /** @var array{columndId?: int, order?: int, readonly?: bool} */ protected array $data = []; protected const KEYS = [ self::KEY_ID, self::KEY_ORDER, + self::KEY_READONLY, ]; public function __construct( int $columnId, int $order, + bool $readonly = false, ) { $this->offsetSet(self::KEY_ID, $columnId); $this->offsetSet(self::KEY_ORDER, $order); + $this->offsetSet(self::KEY_READONLY, $readonly); + } + + public function getId(): int { + return $this->offsetGet(self::KEY_ID); } public function getOrder(): int { return $this->offsetGet(self::KEY_ORDER); } + public function isReadonly(): bool { + return $this->offsetGet(self::KEY_READONLY) ?? false; + } + public static function fromArray(array $data): static { - $vci = new static($data[self::KEY_ID], $data[self::KEY_ORDER]); - foreach ($data as $key => $value) { - $vci[$key] = $value; - } + $vci = new static( + $data[self::KEY_ID], + $data[self::KEY_ORDER], + $data[self::KEY_READONLY] ?? false, + ); + return $vci; } @@ -57,7 +71,8 @@ public function offsetSet(mixed $offset, mixed $value): void { if (!$this->offsetExists($offset)) { return; } - $this->data[(string)$offset] = $value; + + $this->data[$offset] = $this->ensureType($offset, $value); } public function offsetUnset(mixed $offset): void { @@ -70,4 +85,13 @@ public function offsetUnset(mixed $offset): void { public function jsonSerialize(): array { return $this->data; } + + protected function ensureType(string $offset, mixed $value): mixed { + return match ($offset) { + self::KEY_ID, + self::KEY_ORDER => (int)$value, + self::KEY_READONLY => (bool)$value, + default => throw new \InvalidArgumentException("Invalid offset: $offset"), + }; + } } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index d9ae7bb646..3bddd7ae58 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -598,7 +598,8 @@ public function search(string $term, int $limit = 100, int $offset = 0, ?string public function addColumnToView(View $view, Column $column, ?string $userId = null): void { try { $columnsSettings = $view->getColumnsSettingsArray(); - $nextOrder = empty($columnsSettings) ? 0 : max(array_column($columnsSettings, ViewColumnInformation::KEY_ORDER)) + 1; + $orders = array_map(fn (ViewColumnInformation $setting) => $setting->getOrder(), $view->getColumnsSettingsArray()); + $nextOrder = $orders ? max($orders) + 1 : 0; $columnsSettings[] = new ViewColumnInformation($column->getId(), $nextOrder); $this->update($view->getId(), ['columnSettings' => json_encode($columnsSettings)], $userId, true); } catch (Exception $e) { diff --git a/openapi.json b/openapi.json index a6fe04caac..273954189a 100644 --- a/openapi.json +++ b/openapi.json @@ -101,7 +101,8 @@ "usergroupSelectUsers", "usergroupSelectGroups", "usergroupSelectTeams", - "showUserStatus" + "showUserStatus", + "viewColumnInformation" ], "properties": { "id": { @@ -204,6 +205,28 @@ }, "showUserStatus": { "type": "boolean" + }, + "viewColumnInformation": { + "type": "object", + "nullable": true, + "required": [ + "columnId", + "order", + "readonly" + ], + "properties": { + "columnId": { + "type": "integer", + "format": "int64" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "readonly": { + "type": "boolean" + } + } } } }, @@ -647,7 +670,8 @@ "type": "object", "required": [ "columnId", - "order" + "order", + "readonly" ], "properties": { "columnId": { @@ -657,6 +681,9 @@ "order": { "type": "integer", "format": "int64" + }, + "readonly": { + "type": "boolean" } } } diff --git a/src/modules/main/partials/ColumnFormComponent.vue b/src/modules/main/partials/ColumnFormComponent.vue index 4c93aeff4a..be2f6a303f 100644 --- a/src/modules/main/partials/ColumnFormComponent.vue +++ b/src/modules/main/partials/ColumnFormComponent.vue @@ -90,3 +90,9 @@ export default { }, } + + diff --git a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue index 5bba4541f2..6f6a278f7b 100644 --- a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue +++ b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue @@ -9,7 +9,7 @@
-
+
- {{ column.title }} + {{ column.title }}
({{ t('tables', 'Metadata') }})
+ +
+ + + {{ t('tables', 'Read only') }} + + +
+