From c3f6a2d8e338beaa3e324dbc49a9bce0886569cc Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Fri, 9 Jan 2026 13:45:05 +0100 Subject: [PATCH] feat: add relation column type Signed-off-by: Kostiantyn Miakshyn --- appinfo/routes.php | 3 + lib/Controller/Api1Controller.php | 74 +++++- lib/Controller/RowController.php | 2 +- lib/Db/Column.php | 1 + lib/Db/RowCellRelation.php | 19 ++ lib/Db/RowCellRelationMapper.php | 37 +++ lib/Helper/ColumnsHelper.php | 4 + .../Version002001Date20260109000000.php | 42 +++ lib/Service/ColumnTypes/RelationBusiness.php | 76 ++++++ lib/Service/ImportService.php | 4 + lib/Service/RelationService.php | 241 ++++++++++++++++++ openapi.json | 17 +- .../main/partials/ColumnFormComponent.vue | 2 + .../main/partials/ColumnTypeSelection.vue | 5 + src/modules/main/sections/MainWrapper.vue | 9 +- src/modules/modals/CreateColumn.vue | 18 ++ src/modules/modals/EditColumn.vue | 8 +- src/modules/modals/ImportPreview.vue | 2 + .../ncTable/mixins/columnHandler.js | 1 + .../components/ncTable/mixins/columnParser.js | 2 + .../ncTable/mixins/columnsTypes/relation.js | 93 +++++++ .../components/ncTable/mixins/filter.js | 22 +- .../ncTable/partials/FilterLabel.vue | 2 +- .../ncTable/partials/TableCellRelation.vue | 50 ++++ .../partials/TableHeaderColumnOptions.vue | 2 +- .../components/ncTable/partials/TableRow.vue | 2 + .../columnTypePartials/forms/RelationForm.vue | 217 ++++++++++++++++ .../partials/rowTypePartials/RelationForm.vue | 110 ++++++++ src/store/data.js | 47 ++++ 29 files changed, 1098 insertions(+), 14 deletions(-) create mode 100644 lib/Db/RowCellRelation.php create mode 100644 lib/Db/RowCellRelationMapper.php create mode 100644 lib/Migration/Version002001Date20260109000000.php create mode 100644 lib/Service/ColumnTypes/RelationBusiness.php create mode 100644 lib/Service/RelationService.php create mode 100644 src/shared/components/ncTable/mixins/columnsTypes/relation.js create mode 100644 src/shared/components/ncTable/partials/TableCellRelation.vue create mode 100644 src/shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue create mode 100644 src/shared/components/ncTable/partials/rowTypePartials/RelationForm.vue diff --git a/appinfo/routes.php b/appinfo/routes.php index e279bb967a..d80c59cd8f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -50,6 +50,9 @@ ['name' => 'api1#updateColumn', 'url' => '/api/1/columns/{columnId}', 'verb' => 'PUT'], ['name' => 'api1#getColumn', 'url' => '/api/1/columns/{columnId}', 'verb' => 'GET'], ['name' => 'api1#deleteColumn', 'url' => '/api/1/columns/{columnId}', 'verb' => 'DELETE'], + // -> relations + ['name' => 'api1#indexTableRelations', 'url' => '/api/1/tables/{tableId}/relations', 'verb' => 'GET'], + ['name' => 'api1#indexViewRelations', 'url' => '/api/1/views/{viewId}/relations', 'verb' => 'GET'], // -> rows ['name' => 'api1#indexTableRowsSimple', 'url' => '/api/1/tables/{tableId}/rows/simple', 'verb' => 'GET'], ['name' => 'api1#indexTableRows', 'url' => '/api/1/tables/{tableId}/rows', 'verb' => 'GET'], diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 361497f0f4..96caf02a0a 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -23,6 +23,7 @@ use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ImportService; +use OCA\Tables\Service\RelationService; use OCA\Tables\Service\RowService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\TableService; @@ -57,6 +58,7 @@ class Api1Controller extends ApiController { private RowService $rowService; private ImportService $importService; private ViewService $viewService; + private RelationService $relationService; private ViewMapper $viewMapper; private IL10N $l10N; @@ -77,6 +79,7 @@ public function __construct( RowService $rowService, ImportService $importService, ViewService $viewService, + RelationService $relationService, ViewMapper $viewMapper, V1Api $v1Api, LoggerInterface $logger, @@ -90,6 +93,7 @@ public function __construct( $this->rowService = $rowService; $this->importService = $importService; $this->viewService = $viewService; + $this->relationService = $relationService; $this->viewMapper = $viewMapper; $this->userId = $userId; $this->v1Api = $v1Api; @@ -803,13 +807,77 @@ public function indexViewColumns(int $viewId): DataResponse { } } + /** + * Get all relation data for a table + * + * @param int $tableId Table ID + * @return DataResponse>, array{}>|DataResponse + * + * 200: Relation data returned + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[CORS] + #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] + public function indexTableRelations(int $tableId): DataResponse { + try { + return new DataResponse($this->relationService->getRelationsForTable($tableId)); + } catch (PermissionError $e) { + $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_FORBIDDEN); + } catch (InternalError $e) { + $this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]); + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (NotFoundError $e) { + $this->logger->info('A not found error occurred: ' . $e->getMessage(), ['exception' => $e]); + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_NOT_FOUND); + } + } + + /** + * Get all relation data for a view + * + * @param int $viewId View ID + * @return DataResponse>, array{}>|DataResponse + * + * 200: Relation data returned + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[CORS] + #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] + public function indexViewRelations(int $viewId): DataResponse { + try { + return new DataResponse($this->relationService->getRelationsForView($viewId)); + } catch (PermissionError $e) { + $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_FORBIDDEN); + } catch (InternalError $e) { + $this->logger->error('An internal error or exception occurred: ' . $e->getMessage(), ['exception' => $e]); + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (NotFoundError $e) { + $this->logger->info('A not found error occurred: ' . $e->getMessage(), ['exception' => $e]); + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_NOT_FOUND); + } + } + /** * Create a column * * @param int|null $tableId Table ID * @param int|null $viewId View ID * @param string $title Title - * @param 'text'|'number'|'datetime'|'select'|'usergroup' $type Column main type + * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory * @param string|null $description Description @@ -1311,7 +1379,7 @@ public function createRowInTable(int $tableId, $data): DataResponse { #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getRow(int $rowId): DataResponse { try { - return new DataResponse($this->rowService->find($rowId)->jsonSerialize()); + return new DataResponse($this->rowService->find($rowId, $this->userId)->jsonSerialize()); } catch (PermissionError $e) { $this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]); $message = ['message' => $e->getMessage()]; @@ -1572,7 +1640,7 @@ public function createTableShare(int $tableId, string $receiver, string $receive * * @param int $tableId Table ID * @param string $title Title - * @param 'text'|'number'|'datetime'|'select'|'usergroup' $type Column main type + * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory * @param string|null $description Description diff --git a/lib/Controller/RowController.php b/lib/Controller/RowController.php index 144089f7e4..50b1dd7ed2 100644 --- a/lib/Controller/RowController.php +++ b/lib/Controller/RowController.php @@ -47,7 +47,7 @@ public function indexView(int $viewId): DataResponse { #[NoAdminRequired] public function show(int $id): DataResponse { return $this->handleError(function () use ($id) { - return $this->service->find($id); + return $this->service->find($id, $this->userId); }); } diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 3710956208..f722010581 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -100,6 +100,7 @@ class Column extends EntitySuper implements JsonSerializable { public const TYPE_NUMBER = 'number'; public const TYPE_DATETIME = 'datetime'; public const TYPE_USERGROUP = 'usergroup'; + public const TYPE_RELATION = 'relation'; public const SUBTYPE_DATETIME_DATE = 'date'; public const SUBTYPE_DATETIME_TIME = 'time'; diff --git a/lib/Db/RowCellRelation.php b/lib/Db/RowCellRelation.php new file mode 100644 index 0000000000..dcfe0eb1a0 --- /dev/null +++ b/lib/Db/RowCellRelation.php @@ -0,0 +1,19 @@ +addType('value', 'integer'); + } + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value, $this->valueType); + } +} diff --git a/lib/Db/RowCellRelationMapper.php b/lib/Db/RowCellRelationMapper.php new file mode 100644 index 0000000000..3a2688df73 --- /dev/null +++ b/lib/Db/RowCellRelationMapper.php @@ -0,0 +1,37 @@ +table, RowCellRelation::class); + } + + /** + * @inheritDoc + */ + public function hasMultipleValues(): bool { + return false; + } + + /** + * @inheritDoc + */ + public function getDbParamType() { + return IQueryBuilder::PARAM_INT; + } + + /** + * @inheritDoc + */ + public function format(Column $column, ?string $value) { + return (int)$value; + } +} diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index e06ece6bfc..43b5a7bed0 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -24,6 +24,7 @@ class ColumnsHelper { Column::TYPE_DATETIME, Column::TYPE_SELECTION, Column::TYPE_USERGROUP, + Column::TYPE_RELATION, ]; public function __construct( @@ -37,6 +38,9 @@ public function resolveSearchValue(string $placeholder, string $userId, ?Column if (str_starts_with($placeholder, '@selection-id-')) { return substr($placeholder, 14); } + if (str_starts_with($placeholder, '@relation-id-')) { + return substr($placeholder, 13); + } $placeholderParts = explode(':', $placeholder, 2); $placeholderName = ltrim($placeholderParts[0], '@'); diff --git a/lib/Migration/Version002001Date20260109000000.php b/lib/Migration/Version002001Date20260109000000.php new file mode 100644 index 0000000000..5f04de70e0 --- /dev/null +++ b/lib/Migration/Version002001Date20260109000000.php @@ -0,0 +1,42 @@ +createRelationTable($schema, 'relation', Types::INTEGER); + return $changes; + } + + private function createRelationTable(ISchemaWrapper $schema, string $name, string $type): ?ISchemaWrapper { + if (!$schema->hasTable('tables_row_cells_' . $name)) { + $table = $schema->createTable('tables_row_cells_' . $name); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('column_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('row_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('value', $type, ['notnull' => false]); + $table->addColumn('last_edit_at', Types::DATETIME, ['notnull' => true]); + $table->addColumn('last_edit_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addIndex(['column_id', 'row_id']); + $table->addIndex(['column_id', 'value']); + $table->setPrimaryKey(['id']); + return $schema; + } + + return null; + } +} diff --git a/lib/Service/ColumnTypes/RelationBusiness.php b/lib/Service/ColumnTypes/RelationBusiness.php new file mode 100644 index 0000000000..93b1a2ee99 --- /dev/null +++ b/lib/Service/ColumnTypes/RelationBusiness.php @@ -0,0 +1,76 @@ +logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . __CLASS__, ['exception' => new \Exception()]); + return ''; + } + + $relationData = $this->relationService->getRelationData($column); + + if (is_array($value) && isset($value['context']) && $value['context'] === 'import') { + $matchingRelation = array_filter($relationData, fn($relation) => $relation['label'] === $value['value']); + if (!empty($matchingRelation)) { + return json_encode(reset($matchingRelation)['id']); + } + } else { + if (isset($relationData[$value])) { + return json_encode($relationData[$value]['id']); + } + } + + return ''; + } + + /** + * @param mixed $value (array|string|null) + * @param Column|null $column + * @return bool + */ + public function canBeParsed($value, ?Column $column = null): bool { + if (!$column) { + $this->logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . __CLASS__, ['exception' => new \Exception()]); + return false; + } + if ($value === null) { + return true; + } + + $relationData = $this->relationService->getRelationData($column); + + if (is_array($value) && isset($value['context']) && $value['context'] === 'import') { + $matchingRelation = array_filter($relationData, fn($relation) => $relation['label'] === $value['value']); + if (!empty($matchingRelation)) { + return true; + } + } else { + if (isset($relationData[$value])) { + return true; + } + } + + return false; + } +} diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 6e349daba5..13946885c2 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -183,6 +183,10 @@ private function getPreviewData(Worksheet $worksheet): array { $value = $cell->getValue(); // $cellIterator`s index is based on 1, not 0. $colIndex = $cellIterator->getCurrentColumnIndex() - 1; + if (!array_key_exists($colIndex, $this->columns)) { + continue; + } + $column = $this->columns[$colIndex]; if (!array_key_exists($colIndex, $columns)) { diff --git a/lib/Service/RelationService.php b/lib/Service/RelationService.php new file mode 100644 index 0000000000..eda48301b0 --- /dev/null +++ b/lib/Service/RelationService.php @@ -0,0 +1,241 @@ + Cache for relation data */ + private array $cacheRelationData = []; + + public function __construct( + ColumnMapper $columnMapper, + ViewMapper $viewMapper, + Row2Mapper $row2Mapper, + ColumnService $columnService, + LoggerInterface $logger, + ?string $userId + ) { + $this->columnMapper = $columnMapper; + $this->viewMapper = $viewMapper; + $this->row2Mapper = $row2Mapper; + $this->columnService = $columnService; + $this->logger = $logger; + $this->userId = $userId; + } + + /** + * Get all relation data for a table + * + * @param int $tableId + * @return array Relation data grouped by column ID + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function getRelationsForTable(int $tableId): array { + // Check table permissions through ColumnService + $columns = $this->columnService->findAllByTable($tableId); + + $relationColumns = array_filter($columns, function($column) { + return $column->getType() === Column::TYPE_RELATION; + }); + + return $this->getRelationsForColumns($relationColumns); + } + + /** + * Get all relation data for a view + * + * @param int $viewId + * @return array Relation data grouped by column ID + * @throws InternalError + * @throws NotFoundError + * @throws PermissionError + */ + public function getRelationsForView(int $viewId): array { + // Check view permissions through ColumnService + $columns = $this->columnService->findAllByView($viewId); + + $relationColumns = array_filter($columns, function($column) { + return $column->getType() === Column::TYPE_RELATION; + }); + + return $this->getRelationsForColumns($relationColumns); + } + + /** + * Get relation data for specific columns + * + * @param Column[] $relationColumns + * @return array Relation data grouped by column ID + * @throws InternalError + */ + private function getRelationsForColumns(array $relationColumns): array { + $result = []; + + // Group columns by their target (relationType + targetId + displayField) + $groupedColumns = $this->groupColumnsByTarget($relationColumns); + + foreach ($groupedColumns as $target => $columns) { + $relationData = $this->getRelationDataForTarget($target, $columns[0]); + + // Assign the same data to all columns with this target + foreach ($columns as $column) { + $result[$column->getId()] = $relationData; + } + } + + return $result; + } + + /** + * Group relation columns by their target configuration + * + * @param Column[] $columns + * @return array + */ + private function groupColumnsByTarget(array $columns): array { + $groups = []; + + foreach ($columns as $column) { + $settings = $column->getCustomSettingsArray(); + if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['displayField'])) { + continue; + } + + $target = sprintf('%s_%s_%s', + $settings['relationType'], + $settings['targetId'], + $settings['displayField'] + ); + + if (!isset($groups[$target])) { + $groups[$target] = []; + } + $groups[$target][] = $column; + } + + return $groups; + } + + /** + * Get relation data for a specific column + * + * @param Column $column + * @return array + */ + public function getRelationData(Column $column): array { + if ($column->getType() !== Column::TYPE_RELATION) { + return []; + } + + $settings = $column->getCustomSettingsArray(); + if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['displayField'])) { + return []; + } + + $target = sprintf('%s_%s_%s', + $settings['relationType'], + $settings['targetId'], + $settings['displayField'] + ); + + return $this->getRelationDataForTarget($target, $column); + } + + /** + * Get relation data for a specific target + * + * @param string $target + * @param Column $column + * @return array + * @throws InternalError + */ + private function getRelationDataForTarget(string $target, Column $column): array { + // Check cache first + $cacheKey = $target . '_' . ($this->userId ?? 'anonymous'); + if (isset($this->cacheRelationData[$cacheKey])) { + return $this->cacheRelationData[$cacheKey]; + } + + $settings = $column->getCustomSettingsArray(); + if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['displayField'])) { + $this->cacheRelationData[$cacheKey] = []; + return []; + } + + $isView = $settings['relationType'] === 'view'; + $targetId = $settings['targetId'] ?? null; + + try { + $targetColumn = $this->columnMapper->find($settings['displayField']); + if ($isView) { + $view = $this->viewMapper->find($targetId); + $rows = $this->row2Mapper->findAll( + [$targetColumn], + [$targetColumn], + $view->getTableId(), + null, + null, + $view->getFilterArray(), + $view->getSortArray(), + $this->userId + ); + } else { + $rows = $this->row2Mapper->findAll( + [$targetColumn], + [$targetColumn], + $targetId, + null, + null, + null, + null, + $this->userId + ); + } + } catch (DoesNotExistException $e) { + $this->cacheRelationData[$cacheKey] = []; + return []; + } + + $result = []; + foreach ($rows as $row) { + $data = $row->getData(); + $displayFieldData = array_filter($data, function($item) use ($settings) { + return $item['columnId'] === (int)$settings['displayField']; + }); + $value = reset($displayFieldData)['value'] ?? null; + + // Structure compatible with Row2 format: {id: int, label: string} + $result[$row->getId()] = [ + 'id' => $row->getId(), + 'label' => $value, + ]; + } + + $this->cacheRelationData[$cacheKey] = $result; + return $result; + } +} diff --git a/openapi.json b/openapi.json index 426bf59f1e..37bb68f7b6 100644 --- a/openapi.json +++ b/openapi.json @@ -130,7 +130,16 @@ "type": "string" }, "type": { - "type": "string" + "type": "string", + "enum": [ + "text", + "number", + "datetime", + "select", + "usergroup", + "relation" + ], + "description": "Column main type" }, "subtype": { "type": "string" @@ -3433,7 +3442,8 @@ "number", "datetime", "select", - "usergroup" + "usergroup", + "relation" ], "description": "Column main type" }, @@ -3843,7 +3853,8 @@ "number", "datetime", "select", - "usergroup" + "usergroup", + "relation" ], "description": "Column main type" }, diff --git a/src/modules/main/partials/ColumnFormComponent.vue b/src/modules/main/partials/ColumnFormComponent.vue index be2f6a303f..2715748585 100644 --- a/src/modules/main/partials/ColumnFormComponent.vue +++ b/src/modules/main/partials/ColumnFormComponent.vue @@ -22,6 +22,7 @@ import DatetimeDateForm from '../../../shared/components/ncTable/partials/rowTyp import DatetimeTimeForm from '../../../shared/components/ncTable/partials/rowTypePartials/DatetimeTimeForm.vue' import TextRichForm from '../../../shared/components/ncTable/partials/rowTypePartials/TextRichForm.vue' import UsergroupForm from '../../../shared/components/ncTable/partials/rowTypePartials/UsergroupForm.vue' +import RelationForm from '../../../shared/components/ncTable/partials/rowTypePartials/RelationForm.vue' export default { name: 'ColumnFormComponent', @@ -40,6 +41,7 @@ export default { DatetimeDateForm, DatetimeTimeForm, UsergroupForm, + RelationForm, }, props: { column: { diff --git a/src/modules/main/partials/ColumnTypeSelection.vue b/src/modules/main/partials/ColumnTypeSelection.vue index 71536b3c7c..a8f02cc2f2 100644 --- a/src/modules/main/partials/ColumnTypeSelection.vue +++ b/src/modules/main/partials/ColumnTypeSelection.vue @@ -19,6 +19,7 @@ +
{{ props.label }}
@@ -33,6 +34,7 @@ +
{{ props.label }}
@@ -50,6 +52,7 @@ import ProgressIcon from 'vue-material-design-icons/ArrowRightThin.vue' import SelectionIcon from 'vue-material-design-icons/FormSelect.vue' import DatetimeIcon from 'vue-material-design-icons/ClipboardTextClockOutline.vue' import ContactsIcon from 'vue-material-design-icons/ContactsOutline.vue' +import RelationIcon from 'vue-material-design-icons/LinkVariant.vue' import { NcSelect } from '@nextcloud/vue' export default { @@ -63,6 +66,7 @@ export default { TextLongIcon, NcSelect, ContactsIcon, + RelationIcon, }, props: { columnId: { @@ -86,6 +90,7 @@ export default { { id: 'datetime', label: t('tables', 'Date and time') }, { id: 'usergroup', label: t('tables', 'Users and groups') }, + { id: 'relation', label: t('tables', 'Relation') }, ], } }, diff --git a/src/modules/main/sections/MainWrapper.vue b/src/modules/main/sections/MainWrapper.vue index bbd8099484..13080f5600 100644 --- a/src/modules/main/sections/MainWrapper.vue +++ b/src/modules/main/sections/MainWrapper.vue @@ -99,7 +99,7 @@ export default { }, methods: { - ...mapActions(useDataStore, ['removeRows', 'clearState', 'loadColumnsFromBE', 'loadRowsFromBE']), + ...mapActions(useDataStore, ['removeRows', 'clearState', 'loadColumnsFromBE', 'loadRowsFromBE', 'loadRelationsFromBE']), createColumn() { emit('tables:column:create', { isView: this.isView, element: this.element }) }, @@ -141,6 +141,13 @@ export default { view: this.isView ? this.element : null, tableId: !this.isView ? this.element.id : null, }) + + // Load relations data for displaying relation columns + this.loadRelationsFromBE({ + viewId: this.isView ? this.element.id : null, + tableId: !this.isView ? this.element.id : null, + force: true, + }) if (this.canReadData(this.element)) { await this.loadRowsFromBE({ viewId: this.isView ? this.element.id : null, diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index 91c70d8a17..3cb812f666 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -113,6 +113,7 @@ import ColumnTypeSelection from '../main/partials/ColumnTypeSelection.vue' import TextRichForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/TextRichForm.vue' import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandler.js' import UsergroupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/UsergroupForm.vue' +import RelationForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue' import { useTablesStore } from '../../store/store.js' import { useDataStore } from '../../store/data.js' import { mapActions } from 'pinia' @@ -139,6 +140,7 @@ export default { SelectionForm, SelectionMultiForm, UsergroupForm, + RelationForm, }, props: { showModal: { @@ -209,6 +211,7 @@ export default { { id: 'datetime', label: t('tables', 'Date and time') }, { id: 'usergroup', label: t('tables', 'Users and groups') }, + { id: 'relation', label: t('tables', 'Relation') }, ], } }, @@ -300,6 +303,16 @@ export default { this.titleMissingError = false showInfo(t('tables', 'You need to select a type for the new column.')) this.typeMissingError = true + } else if (this.column.type === 'relation' && !this.column.customSettings?.relationType) { + console.log('customSettings', this.column.customSettings?.relationType) + showInfo(t('tables', 'Please select a relation type.')) + return + } else if (this.column.type === 'relation' && !this.column.customSettings?.targetId) { + showInfo(t('tables', 'Please select a entity.')) + return + } else if (this.column.type === 'relation' && !this.column.customSettings?.displayField) { + showInfo(t('tables', 'Please select a display field.')) + return } else { this.$emit('save', this.prepareSubmitData()) if (this.isCustomSave) { @@ -366,7 +379,12 @@ export default { data.numberPrefix = this.column.numberPrefix data.numberSuffix = this.column.numberSuffix } + } else if (this.column.type === 'relation') { + data.customSettings.relationType = this.column.customSettings.relationType + data.customSettings.targetId = this.column.customSettings.targetId + data.customSettings.displayField = this.column.customSettings.displayField } + return data }, async sendNewColumnToBE() { diff --git a/src/modules/modals/EditColumn.vue b/src/modules/modals/EditColumn.vue index e5981514a4..7b25094158 100644 --- a/src/modules/modals/EditColumn.vue +++ b/src/modules/modals/EditColumn.vue @@ -66,6 +66,7 @@ import DatetimeForm from '../../shared/components/ncTable/partials/columnTypePar import DatetimeDateForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/DatetimeDateForm.vue' import DatetimeTimeForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/DatetimeTimeForm.vue' import UsergroupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/UsergroupForm.vue' +import RelationForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue' import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandler.js' import moment from '@nextcloud/moment' import { mapActions } from 'pinia' @@ -96,6 +97,7 @@ export default { NcButton, NcUserBubble, UsergroupForm, + RelationForm, }, filters: { truncate(text, length, suffix) { @@ -191,7 +193,11 @@ export default { }, async updateLocalColumn() { const data = Object.assign({}, this.editColumn) - if ((this.column.type === ColumnTypes.SelectionMulti || this.column.type === ColumnTypes.SelectionCheck) && data.selectionDefault !== null) data.selectionDefault = JSON.stringify(data.selectionDefault) + + if ((this.column.type === ColumnTypes.SelectionMulti || this.column.type === ColumnTypes.SelectionCheck) && data.selectionDefault !== null) { + data.selectionDefault = JSON.stringify(data.selectionDefault) + } + data.numberDefault = data.numberDefault === '' ? null : data.numberDefault data.numberDecimals = data.numberDecimals === '' ? null : data.numberDecimals data.numberMin = data.numberMin === '' ? null : data.numberMin diff --git a/src/modules/modals/ImportPreview.vue b/src/modules/modals/ImportPreview.vue index 793f3c6f33..445ef3f576 100644 --- a/src/modules/modals/ImportPreview.vue +++ b/src/modules/modals/ImportPreview.vue @@ -211,6 +211,8 @@ export default { case ColumnTypes.DatetimeDate: case ColumnTypes.DatetimeTime: return t('tables', 'Date and time') + case ColumnTypes.Relation: + return t('tables', 'Relation') default: return '' } diff --git a/src/shared/components/ncTable/mixins/columnHandler.js b/src/shared/components/ncTable/mixins/columnHandler.js index 8bae9c213c..08ae801310 100644 --- a/src/shared/components/ncTable/mixins/columnHandler.js +++ b/src/shared/components/ncTable/mixins/columnHandler.js @@ -17,6 +17,7 @@ export const ColumnTypes = { DatetimeTime: 'datetime-time', Datetime: 'datetime', Usergroup: 'usergroup', + Relation: 'relation', } export function getColumnWidthStyle(column) { diff --git a/src/shared/components/ncTable/mixins/columnParser.js b/src/shared/components/ncTable/mixins/columnParser.js index 5ff87c4637..4461e6e5ce 100644 --- a/src/shared/components/ncTable/mixins/columnParser.js +++ b/src/shared/components/ncTable/mixins/columnParser.js @@ -17,6 +17,7 @@ import TextLinkColumn from './columnsTypes/textLink.js' import TextLongColumn from './columnsTypes/textLong.js' import TextRichColumn from './columnsTypes/textRich.js' import UsergroupColumn from './columnsTypes/usergroup.js' +import RelationColumn from './columnsTypes/relation.js' export function parseCol(col) { const columnType = col.type + (col.subtype === '' ? '' : '-' + col.subtype) @@ -35,6 +36,7 @@ export function parseCol(col) { case ColumnTypes.DatetimeDate: return new DatetimeDateColumn(col) case ColumnTypes.DatetimeTime: return new DatetimeTimeColumn(col) case ColumnTypes.Usergroup: return new UsergroupColumn(col) + case ColumnTypes.Relation: return new RelationColumn(col) default: throw Error(columnType + ' is not a valid column type!') } } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/relation.js b/src/shared/components/ncTable/mixins/columnsTypes/relation.js new file mode 100644 index 0000000000..a2a9a915ca --- /dev/null +++ b/src/shared/components/ncTable/mixins/columnsTypes/relation.js @@ -0,0 +1,93 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { AbstractColumn } from '../columnClass.js' +import { useDataStore } from '../../../../../store/data.js' +import { useTablesStore } from '../../../../../store/store.js' +import { FilterIds } from '../filter.js' + +export default class RelationColumn extends AbstractColumn { + constructor(col) { + super(col) + this.type = 'relation' + this.subtype = '' + } + + /** + * Format the value for display + * @param {any} value The value to format + * @returns {string} The formatted value + */ + formatValue(value) { + if (value === null || value === undefined) { + return '' + } + // For single relations, return the value as is + return String(value) + } + + /** + * Parse the value from input + * @param {any} value The value to parse + * @returns {any} The parsed value + */ + parseValue(value) { + if (value === null || value === undefined || value === '') { + return null + } + // For single relations, return the value as is + return value + } + + getValueString(valueObject) { + valueObject = valueObject || this.value || null + return this.getLabel(valueObject.value) + } + + getLabel(id) { + // Try to get relation data from the store + try { + const tablesStore = useTablesStore() + const dataStore = useDataStore() + + const activeElement = tablesStore.activeView || tablesStore.activeTable + if (!activeElement) { + return '' + } + + const columnRelations = dataStore.getRelations(this.id) + const option = columnRelations[id] + + return option ? option.label : '' + } catch (error) { + console.warn('Failed to get relation label:', error) + return '' + } + } + + default() { + return null + } + + /** + * Check if filter matches the cell value + * @param {any} cell The cell to check + * @param {any} filter The filter to apply + * @returns {boolean} Whether the filter matches + */ + isFilterFound(cell, filter) { + const filterMethod = { + [FilterIds.IsNotEmpty]() { return cell.value !== null && cell.value !== undefined && cell.value !== '' }, + [FilterIds.IsEmpty]() { return cell.value === null || cell.value === undefined || cell.value === '' }, + [FilterIds.IsEqual]() { return cell.value === filter.value }, + [FilterIds.IsNotEqual]() { return cell.value !== filter.value }, + }[filter.operator.id] + return super.isFilterFound(filterMethod, cell) + } + + isSearchStringFound(cell, searchString) { + const value = this.getValueString(cell) + return super.isSearchStringFound(value, cell, searchString) + } +} diff --git a/src/shared/components/ncTable/mixins/filter.js b/src/shared/components/ncTable/mixins/filter.js index 0e3a4f91c8..bd28bb4e77 100644 --- a/src/shared/components/ncTable/mixins/filter.js +++ b/src/shared/components/ncTable/mixins/filter.js @@ -49,6 +49,8 @@ export const FilterIds = { IsLowerThan: 'is-lower-than', IsLowerThanOrEqual: 'is-lower-than-or-equal', IsEmpty: 'is-empty', + IsNotEmpty: 'is-not-empty', + IsNotEqual: 'is-not-equal', } export const Filters = { @@ -80,7 +82,7 @@ export const Filters = { id: FilterIds.IsEqual, label: t('tables', 'Is equal'), shortLabel: '=', - goodFor: [ColumnTypes.TextLine, ColumnTypes.Number, ColumnTypes.SelectionCheck, ColumnTypes.TextLink, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup], + goodFor: [ColumnTypes.TextLine, ColumnTypes.Number, ColumnTypes.SelectionCheck, ColumnTypes.TextLink, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Relation], incompatibleWith: [FilterIds.IsNotEqual, FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsNotEqual: new Filter({ @@ -121,8 +123,22 @@ export const Filters = { IsEmpty: new Filter({ id: FilterIds.IsEmpty, label: t('tables', 'Is empty'), - goodFor: [ColumnTypes.TextLine, ColumnTypes.TextRich, ColumnTypes.Number, ColumnTypes.TextLink, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.SelectionCheck, ColumnTypes.Usergroup], - incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty], + goodFor: [ColumnTypes.TextLine, ColumnTypes.TextRich, ColumnTypes.Number, ColumnTypes.TextLink, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.SelectionCheck, ColumnTypes.Usergroup, ColumnTypes.Relation], + incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty, FilterIds.IsNotEmpty], noSearchValue: true, }), + IsNotEmpty: new Filter({ + id: FilterIds.IsNotEmpty, + label: t('tables', 'Is not empty'), + goodFor: [ColumnTypes.Relation], + incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty, FilterIds.IsNotEmpty], + noSearchValue: true, + }), + IsNotEqual: new Filter({ + id: FilterIds.IsNotEqual, + label: t('tables', 'Is not equal'), + shortLabel: '!=', + goodFor: [ColumnTypes.Relation], + incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty, FilterIds.IsNotEmpty], + }), } diff --git a/src/shared/components/ncTable/partials/FilterLabel.vue b/src/shared/components/ncTable/partials/FilterLabel.vue index c530089ed2..65df965250 100644 --- a/src/shared/components/ncTable/partials/FilterLabel.vue +++ b/src/shared/components/ncTable/partials/FilterLabel.vue @@ -49,7 +49,7 @@ export default { return value }, labelText() { - if (this.operator.id === FilterIds.IsEmpty) { + if (this.operator.id === FilterIds.IsEmpty || this.operator.id === FilterIds.IsNotEmpty) { return this.operator.getOperatorLabel() } else { return this.operator.getOperatorLabel() + ' "' + this.getValue + '"' diff --git a/src/shared/components/ncTable/partials/TableCellRelation.vue b/src/shared/components/ncTable/partials/TableCellRelation.vue new file mode 100644 index 0000000000..999468bf0c --- /dev/null +++ b/src/shared/components/ncTable/partials/TableCellRelation.vue @@ -0,0 +1,50 @@ + + + + + + diff --git a/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue b/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue index a647b4682c..88cc2e3b6d 100644 --- a/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue +++ b/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue @@ -306,7 +306,7 @@ export default { changeFilterOperator(op) { this.selectedOperator = op this.selectOperator = false - if (op.id === FilterIds.IsEmpty) { + if (op.id === FilterIds.IsEmpty || op.id === FilterIds.IsNotEmpty) { this.createFilter() } else { this.selectValue = true diff --git a/src/shared/components/ncTable/partials/TableRow.vue b/src/shared/components/ncTable/partials/TableRow.vue index f654616137..bed74908b0 100644 --- a/src/shared/components/ncTable/partials/TableRow.vue +++ b/src/shared/components/ncTable/partials/TableRow.vue @@ -72,6 +72,7 @@ export default { TableCellMultiSelection, TableCellTextRich, TableCellUsergroup, + TableCellRelation, }, mixins: [activityMixin], @@ -144,6 +145,7 @@ export default { case ColumnTypes.DatetimeDate: return 'TableCellDateTime' case ColumnTypes.DatetimeTime: return 'TableCellDateTime' case ColumnTypes.Usergroup: return 'TableCellUsergroup' + case ColumnTypes.Relation: return 'TableCellRelation' default: return 'TableCellHtml' } }, diff --git a/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue b/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue new file mode 100644 index 0000000000..e8fe76e668 --- /dev/null +++ b/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/src/shared/components/ncTable/partials/rowTypePartials/RelationForm.vue b/src/shared/components/ncTable/partials/rowTypePartials/RelationForm.vue new file mode 100644 index 0000000000..1321236b4a --- /dev/null +++ b/src/shared/components/ncTable/partials/rowTypePartials/RelationForm.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/store/data.js b/src/store/data.js index 80303364b6..af9833f416 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -22,6 +22,8 @@ export const useDataStore = defineStore('data', { loading: {}, rows: {}, columns: {}, + relations: {}, + relationsLoading: {}, }), getters: { @@ -33,6 +35,16 @@ export const useDataStore = defineStore('data', { const stateId = genStateKey(isView, elementId) return state.rows[stateId] ?? [] }, + getRelations: (state) => (columnId) => { + if (state.relations[columnId] === undefined) { + set(state.relations, columnId, {}) + } + return state.relations[columnId] + }, + getRelationsLoading: (state) => (isView, elementId) => { + const stateId = genStateKey(isView, elementId) + return state.relationsLoading[stateId] === true + }, }, actions: { @@ -40,6 +52,8 @@ export const useDataStore = defineStore('data', { this.loading = {} this.columns = {} this.rows = {} + this.relations = {} + this.relationsLoading = {} }, // COLUMNS @@ -154,6 +168,39 @@ export const useDataStore = defineStore('data', { return true }, + // RELATIONS + async loadRelationsFromBE({ tableId, viewId, force = false }) { + const stateId = genStateKey(!!(viewId), viewId ?? tableId) + + // prevent double-loading + if (this.relationsLoading[stateId] === true || (this.relationsLoading[stateId] === false && !force)) { + return + } + + set(this.relationsLoading, stateId, true) + + let res = null + + try { + if (viewId) { + res = await axios.get(generateUrl('/apps/tables/api/1/views/' + viewId + '/relations')) + } else { + res = await axios.get(generateUrl('/apps/tables/api/1/tables/' + tableId + '/relations')) + } + } catch (e) { + displayError(e, t('tables', 'Could not load relation data.')) + set(this.relationsLoading, stateId, false) + return {} + } + + Object.entries(res.data).forEach(([columnId, relations]) => { + relations.column = relations.column ? parseCol(relations.column) : null + set(this.relations, columnId, relations) + }) + set(this.relationsLoading, stateId, false) + return + }, + // ROWS async loadRowsFromBE({ tableId, viewId }) { const stateId = genStateKey(!!(viewId), viewId ?? tableId)