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/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 361497f0f4..61ceeaf10b 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'|'relation_lookup' $type Column main type * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory * @param string|null $description Description @@ -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'|'relation_lookup' $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 3710956208..d230554b87 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -100,6 +100,8 @@ 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 TYPE_RELATION_LOOKUP = 'relation_lookup'; public const SUBTYPE_DATETIME_DATE = 'date'; public const SUBTYPE_DATETIME_TIME = 'time'; diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 05ec003cd3..e610a0d4fe 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -58,6 +58,9 @@ public function delete(Row2 $row): Row2 { $this->db->beginTransaction(); try { foreach ($this->columnsHelper->columns as $columnType) { + if ($this->isVirtualColumn($columnType)) { + continue; + } $this->getCellMapperFromType($columnType)->deleteAllForRow($row->getId()); } $this->rowSleeveMapper->deleteById($row->getId()); @@ -195,6 +198,8 @@ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, private function getRows(array $rowIds, array $columnIds): array { $qb = $this->db->getQueryBuilder(); + $columnIds = $this->addRelationColumnIdsForSupplementColumns($columnIds); + $qbSqlForColumnTypes = null; foreach ($this->columnsHelper->columns as $columnType) { $qbTmp = $this->db->getQueryBuilder(); @@ -210,6 +215,9 @@ private function getRows(array $rowIds, array $columnIds): array { $qbTmp->selectAlias($qbTmp->createFunction('NULL'), 'value_type'); } + if ($this->isVirtualColumn($columnType)) { + continue; + } $qbTmp ->from('tables_row_cells_' . $columnType) ->where($qb->expr()->in('column_id', $qb->createNamedParameter($columnIds, IQueryBuilder::PARAM_INT_ARRAY, ':columnIds'))) @@ -798,6 +806,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($this->isVirtualColumn($column->getType())) { + return; + } + // insert new cell $cellMapper = $this->getCellMapper($column); @@ -836,6 +848,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit * @throws InternalError */ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $value, Column $column): void { + if ($this->isVirtualColumn($column->getType())) { + return; + } + $this->getCellMapper($column)->applyDataToEntity($column, $cell, $value); $this->updateMetaData($cell); $mapper->updateWrapper($cell); @@ -846,6 +862,9 @@ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $val */ private function insertOrUpdateCell(int $rowId, int $columnId, $value): void { $column = $this->columnMapper->find($columnId); + if ($this->isVirtualColumn($column->getType())) { + return; + } $cellMapper = $this->getCellMapper($column); try { if ($cellMapper->hasMultipleValues()) { @@ -872,6 +891,9 @@ private function getCellMapper(Column $column): RowCellMapperSuper { } private function getCellMapperFromType(string $columnType): RowCellMapperSuper { + if ($this->isVirtualColumn($columnType)) { + throw new InternalError('Virtual columns do not have cell mappers'); + } $cellMapperClassName = 'OCA\Tables\Db\RowCell' . ucfirst($columnType) . 'Mapper'; /** @var RowCellMapperSuper $cellMapper */ try { @@ -893,6 +915,9 @@ private function getColumnDbParamType(Column $column): int { * @throws InternalError */ public function deleteDataForColumn(Column $column): void { + if ($this->isVirtualColumn($column->getType())) { + return; + } try { $this->getCellMapper($column)->deleteAllForColumn($column->getId()); } catch (Exception $e) { @@ -961,6 +986,9 @@ private function getFormattedDefaultValue(Column $column) { case Column::TYPE_USERGROUP: $defaultValue = $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault()); break; + case Column::TYPE_RELATION_LOOKUP: + $defaultValue = null; + break; } return $defaultValue; } @@ -988,4 +1016,29 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array { return $sortedRows; } + + public function isVirtualColumn(string $columnType): bool { + return $columnType === Column::TYPE_RELATION_LOOKUP; + } + + /** + * @param int[] $columnIds + * @return int[] + */ + public function addRelationColumnIdsForSupplementColumns(array $columnIds): array { + $allColumns = $this->columnMapper->findAll($columnIds); + foreach ($allColumns as $column) { + if ($column->getType() !== Column::TYPE_RELATION_LOOKUP) { + continue; + } + + $customSettings = $column->getCustomSettingsArray(); + $relationColumnId = $customSettings['relationColumnId'] ?? null; + if ($relationColumnId && !in_array($relationColumnId, $columnIds)) { + $columnIds[] = $relationColumnId; + } + } + return $columnIds; + } + } 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..d28889e815 --- /dev/null +++ b/lib/Db/RowCellRelationMapper.php @@ -0,0 +1,40 @@ + */ +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; + } + + public function formatRowData(Column $column, array $row) { + $value = $row['value']; + return (int)$value; + } +} diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index 035dfcc1b6..bbc6258151 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -18,6 +18,8 @@ class ColumnsHelper { Column::TYPE_DATETIME, Column::TYPE_SELECTION, Column::TYPE_USERGROUP, + Column::TYPE_RELATION, + Column::TYPE_RELATION_LOOKUP, ]; public function __construct( @@ -30,6 +32,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/ColumnService.php b/lib/Service/ColumnService.php index 10bc09bf10..5981724e9d 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -477,6 +477,11 @@ public function findOrCreateColumnsByTitleForTableAsArray(?int $tableId, ?int $v foreach ($titles as $title) { $i++; foreach ($allColumns as $column) { + // Skip matching columns with type relation_lookup + if ($column->getType() === Column::TYPE_RELATION_LOOKUP) { + continue; + } + if ($column->getTitle() === $title) { $result[$i] = $column; $countMatchingColumns++; diff --git a/lib/Service/ColumnTypes/RelationBusiness.php b/lib/Service/ColumnTypes/RelationBusiness.php new file mode 100644 index 0000000000..f2bb66d2fe --- /dev/null +++ b/lib/Service/ColumnTypes/RelationBusiness.php @@ -0,0 +1,79 @@ +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..38f6f5ec56 --- /dev/null +++ b/lib/Service/RelationService.php @@ -0,0 +1,482 @@ + Cache for relation data */ + private array $cacheRelationData = []; + + public function __construct( + ColumnMapper $columnMapper, + ViewMapper $viewMapper, + Row2Mapper $row2Mapper, + ColumnService $columnService, + private PermissionsService $permissionsService, + 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 if the current user has read access to the table + if (!$this->permissionsService->canReadColumnsByTableId($tableId, $this->userId)) { + throw new PermissionError('User does not have read access to this table'); + } + + $columns = $this->columnService->findAllByTable($tableId); + + return $this->getRelationsForColumns($columns); + } + + /** + * 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 if the current user has read access to the view + if (!$this->permissionsService->canReadColumnsByViewId($viewId, $this->userId)) { + throw new PermissionError('User does not have read access to this view'); + } + + $columns = $this->columnService->findAllByView($viewId); + + return $this->getRelationsForColumns($columns); + } + + /** + * Get relation data for specific columns + * + * @param Column[] $relationColumns + * @return array Relation data grouped by column ID + * @throws InternalError + */ + private function getRelationsForColumns(array $columns): array { + $relationColumns = array_filter($columns, function ($column) { + return $column->getType() === Column::TYPE_RELATION; + }); + $relationLookupColumns = array_filter($columns, function ($column) { + return $column->getType() === Column::TYPE_RELATION_LOOKUP; + }); + + $relationColumnsSettings = []; + foreach ($relationColumns as $column) { + $relationColumnsSettings[$column->getId()] = $column->getCustomSettingsArray(); + } + $groupedColumns = $this->groupColumnsRelationByTarget($relationColumns); + $groupedColumns = $this->groupColumnsRelationLookupByTarget($groupedColumns, $relationColumnsSettings, $relationLookupColumns); + + $result = []; + foreach ($groupedColumns as $target => $data) { + $relationData = $this->getRelationDataForTarget( + $data['relationType'], + $data['targetId'], + array_keys($data['columns']) + ); + // Assign data to each column based on its displayField + foreach ($relationData as $targetColumnId => $columnData) { + foreach ($data['columns'][$targetColumnId] as $columnId) { + $result[$columnId] = $columnData; + } + } + } + + return $result; + } + /** + * Group relation columns by their target configuration + * + * @param Column[] $columns + * @return array + */ + private function groupColumnsRelationByTarget(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', $settings['relationType'], $settings['targetId']); + if (!isset($groups[$target])) { + $groups[$target] = ['relationType' => $settings['relationType'], 'targetId' => (int)$settings['targetId'], 'columns' => []]; + } + + if (!isset($groups[$target]['columns'][$settings['displayField']])) { + $groups[$target]['columns'][$settings['displayField']] = []; + } + + $groups[$target]['columns'][$settings['displayField']][] = $column->getId(); + } + + return $groups; + } + + private function groupColumnsRelationLookupByTarget(array $groupedColumns, array $relationColumnsSettings, array $relationLookupColumns): array { + foreach ($relationLookupColumns as $column) { + $relationLookupSettings = $column->getCustomSettingsArray(); + $relationColumnId = $relationLookupSettings['relationColumnId']; + if (!isset($relationColumnsSettings[$relationColumnId])) { + try { + $relationColumn = $this->columnMapper->find($relationColumnId); + } catch (DoesNotExistException $e) { + continue; + } + + $relationColumnsSettings[$relationColumnId] = $relationColumn->getCustomSettingsArray(); + } + + $settings = $relationColumnsSettings[$relationColumnId]; + + $target = sprintf('%s_%s', $settings['relationType'], $settings['targetId']); + + if (!isset($groupedColumns[$target])) { + $groupedColumns[$target] = ['relationType' => $settings['relationType'], 'targetId' => (int)$settings['targetId'], 'columns' => []]; + } + + if (!isset($groupedColumns[$target]['columns'][$relationLookupSettings['targetColumnId']])) { + $groupedColumns[$target]['columns'][$relationLookupSettings['targetColumnId']] = []; + } + + $groupedColumns[$target]['columns'][$relationLookupSettings['targetColumnId']][] = $column->getId(); + } + + return $groupedColumns; + } + + /** + * 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['labelColumn'])) { + return []; + } + + $relationData = $this->getRelationDataForTarget( + $settings['relationType'], + (int)$settings['targetId'], + [(int)$settings['displayField']] + ); + + return $relationData[(int)$settings['displayField']]['data'] ?? []; + } + + /** + * Create a meta-column object for the given meta ID and table ID + */ + private function createMetaColumn(int $metaId, int $tableId): Column { + $column = new Column(); + $column->setTableId($tableId); + $column->setTitle('Meta Column'); + $column->setType($this->getMetaColumnType($metaId)); + $column->setSubtype($this->getMetaColumnSubtype($metaId)); + $column->setId($metaId); + return $column; + } + + /** + * Get the type for a meta-column + */ + private function getMetaColumnType(int $metaId): string { + return match ($metaId) { + Column::TYPE_META_ID => 'number', + Column::TYPE_META_CREATED_BY => 'text', + Column::TYPE_META_CREATED_AT => 'datetime', + Column::TYPE_META_UPDATED_BY => 'text', + Column::TYPE_META_UPDATED_AT => 'datetime', + default => 'text-line' + }; + } + + /** + * Get the subtype for a meta-column + */ + private function getMetaColumnSubtype(int $metaId): string { + return match ($metaId) { + Column::TYPE_META_ID => '', + Column::TYPE_META_CREATED_BY => 'line', + Column::TYPE_META_CREATED_AT => '', + Column::TYPE_META_UPDATED_BY => 'line', + Column::TYPE_META_UPDATED_AT => '', + default => '' + }; + } + + /** + * Get relation data for the target table/view with support for meta columns + * + * @param string $target + * @param Column $column + * @return array + * @throws InternalError + */ + private function getRelationDataForTarget(string $relationType, int $targetId, array $columnIds): array { + $isView = $relationType === 'view'; + $result = []; + + $columnsToFetch = []; + $metaColumnsToFetch = []; + $normalColumnsToFetch = []; + + // Check the cache for each column and collect columns that need to be fetched + foreach ($columnIds as $columnId) { + $cacheKey = sprintf('%s_%d_%d_%s', $relationType, $targetId, $columnId, $this->userId ?? 'anonymous'); + if (isset($this->cacheRelationData[$cacheKey])) { + $result[$columnId] = $this->cacheRelationData[$cacheKey]; + continue; + } + $columnsToFetch[] = $columnId; + if (\OCA\Tables\Db\Column::isValidMetaTypeId((int)$columnId)) { + $metaColumnsToFetch[] = (int)$columnId; + } else { + $normalColumnsToFetch[] = (int)$columnId; + } + } + + // If all columns are cached, return immediately + if (empty($columnsToFetch)) { + return $result; + } + + // Prepare Column entities for normal columns + $targetColumns = []; + $targetColumnsById = []; + foreach ($normalColumnsToFetch as $columnId) { + try { + $columnEntity = $this->columnMapper->find($columnId); + $targetColumns[] = $columnEntity; + $targetColumnsById[$columnId] = $columnEntity; + } catch (DoesNotExistException $e) { + // Cache empty result for non-existent column + $cacheKey = sprintf('%s_%d_%d_%s', $relationType, $targetId, $columnId, $this->userId ?? 'anonymous'); + $this->cacheRelationData[$cacheKey] = ['data' => [], 'column' => null]; + $result[$columnId] = $this->cacheRelationData[$cacheKey]; + } + } + + // If there are no normal columns but meta columns exist, add a fallback column + if (empty($targetColumns) && !empty($metaColumnsToFetch)) { + try { + if ($isView) { + $view = $this->viewMapper->find($targetId); + $available = $this->columnService->findAllByTable($view->getTableId()); + } else { + $available = $this->columnService->findAllByTable($targetId); + } + foreach ($available as $col) { + if ($col->getType() !== \OCA\Tables\Db\Column::TYPE_RELATION_LOOKUP) { + $targetColumns[] = $col; + break; + } + } + } catch (DoesNotExistException $e) { + // ignore; no fallback + } + } + + // Fetch rows for the target + $rows = []; + if (!empty($targetColumns)) { + try { + $targetColumnIds = array_map(fn ($column) => $column->getId(), $targetColumns); + if ($isView) { + $view = $this->viewMapper->find($targetId); + $rows = $this->row2Mapper->findAll( + $targetColumnIds, + $view->getTableId(), + null, + null, + $view->getFilterArray(), + $view->getSortArray(), + $this->userId + ); + } else { + $rows = $this->row2Mapper->findAll( + $targetColumnIds, + $targetId, + null, + null, + null, + null, + $this->userId + ); + } + } catch (DoesNotExistException $e) { + // Cache empty results for all columns that were being fetched + foreach ($columnsToFetch as $columnId) { + $cacheKey = sprintf('%s_%d_%d_%s', $relationType, $targetId, $columnId, $this->userId ?? 'anonymous'); + $this->cacheRelationData[$cacheKey] = ['data' => [], 'column' => null]; + $result[$columnId] = $this->cacheRelationData[$cacheKey]; + } + return $result; + } + } else { + // No columns available to fetch rows; return empty for requested columns + foreach ($columnsToFetch as $columnId) { + $cacheKey = sprintf('%s_%d_%d_%s', $relationType, $targetId, $columnId, $this->userId ?? 'anonymous'); + $this->cacheRelationData[$cacheKey] = ['data' => [], 'column' => null]; + $result[$columnId] = $this->cacheRelationData[$cacheKey]; + } + return $result; + } + + // Determine table id for meta column objects + $targetTableId = null; + if ($isView) { + try { + $view = $this->viewMapper->find($targetId); + $targetTableId = $view->getTableId(); + } catch (DoesNotExistException $e) { + $targetTableId = null; + } + } else { + $targetTableId = $targetId; + } + + // Process rows and cache data for each normal column + foreach ($normalColumnsToFetch as $columnId) { + $columnData = []; + foreach ($rows as $row) { + $data = $row->getData(); + $columnFieldData = array_filter($data, function ($item) use ($columnId) { + return $item['columnId'] === $columnId; + }); + $value = reset($columnFieldData)['value'] ?? null; + + $columnData[$row->getId()] = [ + 'id' => $row->getId(), + 'label' => $value, + ]; + } + + $cacheKey = sprintf('%s_%d_%d_%s', $relationType, $targetId, $columnId, $this->userId ?? 'anonymous'); + // If the target column is of type relation, expose it as selection with populated options + $columnForCache = null; + $baseTargetColumn = $targetColumnsById[$columnId] ?? null; + if ($baseTargetColumn !== null && $baseTargetColumn->getType() === Column::TYPE_RELATION) { + $settings = $baseTargetColumn->getCustomSettingsArray(); + $selectionOptions = []; + if (!empty($settings['relationType']) && !empty($settings['targetId']) && !empty($settings['displayField'])) { + // Build options from the relation's own display values + $nestedRelationData = $this->getRelationDataForTarget( + $settings['relationType'], + (int)$settings['targetId'], + [(int)$settings['displayField']] + ); + $optionsData = $nestedRelationData[(int)$settings['displayField']]['data'] ?? []; + foreach ($optionsData as $opt) { + $selectionOptions[] = [ + 'id' => $opt['id'], + 'label' => (string)($opt['label'] ?? ''), + ]; + } + } + + $selectionColumn = new Column(); + $selectionColumn->setTableId($baseTargetColumn->getTableId()); + $selectionColumn->setTitle($baseTargetColumn->getTitle()); + $selectionColumn->setType(Column::TYPE_SELECTION); + $selectionColumn->setSubtype(''); + $selectionColumn->setSelectionOptionsArray($selectionOptions); + $columnForCache = $selectionColumn; + } else { + // Fallback: original column + try { + $columnForCache = $this->columnMapper->find($columnId); + } catch (DoesNotExistException $e) { + $columnForCache = null; + } + } + + $this->cacheRelationData[$cacheKey] = ['data' => $columnData, 'column' => $columnForCache]; + $result[$columnId] = $this->cacheRelationData[$cacheKey]; + } + + // Process rows and cache data for each meta column + foreach ($metaColumnsToFetch as $metaId) { + $columnData = []; + foreach ($rows as $row) { + $label = null; + switch ($metaId) { + case \OCA\Tables\Db\Column::TYPE_META_ID: + $label = $row->getId(); + break; + case \OCA\Tables\Db\Column::TYPE_META_CREATED_BY: + $label = $row->getCreatedBy(); + break; + case \OCA\Tables\Db\Column::TYPE_META_CREATED_AT: + $label = $row->getCreatedAt(); + break; + case \OCA\Tables\Db\Column::TYPE_META_UPDATED_BY: + $label = $row->getLastEditBy(); + break; + case \OCA\Tables\Db\Column::TYPE_META_UPDATED_AT: + $label = $row->getLastEditAt(); + break; + } + $columnData[$row->getId()] = [ + 'id' => $row->getId(), + 'label' => $label, + ]; + } + + $cacheKey = sprintf('%s_%d_%d_%s', $relationType, $targetId, $metaId, $this->userId ?? 'anonymous'); + $columnObj = $targetTableId !== null ? $this->createMetaColumn($metaId, $targetTableId) : null; + $this->cacheRelationData[$cacheKey] = ['data' => $columnData, 'column' => $columnObj]; + $result[$metaId] = $this->cacheRelationData[$cacheKey]; + } + + return $result; + } +} diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 6a92f79c57..0a76cd2969 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -340,6 +340,10 @@ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $column = $this->getColumnFromColumnsArray($columnId, $columns); + if ($column && $this->row2Mapper->isVirtualColumn($column->getType())) { + continue; + } + if ($column) { $columnBusiness = $this->getColumnBusiness($column); $columnBusiness->validateValue($entry['value'], $column, $this->userId, $tableId, $rowId); @@ -830,7 +834,12 @@ private function filterRowResult(?View $view, Row2 $row): Row2 { return $row; } - $row->filterDataByColumns($view->getColumnIds()); + $columnIds = $view->getColumnIds(); + + // Додаємо relationColumnId для relation_lookup колонок + $columnIds = $this->row2Mapper->addRelationColumnIdsForSupplementColumns($columnIds); + + $row->filterDataByColumns($columnIds); return $row; } diff --git a/openapi.json b/openapi.json index 477c06e17b..37c0e94cd4 100644 --- a/openapi.json +++ b/openapi.json @@ -3661,7 +3661,9 @@ "number", "datetime", "select", - "usergroup" + "usergroup", + "relation", + "relation_lookup" ], "description": "Column main type" }, @@ -4071,7 +4073,9 @@ "number", "datetime", "select", - "usergroup" + "usergroup", + "relation", + "relation_lookup" ], "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..e56a9329b6 100644 --- a/src/modules/main/partials/ColumnTypeSelection.vue +++ b/src/modules/main/partials/ColumnTypeSelection.vue @@ -19,6 +19,8 @@ + +
{{ props.label }}
@@ -33,6 +35,8 @@ + +
{{ props.label }}
@@ -50,6 +54,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 +68,7 @@ export default { TextLongIcon, NcSelect, ContactsIcon, + RelationIcon, }, props: { columnId: { @@ -86,6 +92,8 @@ export default { { id: 'datetime', label: t('tables', 'Date and time') }, { id: 'usergroup', label: t('tables', 'Users and groups') }, + { id: 'relation', label: t('tables', 'Relation') }, + { id: 'relation_lookup', label: t('tables', 'Relation lookup') }, ], } }, diff --git a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue index 2bc7f64a37..f1b9961e7f 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..fe0a8907f3 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -72,7 +72,8 @@
- +
@@ -113,6 +114,8 @@ 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 RelationLookupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue' import { useTablesStore } from '../../store/store.js' import { useDataStore } from '../../store/data.js' import { mapActions } from 'pinia' @@ -139,6 +142,8 @@ export default { SelectionForm, SelectionMultiForm, UsergroupForm, + RelationForm, + RelationLookupForm, }, props: { showModal: { @@ -209,6 +214,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 +306,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) { + showInfo(t('tables', 'Please select a relation type.')) + } else if (this.column.type === 'relation' && !this.column.customSettings?.targetId) { + showInfo(t('tables', 'Please select a target.')) + } else if (this.column.type === 'relation' && !this.column.customSettings?.labelColumn) { + showInfo(t('tables', 'Please select a value selection label.')) + } else if (this.column.type === 'relation_lookup' && !this.column.customSettings?.relationColumnId) { + showInfo(t('tables', 'Please select a relation column.')) + } else if (this.column.type === 'relation_lookup' && !this.column.customSettings?.targetColumnId) { + showInfo(t('tables', 'Please select a target column.')) } else { this.$emit('save', this.prepareSubmitData()) if (this.isCustomSave) { @@ -320,6 +336,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 +385,13 @@ 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.labelColumn = this.column.customSettings.labelColumn + } else if (this.column.type === 'relation_lookup') { + data.customSettings.relationColumnId = this.column.customSettings.relationColumnId + data.customSettings.targetColumnId = this.column.customSettings.targetColumnId } return data }, diff --git a/src/modules/modals/CreateRow.vue b/src/modules/modals/CreateRow.vue index 8d1c320407..9ddd9cc426 100644 --- a/src/modules/modals/CreateRow.vue +++ b/src/modules/modals/CreateRow.vue @@ -10,7 +10,12 @@ @closing="actionCancel">
@@ -66,6 +68,8 @@ 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 RelationLookupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue' import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandler.js' import moment from '@nextcloud/moment' import { mapActions } from 'pinia' @@ -96,6 +100,8 @@ export default { NcButton, NcUserBubble, UsergroupForm, + RelationForm, + RelationLookupForm, }, filters: { truncate(text, length, suffix) { @@ -164,6 +170,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 +212,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/EditRow.vue b/src/modules/modals/EditRow.vue index 25d27c0b7b..e2ae4c8c8a 100644 --- a/src/modules/modals/EditRow.vue +++ b/src/modules/modals/EditRow.vue @@ -35,7 +35,12 @@
- + + + { - tmp[item.columnId] = item.value + // Only include columns that are in nonMetaColumns + if (this.nonMetaColumns.some(col => col.id === item.columnId)) { + tmp[item.columnId] = item.value + } }) // Ensure all columns have entries, even if missing from row data diff --git a/src/modules/modals/ImportPreview.vue b/src/modules/modals/ImportPreview.vue index 793f3c6f33..b6bc4c3760 100644 --- a/src/modules/modals/ImportPreview.vue +++ b/src/modules/modals/ImportPreview.vue @@ -117,7 +117,10 @@ export default { return [] } - const columns = this.existingColumns.map(column => ({ + // Filter out columns with type relation_lookup + const filteredColumns = this.existingColumns.filter(column => column.type !== ColumnTypes.RelationLookup) + + const columns = filteredColumns.map(column => ({ id: column.id, label: column.title, })) @@ -211,6 +214,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..e985a264a0 100644 --- a/src/shared/components/ncTable/mixins/columnHandler.js +++ b/src/shared/components/ncTable/mixins/columnHandler.js @@ -17,6 +17,8 @@ export const ColumnTypes = { DatetimeTime: 'datetime-time', Datetime: 'datetime', Usergroup: 'usergroup', + Relation: 'relation', + RelationLookup: 'relation_lookup', } export function getColumnWidthStyle(column) { diff --git a/src/shared/components/ncTable/mixins/columnParser.js b/src/shared/components/ncTable/mixins/columnParser.js index 5ff87c4637..4783ef40cb 100644 --- a/src/shared/components/ncTable/mixins/columnParser.js +++ b/src/shared/components/ncTable/mixins/columnParser.js @@ -17,6 +17,8 @@ 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' +import RelationLookupColumn from './columnsTypes/relationLookup.js' export function parseCol(col) { const columnType = col.type + (col.subtype === '' ? '' : '-' + col.subtype) @@ -35,6 +37,8 @@ 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) + case ColumnTypes.RelationLookup: return new RelationLookupColumn(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..332b768095 --- /dev/null +++ b/src/shared/components/ncTable/mixins/columnsTypes/relation.js @@ -0,0 +1,95 @@ +/** + * 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 + * @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.data?.[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 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/columnsTypes/relationLookup.js b/src/shared/components/ncTable/mixins/columnsTypes/relationLookup.js new file mode 100644 index 0000000000..14854eb8e3 --- /dev/null +++ b/src/shared/components/ncTable/mixins/columnsTypes/relationLookup.js @@ -0,0 +1,116 @@ +/** + * 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' + +export default class RelationLookupColumn extends AbstractColumn { + + constructor(col) { + super(col) + this.type = 'relation_lookup' + 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 '' + } + // Return the virtual value as is - it comes from the related table + return String(value) + } + + /** + * Parse the value from input + * @param {any} value The value to parse + * @return {any} The parsed value + */ + parseValue(value) { + // This is a virtual column, no input parsing needed + // Values come from related rows via backend + return value + } + + getValueString(valueObject) { + valueObject = valueObject || this.value || null + if (!valueObject || valueObject.value === null || valueObject.value === undefined) { + return '' + } + + // Check if we have relation data to get the label + if (this.customSettings?.targetColumnId) { + try { + const dataStore = useDataStore() + const relationData = dataStore.getRelations(this.id) + + let value = valueObject.value + if (typeof value === 'object' && value !== null) { + value = value.value + } + + if (relationData?.data && value) { + // Get the related row data for the current value (which is the relation ID) + const relatedRow = relationData.data[value] + if (relatedRow) { + return relationData.column.getValueString(relatedRow.label) + } + } + } catch (error) { + console.warn('Failed to get relation supplement label:', error) + } + } + + // Fallback to original behavior + return String(valueObject.value) + } + + default() { + // Virtual columns don't have default values + 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) { + return false + } + + isSearchStringFound(rowData, cell, searchString) { + const dataStore = useDataStore() + const relationLookup = dataStore.getRelations(this.id) + const cellRelationLookup = rowData?.find(item => item.columnId === this.customSettings.relationColumnId) + if (!cellRelationLookup) return '' + + let value = '' + if (relationLookup.data[cellRelationLookup.value].label) { + value = relationLookup.column.isSearchStringFound({ value: relationLookup.data[cellRelationLookup.value].label }, searchString) + } + + return value + } + + sort(mode, nextSorts) { + const factor = mode === 'DESC' ? -1 : 1 + return (rowA, rowB) => { + let valueA = rowA.data.find(item => item.columnId === this.id)?.value || '' + let valueB = rowB.data.find(item => item.columnId === this.id)?.value || '' + + // Convert to strings for comparison + valueA = String(valueA).toLowerCase() + valueB = String(valueB).toLowerCase() + + return valueA.localeCompare(valueB, undefined) * factor || super.getNextSortsResult(nextSorts, rowA, rowB) + } + } + +} diff --git a/src/shared/components/ncTable/mixins/exportTableMixin.js b/src/shared/components/ncTable/mixins/exportTableMixin.js index 94fd5ac8da..0c9fe40647 100644 --- a/src/shared/components/ncTable/mixins/exportTableMixin.js +++ b/src/shared/components/ncTable/mixins/exportTableMixin.js @@ -7,6 +7,7 @@ import generalHelper from '../../../mixins/generalHelper.js' import { TYPE_META_ID, TYPE_META_CREATED_BY, TYPE_META_CREATED_AT, TYPE_META_UPDATED_BY, TYPE_META_UPDATED_AT, } from '../../../../shared/constants.ts' +import { useDataStore } from '../../../../store/data.js' export default { @@ -19,14 +20,53 @@ export default { console.debug('downloadCSV has empty parameter, expected array ob row objects', rows) } + const dataStore = useDataStore() const data = [] rows.forEach(row => { const rowData = { ID: row.id } columns.forEach(column => { // if a normal column if (column.id >= 0) { - const set = row.data ? row.data.find(d => d.columnId === column.id) || '' : null - rowData[column.title] = set ? column.getValueString(set) : '' + if (column.type === 'relation_lookup') { + const relationColumnId = column.customSettings?.relationColumnId + if (relationColumnId) { + const relationCell = row.data ? row.data.find(d => d.columnId === relationColumnId) : null + if (relationCell) { + try { + const relationLookup = dataStore.getRelations(column.id) + if (relationLookup?.data && relationCell.value) { + const relatedRow = relationLookup.data[relationCell.value] + if (relatedRow && relatedRow.label !== null && relatedRow.label !== undefined) { + // For selection columns, relatedRow.label is the option ID (number or string) + // getValueString expects an object with a 'value' property + // Note: relatedRow.label can be 0 (valid selection option ID), so we check for null/undefined only + try { + const valueString = relationLookup.column.getValueString({ value: relatedRow.label }) + rowData[column.title] = valueString || '' + } catch (error) { + console.warn('Failed to get value string for relation supplement:', error) + rowData[column.title] = '' + } + } else { + rowData[column.title] = '' + } + } else { + rowData[column.title] = '' + } + } catch (error) { + console.warn('Failed to get relation supplement value for export:', error) + rowData[column.title] = '' + } + } else { + rowData[column.title] = '' + } + } else { + rowData[column.title] = '' + } + } else { + const set = row.data ? row.data.find(d => d.columnId === column.id) || '' : null + rowData[column.title] = set ? column.getValueString(set) : '' + } } else { // if is a meta data column (id < 0) switch (column.id) { diff --git a/src/shared/components/ncTable/mixins/filter.js b/src/shared/components/ncTable/mixins/filter.js index 0e3a4f91c8..c5384aaa4d 100644 --- a/src/shared/components/ncTable/mixins/filter.js +++ b/src/shared/components/ncTable/mixins/filter.js @@ -49,6 +49,7 @@ export const FilterIds = { IsLowerThan: 'is-lower-than', IsLowerThanOrEqual: 'is-lower-than-or-equal', IsEmpty: 'is-empty', + IsNotEmpty: 'is-not-empty', } export const Filters = { @@ -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/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/TableCell.vue b/src/shared/components/ncTable/partials/TableCell.vue new file mode 100644 index 0000000000..6897946968 --- /dev/null +++ b/src/shared/components/ncTable/partials/TableCell.vue @@ -0,0 +1,92 @@ + + + + diff --git a/src/shared/components/ncTable/partials/TableCellRelation.vue b/src/shared/components/ncTable/partials/TableCellRelation.vue new file mode 100644 index 0000000000..093ad97e40 --- /dev/null +++ b/src/shared/components/ncTable/partials/TableCellRelation.vue @@ -0,0 +1,188 @@ + + + + + + diff --git a/src/shared/components/ncTable/partials/TableCellRelationLookup.vue b/src/shared/components/ncTable/partials/TableCellRelationLookup.vue new file mode 100644 index 0000000000..1bb4da6b3a --- /dev/null +++ b/src/shared/components/ncTable/partials/TableCellRelationLookup.vue @@ -0,0 +1,73 @@ + + + + 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..270db40535 100644 --- a/src/shared/components/ncTable/partials/TableRow.vue +++ b/src/shared/components/ncTable/partials/TableRow.vue @@ -15,13 +15,13 @@ 'fixed-width': col.customSettings?.width > 0 }" @click="handleCellClick(col)"> - + /> @@ -36,43 +36,21 @@ + + diff --git a/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue b/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue new file mode 100644 index 0000000000..d2446549e6 --- /dev/null +++ b/src/shared/components/ncTable/partials/columnTypePartials/forms/RelationLookupForm.vue @@ -0,0 +1,157 @@ + + + + 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..e200bf1f31 --- /dev/null +++ b/src/shared/components/ncTable/partials/rowTypePartials/RelationForm.vue @@ -0,0 +1,95 @@ + + + + + + diff --git a/src/shared/components/ncTable/sections/CustomTable.vue b/src/shared/components/ncTable/sections/CustomTable.vue index 350b2895bd..b94403241e 100644 --- a/src/shared/components/ncTable/sections/CustomTable.vue +++ b/src/shared/components/ncTable/sections/CustomTable.vue @@ -265,7 +265,11 @@ export default { // if we should search if (searchString) { console.debug('look for searchString', searchString) - searchStatus = column.isSearchStringFound(cell, searchString.toLowerCase()) + if (column.type === 'relation_lookup') { + searchStatus = column.isSearchStringFound(row.data, cell, searchString.toLowerCase()) + } else { + searchStatus = column.isSearchStringFound(cell, searchString.toLowerCase()) + } } if (debug) { @@ -408,10 +412,6 @@ export default {