diff --git a/cypress/e2e/view-filtering-selection.cy.js b/cypress/e2e/view-filtering-selection.cy.js index d0b50a118a..ab97a9f3cf 100644 --- a/cypress/e2e/view-filtering-selection.cy.js +++ b/cypress/e2e/view-filtering-selection.cy.js @@ -128,7 +128,7 @@ describe('Filtering in a view by selection columns', () => { // ## update view cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') - cy.contains('button', 'Save View').click() + cy.contains('button', 'Save modified View').click() cy.wait('@updateView') // # check for expected rows diff --git a/cypress/e2e/view-mandatory-state.cy.js b/cypress/e2e/view-mandatory-state.cy.js new file mode 100644 index 0000000000..28361d01cd --- /dev/null +++ b/cypress/e2e/view-mandatory-state.cy.js @@ -0,0 +1,149 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +let localUser + +describe('Mandatory Column Functionality', () => { + + before(function() { + cy.createRandomUser().then(user => { + localUser = user + }) + }) + + beforeEach(function() { + cy.login(localUser) + cy.visit('apps/tables') + }) + + it('Setup table with mandatory test columns and one row', () => { + cy.createTable('Mandatory test table') + cy.createTextLineColumn('title', null, null, true) + cy.createTextLineColumn('description', null, null, false) + + // create one row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'first row') + cy.fillInValueTextLine('description', 'desc 1') + cy.get('[data-cy="createRowSaveButton"]').click() + + // create a default view + cy.get('[data-cy="customTableAction"] button').click() + cy.get('.v-popper__popper li button span') + .contains('Create view') + .click({ force: true }) + cy.get('[data-cy="viewSettingsDialog"]').should('be.visible') + cy.get('[data-cy="viewSettingsDialogSection"] input').type('Mandatory test view') + cy.get('[data-cy="modifyViewBtn"]').click() + cy.get('.icon-loading').should('not.exist') + }) + + describe('SelectedViewColumns - Mandatory Checkbox', () => { + beforeEach(() => { + cy.loadTable('Mandatory test table') + + // create a new view + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type('Mandatory test view') + + // ensure dialog is visible + cy.get('[data-cy="viewSettingsDialog"]').should('be.visible') + }) + + const openColumnMenu = (columnTitle) => { + cy.contains('.column-entry', columnTitle) + .find('[data-cy="customColumnAction"] button') + .click({ force: true }) + } + + const getMandatoryCheckbox = () => cy.get('[data-cy="columnMandatoryCheckbox"]').contains('Mandatory') + + const getReadonlyCheckbox = () => cy.get('[data-cy="columnReadonlyCheckbox"]').contains('Read only') + + it('should display mandatory checkbox for selected columns', () => { + openColumnMenu('title') + getMandatoryCheckbox().should('be.visible') + }) + + it('should disable mandatory checkbox when readonly is enabled', () => { + openColumnMenu('title') + + getReadonlyCheckbox().should('be.visible').click({ force: true }) + + // Check that the readonly checkbox is checked + cy.get('[data-cy="columnReadonlyCheckbox"] input').should('be.checked') + + // Check that mandatory checkbox input is disabled + cy.get('[data-cy="columnMandatoryCheckbox"] input').should('be.disabled') + }) + + it('should disable readonly checkbox when mandatory is enabled', () => { + openColumnMenu('title') + + getMandatoryCheckbox().should('be.visible').click({ force: true }) + + // Check that the mandatory checkbox is checked + cy.get('[data-cy="columnMandatoryCheckbox"] input').should('be.checked') + + // Check that readonly checkbox input is disabled + cy.get('[data-cy="columnReadonlyCheckbox"] input').should('be.disabled') + }) + }) + + describe('EditRow - Mandatory Field Validation', () => { + beforeEach(() => { + cy.loadTable('Mandatory test table') + + // Create a view with mandatory settings first + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type('Mandatory validation test view') + + // Set title column as mandatory in the view + cy.contains('.column-entry', 'title') + .find('[data-cy="customColumnAction"] button') + .click({ force: true }) + cy.get('[data-cy="columnMandatoryCheckbox"]').contains('Mandatory').click({ force: true }) + + // Save the view + cy.get('[data-cy="modifyViewBtn"]').click() + cy.get('.icon-loading').should('not.exist') + + // Now open edit row dialog + cy.get('[data-cy="editRowBtn"]').first().click() + cy.get('[data-cy="editRowModal"]').should('be.visible') + }) + + it('should show error when mandatory field is empty', () => { + cy.get('[data-cy="editRowModal"] input').first().clear().blur() + + // Try multiple possible selectors for NcNoteCard with type="error" + cy.get('[data-cy="editRowModal"]').within(() => { + // Try different possible selectors + cy.get('.notecard--error, .note-card--error, [type="error"], .notecard[type="error"], .error', { timeout: 5000 }) + .should('exist') + }) + }) + + it('should disable save button when mandatory field is empty', () => { + // Clear the mandatory field (should be the title field which is mandatory) + cy.get('[data-cy="editRowModal"] input').first().clear() + + // Trigger validation by blurring and maybe clicking somewhere else + cy.get('[data-cy="editRowModal"] input').first().blur() + + // Wait a bit for validation to process + cy.wait(500) + + // Check that save button is disabled + cy.get('[data-cy="editRowSaveButton"]', { timeout: 5000 }).should('be.disabled') + }) + + it('should enable save button when mandatory field is filled', () => { + cy.get('[data-cy="editRowModal"] input').first().type('filled value') + cy.get('[data-cy="editRowSaveButton"]', { timeout: 5000 }).should('not.be.disabled') + }) + }) +}) diff --git a/cypress/e2e/view.cy.js b/cypress/e2e/view.cy.js index b1f567ebfc..9e267a1507 100644 --- a/cypress/e2e/view.cy.js +++ b/cypress/e2e/view.cy.js @@ -3,7 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ let localUser -const title = 'Test view' +const firstTitle = 'Test view' +const secondTitle = 'Test view 2' +const thirdTitle = 'Test view 3' +const fourthTitle = 'Test view 4' describe('Interact with views', () => { @@ -16,7 +19,9 @@ describe('Interact with views', () => { beforeEach(function() { cy.login(localUser) cy.visit('apps/tables') + }) + it('Setup table', () => { cy.createTable('View test table') cy.createTextLineColumn('title', null, null, true) cy.createSelectionColumn('selection', ['sel1', 'sel2', 'sel3', 'sel4'], null, false) @@ -38,15 +43,10 @@ describe('Interact with views', () => { cy.fillInValueTextLine('title', 'sevenths row') cy.fillInValueSelection('selection', 'sel2') cy.get('[data-cy="createRowSaveButton"]').click() - - // create view - cy.get('[data-cy="customTableAction"] button').click() - cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) - cy.get('[data-cy="viewSettingsDialogSection"] input').type(title) }) - // cleanup - afterEach(function() { + // cleanup after all tests + after(function() { // delete table (with view) cy.get('[data-cy="navigationTableItem"]').contains('View test table').click({ force: true }) cy.get('[data-cy="customTableAction"] button').click() @@ -55,11 +55,20 @@ describe('Interact with views', () => { cy.get('[data-cy="editTableModal"] [data-cy="editTableDeleteBtn"]').click() cy.get('[data-cy="editTableModal"] [data-cy="editTableConfirmDeleteBtn"]').click() cy.wait(10).get('.toastify.toast-success').should('be.visible') - cy.get('[data-cy="navigationTableItem"]').contains('View test table').should('not.exist') - cy.get('[data-cy="navigationTableItem"]').contains(title).should('not.exist') + cy.get('[data-cy="navigationTableItem"]').contains(firstTitle).should('not.exist') + cy.get('[data-cy="navigationTableItem"]').contains(secondTitle).should('not.exist') + cy.get('[data-cy="navigationTableItem"]').contains(thirdTitle).should('not.exist') + cy.get('[data-cy="navigationTableItem"]').contains(fourthTitle).should('not.exist') }) it('Create view and insert rows in the view', () => { + cy.loadTable('View test table') + + // create view + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type(firstTitle) + // ## add filter cy.get('[data-cy="filterFormFilterGroupBtn"]').contains('Add new filter group').click() cy.get('[data-cy="filterEntryColumn"]').click() @@ -75,7 +84,7 @@ describe('Interact with views', () => { cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() cy.wait('@createView') cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(title).should('exist') + cy.get('[data-cy="navigationViewItem"]').contains(firstTitle).should('exist') const expected = ['sevenths row', 'second row'] expected.forEach(item => { @@ -95,13 +104,20 @@ describe('Interact with views', () => { }) it('Create view and update rows in the view', () => { + cy.loadTable('View test table') + + // create view + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type(secondTitle) + // ## save view cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() cy.wait('@createView') cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(title).should('exist') + cy.get('[data-cy="navigationViewItem"]').contains(secondTitle).should('exist') // Update rows in the view cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() @@ -114,6 +130,13 @@ describe('Interact with views', () => { }) it('Create view and make column readonly in the view', () => { + cy.loadTable('View test table') + + // create view + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type(thirdTitle) + // trigger three dot menu and select readonly cy.contains('.column-entry', 'title').find('[data-cy="customColumnAction"] button').click({ force: true }) cy.get('[data-cy="columnReadonlyCheckbox"]').contains('Read only').click() @@ -125,7 +148,7 @@ describe('Interact with views', () => { cy.wait('@createView') cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(title).should('exist') + cy.get('[data-cy="navigationViewItem"]').contains(thirdTitle).should('exist') // TODO: Make sure that column is readonly during edit // cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() @@ -134,6 +157,12 @@ describe('Interact with views', () => { }) it('Create view and delete rows in the view', () => { + cy.loadTable('View test table') + + // create view + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type(fourthTitle) // ## save view cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') @@ -141,15 +170,16 @@ describe('Interact with views', () => { cy.get('[data-cy="modifyViewBtn"]').contains('Create View').click() cy.wait('@createView') cy.wait('@updateView') - cy.get('[data-cy="navigationViewItem"]').contains(title).should('exist') + cy.get('[data-cy="navigationViewItem"]').contains(fourthTitle).should('exist') cy.get('.icon-loading').should('not.exist') - // Delete rows in the view - cy.get('[data-cy="customTableRow"]').contains('first row').closest('[data-cy="customTableRow"]').find('[data-cy="editRowBtn"]').click() + // Delete the first row (whatever it is) + cy.get('[data-cy="customTableRow"]').first().find('[data-cy="editRowBtn"]').click() cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteButton"]').click() cy.get('[data-cy="editRowModal"] [data-cy="editRowDeleteConfirmButton"]').click() cy.get('[data-cy="editRowModal"]').should('not.exist') - cy.get('[data-cy="customTableRow"]').contains('first row').should('not.exist') + // Verify one row was deleted by checking the count decreased + cy.get('[data-cy="customTableRow"]').should('have.length.lessThan', 4) }) }) diff --git a/lib/Db/Column.php b/lib/Db/Column.php index 60f7bf8491..3710956208 100644 --- a/lib/Db/Column.php +++ b/lib/Db/Column.php @@ -105,6 +105,7 @@ class Column extends EntitySuper implements JsonSerializable { public const SUBTYPE_DATETIME_TIME = 'time'; public const SUBTYPE_SELECTION_CHECK = 'check'; + public const SUBTYPE_SELECTION_MULTI = 'selection-multi'; public const SUBTYPE_TEXT_LINE = 'line'; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 56da5c7801..9111ba55f5 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -136,6 +136,7 @@ * columnId: int, * order: int, * readonly: bool, + * mandatory: bool, * }, * customSettings: ?array{ * width: int, diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 34a76ee832..b89c81624e 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -282,20 +282,29 @@ private function enhanceWithViewDefaults(?View $view, RowDataInput $data): RowDa /** * @return array */ - private function extractReadOnlyColumns(View $view): array { - $columnSettings = $view->getColumnsSettingsArray(); - return array_reduce($columnSettings, static function (array $carry, ViewColumnInformation $column) { - $carry[$column->getId()] = $column->isReadonly(); - return $carry; - }, []); + private function extractColumnsByProperty(View $view, string $property): array { + return array_reduce( + $view->getColumnsSettingsArray(), + static function (array $carry, ViewColumnInformation $column) use ($property) { + if (method_exists($column, $property) && $column->{$property}()) { + $carry[$column->getId()] = true; + } + return $carry; + }, + [] + ); } /** + * @param RowDataInput $data * @throws InternalError * @throws BadRequestError + * @return RowDataInput */ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $tableId, ?int $viewId, ?int $rowId = null): RowDataInput { - $readOnlyColumns = $viewId ? $this->extractReadOnlyColumns($this->viewMapper->find($viewId)) : []; + $view = $viewId ? $this->viewMapper->find($viewId) : null; + $readOnlyColumns = $view ? $this->extractColumnsByProperty($view, 'isReadonly') : []; + $mandatoryColumns = $view ? $this->extractColumnsByProperty($view, 'isMandatory') : []; $out = new RowDataInput(); foreach ($data as $entry) { @@ -327,9 +336,108 @@ private function cleanupAndValidateData(RowDataInput $data, array $columns, ?int $out->add((int)$entry['columnId'], $this->parseValueByColumnType($column, $entry['value'])); } + if ($viewId && !empty($mandatoryColumns)) { + $existingRow = null; + if ($rowId !== null) { + try { + $existingRow = $this->getRowById($rowId); + } catch (NotFoundError|InternalError $e) { + $this->logger->debug('Could not load existing row for mandatory validation', ['rowId' => $rowId, 'exception' => $e]); + } + } + $this->validateMandatoryColumns($mandatoryColumns, $out, $columns, $existingRow); + } + return $out; } + /** + * @param array $mandatoryColumns + * @param RowDataInput $data + * @param Column[] $columns + * @param Row2|null $existingRow + * @throws BadRequestError + * @throws InternalError + */ + private function validateMandatoryColumns(array $mandatoryColumns, RowDataInput $data, array $columns, ?Row2 $existingRow = null): void { + foreach ($mandatoryColumns as $columnId => $isMandatory) { + if (!$isMandatory) { + continue; + } + + $column = $this->getColumnFromColumnsArray($columnId, $columns); + if (!$column) { + continue; + } + + $hasValue = false; + $value = null; + + foreach ($data as $entry) { + if ($entry['columnId'] === $columnId) { + $value = $entry['value']; + $hasValue = true; + break; + } + } + + if (!$hasValue && $existingRow !== null) { + foreach ($existingRow->getData() as $existingEntry) { + if ($existingEntry['columnId'] === $columnId) { + $value = $existingEntry['value']; + $hasValue = true; + break; + } + } + } + + if ($hasValue) { + try { + $columnBusiness = $this->getColumnBusiness($column); + $isValid = $this->isValueValidForMandatoryColumn($value, $column, $columnBusiness); + if (!$isValid) { + throw new BadRequestError( + 'Mandatory column "' . $column->getTitle() . '" cannot be empty or invalid.' + ); + } + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { + $this->logger->debug('Column type business class not found for mandatory validation', ['exception' => $e]); + } + } else { + throw new BadRequestError( + 'Mandatory column "' . $column->getTitle() . '" cannot be empty.' + ); + } + } + } + + private function isValueValidForMandatoryColumn($value, Column $column, IColumnTypeBusiness $columnBusiness): bool { + if ($column->getSubtype() === Column::SUBTYPE_SELECTION_CHECK) { + return true; + } + + if ($value === null || $value === '') { + return false; + } + if ($column->getType() === Column::TYPE_SELECTION) { + if (is_array($value)) { + return count($value) > 0; + } + if (is_numeric($value) && $value > 0) { + return true; + } + return $column->getSelectionDefault() !== null && $column->getSelectionDefault() !== ''; + } + if ($column->getSubtype() === Column::SUBTYPE_SELECTION_MULTI) { + if (is_array($value)) { + return count($value) > 0; + } + $defaultValue = $column->getSelectionDefault(); + return $defaultValue !== null && $defaultValue !== '' && $defaultValue !== '[]'; + } + return $value !== null && $value !== '' && $value !== []; + } + /** * @param Column $column * @param string|array|int|float|bool|null $value diff --git a/lib/Service/ValueObject/ViewColumnInformation.php b/lib/Service/ValueObject/ViewColumnInformation.php index 61b4ccf24b..590fed09df 100644 --- a/lib/Service/ValueObject/ViewColumnInformation.php +++ b/lib/Service/ValueObject/ViewColumnInformation.php @@ -18,23 +18,27 @@ class ViewColumnInformation implements ArrayAccess, JsonSerializable { public const KEY_ID = 'columnId'; public const KEY_ORDER = 'order'; public const KEY_READONLY = 'readonly'; + public const KEY_MANDATORY = 'mandatory'; - /** @var array{columndId?: int, order?: int, readonly?: bool} */ + /** @var array{columndId?: int, order?: int, readonly?: bool, mandatory?: bool} */ protected array $data = []; protected const KEYS = [ self::KEY_ID, self::KEY_ORDER, self::KEY_READONLY, + self::KEY_MANDATORY, ]; public function __construct( int $columnId, int $order, bool $readonly = false, + bool $mandatory = false, ) { $this->offsetSet(self::KEY_ID, $columnId); $this->offsetSet(self::KEY_ORDER, $order); $this->offsetSet(self::KEY_READONLY, $readonly); + $this->offsetSet(self::KEY_MANDATORY, $mandatory); } public function getId(): int { @@ -49,11 +53,16 @@ public function isReadonly(): bool { return $this->offsetGet(self::KEY_READONLY) ?? false; } + public function isMandatory(): bool { + return $this->offsetGet(self::KEY_MANDATORY) ?? false; + } + public static function fromArray(array $data): static { $vci = new static( $data[self::KEY_ID], $data[self::KEY_ORDER], $data[self::KEY_READONLY] ?? false, + $data[self::KEY_MANDATORY] ?? false, ); return $vci; @@ -91,6 +100,7 @@ protected function ensureType(string $offset, mixed $value): mixed { self::KEY_ID, self::KEY_ORDER => (int)$value, self::KEY_READONLY => (bool)$value, + self::KEY_MANDATORY => (bool)$value, default => throw new \InvalidArgumentException("Invalid offset: $offset"), }; } diff --git a/openapi.json b/openapi.json index de9910529a..16b40429e1 100644 --- a/openapi.json +++ b/openapi.json @@ -213,7 +213,8 @@ "required": [ "columnId", "order", - "readonly" + "readonly", + "mandatory" ], "properties": { "columnId": { @@ -226,6 +227,9 @@ }, "readonly": { "type": "boolean" + }, + "mandatory": { + "type": "boolean" } } }, diff --git a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue index 00d579174e..2e0ae25c68 100644 --- a/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue +++ b/src/modules/main/partials/editViewPartials/SelectedViewColumns.vue @@ -23,7 +23,10 @@ :checked="selectedColumns.includes(column.id)" class="display-checkbox" @update:checked="onToggle(column.id)" /> - {{ column.title }} + + {{ column.title }} + * +
({{ t('tables', 'Metadata') }})
@@ -31,13 +34,24 @@
+ {{ t('tables', 'Read only') }} + + + {{ t('tables', 'Mandatory') }} +
@@ -112,6 +126,22 @@ export default { startDragIndex: null, } }, + watch: { + columns: { + handler(newColumns) { + this.mutableColumns = newColumns + }, + deep: true, + immediate: true, + }, + selectedColumns: { + handler(newSelectedColumns) { + this.mutableSelectedColumns = newSelectedColumns + }, + deep: true, + immediate: true, + }, + }, methods: { isLocallyRemoved(columnId) { if (!this.viewColumnIds || !this.generatedColumnIds) return false @@ -146,9 +176,23 @@ export default { }, onReadonlyChanged(columnId, readonly) { const column = this.mutableColumns.find(col => col.id === columnId) - if (column) { - column.viewColumnInformation.readonly = readonly + if (!column) return + + if (!column.viewColumnInformation) { + this.$set(column, 'viewColumnInformation', {}) } + + this.$set(column.viewColumnInformation, 'readonly', readonly) + }, + onMandatoryChanged(columnId, mandatory) { + const column = this.mutableColumns.find(col => col.id === columnId) + if (!column) return + + if (!column.viewColumnInformation) { + this.$set(column, 'viewColumnInformation', {}) + } + + this.$set(column.viewColumnInformation, 'mandatory', mandatory) }, async dragEnd(goalIndex) { if (this.draggedItem === null) return @@ -249,4 +293,11 @@ export default { .locallyRemoved { background-color: var(--color-error-hover); } + +.mandatory-indicator { + color: var(--color-error); + margin-left: 4px; + font-size: 16px; + line-height: 1; +} diff --git a/src/modules/modals/CreateRow.vue b/src/modules/modals/CreateRow.vue index 3866b229d1..92cc6634ab 100644 --- a/src/modules/modals/CreateRow.vue +++ b/src/modules/modals/CreateRow.vue @@ -13,7 +13,7 @@ - {{ t('tables', '"{columnTitle}" should not be empty', { columnTitle: column.title }) }} diff --git a/src/modules/modals/EditRow.vue b/src/modules/modals/EditRow.vue index d963985816..cf10231906 100644 --- a/src/modules/modals/EditRow.vue +++ b/src/modules/modals/EditRow.vue @@ -13,7 +13,7 @@ - {{ t('tables', '"{columnTitle}" should not be empty', { columnTitle: column.title }) }} @@ -141,6 +141,19 @@ export default { this.row.data.forEach(item => { tmp[item.columnId] = item.value }) + + // 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') { + tmp[column.id] = [] + } else { + tmp[column.id] = null + } + } + }) + this.localRow = Object.assign({}, tmp) } }, diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index 795d1fe196..fef14d5f89 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -154,7 +154,7 @@ export default { saveText() { if (this.createView) { return t('tables', 'Create View') - } else if (this.viewSettings) { + } else if (this.viewSetting) { return t('tables', 'Save modified View') } else { return t('tables', 'Save View') @@ -309,6 +309,7 @@ export default { columnId: col.id, order: index, readonly: col.viewColumnInformation?.readonly, + mandatory: col.viewColumnInformation?.mandatory ?? false, })) const data = { data: { diff --git a/src/shared/components/ncTable/mixins/rowHelper.js b/src/shared/components/ncTable/mixins/rowHelper.js index 172b093543..220feee220 100644 --- a/src/shared/components/ncTable/mixins/rowHelper.js +++ b/src/shared/components/ncTable/mixins/rowHelper.js @@ -4,33 +4,102 @@ */ import { ColumnTypes } from './columnHandler.js' import { ALLOWED_PROTOCOLS } from '../../../constants.ts' +import Moment from '@nextcloud/moment' export default { methods: { + isMandatory(column) { + const viewInfo = column?.viewColumnInformation ?? {} + return viewInfo.mandatory ?? column?.mandatory ?? false + }, isValueValidForColumn(value, column) { + switch (column.type) { + case ColumnTypes.Datetime: + case ColumnTypes.DatetimeDate: + case ColumnTypes.DatetimeTime: + return this.isDatetimeValueValid(value, column) + case ColumnTypes.Selection: + return this.isSelectionValueValid(value, column) + case ColumnTypes.MultiSelection: + return this.isMultiSelectionValueValid(value, column) + case ColumnTypes.Usergroup: + return this.isUsergroupValueValid(value, column) + default: + return this.isStandardValueValid(value, column) + } + }, + getColumnTypeDefault(column) { const type = column?.type?.split('-')[0] - const columnTypeDefault = type + 'Default' - if (column.type === ColumnTypes.Selection) { - if ( - (value instanceof Array && value.length > 0) - || (value === parseInt(value)) - ) { - return true - } - return columnTypeDefault in column && !(['', 'null'].includes(column[columnTypeDefault])) + return type + 'Default' + }, + isDatetimeValueValid(value, column) { + const columnTypeDefault = this.getColumnTypeDefault(column) + + if (!value || value === 'none') { + return !this.isMandatory(column) || (!!column[columnTypeDefault] && column[columnTypeDefault] !== 'none') + } + if (column.type === ColumnTypes.DatetimeTime) { + return Moment(value, 'HH:mm', true).isValid() + } + return !isNaN(Date.parse(value)) + }, + isSelectionValueValid(value, column) { + const columnTypeDefault = this.getColumnTypeDefault(column) + + if ((value instanceof Array && value.length > 0) || (value === parseInt(value))) { + return true } - let hasDefaultValue = columnTypeDefault in column && !(['', null].includes(column[columnTypeDefault])) - if (column.type === ColumnTypes.SelectionMulti) { - hasDefaultValue = columnTypeDefault in column && column[columnTypeDefault] !== '[]' - return (value instanceof Array && value.length > 0) || hasDefaultValue + const hasDefaultValue = columnTypeDefault in column && !(['', 'null'].includes(column[columnTypeDefault])) + return hasDefaultValue + }, + isMultiSelectionValueValid(value, column) { + const columnTypeDefault = this.getColumnTypeDefault(column) + + const hasDefaultValue = columnTypeDefault in column && column[columnTypeDefault] !== '[]' + return (value instanceof Array && value.length > 0) || hasDefaultValue + }, + isUsergroupValueValid(value, column) { + const columnTypeDefault = this.getColumnTypeDefault(column) + + // Handle array values + if (value instanceof Array) { + return value.length > 0 } + + // Handle string values (JSON from backend) + if (typeof value === 'string') { + // Empty string or empty array string + if (value === '' || value === '[]') { + const hasDefaultValue = columnTypeDefault in column && column[columnTypeDefault] !== '[]' && column[columnTypeDefault] !== '' + return hasDefaultValue + } + // Try to parse as JSON array + try { + const parsed = JSON.parse(value) + if (parsed instanceof Array) { + return parsed.length > 0 + } + } catch (e) { + // Not valid JSON, fall through to default check + } + } + + // Handle null/undefined and any other edge cases + const hasDefaultValue = columnTypeDefault in column && column[columnTypeDefault] !== '[]' && column[columnTypeDefault] !== '' + return hasDefaultValue + }, + isStandardValueValid(value, column) { + const columnTypeDefault = this.getColumnTypeDefault(column) + + const hasDefaultValue = columnTypeDefault in column && !(['', null].includes(column[columnTypeDefault])) return (!!value || value === 0) || hasDefaultValue }, checkMandatoryFields(row) { let mandatoryFieldsEmpty = false + if (!this.columns) return false this.columns.forEach(col => { - if (col.mandatory) { + if (this.isMandatory(col)) { const validValue = this.isValueValidForColumn(row[col.id], col) mandatoryFieldsEmpty = mandatoryFieldsEmpty || !validValue } diff --git a/src/shared/components/ncTable/partials/TableCellDateTime.vue b/src/shared/components/ncTable/partials/TableCellDateTime.vue index 9a623c3caa..bce5d6da7f 100644 --- a/src/shared/components/ncTable/partials/TableCellDateTime.vue +++ b/src/shared/components/ncTable/partials/TableCellDateTime.vue @@ -89,7 +89,7 @@ export default { }, canBeCleared() { - return !this.column.mandatory + return !(this.column.viewColumnInformation?.mandatory ?? this.column.mandatory) }, }, diff --git a/src/shared/components/ncTable/partials/rowTypePartials/DatetimeDateForm.vue b/src/shared/components/ncTable/partials/rowTypePartials/DatetimeDateForm.vue index a59c8b5916..34c3122be9 100644 --- a/src/shared/components/ncTable/partials/rowTypePartials/DatetimeDateForm.vue +++ b/src/shared/components/ncTable/partials/rowTypePartials/DatetimeDateForm.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->