diff --git a/appinfo/routes.php b/appinfo/routes.php index 050db4d07d..801d321302 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -51,6 +51,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/Constants/ColumnType.php b/lib/Constants/ColumnType.php index 1be1f5ecdf..f296de2f74 100644 --- a/lib/Constants/ColumnType.php +++ b/lib/Constants/ColumnType.php @@ -15,4 +15,5 @@ enum ColumnType: string { case SELECTION = 'selection'; case DATETIME = 'datetime'; case PEOPLE = 'usergroup'; + case RELATION = 'relation'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 0db4b1eb8b..80e71a825a 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -25,6 +25,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; @@ -60,6 +61,7 @@ class Api1Controller extends ApiController { private RowService $rowService; private ImportService $importService; private ViewService $viewService; + private RelationService $relationService; private ViewMapper $viewMapper; private IL10N $l10N; @@ -80,6 +82,7 @@ public function __construct( RowService $rowService, ImportService $importService, ViewService $viewService, + RelationService $relationService, ViewMapper $viewMapper, V1Api $v1Api, LoggerInterface $logger, @@ -93,6 +96,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; @@ -816,13 +820,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 @@ -1600,7 +1668,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/Db/Column.php b/lib/Db/Column.php index 7ebcdacf35..e58f45dbea 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -103,6 +103,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'; @@ -114,6 +115,10 @@ class Column extends EntitySuper implements JsonSerializable { public const META_ID_TITLE = 'id'; + public const RELATION_TYPE = 'relationType'; + public const RELATION_TARGET_ID = 'targetId'; + public const RELATION_LABEL_COLUMN = 'labelColumn'; + protected ?string $title = null; protected ?int $tableId = null; protected ?string $createdBy = null; diff --git a/lib/Db/RowCellRelation.php b/lib/Db/RowCellRelation.php new file mode 100644 index 0000000000..73acc5ec3c --- /dev/null +++ b/lib/Db/RowCellRelation.php @@ -0,0 +1,23 @@ + */ +class RowCellRelation extends RowCellSuper { + protected ?int $value = null; + + public function __construct() { + parent::__construct(); + $this->addType('value', 'integer'); + } + + public function jsonSerialize(): array { + return parent::jsonSerializePreparation($this->value); + } +} diff --git a/lib/Db/RowCellRelationMapper.php b/lib/Db/RowCellRelationMapper.php new file mode 100644 index 0000000000..0135ff059a --- /dev/null +++ b/lib/Db/RowCellRelationMapper.php @@ -0,0 +1,35 @@ + */ +class RowCellRelationMapper extends RowCellMapperSuper { + protected string $table = 'tables_row_cells_relation'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, RowCellRelation::class); + } + + /** + * @inheritDoc + */ + public function hasMultipleValues(): bool { + return false; + } + + /** + * @inheritDoc + */ + public function getDbParamType() { + return IQueryBuilder::PARAM_INT; + } +} diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index 385f7a1ed5..f926d2ba3c 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -20,6 +20,7 @@ class ColumnsHelper { Column::TYPE_DATETIME, Column::TYPE_SELECTION, Column::TYPE_USERGROUP, + Column::TYPE_RELATION, ]; /** @@ -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..de03978d60 --- /dev/null +++ b/lib/Migration/Version002001Date20260109000000.php @@ -0,0 +1,46 @@ +createRowValueTable($schema, 'relation', Types::INTEGER); + return $changes; + } + + private function createRowValueTable(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..7ca0a73ba8 --- /dev/null +++ b/lib/Service/ColumnTypes/RelationBusiness.php @@ -0,0 +1,98 @@ +logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . __CLASS__, ['exception' => new \Exception()]); + return ''; + } + + $relationData = $this->relationService->getRelationData($column); + // try to find value by label + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + if (!empty($matchingRelation)) { + return json_encode(reset($matchingRelation)['id']); + } + + // if not found, try to find by id + if (is_numeric($value) && isset($relationData[(int)$value])) { + return json_encode($value); + } + + 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); + // try to find value by label + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + if (!empty($matchingRelation)) { + return true; + } + // if not found, try to find by id + if (is_numeric($value) && isset($relationData[(int)$value])) { + return true; + } + + return false; + } + + public function validateValue(mixed $value, Column $column, string $userId, int $tableId, ?int $rowId): void { + if ($value === null || $value === '') { + return; + } + // Validate that the value exists in the target table/view + $relationData = $this->relationService->getRelationData($column); + + // Try to find value by label first + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + if (!empty($matchingRelation)) { + return; + } + + // If not found by label, try to find by id + if (is_numeric($value) && isset($relationData[(int)$value])) { + return; + } + + throw new BadRequestError('Relation value does not exist in the target table/view'); + } +} diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index e945a127ec..3c04facb65 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -187,6 +187,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)) { @@ -444,7 +448,7 @@ private function upsertRow(Row $row, array $columnBusinesses): void { if (!$cell || $cell->getValue() === null) { $this->logger->info('Cell is empty while fetching rows data for importing.'); if ($column->getMandatory()) { - $this->logger->warning('Mandatory column was not set'); + $this->logger->warning('Mandatory column "' . $column->getTitle() . '" was not set'); $this->countErrors++; return; } diff --git a/lib/Service/RelationService.php b/lib/Service/RelationService.php new file mode 100644 index 0000000000..025c64f3fc --- /dev/null +++ b/lib/Service/RelationService.php @@ -0,0 +1,214 @@ + Cache for relation data */ + private array $cacheRelationData = []; + + public function __construct( + private ColumnMapper $columnMapper, + private ViewMapper $viewMapper, + private Row2Mapper $row2Mapper, + private ColumnService $columnService, + private ?string $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 { + + // Group columns by their target (relationType + targetId + labelColumn) + $result = []; + $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['labelColumn'])) { + continue; + } + + $target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']); + if (!isset($groups[$target])) { + $groups[$target] = []; + } + $groups[$target][] = $column; + } + + return $groups; + } + + /** + * Get relation data for a specific column + * + * @param Column $column + * @return array Indexed per row id + */ + 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['labelColumn'])) { + return []; + } + + $target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']); + + return $this->getRelationDataForTarget($target, $column); + } + + /** + * Get relation data for a specific target + * + * @param string $target + * @param Column $column + * @return array Indexed per row id + * @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[Column::RELATION_TYPE]) || empty($settings[Column::RELATION_TARGET_ID]) || empty($settings[Column::RELATION_LABEL_COLUMN])) { + $this->cacheRelationData[$cacheKey] = []; + return []; + } + + $isView = $settings[Column::RELATION_TYPE] === 'view'; + $targetId = $settings[Column::RELATION_TARGET_ID] ?? null; + + try { + $targetColumn = $this->columnMapper->find($settings[Column::RELATION_LABEL_COLUMN]); + if ($isView) { + $view = $this->viewMapper->find($targetId); + $rows = $this->row2Mapper->findAll( + [$targetColumn->getId()], + $view->getTableId(), + null, + null, + $view->getFilterArray(), + $view->getSortArray(), + $this->userId + ); + } else { + $rows = $this->row2Mapper->findAll( + [$targetColumn->getId()], + $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[Column::RELATION_LABEL_COLUMN]; + }); + $value = reset($displayFieldData)['value'] ?? null; + + // Structure compatible with Row2 format: {id: int, label: string} + $rowId = (int)$row->getId(); + $result[$rowId] = [ + 'id' => $rowId, + 'label' => (string)$value, + ]; + } + + $this->cacheRelationData[$cacheKey] = $result; + return $result; + } +} diff --git a/openapi.json b/openapi.json index 0f452fb652..8b14ca121c 100644 --- a/openapi.json +++ b/openapi.json @@ -3637,7 +3637,8 @@ "number", "datetime", "select", - "usergroup" + "usergroup", + "relation" ], "description": "Column main type" }, @@ -4065,7 +4066,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/partials/editViewPartials/filter/FilterEntry.vue b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue index 2bc7f64a37..8976be865f 100644 --- a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue +++ b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue @@ -30,6 +30,7 @@ v-model="searchValue" class="select-field" :options="magicFields" + :loading="relationsLoading" :aria-label-combobox="getValuePlaceholder" :placeholder="getValuePlaceholder" data-cy="filterEntrySeachValue" @@ -66,11 +67,13 @@ 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..4aeec2b5e5 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -72,7 +72,8 @@
- +
@@ -113,6 +114,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 +141,7 @@ export default { SelectionForm, SelectionMultiForm, UsergroupForm, + RelationForm, }, props: { showModal: { @@ -209,6 +212,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 +304,12 @@ 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 === ColumnTypes.Relation && !this.column.customSettings?.relationType) { + showInfo(t('tables', 'Please select a relation type.')) + } else if (this.column.type === ColumnTypes.Relation && !this.column.customSettings?.targetId) { + showInfo(t('tables', 'Please select a target.')) + } else if (this.column.type === ColumnTypes.Relation && !this.column.customSettings?.labelColumn) { + showInfo(t('tables', 'Please select a value selection label.')) } else { this.$emit('save', this.prepareSubmitData()) if (this.isCustomSave) { @@ -320,6 +330,9 @@ export default { this.reset() this.$emit('close') }, + onUpdateCustomSettings(customSettings) { + this.column.customSettings = { ...this.column.customSettings, ...customSettings } + }, prepareSubmitData() { const data = { type: this.column.type, @@ -366,6 +379,10 @@ export default { data.numberPrefix = this.column.numberPrefix data.numberSuffix = this.column.numberSuffix } + } else if (this.column.type === ColumnTypes.Relation) { + data.customSettings.relationType = this.column.customSettings.relationType + data.customSettings.targetId = this.column.customSettings.targetId + data.customSettings.labelColumn = this.column.customSettings.labelColumn } return data }, diff --git a/src/modules/modals/EditColumn.vue b/src/modules/modals/EditColumn.vue index e5981514a4..fd4f857291 100644 --- a/src/modules/modals/EditColumn.vue +++ b/src/modules/modals/EditColumn.vue @@ -19,7 +19,9 @@ :width-invalid-error="widthInvalidError" />
- +
@@ -66,6 +68,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 +99,7 @@ export default { NcButton, NcUserBubble, UsergroupForm, + RelationForm, }, filters: { truncate(text, length, suffix) { @@ -164,6 +168,9 @@ export default { this.reset() this.$emit('close') }, + onUpdateCustomSettings(customSettings) { + this.editColumn.customSettings = { ...this.editColumn.customSettings, ...customSettings } + }, async saveColumn() { if (this.editColumn.title === '') { showError(t('tables', 'Cannot update column. Title is missing.')) @@ -203,8 +210,6 @@ export default { delete data.createdBy delete data.lastEditAt delete data.lastEditBy - data.customSettings = { width: data.customSettings.width } - console.debug('this column data will be send', data) const res = await this.updateColumn({ id: this.editColumn.id, isView: this.isView, 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..d63e4d9515 --- /dev/null +++ b/src/shared/components/ncTable/mixins/columnsTypes/relation.js @@ -0,0 +1,99 @@ +/** + * 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' +import { ColumnTypes } from '../columnHandler.js' + +export default class RelationColumn extends AbstractColumn { + + constructor(col) { + super(col) + this.type = ColumnTypes.Relation + this.subtype = '' + } + + /** + * Format the value for display + * @param {any} value The value to format + * @return {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 + * @return {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(rowId) { + // 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[rowId] + + return option ? option.label : undefined + } 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 + * @return {boolean} Whether the filter matches + */ + isFilterFound(cell, filter) { + const filterValue = filter.magicValuesEnriched ? filter.magicValuesEnriched : filter.value + const cellLabel = this.getLabel(cell.value) + const filterMethod = { + [FilterIds.Contains]() { return cellLabel?.toLowerCase().includes(filterValue?.toLowerCase()) }, + [FilterIds.DoesNotContain]() { return !cellLabel?.toLowerCase().includes(filterValue?.toLowerCase()) }, + [FilterIds.IsEqual]() { return cellLabel === filterValue }, + [FilterIds.IsNotEqual]() { return cellLabel !== filterValue }, + [FilterIds.IsEmpty]() { return !cellLabel }, + }[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..66ae3ea5ee 100644 --- a/src/shared/components/ncTable/mixins/filter.js +++ b/src/shared/components/ncTable/mixins/filter.js @@ -49,19 +49,20 @@ export const FilterIds = { IsLowerThan: 'is-lower-than', IsLowerThanOrEqual: 'is-lower-than-or-equal', IsEmpty: 'is-empty', + IsNotEmpty: 'is-not-empty', } export const Filters = { Contains: new Filter({ id: FilterIds.Contains, label: t('tables', 'Contains'), - goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Selection], + goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Selection, ColumnTypes.Relation], incompatibleWith: [FilterIds.DoesNotContain, FilterIds.IsEmpty, FilterIds.IsEqual], }), DoesNotContain: new Filter({ id: FilterIds.DoesNotContain, label: t('tables', 'Does not contain'), - goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Selection], + goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Selection, ColumnTypes.Relation], incompatibleWith: [FilterIds.Contains, FilterIds.IsEmpty, FilterIds.IsEqual], }), BeginsWith: new Filter({ @@ -80,14 +81,14 @@ 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({ id: FilterIds.IsNotEqual, label: t('tables', 'Is not 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.IsEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsGreaterThan: new Filter({ @@ -121,8 +122,15 @@ 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, }), } diff --git a/src/shared/components/ncTable/mixins/magicFields.js b/src/shared/components/ncTable/mixins/magicFields.js index 3bcab63738..1522aafa50 100644 --- a/src/shared/components/ncTable/mixins/magicFields.js +++ b/src/shared/components/ncTable/mixins/magicFields.js @@ -45,14 +45,14 @@ export const MagicFields = { id: 'me', label: t('tables', 'Me (user ID)'), icon: 'icon-user', - goodFor: [ColumnTypes.TextLine, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.TextRich, ColumnTypes.TextLink, ColumnTypes.Usergroup], + goodFor: [ColumnTypes.TextLine, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.TextRich, ColumnTypes.TextLink, ColumnTypes.Usergroup, ColumnTypes.Relation], replace: getCurrentUser()?.uid, }), MyName: new MagicField({ id: 'my-name', label: t('tables', 'Me (name)'), icon: 'icon-user', - goodFor: [ColumnTypes.TextLine, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.TextRich, ColumnTypes.TextLink, ColumnTypes.Usergroup], + goodFor: [ColumnTypes.TextLine, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.TextRich, ColumnTypes.TextLink, ColumnTypes.Usergroup, ColumnTypes.Relation], replace: getCurrentUser()?.displayName, }), Checked: new MagicField({ 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..9d2f6b9c68 --- /dev/null +++ b/src/shared/components/ncTable/partials/TableCellRelation.vue @@ -0,0 +1,188 @@ + + + + + + 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 1412f92b93..345b880b86 100644 --- a/src/shared/components/ncTable/partials/TableRow.vue +++ b/src/shared/components/ncTable/partials/TableRow.vue @@ -48,6 +48,7 @@ import TableCellSelection from './TableCellSelection.vue' import TableCellMultiSelection from './TableCellMultiSelection.vue' import TableCellTextRich from './TableCellEditor.vue' import TableCellUsergroup from './TableCellUsergroup.vue' +import TableCellRelation from './TableCellRelation.vue' import { ColumnTypes, getColumnWidthStyle } from './../mixins/columnHandler.js' import { translate as t } from '@nextcloud/l10n' import { @@ -73,6 +74,7 @@ export default { TableCellMultiSelection, TableCellTextRich, TableCellUsergroup, + TableCellRelation, }, mixins: [activityMixin], @@ -145,6 +147,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..fbd7ad9b63 --- /dev/null +++ b/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue @@ -0,0 +1,171 @@ + + + + + + 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..5191202bb2 --- /dev/null +++ b/src/shared/components/ncTable/partials/rowTypePartials/RelationForm.vue @@ -0,0 +1,95 @@ + + + + + + diff --git a/src/store/data.js b/src/store/data.js index 5463e62748..83907dc27d 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 = typeof elementId === 'string' && elementId.startsWith('public-') ? elementId : 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 @@ -178,6 +192,37 @@ 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]) => { + set(this.relations, columnId, relations) + }) + set(this.relationsLoading, stateId, false) + }, + // ROWS async loadRowsFromBE({ tableId, viewId }) { const stateId = genStateKey(!!(viewId), viewId ?? tableId) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 14fff1e485..4f10258a89 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -2670,7 +2670,7 @@ export interface operations { * @description Column main type * @enum {string} */ - readonly type: "text" | "number" | "datetime" | "select" | "usergroup"; + readonly type: "text" | "number" | "datetime" | "select" | "usergroup" | "relation"; /** @description Column sub type */ readonly subtype?: string | null; /** @description Is the column mandatory */ @@ -2930,7 +2930,7 @@ export interface operations { * @description Column main type * @enum {string} */ - readonly type: "text" | "number" | "datetime" | "select" | "usergroup"; + readonly type: "text" | "number" | "datetime" | "select" | "usergroup" | "relation"; /** @description Column sub type */ readonly subtype?: string | null; /** @description Is the column mandatory */