diff --git a/cypress/component/ContentReferenceWidget-relation.cy.js b/cypress/component/ContentReferenceWidget-relation.cy.js index 1d498fdfbf..6b422c8465 100644 --- a/cypress/component/ContentReferenceWidget-relation.cy.js +++ b/cypress/component/ContentReferenceWidget-relation.cy.js @@ -100,7 +100,10 @@ describe('ContentReferenceWidget - relation', () => { // Mock relation data so the relation cell can resolve its label cy.reply('**/apps/tables/api/1/tables/42/relations', { 2: { - 100: { id: '100', label: 'Alice' }, + column: null, + values: { + 100: { id: 100, value: 'Alice' }, + }, }, }) diff --git a/cypress/component/Lookup.cy.js b/cypress/component/Lookup.cy.js new file mode 100644 index 0000000000..e5ecbcd28a --- /dev/null +++ b/cypress/component/Lookup.cy.js @@ -0,0 +1,229 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia, setActivePinia } from 'pinia' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { useDataStore } from '../../src/store/data.js' +import { useTablesStore } from '../../src/store/store.js' +import { parseCol } from '../../src/shared/components/ncTable/mixins/columnParser.js' +import LookupColumn from '../../src/shared/components/ncTable/mixins/columnsTypes/lookup.js' +import TableCellLookup from '../../src/shared/components/ncTable/partials/TableCellLookup.vue' +import LookupForm from '../../src/shared/components/ncTable/partials/columnTypePartials/forms/LookupForm.vue' +import NcTable from '../../src/shared/components/ncTable/NcTable.vue' +import rowHelper from '../../src/shared/components/ncTable/mixins/rowHelper.js' +import ColumnFormComponent from '../../src/modules/main/partials/ColumnFormComponent.vue' +import TableRow from '../../src/shared/components/ncTable/partials/TableRow.vue' +import TableCellTextLine from '../../src/shared/components/ncTable/partials/TableCellTextLine.vue' + +const targetColumn = (overrides = {}) => ({ + id: 20, + tableId: 2, + title: 'City', + type: 'text', + subtype: 'line', + mandatory: false, + customSettings: {}, + ...overrides, +}) + +const sourceRelation = { + id: 7, + tableId: 1, + title: 'Person', + type: 'relation', + subtype: '', + customSettings: { relationType: 'view', targetId: 2, labelColumn: 20 }, +} + +const sourceRow = (id, value) => ({ id, data: [{ columnId: 7, value }] }) + +let pinia +let store +let column + +function mount(component, props) { + return cy.mount(component, { + props, + global: { + plugins: [pinia], + mixins: [{ methods: { t, n } }], + config: { globalProperties: { OC: window.OC, OCA: window.OCA } }, + }, + }) +} + +function setLookup(target, values, loading = false) { + store.relations[8] = { + column: parseCol(target), + values: Object.fromEntries(Object.entries(values).map(([id, value]) => [id, { id: Number(id), value }])), + loading, + } +} + +describe('Lookup behavior', () => { + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + store = useDataStore() + column = new LookupColumn({ + id: 8, + tableId: 1, + title: 'City of person', + customSettings: { relationColumnId: 7, targetColumnId: 20 }, + }) + store.columns['1'] = [column] + }) + + it('dispatches the lookup form for the lookup API type', () => { + setLookup(targetColumn(), { 42: 'Berlin' }) + mount(ColumnFormComponent, { column, value: 42 }) + cy.contains('Berlin').should('be.visible') + cy.then(() => expect(column.type).to.equal('lookup')) + }) + + it('renders a looked-up text value without an editor', () => { + setLookup(targetColumn(), { 42: 'Berlin' }) + mount(TableCellLookup, { column, value: 42, rowId: 1 }) + cy.contains('Berlin').click() + cy.get('input, textarea').should('not.exist') + cy.then(() => expect(column.getValueString({ value: 42 })).to.equal('Berlin')) + }) + + for (const isView of [false, true]) { + it(`blocks inline lookup editing in a fallback renderer (isView=${isView})`, () => { + mount(TableCellTextLine, { column, value: 'Berlin', rowId: 1, canEdit: true, isView }) + cy.contains('Berlin').click() + cy.get('input, textarea, .inline-editing-container').should('not.exist') + }) + } + + it('keeps lookup clicks read-only while allowing inline editing of ordinary columns', () => { + setLookup(targetColumn(), { 42: 'Berlin' }) + const ownColumn = parseCol(targetColumn({ id: 9, tableId: 1, title: 'Own value' })) + const row = sourceRow(1, 42) + row.data.push({ columnId: 9, value: 'Editable value' }) + const onEditRow = cy.stub().as('editRow') + mount(TableRow, { + row, + columns: [column, ownColumn], + config: { canEditRows: true, canSelectRows: false, showActions: false }, + isView: false, + elementId: 1, + onEditRow, + }) + cy.contains('td', 'Berlin').click() + cy.get('input, textarea, .inline-editing-container').should('not.exist') + cy.get('@editRow').should('not.have.been.called') + cy.contains('td', 'Editable value').click() + cy.get('input').should('be.visible').and('have.value', 'Editable value') + }) + + it('keeps numeric lookup values typed and formats zero correctly', () => { + setLookup(targetColumn({ type: 'number', subtype: '', numberDecimals: 2, numberPrefix: '$', numberSuffix: ' USD' }), { 42: 0 }) + mount(TableCellLookup, { column, value: 42, rowId: 1 }) + cy.get('.cell-number').should('contain', '0.00').and('contain', '$').and('contain', 'USD').and('not.contain', 'NaN') + cy.then(() => { store.relations[8].values[42].value = 12.5 }) + cy.get('.cell-number').should('contain', '12.50') + cy.then(() => expect(column.getValueString({ value: 42 })).to.equal(12.5)) + }) + + it('distinguishes loading, unavailable, and empty values without exporting row IDs', () => { + setLookup(targetColumn(), { 42: 'Old value' }, true) + mount(TableCellLookup, { column, value: 42, rowId: 1 }) + cy.get('[aria-label="Loading lookup value"]').should('exist') + cy.contains('Old value').should('not.exist') + cy.then(() => { store.relations[8] = { column: null, values: {}, loading: false } }) + cy.contains('Unavailable').should('be.visible') + cy.then(() => { + expect(column.getValueString({ value: 42 })).to.equal('') + expect(column.resolveValue(null).status).to.equal('unavailable') + expect(column.isSearchStringFound({ value: 42 }, '42')).to.equal(false) + }) + }) + + it('renders an empty target value without treating the reference as unavailable', () => { + setLookup(targetColumn(), { 42: null }) + mount(TableCellLookup, { column, value: 42, rowId: 1 }) + cy.contains('Unavailable').should('not.exist') + cy.then(() => { + expect(column.getValueString({ value: 42 })).to.equal('') + expect(column.resolveValue(null).status).to.equal('empty') + }) + }) + + it('searches and sorts lookup-only table columns through hidden relation cells', () => { + setLookup(targetColumn(), { 42: 'Zurich', 43: 'Amsterdam', 44: 'Berlin' }) + mount(NcTable, { + columns: [column], + rows: [sourceRow(1, 42), sourceRow(2, 43), sourceRow(3, 44)], + viewSetting: { searchString: 'berlin', sorting: [{ columnId: 8, mode: 'ASC' }] }, + showActions: false, + canSelectRows: false, + }) + cy.get('[data-cy="customTableRow"]').should('have.length', 1).and('contain', 'Berlin') + cy.then(() => { + const rows = [sourceRow(1, 42), sourceRow(2, 43)] + expect(rows.sort(column.sort('ASC')).map(row => row.id)).to.deep.equal([2, 1]) + expect(rows.sort(column.sort('DESC')).map(row => row.id)).to.deep.equal([1, 2]) + }) + }) + + it('sorts numeric lookups numerically, retaining zero and secondary sorting', () => { + setLookup(targetColumn({ type: 'number', subtype: '' }), { 42: 10, 43: 2, 44: 0, 45: -1, 46: 2 }) + cy.then(() => { + const rows = [sourceRow(1, 42), sourceRow(2, 43), sourceRow(3, 44), sourceRow(4, 45), sourceRow(5, 46)] + const secondary = (a, b) => b.id - a.id + expect(rows.sort(column.sort('ASC', [secondary])).map(row => row.id)).to.deep.equal([4, 3, 5, 2, 1]) + }) + }) + + it('ignores legacy mandatory flags on virtual lookup columns', () => { + column.mandatory = true + column.viewColumnInformation = { mandatory: true } + cy.then(() => { + const helper = { ...rowHelper.methods, columns: [column] } + expect(helper.isMandatory(column)).to.equal(false) + expect(helper.checkMandatoryFields({ 7: 42 })).to.equal(false) + }) + }) + + it('configures lookups from a source view without requiring the target view in navigation', () => { + const tables = useTablesStore() + tables.views = [{ id: 3, tableId: 1 }] + tables.setActiveViewId(3) + store.columns = {} + cy.intercept('GET', '**/apps/tables/api/1/tables/1/columns?viewId=3', [sourceRelation]).as('sourceColumns') + cy.intercept('GET', '**/apps/tables/api/1/views/2/columns', [ + targetColumn(), + targetColumn({ id: 21, type: 'relation', subtype: '', title: 'Nested relation' }), + targetColumn({ id: 22, type: 'lookup', subtype: '', title: 'Nested lookup' }), + ]).as('targetColumns') + mount(LookupForm, { column }) + cy.wait(['@sourceColumns', '@targetColumns']) + cy.get('[aria-label="Select target column"]').click() + cy.get('.vs__dropdown-menu').should('contain', 'City').and('not.contain', 'Nested relation').and('not.contain', 'Nested lookup') + }) + + it('loads relation data through the public endpoint and clears omitted entries on refresh', () => { + const token = 'abcdefghijklmnop' + store.setPublicToken(token) + store.columns['public-' + token] = [column] + let response = { 8: { column: targetColumn(), values: { 42: { id: 42, value: 'Berlin' } } } } + cy.intercept('GET', '**/apps/tables/api/2/public/' + token + '/relations*', req => { + req.reply({ ocs: { data: response } }) + }).as('publicRelations') + cy.then(() => store.loadRelationsFromBE({ token })) + cy.then(() => { + expect(column.getValueString({ value: 42 })).to.equal('Berlin') + response = {} + }) + cy.then(() => store.loadRelationsFromBE({ token, force: true })) + cy.then(() => { + expect(column.resolveValue(42).status).to.equal('unavailable') + expect(column.getValueString({ value: 42 })).to.equal('') + expect(store.getRelationsLoading(false, null)).to.equal(false) + }) + }) +}) diff --git a/cypress/component/Relation.cy.js b/cypress/component/Relation.cy.js new file mode 100644 index 0000000000..ebe8cc43ba --- /dev/null +++ b/cypress/component/Relation.cy.js @@ -0,0 +1,54 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia, setActivePinia } from 'pinia' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { useDataStore } from '../../src/store/data.js' +import RelationColumn from '../../src/shared/components/ncTable/mixins/columnsTypes/relation.js' +import TableRow from '../../src/shared/components/ncTable/partials/TableRow.vue' + +for (const relationType of ['table', 'view']) { + describe(`Relation inline selection (${relationType})`, () => { + for (const value of [270, '270']) { + it(`shows the selected label for a ${typeof value} row ID`, () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useDataStore() + const column = new RelationColumn({ + id: 7, + tableId: 1, + title: 'Approver', + customSettings: { relationType, targetId: 2, labelColumn: 20 }, + }) + store.relations[7] = { + column: null, + values: { + 270: { id: 270, value: 'Alice' }, + 271: { id: 271, value: 'Bob' }, + }, + loading: false, + } + cy.mount(TableRow, { + props: { + row: { id: 1, data: [{ columnId: 7, value }] }, + columns: [column], + config: { canEditRows: true, canSelectRows: false, showActions: false }, + isView: false, + elementId: 1, + }, + global: { + plugins: [pinia], + mixins: [{ methods: { t, n } }], + config: { globalProperties: { OC: window.OC, OCA: window.OCA } }, + }, + }) + cy.contains('.cell-relation .non-edit-mode', 'Alice').click() + cy.get('.cell-relation .edit-mode .vs__selected').should('contain', 'Alice').and('not.contain', '270') + cy.get('[aria-label="Select relation value"]').click() + cy.get('.vs__dropdown-menu').should('contain', 'Alice').and('contain', 'Bob') + }) + } + }) +} diff --git a/lib/Analytics/AnalyticsDatasource.php b/lib/Analytics/AnalyticsDatasource.php index 393bf249d4..40e4d50193 100644 --- a/lib/Analytics/AnalyticsDatasource.php +++ b/lib/Analytics/AnalyticsDatasource.php @@ -378,10 +378,10 @@ private function formatRelationValue(Column $column, mixed $value): string { return ''; } - $relationData = $this->relationService->getRelationData($column); + $relationLabels = $this->relationService->getRelationLabels($column); $valueId = (int)$value; - return $relationData[$valueId]['label'] ?? (string)$value; + return $relationLabels[$valueId]['value'] ?? (string)$value; } private function parseDefaultValue(?string $value): mixed { diff --git a/lib/Constants/ColumnType.php b/lib/Constants/ColumnType.php index f296de2f74..8d73ecdd54 100644 --- a/lib/Constants/ColumnType.php +++ b/lib/Constants/ColumnType.php @@ -16,4 +16,5 @@ enum ColumnType: string { case DATETIME = 'datetime'; case PEOPLE = 'usergroup'; case RELATION = 'relation'; + case LOOKUP = 'lookup'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 1e5984a4d9..20753a9969 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -53,6 +53,7 @@ * @psalm-import-type TablesShare from ResponseDefinitions * @psalm-import-type TablesColumn from ResponseDefinitions * @psalm-import-type TablesRow from ResponseDefinitions + * @psalm-import-type TablesRelationData from ResponseDefinitions * @psalm-import-type TablesImportState from ResponseDefinitions * @psalm-import-type TablesContextNavigation from ResponseDefinitions */ @@ -831,7 +832,7 @@ public function indexViewColumns(int $viewId): DataResponse { * Get all relation data for a table * * @param int $tableId Table ID - * @return DataResponse>, array{}>|DataResponse + * @return DataResponse|DataResponse * * 200: Relation data returned * 403: No permissions @@ -841,6 +842,7 @@ public function indexViewColumns(int $viewId): DataResponse { #[NoCSRFRequired] #[CORS] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexTableRelations(int $tableId): DataResponse { try { return new DataResponse($this->relationService->getRelationsForTable($tableId)); @@ -863,7 +865,7 @@ public function indexTableRelations(int $tableId): DataResponse { * Get all relation data for a view * * @param int $viewId View ID - * @return DataResponse>, array{}>|DataResponse + * @return DataResponse|DataResponse * * 200: Relation data returned * 403: No permissions @@ -873,6 +875,7 @@ public function indexTableRelations(int $tableId): DataResponse { #[NoCSRFRequired] #[CORS] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexViewRelations(int $viewId): DataResponse { try { return new DataResponse($this->relationService->getRelationsForView($viewId)); @@ -897,7 +900,7 @@ public function indexViewRelations(int $viewId): DataResponse { * @param int|null $tableId Table ID * @param int|null $viewId View ID * @param string $title Title - * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation' $type Column main type + * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'lookup' $type Column main type * @param string|null $technicalName Technical name of the column * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory @@ -1057,7 +1060,7 @@ public function createColumn( * @param bool|null $usergroupSelectGroups Can select groups, if column type is usergroup * @param bool|null $usergroupSelectTeams Can select teams, if column type is usergroup * @param bool|null $usergroupShowUserStatus Whether to show the user's status, if column type is usergroup - * @param array $customSettings Custom settings for the column + * @param array|null $customSettings Custom settings for the column * * @return DataResponse|DataResponse * @@ -1100,7 +1103,7 @@ public function updateColumn( ?bool $usergroupSelectGroups, ?bool $usergroupSelectTeams, ?bool $usergroupShowUserStatus, - ?array $customSettings = [], + ?array $customSettings = null, ): DataResponse { try { $item = $this->columnService->update( @@ -1131,7 +1134,7 @@ public function updateColumn( usergroupSelectGroups: $usergroupSelectGroups, usergroupSelectTeams: $usergroupSelectTeams, showUserStatus: $usergroupShowUserStatus, - customSettings: json_encode($customSettings), + customSettings: $customSettings !== null ? json_encode($customSettings) : null, ) ); return new DataResponse($item->jsonSerialize()); @@ -1681,7 +1684,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'|'relation' $type Column main type + * @param 'text'|'number'|'datetime'|'select'|'usergroup'|'relation'|'lookup' $type Column main type * @param string|null $technicalName Technical name of the column * @param string|null $subtype Column sub type * @param bool $mandatory Is the column mandatory diff --git a/lib/Db/Column.php b/lib/Db/Column.php index a039dd33c7..7aa475f7f2 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -107,6 +107,7 @@ class Column extends EntitySuper implements JsonSerializable { public const TYPE_DATETIME = 'datetime'; public const TYPE_USERGROUP = 'usergroup'; public const TYPE_RELATION = 'relation'; + public const TYPE_LOOKUP = 'lookup'; public const SUBTYPE_DATETIME_DATE = 'date'; public const SUBTYPE_DATETIME_TIME = 'time'; @@ -354,6 +355,28 @@ public function getCustomSettingsArray(): array { return json_decode((string)$this->customSettings, true) ?: []; } + /** + * @template T + * @param class-string $className + * @return T + * @throws \InvalidArgumentException + */ + public function getCustomSettingsObject(string $className): mixed { + $array = $this->getCustomSettingsArray(); + if (method_exists($className, 'fromArray')) { + return $className::fromArray($array); + } + throw new \InvalidArgumentException("Class $className must have a fromArray method"); + } + + /** + * Virtual columns do not store their own cell data, + * their values are derived from a related column. + */ + public function isVirtual(): bool { + return $this->type === self::TYPE_LOOKUP; + } + /** * @throws ValueError */ diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index aeb7c59c8c..dba7faafd1 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -10,6 +10,7 @@ use DateTime; use DateTimeImmutable; use OCA\Tables\Constants\UsergroupType; +use OCA\Tables\Dto\LookupSettings; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Helper\ColumnsHelper; @@ -52,6 +53,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()); @@ -73,7 +77,7 @@ public function delete(Row2 $row): Row2 { * @throws NotFoundError */ public function find(int $id, array $columns): Row2 { - $columnIdsArray = array_map(fn (Column $column) => $column->getId(), $columns); + $columnIdsArray = $this->getColumnIdsWithRelationColumns($columns); $rows = $this->getRows([$id], $columnIdsArray); if (count($rows) === 1) { @@ -172,6 +176,7 @@ private function getWantedRowIds(string $userId, int $tableId, ?array $filter = */ public function findAll(array $showColumnIds, int $tableId, ?int $limit = null, ?int $offset = null, ?array $filter = null, ?array $sort = null, ?string $userId = null): array { try { + $showColumnIds = $this->getColumnIdsWithRelationColumns($this->columnMapper->findAll($showColumnIds)); $this->columnMapper->preloadColumns($showColumnIds, $filter, $sort); $wantedRowIdsArray = $this->getWantedRowIds($userId, $tableId, $filter, $sort, $limit, $offset); @@ -214,8 +219,12 @@ private function getRows(array $rowIds, array $columnIds): array { private function getRowsChunk(array $rowIds, array $columnIds): array { $qb = $this->db->getQueryBuilder(); + $columnFilter = $qb->expr()->in('column_id', $qb->createNamedParameter($columnIds, IQueryBuilder::PARAM_INT_ARRAY, ':columnIds')); $qbSqlForColumnTypes = null; foreach ($this->columnsHelper->columns as $columnType) { + if ($this->isVirtualColumn($columnType)) { + continue; + } $qbTmp = $this->db->getQueryBuilder(); $qbTmp->select('row_id', 'column_id', 'last_edit_at', 'last_edit_by') ->selectAlias($qb->expr()->castColumn('value', IQueryBuilder::PARAM_STR), 'value'); @@ -231,7 +240,7 @@ private function getRowsChunk(array $rowIds, array $columnIds): array { $qbTmp ->from('tables_row_cells_' . $columnType) - ->where($qb->expr()->in('column_id', $qb->createNamedParameter($columnIds, IQueryBuilder::PARAM_INT_ARRAY, ':columnIds'))) + ->where($columnFilter) ->andWhere($qb->expr()->in('row_id', $qb->createNamedParameter($rowIds, IQueryBuilder::PARAM_INT_ARRAY, ':rowsIds'))); if ($qbSqlForColumnTypes) { @@ -304,6 +313,10 @@ private function addSortQueryForMultipleSleeveFinder(IQueryBuilder $qb, string $ continue; } + if ($column && $column->isVirtual()) { + continue; + } + // if is normal column if ($sortData['columnId'] >= 0) { $valueTable = 'tables_row_cells_' . $column->getType(); @@ -892,6 +905,10 @@ private function insertCell(int $rowId, int $columnId, $value, ?string $lastEdit throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + if ($column->isVirtual()) { + return; + } + // insert new cell $cellMapper = $this->getCellMapper($column); @@ -930,6 +947,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 ($column->isVirtual()) { + return; + } + $this->getCellMapper($column)->applyDataToEntity($column, $cell, $value); $this->updateMetaData($cell); $mapper->updateWrapper($cell); @@ -940,6 +961,9 @@ private function updateCell(RowCellSuper $cell, RowCellMapperSuper $mapper, $val */ private function insertOrUpdateCell(int $rowId, int $columnId, $value): void { $column = $this->columnMapper->find($columnId); + if ($column->isVirtual()) { + return; + } $cellMapper = $this->getCellMapper($column); try { if ($cellMapper->hasMultipleValues()) { @@ -966,6 +990,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 { @@ -987,6 +1014,9 @@ private function getColumnDbParamType(Column $column): int { * @throws InternalError */ public function deleteDataForColumn(Column $column): void { + if ($column->isVirtual()) { + return; + } try { $this->getCellMapper($column)->deleteAllForColumn($column->getId()); } catch (Exception $e) { @@ -1067,6 +1097,7 @@ private function getFormattedDefaultValue(Column $column) { Column::TYPE_NUMBER => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getNumberDefault()), Column::TYPE_TEXT => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getTextDefault()), Column::TYPE_USERGROUP => $this->getCellMapper($column)->filterValueToQueryParam($column, $column->getUsergroupDefault()), + Column::TYPE_LOOKUP => null, default => $defaultValue, }; return $defaultValue; @@ -1095,4 +1126,34 @@ private function sortRowsByIds(array $rows, array $wantedRowIds): array { return $sortedRows; } + + public function isVirtualColumn(string $columnType): bool { + return $columnType === Column::TYPE_LOOKUP; + } + + /** + * Maps columns to their IDs and adds the relation column ID for each lookup + * column, so the cells of the underlying relation column get fetched too. + * + * @param Column[] $columns + * @return int[] + */ + public function getColumnIdsWithRelationColumns(array $columns): array { + $columnIds = []; + foreach ($columns as $column) { + $columnIds[] = $column->getId(); + if (!$column->isVirtual()) { + continue; + } + + try { + $settings = $column->getCustomSettingsObject(LookupSettings::class); + $columnIds[] = $settings->relationColumnId; + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid lookup settings for column ' . $column->getId(), ['exception' => $e]); + } + } + return array_values(array_unique(array_filter($columnIds))); + } + } diff --git a/lib/Dto/LookupSettings.php b/lib/Dto/LookupSettings.php new file mode 100644 index 0000000000..d9411701e0 --- /dev/null +++ b/lib/Dto/LookupSettings.php @@ -0,0 +1,39 @@ +relationType === 'view'; + } +} diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index 2ebe0e254d..d05be27db2 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -21,6 +21,7 @@ class ColumnsHelper { Column::TYPE_SELECTION, Column::TYPE_USERGROUP, Column::TYPE_RELATION, + Column::TYPE_LOOKUP, ]; /** diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 194aa0a24a..19b3becdc0 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -169,9 +169,14 @@ * mandatory: bool, * }, * customSettings: ?array{ - * width: int, + * width?: int, * showPreview?: bool, * imagePreviewSize?: int, + * relationType?: 'table'|'view', + * targetId?: int, + * labelColumn?: int, + * relationColumnId?: int, + * targetColumnId?: int, * }, * } * @@ -212,9 +217,14 @@ * mandatory: bool, * }, * customSettings: ?array{ - * width: int, + * width?: int, * showPreview?: bool, * imagePreviewSize?: int, + * relationType?: 'table'|'view', + * targetId?: int, + * labelColumn?: int, + * relationColumnId?: int, + * targetColumnId?: int, * }, * } * @@ -249,6 +259,9 @@ * notify-column: bool, * notify-row: bool, * } + * + * @psalm-type TablesRelationValue = string|int|float|bool|null|list|list + * @psalm-type TablesRelationData = array|list}>|list */ class ResponseDefinitions { } diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php index 7bb5c79d59..dd6fff32d4 100644 --- a/lib/Service/ColumnService.php +++ b/lib/Service/ColumnService.php @@ -16,13 +16,17 @@ use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; +use OCA\Tables\Db\ViewMapper; use OCA\Tables\Dto\Column as ColumnDto; +use OCA\Tables\Dto\LookupSettings; +use OCA\Tables\Dto\RelationSettings; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\UserHelper; use OCA\Tables\Model\SelectionOptions; +use OCA\Tables\Model\SortRuleSet; use OCA\Tables\Notification\NotificationHelper; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ValueObject\Title; @@ -54,6 +58,7 @@ public function __construct( private readonly IL10N $l, private readonly UserHelper $userHelper, private readonly ColumnDtoValidator $columnDtoValidator, + private readonly ViewMapper $viewMapper, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -294,6 +299,7 @@ public function create( $item = Column::fromDto($columnDto); $item->setTitle($newTitle); $item->setTableId($table->getId()); + $this->validateRelationSettings($item); $this->updateMetadata($item, $userId, true); try { @@ -423,7 +429,10 @@ public function update( $item->setUsergroupSelectTeams($columnDto->getUsergroupSelectTeams()); $item->setShowUserStatus($columnDto->getShowUserStatus()); $this->validateCustomSettings($columnDto->getCustomSettings()); - $item->setCustomSettings($columnDto->getCustomSettings()); + if ($columnDto->getCustomSettings() !== null) { + $item->setCustomSettings($columnDto->getCustomSettings()); + } + $this->validateRelationSettings($item); $this->updateMetadata($item, $userId); try { @@ -507,6 +516,55 @@ private function validateCustomSettings(?string $customSettings): void { } } + private function validateRelationSettings(Column $column): void { + try { + if ($column->getType() === Column::TYPE_RELATION) { + $settings = $column->getCustomSettingsObject(RelationSettings::class); + $label = $this->mapper->find($settings->labelColumn); + $this->assertRelationTargetColumn($settings, $label); + if (!(($label->getType() === Column::TYPE_TEXT && $label->getSubtype() === 'line') + || ($label->getType() === Column::TYPE_NUMBER && $label->getSubtype() === ''))) { + throw new \InvalidArgumentException('Relation labels must use a text-line or number column.'); + } + } + if ($column->getType() !== Column::TYPE_LOOKUP) { + return; + } + if ($column->getMandatory()) { + throw new \InvalidArgumentException('Lookup columns cannot be mandatory.'); + } + $settings = $column->getCustomSettingsObject(LookupSettings::class); + $relation = $this->mapper->find($settings->relationColumnId); + if ($relation->getType() !== Column::TYPE_RELATION || $relation->getTableId() !== $column->getTableId()) { + throw new \InvalidArgumentException('Lookup relation must be a relation column in the same table.'); + } + $target = $this->mapper->find($settings->targetColumnId); + $this->assertRelationTargetColumn($relation->getCustomSettingsObject(RelationSettings::class), $target); + if (in_array($target->getType(), [Column::TYPE_RELATION, Column::TYPE_LOOKUP], true)) { + throw new \InvalidArgumentException('Lookup targets cannot be relation or lookup columns.'); + } + } catch (\InvalidArgumentException|DoesNotExistException $e) { + throw new BadRequestError($e->getMessage(), 0, $e); + } + } + + public function assertRelationTargetColumn(RelationSettings $settings, Column $column): void { + $view = $settings->isView() ? $this->viewMapper->find($settings->targetId) : null; + $tableId = $view ? $view->getTableId() : $settings->targetId; + if ($column->getTableId() !== $tableId || ($view && !in_array($column->getId(), $view->getColumnIds(), true))) { + throw new \InvalidArgumentException('Column must belong to the relation target table or view.'); + } + } + + public function validateSortRules(SortRuleSet $sort): void { + $columnIds = array_filter(array_column($sort->jsonSerialize(), 'columnId'), fn (int $id) => $id > 0); + foreach ($this->mapper->findAll($columnIds) as $column) { + if ($column->isVirtual()) { + throw new \InvalidArgumentException('Lookup columns cannot be used in saved sorting rules.'); + } + } + } + private function normalizeTitle(?string $title, bool $required): ?string { if ($title === null) { if ($required) { @@ -652,6 +710,11 @@ public function findOrCreateColumnsByTitleForTableAsArray(?int $tableId, ?int $v foreach ($titles as $title) { $i++; foreach ($allColumns as $column) { + // Skip matching columns with type lookup + if ($column->isVirtual()) { + continue; + } + if ($column->getTitle() === $title) { $result[$i] = $column; $countMatchingColumns++; diff --git a/lib/Service/ColumnTypes/LookupBusiness.php b/lib/Service/ColumnTypes/LookupBusiness.php new file mode 100644 index 0000000000..9b3c7648a6 --- /dev/null +++ b/lib/Service/ColumnTypes/LookupBusiness.php @@ -0,0 +1,58 @@ +relationService->getRelationData($column); + $relationLabels = $this->relationService->getRelationLabels($column); // try to find value by label - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + $matchingRelation = array_filter($relationLabels, fn (array $relation) => $relation['value'] === $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])) { + if (is_numeric($value) && isset($relationLabels[(int)$value])) { return json_encode($value); } @@ -62,14 +62,14 @@ public function canBeParsed($value, ?Column $column = null): bool { return true; } - $relationData = $this->relationService->getRelationData($column); + $relationLabels = $this->relationService->getRelationLabels($column); // try to find value by label - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + $matchingRelation = array_filter($relationLabels, fn (array $relation) => $relation['value'] === $value); if (!empty($matchingRelation)) { return true; } // if not found, try to find by id - if (is_numeric($value) && isset($relationData[(int)$value])) { + if (is_numeric($value) && isset($relationLabels[(int)$value])) { return true; } @@ -81,16 +81,16 @@ public function validateValue(mixed $value, Column $column, string $userId, int return; } // Validate that the value exists in the target table/view - $relationData = $this->relationService->getRelationData($column); + $relationLabels = $this->relationService->getRelationLabels($column); // Try to find value by label first - $matchingRelation = array_filter($relationData, fn (array $relation) => $relation['label'] === $value); + $matchingRelation = array_filter($relationLabels, fn (array $relation) => $relation['value'] === $value); if (!empty($matchingRelation)) { return; } // If not found by label, try to find by id - if (is_numeric($value) && isset($relationData[(int)$value])) { + if (is_numeric($value) && isset($relationLabels[(int)$value])) { return; } diff --git a/lib/Service/RelationService.php b/lib/Service/RelationService.php index 6d0ca9fade..5239cc4d06 100644 --- a/lib/Service/RelationService.php +++ b/lib/Service/RelationService.php @@ -9,23 +9,37 @@ use OCA\Tables\Db\Column; use OCA\Tables\Db\ColumnMapper; +use OCA\Tables\Db\Row2; use OCA\Tables\Db\Row2Mapper; use OCA\Tables\Db\ViewMapper; +use OCA\Tables\Dto\LookupSettings; +use OCA\Tables\Dto\RelationSettings; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +/** + * @psalm-import-type TablesColumn from ResponseDefinitions + * @psalm-import-type TablesPublicColumn from ResponseDefinitions + * @psalm-import-type TablesRelationData from ResponseDefinitions + * @psalm-import-type TablesRelationValue from ResponseDefinitions + */ class RelationService { - /** @var array Cache for relation data */ - private array $cacheRelationData = []; + /** @var array Cache for formatted relation labels */ + private array $cacheRelationLabels = []; + private array $cacheRawRelationData = []; public function __construct( private readonly ColumnMapper $columnMapper, private readonly ViewMapper $viewMapper, private readonly Row2Mapper $row2Mapper, private readonly ColumnService $columnService, - private readonly ?string $userId, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, ) { } @@ -33,55 +47,92 @@ public function __construct( * Get all relation data for a table * * @param int $tableId - * @return array Relation data grouped by column ID + * @return TablesRelationData Relation data grouped by column ID * @throws InternalError * @throws NotFoundError * @throws PermissionError */ - public function getRelationsForTable(int $tableId): array { + public function getRelationsForTable(int $tableId, ?string $userId = null): array { // Check table permissions through ColumnService - $columns = $this->columnService->findAllByTable($tableId); - - $relationColumns = array_filter($columns, fn ($column) => $column->getType() === Column::TYPE_RELATION); + $columns = $this->columnService->findAllByTable($tableId, $userId); - return $this->getRelationsForColumns($relationColumns); + return $this->getRelationsForColumnList($columns); } /** * Get all relation data for a view * * @param int $viewId - * @return array Relation data grouped by column ID + * @return TablesRelationData Relation data grouped by column ID * @throws InternalError * @throws NotFoundError * @throws PermissionError */ - public function getRelationsForView(int $viewId): array { + public function getRelationsForView(int $viewId, ?string $userId = null): array { // Check view permissions through ColumnService - $columns = $this->columnService->findAllByView($viewId); + $columns = $this->columnService->findAllByView($viewId, $userId); - $relationColumns = array_filter($columns, fn ($column) => $column->getType() === Column::TYPE_RELATION); + return $this->getRelationsForColumnList($columns); + } - return $this->getRelationsForColumns($relationColumns); + /** + * Get relation labels for a specific column + * + * @param Column $column + * @return array Indexed per row id + */ + public function getRelationLabels(Column $column): array { + if ($column->getType() !== Column::TYPE_RELATION) { + return []; + } + + try { + return $this->fetchRelationLabelsForTarget($column); + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid relation settings for column ' . $column->getId() . ': ' . $e->getMessage()); + return []; + } } /** - * Get relation data for specific columns + * Get relation data for a list of columns * - * @param Column[] $relationColumns - * @return array Relation data grouped by column ID + * @param Column[] $columns + * @return TablesRelationData Relation data grouped by column ID * @throws InternalError */ - private function getRelationsForColumns(array $relationColumns): array { - // Group columns by their target (relationType + targetId + labelColumn) + private function getRelationsForColumnList(array $columns): array { + $columnsMap = $this->fetchColumnsByIds($this->collectRelationColumnIds($columns)); + foreach ($columns as $column) { + $columnsMap[$column->getId()] = $column; + } + $relationColumns = array_filter($columns, fn (Column $column) => $column->getType() === Column::TYPE_RELATION); + $lookupColumns = array_filter($columns, fn (Column $column) => $column->getType() === Column::TYPE_LOOKUP); + $this->preloadRelationValues($relationColumns, $lookupColumns, $columnsMap); + $relationLabels = $this->fetchRelationLabelsForColumns($relationColumns); + $lookupData = $this->fetchLookupColumns($lookupColumns, $columnsMap); + + return array_map( + fn (array $data) => ['column' => null, 'values' => $data], + $relationLabels + ) + $lookupData; + } + + /** + * Fetch relation labels for relation columns + * + * @param Column[] $columns + * @return array> Relation labels, indexed per column ID and row ID + * @throws InternalError + */ + private function fetchRelationLabelsForColumns(array $columns): array { $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; + foreach ($columns as $column) { + try { + $result[$column->getId()] = $this->fetchRelationLabelsForTarget($column); + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid relation settings for column ' . $column->getId() . ': ' . $e->getMessage()); } } @@ -89,117 +140,330 @@ private function getRelationsForColumns(array $relationColumns): array { } /** - * Group relation columns by their target configuration + * Fetch relation data for lookup columns * * @param Column[] $columns - * @return array + * @param array $columnsMap + * @return TablesRelationData Relation data grouped by column ID + * @throws InternalError */ - private function groupColumnsByTarget(array $columns): array { - $groups = []; + private function fetchLookupColumns(array $columns, array $columnsMap): array { + $result = []; + // Reuse the batch-fetched columns to avoid duplicate queries foreach ($columns as $column) { - $settings = $column->getCustomSettingsArray(); - if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['labelColumn'])) { - continue; + $result[$column->getId()] = ['column' => null, 'values' => []]; + try { + [$settings, $targetColumn] = $this->resolveLookupTarget($column, $columnsMap); + $lookupSettings = $column->getCustomSettingsObject(LookupSettings::class); + $result[$column->getId()] = [ + 'column' => $targetColumn->jsonSerialize(), + 'values' => $this->fetchRawRelationValues($settings, $lookupSettings->targetColumnId), + ]; + } catch (\InvalidArgumentException|DoesNotExistException $e) { + $this->logger->warning('Invalid lookup settings for column ' . $column->getId() . ': ' . $e->getMessage()); + } + } + + return $result; + } + + private function getCurrentUserId(): string { + return $this->userSession->getUser()?->getUID() ?? ''; + } + + /** + * @param Column[] $relationColumns + * @param Column[] $lookupColumns + * @param array $columnsMap + */ + private function preloadRelationValues(array $relationColumns, array $lookupColumns, array $columnsMap): void { + $targets = []; + foreach ([...$relationColumns, ...$lookupColumns] as $column) { + try { + if ($column->isVirtual()) { + [$settings, $targetColumn] = $this->resolveLookupTarget($column, $columnsMap); + } else { + $settings = $column->getCustomSettingsObject(RelationSettings::class); + $targetColumn = $columnsMap[$settings->labelColumn] ?? null; + if (!$targetColumn) { + continue; + } + $this->columnService->assertRelationTargetColumn($settings, $targetColumn); + } + $targetKey = $settings->relationType . '_' . $settings->targetId; + $targets[$targetKey]['settings'] = $settings; + $targets[$targetKey]['columnIds'][$targetColumn->getId()] = true; + } catch (\InvalidArgumentException|DoesNotExistException $e) { + $this->logger->warning('Invalid relation settings for column ' . $column->getId(), ['exception' => $e]); } + } - $target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']); - $groups[$target] ??= []; - $groups[$target][] = $column; + foreach ($targets as $target) { + $settings = $target['settings']; + $cacheKeys = []; + foreach (array_keys($target['columnIds']) as $columnId) { + $key = $this->buildRelationCacheKey($settings->relationType, $settings->targetId, $columnId); + if (!isset($this->cacheRawRelationData[$key])) { + $cacheKeys[$columnId] = $key; + } + } + if (!$cacheKeys) { + continue; + } + try { + $rows = $this->fetchRowsForTarget($settings, array_keys($cacheKeys)); + } catch (DoesNotExistException) { + $rows = []; + } + foreach ($cacheKeys as $key) { + $this->cacheRawRelationData[$key] = []; + } + foreach ($rows as $row) { + $rowId = (int)$row->getId(); + $values = array_column($row->getData(), 'value', 'columnId'); + foreach ($cacheKeys as $columnId => $key) { + $this->cacheRawRelationData[$key][$rowId] = ['id' => $rowId, 'value' => $values[$columnId] ?? null]; + } + } } + } - return $groups; + /** + * @param array $columnsMap + * @return array{RelationSettings, Column} + */ + private function resolveLookupTarget(Column $column, array $columnsMap): array { + $settings = $column->getCustomSettingsObject(LookupSettings::class); + $relation = $columnsMap[$settings->relationColumnId] ?? null; + $target = $columnsMap[$settings->targetColumnId] ?? null; + if (!$relation || $relation->getType() !== Column::TYPE_RELATION || $relation->getTableId() !== $column->getTableId()) { + throw new \InvalidArgumentException('Lookup relation must belong to the same table.'); + } + if (!$target || in_array($target->getType(), [Column::TYPE_RELATION, Column::TYPE_LOOKUP], true)) { + throw new \InvalidArgumentException('Lookup target is missing or unsupported.'); + } + $relationSettings = $relation->getCustomSettingsObject(RelationSettings::class); + $this->columnService->assertRelationTargetColumn($relationSettings, $target); + return [$relationSettings, $target]; } /** - * Get relation data for a specific column + * Fetch relation labels for a specific target * * @param Column $column - * @return array Indexed per row id + * @return array Indexed per row id + * @throws InternalError */ - public function getRelationData(Column $column): array { - if ($column->getType() !== Column::TYPE_RELATION) { - return []; + private function fetchRelationLabelsForTarget(Column $column): array { + $settings = $column->getCustomSettingsObject(RelationSettings::class); + + $cacheKey = $this->buildRelationCacheKey($settings->relationType, $settings->targetId, $settings->labelColumn); + if (isset($this->cacheRelationLabels[$cacheKey])) { + return $this->cacheRelationLabels[$cacheKey]; } - $settings = $column->getCustomSettingsArray(); - if (empty($settings['relationType']) || empty($settings['targetId']) || empty($settings['labelColumn'])) { + try { + $targetColumn = $this->columnMapper->find($settings->labelColumn); + $this->columnService->assertRelationTargetColumn($settings, $targetColumn); + $rows = $this->fetchRawRelationValues($settings, $settings->labelColumn); + } catch (DoesNotExistException) { + $this->cacheRelationLabels[$cacheKey] = []; return []; } - $target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']); + $result = $this->buildRelationLabels($rows, $targetColumn); + $this->cacheRelationLabels[$cacheKey] = $result; + + return $result; + } - return $this->getRelationDataForTarget($target, $column); + /** + * Build a cache key for relation data + */ + private function buildRelationCacheKey(string $relationType, int $targetId, int $labelColumn): string { + return sprintf('%s_%d_%d_%s', $relationType, $targetId, $labelColumn, $this->getCurrentUserId()); } /** - * Get relation data for a specific target + * Collect all column IDs needed for relation and lookup processing * - * @param string $target - * @param Column $column - * @return array Indexed per row id - * @throws InternalError + * @param Column[] $columns + * @return int[] */ - 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]; + private function collectRelationColumnIds(array $columns): array { + $columnIds = []; + + foreach ($columns as $column) { + try { + if ($column->getType() === Column::TYPE_LOOKUP) { + $settings = $column->getCustomSettingsObject(LookupSettings::class); + $columnIds[] = $settings->targetColumnId; + $columnIds[] = $settings->relationColumnId; + } elseif ($column->getType() === Column::TYPE_RELATION) { + $columnIds[] = $column->getCustomSettingsObject(RelationSettings::class)->labelColumn; + } + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid relation settings for column ' . $column->getId(), ['exception' => $e]); + } } - $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 array_values(array_unique($columnIds)); + } + + /** + * Fetch multiple columns by their IDs in a single batch + * + * @param int[] $columnIds + * @return array + */ + private function fetchColumnsByIds(array $columnIds): array { + if (empty($columnIds)) { 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 - ); + $columns = $this->columnMapper->findAll($columnIds); + $result = []; + foreach ($columns as $column) { + $result[$column->getId()] = $column; } - } catch (DoesNotExistException) { - $this->cacheRelationData[$cacheKey] = []; + return $result; + } catch (\Exception $e) { + $this->logger->warning('Failed to fetch columns: ' . $e->getMessage()); return []; } + } + + /** + * Fetch raw values for a relation target column + * + * @param RelationSettings $settings + * @param int $targetColumnId + * @return array + */ + private function fetchRawRelationValues(RelationSettings $settings, int $targetColumnId): array { + $cacheKey = $this->buildRelationCacheKey($settings->relationType, $settings->targetId, $targetColumnId); + if (isset($this->cacheRawRelationData[$cacheKey])) { + return $this->cacheRawRelationData[$cacheKey]; + } + + try { + // Fetch only the requested column; labels do not determine whether a row is referenced + $rows = $this->fetchRowsForTarget($settings, [$targetColumnId]); + $lookupData = $this->buildRawRelationValues($rows, $targetColumnId); + + $this->cacheRawRelationData[$cacheKey] = $lookupData; + return $lookupData; + } catch (DoesNotExistException $e) { + $this->logger->warning('Failed to fetch lookup data: ' . $e->getMessage()); + $this->cacheRawRelationData[$cacheKey] = []; + return []; + } + } + + /** + * Fetch rows for a relation target + * + * @param RelationSettings $settings + * @param int[] $columnIds + * @return Row2[] + * @throws DoesNotExistException + */ + private function fetchRowsForTarget(RelationSettings $settings, array $columnIds): array { + if ($settings->isView()) { + $view = $this->viewMapper->find($settings->targetId); + return $this->row2Mapper->findAll( + $columnIds, + $view->getTableId(), + filter: $view->getFilterArray(), + sort: $view->getSortArray(), + userId: $this->getCurrentUserId() + ); + } + + return $this->row2Mapper->findAll( + $columnIds, + $settings->targetId, + userId: $this->getCurrentUserId() + ); + } + /** + * Build relation labels from raw values + * + * @param array $rows + * @param Column $column + * @return array + */ + private function buildRelationLabels(array $rows, Column $column): array { + $result = []; + + foreach ($rows as $rowId => $row) { + $result[$rowId] = [ + 'id' => $rowId, + 'value' => $this->formatRelationLabel($column, $row['value']), + ]; + } + + return $result; + } + + /** + * Build lookup values from rows containing the target column + * + * @param Row2[] $rows + * @param int $targetColumnId + * @return array + */ + private function buildRawRelationValues(array $rows, int $targetColumnId): array { $result = []; + foreach ($rows as $row) { $data = $row->getData(); - $displayFieldData = array_filter($data, fn ($item) => $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(); + + // Index the row's values by column ID + $values = array_column($data, 'value', 'columnId'); + + // Find the target column value + $targetValue = $values[$targetColumnId] ?? null; + + // A relation references the row ID independently of its label $result[$rowId] = [ 'id' => $rowId, - 'label' => (string)$value, + 'value' => $targetValue, ]; } - $this->cacheRelationData[$cacheKey] = $result; return $result; } + + /** + * Format a cell value for display as a relation label. + * Relation labels only support text-line and number columns. + */ + private function formatRelationLabel(Column $column, mixed $value): string { + if ($value === null || $value === '' || $value === []) { + return ''; + } + + return match ($column->getType()) { + Column::TYPE_TEXT => trim(strip_tags((string)$value)), + Column::TYPE_NUMBER => $this->formatNumberLabel($column, $value), + default => (string)$value, + }; + } + + private function formatNumberLabel(Column $column, mixed $value): string { + if ($value === null || $value === '') { + return ''; + } + if (!is_numeric($value)) { + return (string)$value; + } + $decimals = $column->getNumberDecimals() ?? 0; + $formatted = number_format((float)$value, $decimals, '.', ''); + return $column->getNumberPrefix() . $formatted . $column->getNumberSuffix(); + } } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 3e98e7d4a2..67353e1528 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -374,7 +374,7 @@ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $readOnlyColumns = $view ? $this->extractColumnsByProperty($view, 'isReadonly') : []; $mandatoryColumns = []; foreach ($columns as $column) { - if ($column->getMandatory()) { + if ($column->getMandatory() && !$column->isVirtual()) { $mandatoryColumns[$column->getId()] = true; } } @@ -393,6 +393,10 @@ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $column = $this->getColumnFromColumnsArray($columnId, $columns); + if ($column && $column->isVirtual()) { + continue; + } + if ($column) { $this->validateColumnValueLimits($column, $entry['value']); $columnBusiness = $this->columnsHelper->getColumnBusinessObject($column); @@ -442,7 +446,7 @@ private function validateMandatoryColumns(array $mandatoryColumns, RowDataInput } $column = $this->getColumnFromColumnsArray($columnId, $columns); - if (!$column) { + if (!$column || $column->isVirtual()) { continue; } @@ -923,6 +927,8 @@ private function filterRowResult(?View $view, Row2 $row): Row2 { } $columnIds = $view->getColumnIds(); + $columnIds = $this->row2Mapper->getColumnIdsWithRelationColumns($this->columnMapper->findAll($columnIds)); + $row->filterDataByColumns($columnIds); $row->filterDataByAliasByColumns($columnIds); diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index adf59034df..aa5fc9ad1c 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -546,6 +546,7 @@ public function update(int $id, ?string $title, ?string $emoji, ?string $descrip $table->setColumnOrder(\json_encode($columnSettings->jsonSerialize())); } if ($sort !== null) { + $this->columnService->validateSortRules($sort); $table->setSort(\json_encode($sort->jsonSerialize())); } $table->setLastEditBy($userId); @@ -784,6 +785,7 @@ public function updateTableStructure(int $tableId, array $columns, array $views, // Update sort rules $sortRuleSet = SortRuleSet::createFromInputArray($sort, $columnsMap); + $this->columnService->validateSortRules($sortRuleSet); $table->setSort(\json_encode($sortRuleSet->jsonSerialize())); try { diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index c50ae3aadd..0f78b0627c 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -268,14 +268,14 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $cardSourcesRequested = false; // Resolved at most once: the column check and the card source rules both need it, // and it costs a query. - $managedColumnIds = null; + $managedColumns = null; $columnSelectionChanged = false; foreach ($data->updateDetail() as $parameter => $value) { if ($parameter === ViewUpdatableParameters::COLUMN_SETTINGS && $value instanceof ColumnSettings ) { - $managedColumnIds ??= $this->findManagedColumnIds($view, $userId); - $this->assertInputColumnsAreValid($value, $managedColumnIds); + $managedColumns ??= $this->findManagedColumnIds($view, $userId); + $this->assertInputColumnsAreValid($value, $managedColumns['columnIds'], $managedColumns['lookupColumnIds']); $columnSelectionChanged = true; } @@ -283,6 +283,10 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $cardSourcesRequested = true; } + if ($value instanceof SortRuleSet) { + \OCP\Server::get(ColumnService::class)->validateSortRules($value); + } + if ($parameter === ViewUpdatableParameters::TECHNICAL_NAME) { $this->assertTechnicalNameValid($value); } @@ -298,8 +302,8 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b if ($cardSourcesRequested || $columnSelectionChanged) { $candidateIds = $view->getColumnIds(); if (empty($candidateIds)) { - $managedColumnIds ??= $this->findManagedColumnIds($view, $userId); - $candidateIds = $managedColumnIds; + $managedColumns ??= $this->findManagedColumnIds($view, $userId); + $candidateIds = $managedColumns['columnIds']; } if ($cardSourcesRequested) { @@ -338,23 +342,38 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } /** - * The ids of the columns the view may use. + * The ids of the columns the view may use, split into all columns and lookup columns. * * ColumnService depends on this service, so it is looked up rather than injected. * - * @return list + * @return array{columnIds: list, lookupColumnIds: list} */ private function findManagedColumnIds(View $view, string $userId): array { - $columnService = \OCP\Server::get(ColumnService::class); + $availableColumns = \OCP\Server::get(ColumnService::class)->findAllByManagedView($view, $userId); + $columnIds = []; + $lookupColumnIds = []; + foreach ($availableColumns as $column) { + $columnIds[] = $column->getId(); + if ($column->getType() === Column::TYPE_LOOKUP) { + $lookupColumnIds[] = $column->getId(); + } + } - return array_map(static fn (Column $column) => $column->getId(), $columnService->findAllByManagedView($view, $userId)); + return ['columnIds' => $columnIds, 'lookupColumnIds' => $lookupColumnIds]; } /** * @param list $availableColumnIds + * @param list $lookupColumnIds */ - protected function assertInputColumnsAreValid(ColumnSettings $columnSettings, array $availableColumnIds): void { + protected function assertInputColumnsAreValid(ColumnSettings $columnSettings, array $availableColumnIds, array $lookupColumnIds): void { foreach ($columnSettings->columnInformation() as $columnInfo) { + if ($columnInfo instanceof ViewColumnInformation + && $columnInfo->isMandatory() + && in_array($columnInfo->getId(), $lookupColumnIds, true) + ) { + throw new InvalidArgumentException('Lookup columns cannot be mandatory.'); + } if (!in_array($columnInfo->getId(), $availableColumnIds, true) && !Column::isValidMetaTypeId($columnInfo->getId()) ) { @@ -780,6 +799,7 @@ public function importView(int $tableId, array $view, string $userId): View { $item->setLastEditAt($view['lastEditAt']); $item->setDescription($view['description']); $item->setColumns(json_encode($view['columnSettings'])); + \OCP\Server::get(ColumnService::class)->validateSortRules(SortRuleSet::createFromInputArray($view['sort'])); $item->setSort(json_encode($view['sort'])); $item->setFilter(json_encode($view['filter'])); // A scheme may carry a layout this version does not know, which is dropped rather diff --git a/openapi.json b/openapi.json index c140ed6423..f5ecc9ff75 100644 --- a/openapi.json +++ b/openapi.json @@ -252,9 +252,6 @@ "customSettings": { "type": "object", "nullable": true, - "required": [ - "width" - ], "properties": { "width": { "type": "integer", @@ -266,6 +263,29 @@ "imagePreviewSize": { "type": "integer", "format": "int64" + }, + "relationType": { + "type": "string", + "enum": [ + "table", + "view" + ] + }, + "targetId": { + "type": "integer", + "format": "int64" + }, + "labelColumn": { + "type": "integer", + "format": "int64" + }, + "relationColumnId": { + "type": "integer", + "format": "int64" + }, + "targetColumnId": { + "type": "integer", + "format": "int64" } } } @@ -610,9 +630,6 @@ "customSettings": { "type": "object", "nullable": true, - "required": [ - "width" - ], "properties": { "width": { "type": "integer", @@ -624,6 +641,29 @@ "imagePreviewSize": { "type": "integer", "format": "int64" + }, + "relationType": { + "type": "string", + "enum": [ + "table", + "view" + ] + }, + "targetId": { + "type": "integer", + "format": "int64" + }, + "labelColumn": { + "type": "integer", + "format": "int64" + }, + "relationColumnId": { + "type": "integer", + "format": "int64" + }, + "targetColumnId": { + "type": "integer", + "format": "int64" } } } @@ -687,6 +727,117 @@ } } }, + "RelationData": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "column", + "values" + ], + "properties": { + "column": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Column" + } + ] + }, + "values": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "id", + "value" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "value": { + "$ref": "#/components/schemas/RelationValue" + } + } + } + }, + { + "type": "array", + "maxItems": 0 + } + ] + } + } + } + }, + { + "type": "array", + "maxItems": 0 + } + ] + }, + "RelationValue": { + "nullable": true, + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ] + } + }, + { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "type", + "displayName" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + } + } + } + } + ] + }, "Row": { "type": "object", "required": [ @@ -3994,7 +4145,8 @@ "datetime", "select", "usergroup", - "relation" + "relation", + "lookup" ], "description": "Column main type" }, @@ -4428,7 +4580,8 @@ "datetime", "select", "usergroup", - "relation" + "relation", + "lookup" ], "description": "Column main type" }, @@ -4832,7 +4985,8 @@ }, "customSettings": { "type": "object", - "default": {}, + "nullable": true, + "default": null, "description": "Custom settings for the column", "additionalProperties": { "type": "object" @@ -5141,6 +5295,228 @@ } } }, + "/index.php/apps/tables/api/1/tables/{tableId}/relations": { + "get": { + "operationId": "api1-index-table-relations", + "summary": "Get all relation data for a table", + "description": "This endpoint allows CORS requests", + "tags": [ + "api1" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "tableId", + "in": "path", + "description": "Table ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Relation data returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelationData" + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/index.php/apps/tables/api/1/views/{viewId}/relations": { + "get": { + "operationId": "api1-index-view-relations", + "summary": "Get all relation data for a view", + "description": "This endpoint allows CORS requests", + "tags": [ + "api1" + ], + "security": [ + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "viewId", + "in": "path", + "description": "View ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Relation data returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelationData" + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/index.php/apps/tables/api/1/tables/{tableId}/rows/simple": { "get": { "operationId": "api1-index-table-rows-simple", diff --git a/playwright/e2e/column-lookup.spec.ts b/playwright/e2e/column-lookup.spec.ts new file mode 100644 index 0000000000..ed7d906dd6 --- /dev/null +++ b/playwright/e2e/column-lookup.spec.ts @@ -0,0 +1,105 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../support/fixtures' +import { + createTable, + createTextLineColumn, + loadTable, + openCreateColumnModal, + openCreateRowModal, + fillInValueTextLine, + selectFromVueDropdown, +} from '../support/commands' + +const sourceTableTitle = 'Test lookup source' +const targetTableTitle = 'Test lookup target' +const nameColumnTitle = 'Name' +const cityColumnTitle = 'City' +const relationColumnTitle = 'Refers to' +const lookupColumnTitle = 'City of person' + +test.describe('Test column lookup', () => { + test.setTimeout(60000) + + test('Create lookup column, row, and verify looked-up value in table', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + + // source table with a name and a city column + await createTable(page, sourceTableTitle) + await loadTable(page, sourceTableTitle) + await createTextLineColumn(page, nameColumnTitle, '', '', true) + await createTextLineColumn(page, cityColumnTitle, '', '', false) + + await openCreateRowModal(page) + await fillInValueTextLine(page, nameColumnTitle, 'Alice') + await fillInValueTextLine(page, cityColumnTitle, 'Berlin') + await page.locator('[data-cy="createRowSaveButton"]').click() + await expect(page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Berlin' })).toBeVisible() + + // target table with relation column to source + await createTable(page, targetTableTitle) + await loadTable(page, targetTableTitle) + + await openCreateColumnModal(page, true) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(relationColumnTitle) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').getByText('Relation', { exact: true }).click() + + const targetColumnsResponse = page.waitForResponse( + r => r.url().includes('/apps/tables/api/1/tables/') + && r.url().includes('/columns') + && r.request().method() === 'GET', + ) + await selectFromVueDropdown(page, 'Select target', sourceTableTitle) + await targetColumnsResponse + await selectFromVueDropdown(page, 'Select label for relation selection', nameColumnTitle) + await page.locator('[data-cy="createColumnSaveBtn"]').click() + await expect( + page.locator('[data-cy="ncTable"] table tr th').filter({ hasText: relationColumnTitle }), + ).toBeVisible() + + // lookup column reading 'City' through the relation column + await openCreateColumnModal(page, false) + await page.locator('[data-cy="columnTypeFormInput"]').clear() + await page.locator('[data-cy="columnTypeFormInput"]').fill(lookupColumnTitle) + await page.locator('.columnTypeSelection .vs__open-indicator').click() + await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').getByText('Lookup', { exact: true }).click() + + const lookupTargetColumnsResponse = page.waitForResponse( + r => r.url().includes('/apps/tables/api/1/tables/') + && r.url().includes('/columns') + && r.request().method() === 'GET', + ) + await selectFromVueDropdown(page, 'Select relation column', relationColumnTitle) + await lookupTargetColumnsResponse + await selectFromVueDropdown(page, 'Select target column', cityColumnTitle) + + await page.locator('[data-cy="createColumnSaveBtn"]').click() + await expect( + page.locator('[data-cy="ncTable"] table tr th').filter({ hasText: lookupColumnTitle }), + ).toBeVisible() + + // create a row and pick the source row as relation + 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') + + // the lookup field previews the looked-up value inside the modal + await expect(page.locator('[data-cy="createRowModal"]')).toContainText('Berlin') + + await page.locator('[data-cy="createRowSaveButton"]').click() + + const row = page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Alice' }) + await expect(row).toBeVisible() + await expect(row).toContainText('Berlin') + }) +}) diff --git a/playwright/e2e/column-relation.spec.ts b/playwright/e2e/column-relation.spec.ts index ab0e6f32d6..577ad68445 100644 --- a/playwright/e2e/column-relation.spec.ts +++ b/playwright/e2e/column-relation.spec.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { type Page } from '@playwright/test' import { test, expect } from '../support/fixtures' import { createContext, @@ -15,6 +14,7 @@ import { openCreateColumnModal, openCreateRowModal, fillInValueTextLine, + selectFromVueDropdown, } from '../support/commands' const sourceTableTitle = 'Test relation source' @@ -22,24 +22,6 @@ const targetTableTitle = 'Test relation target' const sourceColumnTitle = 'Name' const relationColumnTitle = 'Refers to' -async function selectFromVueDropdown(page: Page, label: string, value: string) { - const input = page.locator(`[aria-label="${label}"] input, [aria-label="${label}"]`).first() - await input.click() - await input.clear() - await input.fill(value) - - // NcSelect options may not expose a clean accessible name, so try several selectors - const optionByRole = page.getByRole('option', { name: new RegExp(value, 'i') }).first() - if (await optionByRole.isVisible({ timeout: 3000 }).catch(() => false)) { - await optionByRole.click() - return - } - - const optionByText = page.locator('ul.vs__dropdown-menu li').filter({ hasText: new RegExp(value, 'i') }).first() - await optionByText.waitFor({ state: 'visible', timeout: 5000 }) - await optionByText.click() -} - test.describe('Test column relation', () => { test.setTimeout(60000) @@ -64,7 +46,7 @@ test.describe('Test column relation', () => { await page.locator('[data-cy="columnTypeFormInput"]').clear() await page.locator('[data-cy="columnTypeFormInput"]').fill(relationColumnTitle) await page.locator('.columnTypeSelection .vs__open-indicator').click() - await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').filter({ hasText: 'Relation' }).click() + await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').getByText('Relation', { exact: true }).click() // configure the relation to point at the source table and use 'Name' as label const targetColumnsResponse = page.waitForResponse( @@ -94,6 +76,10 @@ test.describe('Test column relation', () => { await expect( page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Alice' }), ).toBeVisible() + await page.reload() + const row = page.locator('[data-cy="ncTable"] [data-cy="customTableRow"]').filter({ hasText: 'Alice' }) + await row.locator('.cell-relation .non-edit-mode').click() + await expect(row.locator('.cell-relation .vs__selected')).toContainText('Alice') }) test('Relation labels render inside an application context', async ({ userPage: { page } }) => { @@ -120,7 +106,7 @@ test.describe('Test column relation', () => { await page.locator('[data-cy="columnTypeFormInput"]').clear() await page.locator('[data-cy="columnTypeFormInput"]').fill(relationColumnTitle) await page.locator('.columnTypeSelection .vs__open-indicator').click() - await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').filter({ hasText: 'Relation' }).click() + await page.locator('.vs__dropdown-menu .multiSelectOptionLabel').getByText('Relation', { exact: true }).click() const targetColumnsResponse = page.waitForResponse( r => r.url().includes('/apps/tables/api/1/tables/') diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index fe66200ba2..0f0e0883cc 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -1209,3 +1209,21 @@ export async function fillInValueSelectionCheck( ) .click() } + +export async function selectFromVueDropdown(page: Page, label: string, value: string) { + const input = page.locator(`[aria-label="${label}"] input, [aria-label="${label}"]`).first() + await input.click() + await input.clear() + await input.fill(value) + + // NcSelect options may not expose a clean accessible name, so try several selectors + const optionByRole = page.getByRole('option', { name: new RegExp(value, 'i') }).first() + if (await optionByRole.isVisible({ timeout: 3000 }).catch(() => false)) { + await optionByRole.click() + return + } + + const optionByText = page.locator('ul.vs__dropdown-menu li').filter({ hasText: new RegExp(value, 'i') }).first() + await optionByText.waitFor({ state: 'visible', timeout: 5000 }) + await optionByText.click() +} diff --git a/src/modules/main/partials/ColumnFormComponent.vue b/src/modules/main/partials/ColumnFormComponent.vue index 491ae29b7c..5394b2c85c 100644 --- a/src/modules/main/partials/ColumnFormComponent.vue +++ b/src/modules/main/partials/ColumnFormComponent.vue @@ -23,6 +23,7 @@ import DatetimeTimeForm from '../../../shared/components/ncTable/partials/rowTyp 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' +import LookupForm from '../../../shared/components/ncTable/partials/rowTypePartials/LookupForm.vue' export default { name: 'ColumnFormComponent', @@ -42,6 +43,7 @@ export default { DatetimeTimeForm, UsergroupForm, RelationForm, + LookupForm, }, props: { column: { diff --git a/src/modules/main/partials/ColumnTypeSelection.vue b/src/modules/main/partials/ColumnTypeSelection.vue index 9f861c91ab..3f4531fe64 100644 --- a/src/modules/main/partials/ColumnTypeSelection.vue +++ b/src/modules/main/partials/ColumnTypeSelection.vue @@ -20,6 +20,7 @@ +
{{ props.label }}
@@ -35,6 +36,7 @@ +
{{ props.label }}
@@ -94,6 +96,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') }, + { id: 'lookup', label: t('tables', 'Lookup') }, ], } }, diff --git a/src/modules/main/partials/editTablePartials/DefaultSortRules.vue b/src/modules/main/partials/editTablePartials/DefaultSortRules.vue index 2e21447fd3..b47b2cf55b 100644 --- a/src/modules/main/partials/editTablePartials/DefaultSortRules.vue +++ b/src/modules/main/partials/editTablePartials/DefaultSortRules.vue @@ -79,6 +79,7 @@ export default { ColumnTypes.SelectionMulti, ColumnTypes.TextLink, ColumnTypes.Usergroup, + ColumnTypes.Lookup, ].includes(col.type) }, eligibleColumns(selectedId) { diff --git a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue index 9d4526eadd..3a77455609 100644 --- a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue +++ b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue @@ -38,8 +38,8 @@ {{ t('tables', 'Read only') }} @@ -48,7 +48,7 @@ v-if="selectedColumns.includes(column.id)" data-cy="columnMandatoryCheckbox" :model-value="isMandatory(column)" - :disabled="column.viewColumnInformation?.readonly" + :disabled="column.viewColumnInformation?.readonly || column.isVirtual()" @change="onMandatoryChanged(column.id, $event.target.checked)"> {{ t('tables', 'Mandatory') }} @@ -132,6 +132,12 @@ export default { columns: { handler(newColumns) { this.mutableColumns = newColumns + for (const column of newColumns ?? []) { + if (column.isVirtual() && column.viewColumnInformation) { + column.viewColumnInformation.mandatory = false + column.viewColumnInformation.readonly = true + } + } }, deep: true, immediate: true, diff --git a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue index 2abf07d181..d2a38c4a81 100644 --- a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue +++ b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue @@ -230,10 +230,10 @@ export default { // Get relations from DataStore using the column's tableId const dataStore = useDataStore() const columnRelations = dataStore.getRelations(this.selectedColumn.id) - Object.values(columnRelations).forEach(item => { + Object.entries(columnRelations.values || {}).forEach(([, item]) => { options.push({ id: '@relation-id-' + item.id, - label: item.label, + label: item.value, }) }) diff --git a/src/modules/main/partials/editViewPartials/sort/SortForm.vue b/src/modules/main/partials/editViewPartials/sort/SortForm.vue index 52cb907c32..a35356ab6f 100644 --- a/src/modules/main/partials/editViewPartials/sort/SortForm.vue +++ b/src/modules/main/partials/editViewPartials/sort/SortForm.vue @@ -135,6 +135,7 @@ export default { ColumnTypes.SelectionMulti, ColumnTypes.TextLink, ColumnTypes.Usergroup, + ColumnTypes.Lookup, ].includes(col.type) }, eligibleColumns(selectedId) { // filter sortable and unused columns diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index ae8c311e98..791d76d875 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -18,7 +18,8 @@ v-model:selected-views="column.selectedViews" :title-missing-error="titleMissingError" :technical-name-invalid-error="technicalNameInvalidError" - :width-invalid-error="widthInvalidError" /> + :width-invalid-error="widthInvalidError" + :mandatory-disabled="column.isVirtual()" />
@@ -127,6 +128,7 @@ import TextRichForm from '../../shared/components/ncTable/partials/columnTypePar 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 LookupForm from '../../shared/components/ncTable/partials/columnTypePartials/forms/LookupForm.vue' import { useTablesStore } from '../../store/store.js' import { useDataStore } from '../../store/data.js' import { mapActions } from 'pinia' @@ -159,6 +161,7 @@ export default { SelectionMultiForm, UsergroupForm, RelationForm, + LookupForm, }, props: { showModal: { @@ -216,6 +219,9 @@ export default { usergroupSelectTeams: false, showUserStatus: false, customSettings: {}, + isVirtual() { + return this.type === ColumnTypes.Lookup + }, }, textAppAvailable: !!window.OCA?.Text?.createEditor, addNewAfterSave: false, @@ -337,6 +343,10 @@ export default { showInfo(t('tables', 'Please select a target.')) } else if (this.column.type === ColumnTypes.Relation && !this.column.customSettings?.labelColumn) { showInfo(t('tables', 'Please select a label for relation selection.')) + } else if (this.column.type === ColumnTypes.Lookup && !this.column.customSettings?.relationColumnId) { + showInfo(t('tables', 'Please select a relation column.')) + } else if (this.column.type === ColumnTypes.Lookup && !this.column.customSettings?.targetColumnId) { + showInfo(t('tables', 'Please select a target column.')) } else { this.column.title = title this.$emit('save', this.prepareSubmitData()) @@ -417,6 +427,9 @@ export default { 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.isVirtual()) { + data.customSettings.relationColumnId = this.column.customSettings.relationColumnId + data.customSettings.targetColumnId = this.column.customSettings.targetColumnId } return data }, @@ -473,6 +486,9 @@ export default { usergroupSelectTeams: false, showUserStatus: false, customSettings: {}, + isVirtual() { + return this.type === ColumnTypes.Lookup + }, } if (mainForm) { this.column.title = '' diff --git a/src/modules/modals/CreateRow.vue b/src/modules/modals/CreateRow.vue index 3d54a6e720..7fecb0e9e6 100644 --- a/src/modules/modals/CreateRow.vue +++ b/src/modules/modals/CreateRow.vue @@ -11,13 +11,13 @@