From 1aca11efc6594e6b34e751ab153e435298ee9e0c Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Fri, 18 Jul 2025 16:45:39 +0200 Subject: [PATCH 1/3] Enhancement: add a read-only option to view columns Signed-off-by: Kostiantyn Miakshyn --- cypress/e2e/ToDo list.json | 12 +++--- cypress/e2e/view.cy.js | 22 ++++++++++- lib/Controller/Api1Controller.php | 11 ++---- lib/Db/Column.php | 6 ++- lib/Db/View.php | 9 +++++ lib/ResponseDefinitions.php | 3 +- lib/Service/ColumnService.php | 16 +++++--- lib/Service/RowService.php | 19 ++++++++++ .../ValueObject/ViewColumnInformation.php | 36 +++++++++++++++--- lib/Service/ViewService.php | 3 +- openapi.json | 12 +++++- .../main/partials/ColumnFormComponent.vue | 6 +++ .../editViewPartials/SelectedViewColumns.vue | 37 +++++++++++++++++-- src/modules/modals/ViewSettings.vue | 8 ++-- .../components/ncTable/mixins/columnClass.js | 1 + .../rowTypePartials/DatetimeDateForm.vue | 4 +- .../partials/rowTypePartials/DatetimeForm.vue | 4 +- .../rowTypePartials/DatetimeTimeForm.vue | 8 +++- .../partials/rowTypePartials/NumberForm.vue | 1 + .../rowTypePartials/NumberProgressForm.vue | 1 + .../rowTypePartials/NumberStarsForm.vue | 10 +++-- .../rowTypePartials/SelectionCheckForm.vue | 6 ++- .../rowTypePartials/SelectionForm.vue | 6 ++- .../rowTypePartials/SelectionMultiForm.vue | 8 +++- .../partials/rowTypePartials/TextLineForm.vue | 2 +- .../partials/rowTypePartials/TextLinkForm.vue | 1 + .../partials/rowTypePartials/TextLongForm.vue | 1 + .../partials/rowTypePartials/TextRichForm.vue | 4 +- .../rowTypePartials/UsergroupForm.vue | 1 + src/types/openapi/openapi.ts | 2 + 30 files changed, 206 insertions(+), 54 deletions(-) diff --git a/cypress/e2e/ToDo list.json b/cypress/e2e/ToDo list.json index dca16f3c9b..f59cd05b93 100644 --- a/cypress/e2e/ToDo list.json +++ b/cypress/e2e/ToDo list.json @@ -6,6 +6,7 @@ "id": 91, "tableId": 19, "title": "Task", + "readonly": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -25,7 +26,6 @@ "textDefault": "", "textAllowedPattern": "", "textMaxLength": -1, - "textUnique": false, "selectionOptions": [], "selectionDefault": "", "datetimeDefault": "", @@ -40,6 +40,7 @@ "id": 92, "tableId": 19, "title": "Description", + "readonly": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -59,7 +60,6 @@ "textDefault": "", "textAllowedPattern": "", "textMaxLength": -1, - "textUnique": false, "selectionOptions": [], "selectionDefault": "", "datetimeDefault": "", @@ -74,6 +74,7 @@ "id": 93, "tableId": 19, "title": "Target", + "readonly": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -93,7 +94,6 @@ "textDefault": "", "textAllowedPattern": "", "textMaxLength": -1, - "textUnique": false, "selectionOptions": [], "selectionDefault": "", "datetimeDefault": "", @@ -108,6 +108,7 @@ "id": 94, "tableId": 19, "title": "Progress", + "readonly": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -127,7 +128,6 @@ "textDefault": "", "textAllowedPattern": "", "textMaxLength": -1, - "textUnique": false, "selectionOptions": [], "selectionDefault": "", "datetimeDefault": "", @@ -142,6 +142,7 @@ "id": 95, "tableId": 19, "title": "Comments", + "readonly": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -161,7 +162,6 @@ "textDefault": "", "textAllowedPattern": "", "textMaxLength": -1, - "textUnique": false, "selectionOptions": [], "selectionDefault": "", "datetimeDefault": "", @@ -176,6 +176,7 @@ "id": 96, "tableId": 19, "title": "Proofed", + "readonly": null, "createdBy": "admin", "createdByDisplayName": "admin", "createdAt": "2025-07-03 07:23:28", @@ -195,7 +196,6 @@ "textDefault": "", "textAllowedPattern": "", "textMaxLength": -1, - "textUnique": false, "selectionOptions": [], "selectionDefault": "", "datetimeDefault": "", diff --git a/cypress/e2e/view.cy.js b/cypress/e2e/view.cy.js index 6333c5ddc1..2f773986ac 100644 --- a/cypress/e2e/view.cy.js +++ b/cypress/e2e/view.cy.js @@ -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('Readonly').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') + + // Make sure that column is readonly during edit + cy.get('[data-cy="customTableRow"]').contains('first row').parent().parent().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 @@ -131,4 +151,4 @@ describe('Interact with views', () => { 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..50a385abe0 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -79,6 +79,8 @@ * @method setUsergroupSelectTeams(?bool $usergroupSelectTeams) * @method getShowUserStatus(): bool * @method setShowUserStatus(?bool $showUserStatus) + * @method getReadonly(): bool + * @method setReadonly(bool $readonly) */ class Column extends EntitySuper implements JsonSerializable { // Meta column types @@ -147,8 +149,9 @@ class Column extends EntitySuper implements JsonSerializable { // virtual properties protected ?string $createdByDisplayName = null; protected ?string $lastEditByDisplayName = null; + protected ?bool $readonly = null; - protected const VIRTUAL_PROPERTIES = ['createdByDisplayName', 'lastEditByDisplayName']; + protected const VIRTUAL_PROPERTIES = ['createdByDisplayName', 'lastEditByDisplayName', 'readonly']; public function __construct() { $this->addType('id', 'integer'); @@ -254,6 +257,7 @@ public function jsonSerialize(): array { 'lastEditBy' => $this->lastEditBy, 'lastEditByDisplayName' => $this->lastEditByDisplayName, 'lastEditAt' => $this->lastEditAt, + 'readonly' => $this->readonly, '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..ae5a31ddc5 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,7 @@ * usergroupSelectGroups: bool, * usergroupSelectTeams: bool, * showUserStatus: bool, + * readonly: bool, * } * * @psalm-type TablesImportState = array{ diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 5fd2ef1606..b691931aee 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->setReadonly($viewColumnInformation->isReadonly()); + } + 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..b17af06d22 100644 --- a/openapi.json +++ b/openapi.json @@ -101,7 +101,8 @@ "usergroupSelectUsers", "usergroupSelectGroups", "usergroupSelectTeams", - "showUserStatus" + "showUserStatus", + "readonly" ], "properties": { "id": { @@ -204,6 +205,9 @@ }, "showUserStatus": { "type": "boolean" + }, + "readonly": { + "type": "boolean" } } }, @@ -647,7 +651,8 @@ "type": "object", "required": [ "columnId", - "order" + "order", + "readonly" ], "properties": { "columnId": { @@ -657,6 +662,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..b4efe15a92 100644 --- a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue +++ b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue @@ -23,11 +23,24 @@ :checked="selectedColumns.includes(column.id)" class="display-checkbox" @update:checked="onToggle(column.id)" /> - {{ column.title }} + {{ column.title }}
({{ t('tables', 'Metadata') }})
+ +
+ + + {{ t('tables', 'Readonly') }} + + +
+