Skip to content
Merged
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
6 changes: 6 additions & 0 deletions cypress/e2e/ToDo list.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"id": 91,
"tableId": 19,
"title": "Task",
"viewColumnInformation": null,
"createdBy": "admin",
"createdByDisplayName": "admin",
"createdAt": "2025-07-03 07:23:28",
Expand Down Expand Up @@ -40,6 +41,7 @@
"id": 92,
"tableId": 19,
"title": "Description",
"viewColumnInformation": null,
"createdBy": "admin",
"createdByDisplayName": "admin",
"createdAt": "2025-07-03 07:23:28",
Expand Down Expand Up @@ -74,6 +76,7 @@
"id": 93,
"tableId": 19,
"title": "Target",
"viewColumnInformation": null,
"createdBy": "admin",
"createdByDisplayName": "admin",
"createdAt": "2025-07-03 07:23:28",
Expand Down Expand Up @@ -108,6 +111,7 @@
"id": 94,
"tableId": 19,
"title": "Progress",
"viewColumnInformation": null,
"createdBy": "admin",
"createdByDisplayName": "admin",
"createdAt": "2025-07-03 07:23:28",
Expand Down Expand Up @@ -142,6 +146,7 @@
"id": 95,
"tableId": 19,
"title": "Comments",
"viewColumnInformation": null,
"createdBy": "admin",
"createdByDisplayName": "admin",
"createdAt": "2025-07-03 07:23:28",
Expand Down Expand Up @@ -176,6 +181,7 @@
"id": 96,
"tableId": 19,
"title": "Proofed",
"viewColumnInformation": null,
"createdBy": "admin",
"createdByDisplayName": "admin",
"createdAt": "2025-07-03 07:23:28",
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/tables-import-export-scheme.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
31 changes: 26 additions & 5 deletions cypress/e2e/view.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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')
})
})
})
11 changes: 3 additions & 8 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
Koc marked this conversation as resolved.
$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) {
Expand Down
7 changes: 6 additions & 1 deletion lib/Db/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use OCA\Tables\Dto\Column as ColumnDto;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\ValueObject\ViewColumnInformation;

/**
* @psalm-suppress PropertyNotSetInConstructor
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions lib/Db/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ public function getColumnsSettingsArray(): array {
return $result;
}

public function findColumnSettingsForColumn(int $columnId): ?ViewColumnInformation {
Comment thread
Koc marked this conversation as resolved.
foreach ($this->getColumnsSettingsArray() as $columnSettings) {
if ($columnSettings->getId() === $columnId) {
return $columnSettings;
}
}
return null;
}

/**
* @psalm-suppress MismatchingDocblockReturnType
* @return list<array{columnId: int, mode: 'ASC'|'DESC'}>
Expand Down
7 changes: 6 additions & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* lastEditAt: string,
* description: string|null,
* columns: list<int>,
* columnSettings:list<array{columnId: int, order: int}>,
* 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'|'is-equal'|'is-greater-than'|'is-greater-than-or-equal'|'is-lower-than'|'is-lower-than-or-equal'|'is-empty', value: string|int|float}>>,
* isShared: bool,
Expand Down Expand Up @@ -132,6 +132,11 @@
* usergroupSelectGroups: bool,
* usergroupSelectTeams: bool,
* showUserStatus: bool,
* viewColumnInformation: ?array{
* columnId: int,
* order: int,
* readonly: bool,
* },
* }
*
* @psalm-type TablesImportState = array{
Expand Down
16 changes: 11 additions & 5 deletions lib/Service/ColumnService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions lib/Service/RowService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -277,11 +279,24 @@ private function enhanceWithViewDefaults(?View $view, RowDataInput $data): RowDa
return $data;
}

/**
* @return array<int, true>
*/
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);
Expand All @@ -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']));
}
Expand Down
36 changes: 30 additions & 6 deletions lib/Service/ValueObject/ViewColumnInformation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand All @@ -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"),
};
}
}
Loading
Loading