diff --git a/lib/Analytics/AnalyticsDatasource.php b/lib/Analytics/AnalyticsDatasource.php index 393bf249d4..d16d481824 100644 --- a/lib/Analytics/AnalyticsDatasource.php +++ b/lib/Analytics/AnalyticsDatasource.php @@ -374,14 +374,19 @@ private function formatUsergroupValue(mixed $value): string { } private function formatRelationValue(Column $column, mixed $value): string { - if ($value === null || $value === '') { + $ids = $this->normalizeArrayValue($value); + if ($ids === []) { return ''; } $relationData = $this->relationService->getRelationData($column); - $valueId = (int)$value; + $labels = []; + foreach ($ids as $id) { + $valueId = (int)$id; + $labels[] = $relationData[$valueId]['label'] ?? (string)$id; + } - return $relationData[$valueId]['label'] ?? (string)$value; + return implode(', ', array_filter($labels, static fn (string $label): bool => $label !== '')); } private function parseDefaultValue(?string $value): mixed { diff --git a/lib/Db/Column.php b/lib/Db/Column.php index a039dd33c7..c626d2b39c 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -121,6 +121,7 @@ class Column extends EntitySuper implements JsonSerializable { public const RELATION_TYPE = 'relationType'; public const RELATION_TARGET_ID = 'targetId'; public const RELATION_LABEL_COLUMN = 'labelColumn'; + public const RELATION_ALLOW_MULTIPLE = 'allowMultiple'; protected ?string $uuid = null; protected ?string $title = null; diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 3aa2b6388a..7bb8abfbb1 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -468,6 +468,12 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ break; } + if ($column->getType() === Column::TYPE_RELATION) { + $includeDefault = false; + $filterExpression = $qb->expr()->eq('value', $qb->createNamedParameter((int)$value, IQueryBuilder::PARAM_INT)); + break; + } + $includeDefault = str_contains((string)($defaultValue ?? ''), (string)$value); if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { $value = str_replace(['"', '\''], '', $value); @@ -556,6 +562,9 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $qb->expr()->notIn('sl3.id', $qb->createFunction($qb2->getSQL())) ); } + if ($column->getType() === Column::TYPE_RELATION) { + return $this->getRelationExclusionFilter($qb, $qb2, $column, (int)$value); + } $includeDefault = !str_contains((string)($defaultValue ?? ''), (string)$value); if ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { $value = str_replace(['"', '\''], '', $value); @@ -576,6 +585,11 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $filterExpression = $qb->expr()->eq('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ']', $paramType)); break; } + if ($column->getType() === Column::TYPE_RELATION) { + $includeDefault = false; + $filterExpression = $qb->expr()->eq('value', $qb->createNamedParameter((int)$value, IQueryBuilder::PARAM_INT)); + break; + } $filterExpression = $qb->expr()->eq('value', $qb->createNamedParameter($value, $paramType)); break; case 'is-not-equal': @@ -585,6 +599,9 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $filterExpression = $qb->expr()->neq('value', $qb->createNamedParameter('[' . $this->db->escapeLikeParameter($value) . ']', $paramType)); break; } + if ($column->getType() === Column::TYPE_RELATION) { + return $this->getRelationExclusionFilter($qb, $qb2, $column, (int)$value); + } $filterExpression = $qb->expr()->neq('value', $qb->createNamedParameter($value, $paramType)); break; case 'is-greater-than': @@ -649,6 +666,23 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ ); } + /** + * Rows that do not have the given related id among their relation cell values. + */ + private function getRelationExclusionFilter(IQueryBuilder $qb, IQueryBuilder $qb2, Column $column, int $value): IQueryBuilder { + $qb2->andWhere($qb->expr()->eq('value', $qb->createNamedParameter($value, IQueryBuilder::PARAM_INT))); + + return $this->db->getQueryBuilder() + ->selectAlias('sl3.id', 'row_id') + ->from('tables_row_sleeves', 'sl3') + ->where( + $qb->expr()->eq('sl3.table_id', $qb->createNamedParameter($column->getTableId(), IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->notIn('sl3.id', $qb->createFunction($qb2->getSQL())) + ); + } + /** * @throws InternalError */ @@ -727,7 +761,7 @@ private function parseEntities(IResult $result, array $sleeves): array { } $rowValues = []; - $keyToColumnId = []; + $keyToColumn = []; $keyToRowId = []; $cellMapperCache = []; @@ -752,12 +786,17 @@ private function parseEntities(IResult $result, array $sleeves): array { } else { $rowValues[$compositeKey] = $value; } - $keyToColumnId[$compositeKey] = $rowData['column_id']; + $keyToColumn[$compositeKey] = $column; $keyToRowId[$compositeKey] = $rowData['row_id']; } foreach ($rowValues as $compositeKey => $value) { - $rows[$keyToRowId[$compositeKey]]->addCell($keyToColumnId[$compositeKey], $value); + $column = $keyToColumn[$compositeKey]; + $columnType = $column->getType(); + if ($cellMapperCache[$columnType]->hasMultipleValues() && is_array($value)) { + $value = $cellMapperCache[$columnType]->formatAggregatedValues($column, $value); + } + $rows[$keyToRowId[$compositeKey]]->addCell($column->getId(), $value); } return array_values($rows); diff --git a/lib/Db/RowCellMapperSuper.php b/lib/Db/RowCellMapperSuper.php index 460e2095e8..7626ecc44a 100644 --- a/lib/Db/RowCellMapperSuper.php +++ b/lib/Db/RowCellMapperSuper.php @@ -63,6 +63,16 @@ public function hasMultipleValues(): bool { return false; } + /** + * Shape aggregated multi-cell values for the API response. + * + * @param list $values + * @return mixed + */ + public function formatAggregatedValues(Column $column, array $values): mixed { + return $values; + } + /** * @throws Exception */ diff --git a/lib/Db/RowCellRelationMapper.php b/lib/Db/RowCellRelationMapper.php index fcbd3edb03..a6b2cfd658 100644 --- a/lib/Db/RowCellRelationMapper.php +++ b/lib/Db/RowCellRelationMapper.php @@ -14,6 +14,8 @@ /** @template-extends RowCellMapperSuper */ class RowCellRelationMapper extends RowCellMapperSuper { + private const DB_CHUNK_SIZE = 1_000; + protected string $table = 'tables_row_cells_relation'; public function __construct(IDBConnection $db) { @@ -21,16 +23,104 @@ public function __construct(IDBConnection $db) { } /** - * @inheritDoc + * Relation values are stored as one cell row per related id (usergroup-style), + * so single- and multi-select columns share the same storage shape. */ public function hasMultipleValues(): bool { - return false; + return true; } - /** - * @inheritDoc - */ public function getDbParamType() { return IQueryBuilder::PARAM_INT; } + + public function formatRowData(Column $column, array $row) { + $value = $row['value']; + if ($value === null || $value === '') { + return null; + } + return (int)$value; + } + + /** + * @param list $values + * @return list|int|null + */ + public function formatAggregatedValues(Column $column, array $values): mixed { + $values = array_values(array_filter($values, static fn ($value) => $value !== null)); + if (!(bool)($column->getCustomSettingsArray()[Column::RELATION_ALLOW_MULTIPLE] ?? false)) { + return $values[0] ?? null; + } + return $values; + } + + public function applyDataToEntity(Column $column, RowCellSuper $cell, $data): void { + $cell->setValue($data === null || $data === '' ? null : (int)$data); + } + + /** + * Keep only the first related value per row (lowest cell id). + * Used when allowMultiple is turned off on an existing column. + */ + public function truncateToSingleValuePerRow(int $columnId): void { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'row_id') + ->from($this->table) + ->where($qb->expr()->eq('column_id', $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT))) + ->orderBy('row_id', 'ASC') + ->addOrderBy('id', 'ASC'); + + $result = $qb->executeQuery(); + $seenRows = []; + $idsToDelete = []; + while ($row = $result->fetchAssociative()) { + $rowId = (int)$row['row_id']; + if (isset($seenRows[$rowId])) { + $idsToDelete[] = (int)$row['id']; + if (count($idsToDelete) >= self::DB_CHUNK_SIZE) { + $this->deleteByIds($idsToDelete); + $idsToDelete = []; + } + } else { + $seenRows[$rowId] = true; + } + } + $result->closeCursor(); + + if ($idsToDelete !== []) { + $this->deleteByIds($idsToDelete); + } + } + + /** + * @param list $ids + */ + private function deleteByIds(array $ids): void { + $deleteQb = $this->db->getQueryBuilder(); + $deleteQb->delete($this->table) + ->where($deleteQb->expr()->in('id', $deleteQb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + $deleteQb->executeStatement(); + } + + /** + * Whether any table row has no related value for this column. + */ + public function hasRowsWithoutValue(int $columnId, int $tableId): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('sl.id') + ->from('tables_row_sleeves', 'sl') + ->leftJoin('sl', $this->table, 'c', $qb->expr()->andX( + $qb->expr()->eq('sl.id', 'c.row_id'), + $qb->expr()->eq('c.column_id', $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT)), + $qb->expr()->isNotNull('c.value'), + )) + ->where($qb->expr()->eq('sl.table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('c.id')) + ->setMaxResults(1); + + $result = $qb->executeQuery(); + $hasEmpty = $result->fetchOne() !== false; + $result->closeCursor(); + return $hasEmpty; + } } diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 52a84b03c1..f8af759337 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -13,6 +13,7 @@ use OCA\Tables\Constants\ColumnType; use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; +use OCA\Tables\Db\RowCellRelationMapper; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; @@ -54,6 +55,7 @@ public function __construct( private readonly IL10N $l, private readonly UserHelper $userHelper, private readonly ColumnDtoValidator $columnDtoValidator, + private readonly RowCellRelationMapper $rowCellRelationMapper, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -374,6 +376,9 @@ public function update( $this->columnDtoValidator->validate($columnDto); $title = $this->normalizeTitle($columnDto->getTitle(), false); + $wasMandatory = (bool)$item->getMandatory(); + $previousAllowMultiple = (bool)($item->getCustomSettingsArray()[Column::RELATION_ALLOW_MULTIPLE] ?? false); + if ($title !== null) { $item->setTitle($title); } @@ -425,10 +430,24 @@ public function update( $this->validateCustomSettings($columnDto->getCustomSettings()); $item->setCustomSettings($columnDto->getCustomSettings()); + $willBeMandatory = $columnDto->isMandatory() !== null ? (bool)$columnDto->isMandatory() : $wasMandatory; + $newAllowMultiple = (bool)($item->getCustomSettingsArray()[Column::RELATION_ALLOW_MULTIPLE] ?? false); + if ($item->getType() === Column::TYPE_RELATION && $willBeMandatory && !$wasMandatory) { + if ($this->rowCellRelationMapper->hasRowsWithoutValue($item->getId(), $item->getTableId())) { + throw new BadRequestError( + 'Cannot make this relation column mandatory while some rows have no related value.' + ); + } + } + $this->updateMetadata($item, $userId); try { $updatedColumn = $this->mapper->update($item); + if ($updatedColumn->getType() === Column::TYPE_RELATION && $previousAllowMultiple && !$newAllowMultiple) { + $this->rowCellRelationMapper->truncateToSingleValuePerRow($updatedColumn->getId()); + } + $this->activityManager->triggerEvent( objectType: ActivityManager::TABLES_OBJECT_COLUMN, object: $updatedColumn, diff --git a/lib/Service/ColumnTypes/RelationBusiness.php b/lib/Service/ColumnTypes/RelationBusiness.php index f072904cd5..a60ba65116 100644 --- a/lib/Service/ColumnTypes/RelationBusiness.php +++ b/lib/Service/ColumnTypes/RelationBusiness.php @@ -22,7 +22,7 @@ public function __construct( } /** - * @param mixed $value (array|string|null) + * @param mixed $value (array|string|int|null) * @param Column|null $column * * @return false|string @@ -30,26 +30,15 @@ public function __construct( public function parseValue($value, ?Column $column = null): string|false { if (!$column) { $this->logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . self::class, ['exception' => new \Exception()]); - return ''; + return json_encode([]); } - $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 ''; + $ids = $this->normalizeToIds($value, $column); + return json_encode($ids); } /** - * @param mixed $value (array|string|null) + * @param mixed $value (array|string|int|null) * @param Column|null $column * @return bool */ @@ -58,42 +47,151 @@ public function canBeParsed($value, ?Column $column = null): bool { $this->logger->warning('No column given, but expected on ' . __FUNCTION__ . ' within ' . self::class, ['exception' => new \Exception()]); return false; } - if ($value === null) { + if ($value === null || $value === '' || $value === []) { 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)) { + try { + $this->normalizeToIds($value, $column, throwOnInvalid: true); return true; + } catch (BadRequestError) { + return false; } - // if not found, try to find by id - if (is_numeric($value) && isset($relationData[(int)$value])) { + } + + /** + * Import / display parsing keeps valid relation targets when some labels fail. + */ + public function canBeParsedDisplayValue($value, Column $column): bool { + if ($value === null || $value === '' || $value === []) { return true; } - return false; + return $this->normalizeToIds($value, $column, throwOnInvalid: false) !== []; } public function validateValue(mixed $value, Column $column, string $userId, int $tableId, ?int $rowId): void { - if ($value === null || $value === '') { + if ($value === null || $value === '' || $value === []) { + // Emptiness for mandatory columns is enforced in RowService::validateMandatoryColumns() + // (including view-specific mandatory settings). return; } - // Validate that the value exists in the target table/view + + $ids = $this->normalizeToIds($value, $column, throwOnInvalid: true); + $allowMultiple = (bool)($column->getCustomSettingsArray()[Column::RELATION_ALLOW_MULTIPLE] ?? false); + + if (!$allowMultiple && count($ids) > 1) { + throw new BadRequestError('Relation column does not allow multiple values'); + } + } + + /** + * Resolve labels/ids into a de-duplicated list of related row ids. + * + * Accepts: + * - null / '' / [] → [] + * - single int/string id or label (legacy single-value clients) + * - array of ints/strings (ids or labels) + * - JSON-encoded id / list of ids + * + * Comma-separated strings are only expanded when the full string is not a + * valid label (labels may contain commas). + * + * @return list + * @throws BadRequestError + */ + private function normalizeToIds(mixed $value, Column $column, bool $throwOnInvalid = false): array { + if ($value === null || $value === '' || $value === []) { + return []; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } elseif (ctype_digit($value)) { + // Keep numeric ids as ints so resolveRelationId does not depend on string equality + $value = [(int)$value]; + } else { + // Keep as a single token first — labels may contain commas + $value = [$value]; + } + } + + if (!is_array($value)) { + $value = [$value]; + } + $relationData = $this->relationService->getRelationData($column); + $ids = []; - // Try to find value by label first - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); - if (!empty($matchingRelation)) { - return; + foreach ($value as $item) { + if ($item === null || $item === '') { + continue; + } + + $resolvedIds = $this->resolveRelationIds($item, $relationData, $throwOnInvalid); + foreach ($resolvedIds as $resolvedId) { + $ids[] = $resolvedId; + } } - // If not found by label, try to find by id - if (is_numeric($value) && isset($relationData[(int)$value])) { - return; + return array_values(array_unique($ids, SORT_NUMERIC)); + } + + /** + * @param array $relationData + * @return list + * @throws BadRequestError + */ + private function resolveRelationIds(mixed $value, array $relationData, bool $throwOnInvalid): array { + $resolvedId = $this->resolveRelationId($value, $relationData); + if ($resolvedId !== null) { + return [$resolvedId]; + } + + // Only expand comma-separated input when the full string is not a label + if (is_string($value) && str_contains($value, ',')) { + $partIds = []; + foreach (array_map(trim(...), explode(',', $value)) as $part) { + if ($part === '') { + continue; + } + $partId = $this->resolveRelationId($part, $relationData); + if ($partId === null) { + if ($throwOnInvalid) { + throw new BadRequestError('Relation value does not exist in the target table/view'); + } + continue; + } + $partIds[] = $partId; + } + return $partIds; + } + + if ($throwOnInvalid) { + throw new BadRequestError('Relation value does not exist in the target table/view'); + } + return []; + } + + /** + * @param array $relationData + */ + private function resolveRelationId(mixed $value, array $relationData): ?int { + // Prefer numeric id match so stringified ints from clients resolve reliably + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + $id = (int)$value; + if (isset($relationData[$id])) { + return $id; + } + } + + $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + if (!empty($matchingRelation)) { + return (int)reset($matchingRelation)['id']; } - throw new BadRequestError('Relation value does not exist in the target table/view'); + return null; } } diff --git a/playwright/e2e/column-relation.spec.ts b/playwright/e2e/column-relation.spec.ts index ab0e6f32d6..168ea794c5 100644 --- a/playwright/e2e/column-relation.spec.ts +++ b/playwright/e2e/column-relation.spec.ts @@ -96,6 +96,62 @@ test.describe('Test column relation', () => { ).toBeVisible() }) + test('Create multi relation column and select multiple values', async ({ userPage: { page } }) => { + const multiSourceTitle = 'Multi relation source' + const multiTargetTitle = 'Multi relation target' + const multiRelationTitle = 'Refers to many' + + await page.goto('/index.php/apps/tables') + + await createTable(page, multiSourceTitle) + await loadTable(page, multiSourceTitle) + await createTextLineColumn(page, sourceColumnTitle, '', '', true) + + await openCreateRowModal(page) + await fillInValueTextLine(page, sourceColumnTitle, 'Alice') + await page.locator('[data-cy="createRowSaveButton"]').click() + await openCreateRowModal(page) + await fillInValueTextLine(page, sourceColumnTitle, 'Bob') + await page.locator('[data-cy="createRowSaveButton"]').click() + + await createTable(page, multiTargetTitle) + await loadTable(page, multiTargetTitle) + + await openCreateColumnModal(page, true) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(multiRelationTitle) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').filter({ hasText: 'Relation' }).click() + + await selectFromVueDropdown(page, 'Select target', multiSourceTitle) + await page.waitForResponse( + r => r.url().includes('/apps/tables/api/1/tables/') + && r.url().includes('/columns') + && r.request().method() === 'GET', + ) + await selectFromVueDropdown(page, 'Select label for relation selection', sourceColumnTitle) + await page.locator('[data-cy="relationMultipleSwitch"]').click() + + await page.locator('[data-cy="createColumnSaveBtn"]').click() + await expect( + page.locator('[data-cy="ncTable"] table tr th').filter({ hasText: multiRelationTitle }), + ).toBeVisible() + + const relationOptionsResponse = page.waitForResponse( + r => r.url().includes('/apps/tables/api/1/') + && r.url().includes('/relations') + && r.request().method() === 'GET', + ) + await openCreateRowModal(page) + await relationOptionsResponse + await selectFromVueDropdown(page, 'Select relation value', 'Alice') + await selectFromVueDropdown(page, 'Select relation value', 'Bob') + await page.locator('[data-cy="createRowSaveButton"]').click() + await expect( + page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Alice' }).filter({ hasText: 'Bob' }), + ).toBeVisible() + }) + test('Relation labels render inside an application context', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index b6095ffd7f..a165c4aa41 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -409,6 +409,7 @@ export default { data.customSettings.relationType = this.column.customSettings.relationType data.customSettings.targetId = this.column.customSettings.targetId data.customSettings.labelColumn = this.column.customSettings.labelColumn + data.customSettings.allowMultiple = !!this.column.customSettings.allowMultiple } return data }, diff --git a/src/modules/modals/EditRow.vue b/src/modules/modals/EditRow.vue index 22be6ab5eb..e0aab1bc22 100644 --- a/src/modules/modals/EditRow.vue +++ b/src/modules/modals/EditRow.vue @@ -199,12 +199,20 @@ export default { // Ensure all columns have entries, even if missing from row data this.columns.forEach(column => { if (!(column.id in tmp)) { - // For usergroup columns, initialize as empty array - if (column.type === 'usergroup') { + // For usergroup/relation columns, initialize as empty array + if (column.type === 'usergroup' || column.type === 'relation') { tmp[column.id] = [] } else { tmp[column.id] = null } + } else if (column.type === 'relation') { + // Normalize legacy single relation values to arrays + const value = tmp[column.id] + if (value === null || value === undefined || value === '') { + tmp[column.id] = [] + } else if (!Array.isArray(value)) { + tmp[column.id] = [value] + } } }) diff --git a/src/shared/components/ncTable/mixins/columnsTypes/relation.js b/src/shared/components/ncTable/mixins/columnsTypes/relation.js index 1464275892..24c424092c 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/relation.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/relation.js @@ -15,45 +15,43 @@ export default class RelationColumn extends AbstractColumn { this.subtype = '' } + get allowMultiple() { + return !!this.customSettings?.allowMultiple + } + /** * Format the value for display * @param {unknown} value The value to format * @return {string} The formatted value */ formatValue(value) { - if (value === null || value === undefined) { + const ids = this.normalizeIds(value) + if (ids.length === 0) { return '' } - // For single relations, return the value as is - return String(value) + return ids.map(id => this.getLabel(id) || String(id)).join(', ') } /** * Parse the value from input * @param {unknown} value The value to parse - * @return {unknown} The parsed value + * @return {number[]} The parsed value */ parseValue(value) { - if (value === null || value === undefined || value === '') { - return null - } - // For single relations, return the value as is - return value + return this.normalizeIds(value) } getValueString(valueObject) { valueObject = valueObject || this.value || null - return this.getLabel(valueObject.value) + const ids = this.normalizeIds(valueObject?.value ?? valueObject) + return ids.map(id => this.getLabel(id) || String(id)).filter(Boolean).join(', ') } getLabel(rowId) { - // Try to get relation data from the store try { const dataStore = useDataStore() - const columnRelations = dataStore.getRelations(this.id) const option = columnRelations[rowId] - return option ? option.label : '' } catch (error) { console.warn('Failed to get relation label:', error) @@ -61,8 +59,18 @@ export default class RelationColumn extends AbstractColumn { } } + normalizeIds(value) { + if (value === null || value === undefined || value === '') { + return [] + } + const list = Array.isArray(value) ? value : [value] + return list + .map(id => parseInt(id)) + .filter(id => !Number.isNaN(id)) + } + default() { - return null + return [] } /** @@ -72,15 +80,34 @@ export default class RelationColumn extends AbstractColumn { * @return {boolean} Whether the filter matches */ isFilterFound(cell, filter) { - const filterValue = (filter.magicValuesEnriched ? filter.magicValuesEnriched : filter.value).toLowerCase() - const cellLabel = this.getLabel(cell.value)?.toLowerCase() + const rawFilter = filter.magicValuesEnriched ? filter.magicValuesEnriched : filter.value + const filterValue = String(rawFilter ?? '').toLowerCase() + const ids = this.normalizeIds(cell?.value) + const labels = ids.map(id => (this.getLabel(id) || String(id)).toLowerCase()) + const filterId = Number.parseInt(String(rawFilter), 10) + const filterIsNumericId = String(rawFilter) === String(filterId) && !Number.isNaN(filterId) + const hasId = filterIsNumericId && ids.includes(filterId) + const hasExactLabel = labels.includes(filterValue) + const hasPartialLabel = labels.some(label => label.includes(filterValue)) + const allowMultiple = this.allowMultiple + const filterMethod = { - [FilterIds.Contains]() { return cellLabel?.includes(filterValue) }, - [FilterIds.DoesNotContain]() { return !cellLabel?.includes(filterValue) }, - [FilterIds.IsEqual]() { return cellLabel === filterValue }, - [FilterIds.IsNotEqual]() { return cellLabel !== filterValue }, - [FilterIds.IsEmpty]() { return !cellLabel }, - [FilterIds.IsNotEmpty]() { return !!cellLabel }, + [FilterIds.Contains]() { return hasId || hasPartialLabel }, + [FilterIds.DoesNotContain]() { return !hasId && !hasPartialLabel }, + [FilterIds.IsEqual]() { + if (filterIsNumericId) { + return allowMultiple ? ids.includes(filterId) : (ids.length === 1 && ids[0] === filterId) + } + return allowMultiple ? hasExactLabel : (labels.length === 1 && labels[0] === filterValue) + }, + [FilterIds.IsNotEqual]() { + if (filterIsNumericId) { + return allowMultiple ? !ids.includes(filterId) : !(ids.length === 1 && ids[0] === filterId) + } + return allowMultiple ? !hasExactLabel : !(labels.length === 1 && labels[0] === filterValue) + }, + [FilterIds.IsEmpty]() { return ids.length === 0 }, + [FilterIds.IsNotEmpty]() { return ids.length > 0 }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/rowHelper.js b/src/shared/components/ncTable/mixins/rowHelper.js index 220feee220..8da2ee30da 100644 --- a/src/shared/components/ncTable/mixins/rowHelper.js +++ b/src/shared/components/ncTable/mixins/rowHelper.js @@ -24,6 +24,8 @@ export default { return this.isMultiSelectionValueValid(value, column) case ColumnTypes.Usergroup: return this.isUsergroupValueValid(value, column) + case ColumnTypes.Relation: + return this.isRelationValueValid(value, column) default: return this.isStandardValueValid(value, column) } @@ -88,6 +90,15 @@ export default { const hasDefaultValue = columnTypeDefault in column && column[columnTypeDefault] !== '[]' && column[columnTypeDefault] !== '' return hasDefaultValue }, + isRelationValueValid(value) { + if (value instanceof Array) { + return value.length > 0 + } + if (value === null || value === undefined || value === '') { + return false + } + return !Number.isNaN(parseInt(value)) + }, isStandardValueValid(value, column) { const columnTypeDefault = this.getColumnTypeDefault(column) diff --git a/src/shared/components/ncTable/partials/TableCellRelation.vue b/src/shared/components/ncTable/partials/TableCellRelation.vue index cd6b75ef7d..818b3050c8 100644 --- a/src/shared/components/ncTable/partials/TableCellRelation.vue +++ b/src/shared/components/ncTable/partials/TableCellRelation.vue @@ -5,12 +5,20 @@