From 6cdfa7560e92964f4169de4c6997c58761edb61d Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sat, 2 Aug 2025 15:39:41 +0200 Subject: [PATCH 1/3] refactor(wikibase-schema-editor): extract common components around drop zones --- frontend/.eslintrc-auto-import.json | 2 + frontend/auto-imports.d.ts | 4 + frontend/components.d.ts | 4 + frontend/src/components/SchemaDropZone.vue | 34 +- frontend/src/components/StatementEditor.vue | 20 +- frontend/src/components/TermSection.vue | 55 +++ frontend/src/components/TermsEditor.vue | 51 ++- frontend/src/components/ValidationDisplay.vue | 384 ++++++++++++++++++ .../src/components/ValidationErrorDisplay.vue | 206 ---------- .../src/components/WikibaseSchemaEditor.vue | 53 ++- .../useDataTypeCompatibility.test.ts | 44 +- .../useReferenceValueMapping.test.ts | 4 +- .../__tests__/useSchemaDropZone.test.ts | 8 + .../__tests__/useSchemaValidationUI.test.ts | 10 +- .../__tests__/useValidationCore.test.ts | 288 +++++++++++++ .../src/composables/useDropZoneStyling.ts | 85 ++++ .../src/composables/useRealTimeValidation.ts | 114 ++---- frontend/src/composables/useSchemaDropZone.ts | 113 +++--- .../src/composables/useSchemaValidationUI.ts | 69 ++-- frontend/src/composables/useValidationCore.ts | 121 ++++++ frontend/src/stores/schema.store.ts | 13 +- 21 files changed, 1214 insertions(+), 468 deletions(-) create mode 100644 frontend/src/components/TermSection.vue create mode 100644 frontend/src/components/ValidationDisplay.vue delete mode 100644 frontend/src/components/ValidationErrorDisplay.vue create mode 100644 frontend/src/composables/__tests__/useValidationCore.test.ts create mode 100644 frontend/src/composables/useDropZoneStyling.ts create mode 100644 frontend/src/composables/useValidationCore.ts diff --git a/frontend/.eslintrc-auto-import.json b/frontend/.eslintrc-auto-import.json index 3afe6ef..fa13cc7 100644 --- a/frontend/.eslintrc-auto-import.json +++ b/frontend/.eslintrc-auto-import.json @@ -247,6 +247,7 @@ "useDragDropStore": true, "useDraggable": true, "useDropZone": true, + "useDropZoneStyling": true, "useElementBounding": true, "useElementByPoint": true, "useElementHover": true, @@ -378,6 +379,7 @@ "useUserMedia": true, "useVModel": true, "useVModels": true, + "useValidationCore": true, "useValidationErrors": true, "useValidationStore": true, "useValueMapping": true, diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts index 35e6ce2..570f235 100644 --- a/frontend/auto-imports.d.ts +++ b/frontend/auto-imports.d.ts @@ -188,6 +188,7 @@ declare global { const useDragDropStore: typeof import('./src/stores/drag-drop.store')['useDragDropStore'] const useDraggable: typeof import('@vueuse/core')['useDraggable'] const useDropZone: typeof import('@vueuse/core')['useDropZone'] + const useDropZoneStyling: typeof import('./src/composables/useDropZoneStyling')['useDropZoneStyling'] const useElementBounding: typeof import('@vueuse/core')['useElementBounding'] const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint'] const useElementHover: typeof import('@vueuse/core')['useElementHover'] @@ -319,6 +320,7 @@ declare global { const useUserMedia: typeof import('@vueuse/core')['useUserMedia'] const useVModel: typeof import('@vueuse/core')['useVModel'] const useVModels: typeof import('@vueuse/core')['useVModels'] + const useValidationCore: typeof import('./src/composables/useValidationCore')['useValidationCore'] const useValidationErrors: typeof import('./src/composables/useValidationErrors')['useValidationErrors'] const useValidationStore: typeof import('./src/stores/validation.store')['useValidationStore'] const useValueMapping: typeof import('./src/composables/useValueMapping')['useValueMapping'] @@ -569,6 +571,7 @@ declare module 'vue' { readonly useDragDropStore: UnwrapRef readonly useDraggable: UnwrapRef readonly useDropZone: UnwrapRef + readonly useDropZoneStyling: UnwrapRef readonly useElementBounding: UnwrapRef readonly useElementByPoint: UnwrapRef readonly useElementHover: UnwrapRef @@ -700,6 +703,7 @@ declare module 'vue' { readonly useUserMedia: UnwrapRef readonly useVModel: UnwrapRef readonly useVModels: UnwrapRef + readonly useValidationCore: UnwrapRef readonly useValidationErrors: UnwrapRef readonly useValidationStore: UnwrapRef readonly useValueMapping: UnwrapRef diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 4192240..7779f7b 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -49,10 +49,14 @@ declare module 'vue' { TabPanels: typeof import('primevue/tabpanels')['default'] Tabs: typeof import('primevue/tabs')['default'] Tag: typeof import('primevue/tag')['default'] + TermSection: typeof import('./src/components/TermSection.vue')['default'] TermsEditor: typeof import('./src/components/TermsEditor.vue')['default'] Toast: typeof import('primevue/toast')['default'] ToggleSwitch: typeof import('primevue/toggleswitch')['default'] + ValidationDisplay: typeof import('./src/components/ValidationDisplay.vue')['default'] ValidationErrorDisplay: typeof import('./src/components/ValidationErrorDisplay.vue')['default'] + ValidationStatusBar: typeof import('./src/components/ValidationStatusBar.vue')['default'] + ValidationSuggestions: typeof import('./src/components/ValidationSuggestions.vue')['default'] WikibaseSchemaEditor: typeof import('./src/components/WikibaseSchemaEditor.vue')['default'] } export interface GlobalDirectives { diff --git a/frontend/src/components/SchemaDropZone.vue b/frontend/src/components/SchemaDropZone.vue index b5c0281..be7cebc 100644 --- a/frontend/src/components/SchemaDropZone.vue +++ b/frontend/src/components/SchemaDropZone.vue @@ -34,6 +34,23 @@ const { setLanguageCode, } = useSchemaDropZone() +// Schema validation UI for enhanced drop zone styling +const { getDropZoneClasses, currentDragFeedback } = useSchemaValidationUI() + +// Computed drop zone classes that combine both systems +const enhancedDropZoneClasses = computed(() => { + const targetPath = `item.terms.${props.termType}s.${props.languageCode}` + const validationClasses = getDropZoneClasses(targetPath) + + // dropZoneClasses returns an object for Vue class binding + // validationClasses returns an array of class names + // We need to combine them properly + return [ + dropZoneClasses.value, // Object for Vue class binding + ...validationClasses, // Array of class strings + ] +}) + // Set configuration from props setTermType(props.termType as 'label' | 'description' | 'alias') setLanguageCode(props.languageCode) @@ -58,8 +75,8 @@ watch(

{{ placeholder }}

+ + +
+ {{ currentDragFeedback.message }} +
diff --git a/frontend/src/components/StatementEditor.vue b/frontend/src/components/StatementEditor.vue index 585026f..8755bee 100644 --- a/frontend/src/components/StatementEditor.vue +++ b/frontend/src/components/StatementEditor.vue @@ -85,6 +85,9 @@ const { getValidationSeverity, } = useStatementValidationDisplay() +// Schema validation UI for enhanced validation feedback +const { getDropZoneClasses, currentDragFeedback } = useSchemaValidationUI() + // Set up the column drop callback setOnColumnDrop((_column) => { handleColumnDrop(_column) @@ -352,7 +355,7 @@ watch(
Drop column here + + +
+ {{ currentDragFeedback.message }} +
diff --git a/frontend/src/components/TermSection.vue b/frontend/src/components/TermSection.vue new file mode 100644 index 0000000..9c746c3 --- /dev/null +++ b/frontend/src/components/TermSection.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/components/TermsEditor.vue b/frontend/src/components/TermsEditor.vue index f5fca44..1f3dedf 100644 --- a/frontend/src/components/TermsEditor.vue +++ b/frontend/src/components/TermsEditor.vue @@ -3,36 +3,33 @@ diff --git a/frontend/src/components/ValidationDisplay.vue b/frontend/src/components/ValidationDisplay.vue new file mode 100644 index 0000000..876c1c2 --- /dev/null +++ b/frontend/src/components/ValidationDisplay.vue @@ -0,0 +1,384 @@ + + + diff --git a/frontend/src/components/ValidationErrorDisplay.vue b/frontend/src/components/ValidationErrorDisplay.vue deleted file mode 100644 index d75a3c6..0000000 --- a/frontend/src/components/ValidationErrorDisplay.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - diff --git a/frontend/src/components/WikibaseSchemaEditor.vue b/frontend/src/components/WikibaseSchemaEditor.vue index 4cea86b..d1c619a 100644 --- a/frontend/src/components/WikibaseSchemaEditor.vue +++ b/frontend/src/components/WikibaseSchemaEditor.vue @@ -20,6 +20,17 @@ const { showError, showSuccess } = useErrorHandling() const { convertProjectColumnsToColumnInfo } = useColumnConversion() const { setAvailableColumns, initializeStatement, resetStatement } = useStatementEditor() +// Schema validation UI composable +const { + hasValidationErrors, + hasValidationWarnings, + validationErrorCount, + validationWarningCount, + enableRealTimeValidation, + disableRealTimeValidation, + clearAllValidation, +} = useSchemaValidationUI() + // Reactive state const isInitialized = ref(false) const isConfiguringItem = ref(false) @@ -82,6 +93,8 @@ const isEditingStatement = computed(() => { // Lifecycle onMounted(async () => { await initializeEditor() + // Enable real-time validation + enableRealTimeValidation() }) // Methods @@ -322,8 +335,9 @@ const handleCancelStatementEdit = () => { // Cleanup onUnmounted(() => { dragDropStore.$reset() - // Clear validation errors when clearing schema - // validationStore.clearAll() // Method not available in current store + // Disable real-time validation and clear errors + disableRealTimeValidation() + clearAllValidation() isConfiguringItem.value = false }) @@ -347,6 +361,28 @@ onUnmounted(() => { > + + +
+ + + {{ validationErrorCount }} {{ validationErrorCount === 1 ? 'error' : 'errors' }} + + , {{ validationWarningCount }} + {{ validationWarningCount === 1 ? 'warning' : 'warnings' }} + + +
@@ -388,12 +424,21 @@ onUnmounted(() => {
- + +
+ +
+ +
- +
diff --git a/frontend/src/composables/__tests__/useDataTypeCompatibility.test.ts b/frontend/src/composables/__tests__/useDataTypeCompatibility.test.ts index 69fb688..8c9c0a9 100644 --- a/frontend/src/composables/__tests__/useDataTypeCompatibility.test.ts +++ b/frontend/src/composables/__tests__/useDataTypeCompatibility.test.ts @@ -12,124 +12,124 @@ describe('useDataTypeCompatibility Composable', () => { describe('isValidTextColumn', () => { test('should validate VARCHAR columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'title', dataType: 'VARCHAR', sampleValues: ['Sample Title'], nullable: false, } - expect(isValidTextColumn(column)).toBe(true) + expect(isValidTextColumn(columnInfo)).toBe(true) }) test('should validate TEXT columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'description', dataType: 'TEXT', sampleValues: ['Sample Description'], nullable: false, } - expect(isValidTextColumn(column)).toBe(true) + expect(isValidTextColumn(columnInfo)).toBe(true) }) test('should validate STRING columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'name', dataType: 'STRING', sampleValues: ['Sample Name'], nullable: false, } - expect(isValidTextColumn(column)).toBe(true) + expect(isValidTextColumn(columnInfo)).toBe(true) }) test('should validate CHAR columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'code', dataType: 'CHAR', sampleValues: ['A'], nullable: false, } - expect(isValidTextColumn(column)).toBe(true) + expect(isValidTextColumn(columnInfo)).toBe(true) }) test('should reject INTEGER columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'count', dataType: 'INTEGER', sampleValues: ['123'], nullable: false, } - expect(isValidTextColumn(column)).toBe(false) + expect(isValidTextColumn(columnInfo)).toBe(false) }) test('should reject DECIMAL columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'price', dataType: 'DECIMAL', sampleValues: ['19.99'], nullable: false, } - expect(isValidTextColumn(column)).toBe(false) + expect(isValidTextColumn(columnInfo)).toBe(false) }) test('should reject BOOLEAN columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'active', dataType: 'BOOLEAN', sampleValues: ['true'], nullable: false, } - expect(isValidTextColumn(column)).toBe(false) + expect(isValidTextColumn(columnInfo)).toBe(false) }) test('should reject DATE columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'created_at', dataType: 'DATE', sampleValues: ['2023-01-01'], nullable: false, } - expect(isValidTextColumn(column)).toBe(false) + expect(isValidTextColumn(columnInfo)).toBe(false) }) test('should reject TIMESTAMP columns', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'updated_at', dataType: 'TIMESTAMP', sampleValues: ['2023-01-01 12:00:00'], nullable: false, } - expect(isValidTextColumn(column)).toBe(false) + expect(isValidTextColumn(columnInfo)).toBe(false) }) test('should handle case insensitivity for varchar', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'title', dataType: 'varchar', sampleValues: ['Sample'], nullable: false, } - expect(isValidTextColumn(column)).toBe(true) + expect(isValidTextColumn(columnInfo)).toBe(true) }) test('should handle case insensitivity for text', () => { - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'description', dataType: 'text', sampleValues: ['Sample'], nullable: false, } - expect(isValidTextColumn(column)).toBe(true) + expect(isValidTextColumn(columnInfo)).toBe(true) }) test('should handle null column', () => { diff --git a/frontend/src/composables/__tests__/useReferenceValueMapping.test.ts b/frontend/src/composables/__tests__/useReferenceValueMapping.test.ts index 39c1f97..4485c5b 100644 --- a/frontend/src/composables/__tests__/useReferenceValueMapping.test.ts +++ b/frontend/src/composables/__tests__/useReferenceValueMapping.test.ts @@ -40,14 +40,14 @@ describe('useReferenceValueMapping', () => { test('should create reference value mapping from column', () => { const { createReferenceValueMappingFromColumn } = useReferenceValueMapping() - const column: ColumnInfo = { + const columnInfo: ColumnInfo = { name: 'test_column', dataType: 'VARCHAR', sampleValues: [], nullable: false, } - const mapping = createReferenceValueMappingFromColumn(column, 'string') + const mapping = createReferenceValueMappingFromColumn(columnInfo, 'string') expect(mapping.type).toBe('column') expect(mapping.source).toEqual({ diff --git a/frontend/src/composables/__tests__/useSchemaDropZone.test.ts b/frontend/src/composables/__tests__/useSchemaDropZone.test.ts index 27cfd42..e766734 100644 --- a/frontend/src/composables/__tests__/useSchemaDropZone.test.ts +++ b/frontend/src/composables/__tests__/useSchemaDropZone.test.ts @@ -274,6 +274,7 @@ describe('useSchemaDropZone Composable', () => { test('should handle drop event with valid column data', () => { const schemaStore = useSchemaStore() + const dragDropStore = useDragDropStore() const { handleDrop, isOverDropZone, setTermType, setLanguageCode } = useSchemaDropZone() setTermType('label') @@ -288,6 +289,9 @@ describe('useSchemaDropZone Composable', () => { nullable: false, } + // Set up drag state before calling handleDrop + dragDropStore.startDrag(columnData) + const mockEvent = { preventDefault: () => {}, dataTransfer: { @@ -310,6 +314,7 @@ describe('useSchemaDropZone Composable', () => { test('should handle drop event with invalid column data', () => { const schemaStore = useSchemaStore() + const dragDropStore = useDragDropStore() const { handleDrop, setTermType, setLanguageCode } = useSchemaDropZone() setTermType('label') @@ -322,6 +327,9 @@ describe('useSchemaDropZone Composable', () => { nullable: false, } + // Set up drag state before calling handleDrop + dragDropStore.startDrag(columnData) + const mockEvent = { preventDefault: () => {}, dataTransfer: { diff --git a/frontend/src/composables/__tests__/useSchemaValidationUI.test.ts b/frontend/src/composables/__tests__/useSchemaValidationUI.test.ts index e49635a..cc075d3 100644 --- a/frontend/src/composables/__tests__/useSchemaValidationUI.test.ts +++ b/frontend/src/composables/__tests__/useSchemaValidationUI.test.ts @@ -209,7 +209,7 @@ describe('useSchemaValidationUI', () => { describe('Drop Zone Classes', () => { it('should provide appropriate classes when no drag is in progress', () => { const ui = useSchemaValidationUI() - const classes = ui.getDropZoneClasses('item.terms.labels.en', ['string']) + const classes = ui.getDropZoneClasses('item.terms.labels.en') expect(classes).toContain('drop-zone') expect(classes).toContain('transition-colors') @@ -238,14 +238,14 @@ describe('useSchemaValidationUI', () => { expect(dragDropStore.validDropTargets).toContain('item.terms.labels.en') // Valid target, not hovered - const validClasses = ui.getDropZoneClasses('item.terms.labels.en', ['string']) + const validClasses = ui.getDropZoneClasses('item.terms.labels.en') expect(validClasses).toContain('border-green-300') expect(validClasses).toContain('bg-green-25') expect(validClasses).toContain('border-dashed') // Valid target, hovered dragDropStore.setHoveredTarget('item.terms.labels.en') - const hoveredValidClasses = ui.getDropZoneClasses('item.terms.labels.en', ['string']) + const hoveredValidClasses = ui.getDropZoneClasses('item.terms.labels.en') expect(hoveredValidClasses).toContain('border-green-400') expect(hoveredValidClasses).toContain('bg-green-50') expect(hoveredValidClasses).toContain('border-2') @@ -266,13 +266,13 @@ describe('useSchemaValidationUI', () => { dragDropStore.startDrag(incompatibleColumn) // Invalid target, not hovered - const invalidClasses = ui.getDropZoneClasses('item.terms.labels.en', ['string']) + const invalidClasses = ui.getDropZoneClasses('item.terms.labels.en') expect(invalidClasses).toContain('border-surface-200') expect(invalidClasses).toContain('opacity-50') // Invalid target, hovered dragDropStore.setHoveredTarget('item.terms.labels.en') - const hoveredInvalidClasses = ui.getDropZoneClasses('item.terms.labels.en', ['string']) + const hoveredInvalidClasses = ui.getDropZoneClasses('item.terms.labels.en') expect(hoveredInvalidClasses).toContain('border-red-400') expect(hoveredInvalidClasses).toContain('bg-red-50') expect(hoveredInvalidClasses).toContain('border-2') diff --git a/frontend/src/composables/__tests__/useValidationCore.test.ts b/frontend/src/composables/__tests__/useValidationCore.test.ts new file mode 100644 index 0000000..377f77f --- /dev/null +++ b/frontend/src/composables/__tests__/useValidationCore.test.ts @@ -0,0 +1,288 @@ +import { describe, test, expect } from 'bun:test' +import { useValidationCore } from '@frontend/composables/useValidationCore' +import type { ColumnInfo } from '@frontend/types/wikibase-schema' +import type { DropTarget } from '@frontend/types/drag-drop' + +describe('useValidationCore', () => { + const { validateColumnForTarget, validateForStyling, validateForDrop, isAliasDuplicate } = + useValidationCore() + + describe('Core Validation', () => { + test('should validate compatible data types', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: false, + sampleValues: ['test1', 'test2'], + } + + const target: DropTarget = { + path: 'item.terms.labels.en', + type: 'label', + acceptedTypes: ['string'], + isRequired: false, + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(true) + }) + + test('should reject incompatible data types', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'number', + nullable: false, + sampleValues: ['1', '2', '3'], + } + + const target: DropTarget = { + path: 'item.terms.labels.en', + type: 'label', + acceptedTypes: ['string'], + isRequired: false, + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('incompatible_data_type') + }) + + test('should reject nullable columns for required fields', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: true, + sampleValues: ['test1', 'test2'], + } + + const target: DropTarget = { + path: 'item.terms.labels.en', + type: 'label', + acceptedTypes: ['string'], + isRequired: true, + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('nullable_required_field') + }) + + test('should validate length constraints for labels', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: false, + sampleValues: ['a'.repeat(300)], // Too long for label + } + + const target: DropTarget = { + path: 'item.terms.labels.en', + type: 'label', + acceptedTypes: ['string'], + isRequired: false, + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('length_constraint') + }) + + test('should validate length constraints for aliases', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: false, + sampleValues: ['a'.repeat(150)], // Too long for alias + } + + const target: DropTarget = { + path: 'item.terms.aliases.en', + type: 'alias', + acceptedTypes: ['string'], + isRequired: false, + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('length_constraint') + }) + + test('should require property ID for statements', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: false, + sampleValues: ['test1', 'test2'], + } + + const target: DropTarget = { + path: 'item.statements[0].value', + type: 'statement', + acceptedTypes: ['string'], + isRequired: false, + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('missing_property_id') + }) + + test('should accept statements with property ID', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: false, + sampleValues: ['test1', 'test2'], + } + + const target: DropTarget = { + path: 'item.statements[0].value', + type: 'statement', + acceptedTypes: ['string'], + isRequired: false, + propertyId: 'P123', + } + + const result = validateColumnForTarget(columnInfo, target) + expect(result.isValid).toBe(true) + }) + }) + + describe('Alias Deduplication', () => { + test('should detect duplicate aliases', () => { + const columnInfo: ColumnInfo = { + name: 'existing_column', + dataType: 'string', + nullable: false, + sampleValues: ['alias1', 'alias2'], + } + + const existingAliases = [ + { columnName: 'existing_column', dataType: 'string' }, + { columnName: 'other_column', dataType: 'string' }, + ] + + const result = isAliasDuplicate(columnInfo, existingAliases) + expect(result).toBe(true) + }) + + test('should not detect duplicates for different columns', () => { + const columnInfo: ColumnInfo = { + name: 'new_column', + dataType: 'string', + nullable: false, + sampleValues: ['alias1', 'alias2'], + } + + const existingAliases = [ + { columnName: 'existing_column', dataType: 'string' }, + { columnName: 'other_column', dataType: 'string' }, + ] + + const result = isAliasDuplicate(columnInfo, existingAliases) + expect(result).toBe(false) + }) + + test('should not detect duplicates for different data types', () => { + const columnInfo: ColumnInfo = { + name: 'existing_column', + dataType: 'number', + nullable: false, + sampleValues: ['1', '2', '3'], + } + + const existingAliases = [ + { columnName: 'existing_column', dataType: 'string' }, + { columnName: 'other_column', dataType: 'string' }, + ] + + const result = isAliasDuplicate(columnInfo, existingAliases) + expect(result).toBe(false) + }) + }) + + describe('Validation for Styling vs Drop', () => { + test('validateForStyling should ignore duplicates', () => { + const columnInfo: ColumnInfo = { + name: 'test_column', + dataType: 'string', + nullable: false, + sampleValues: ['alias1', 'alias2'], + } + + const target: DropTarget = { + path: 'item.terms.aliases.en', + type: 'alias', + acceptedTypes: ['string'], + isRequired: false, + } + + const result = validateForStyling(columnInfo, target) + expect(result.isValid).toBe(true) + }) + + test('validateForDrop should check duplicates for aliases', () => { + const columnInfo: ColumnInfo = { + name: 'existing_column', + dataType: 'string', + nullable: false, + sampleValues: ['alias1', 'alias2'], + } + + const target: DropTarget = { + path: 'item.terms.aliases.en', + type: 'alias', + acceptedTypes: ['string'], + isRequired: false, + } + + const existingAliases = [{ columnName: 'existing_column', dataType: 'string' }] + + const result = validateForDrop(columnInfo, target, existingAliases) + expect(result.isValid).toBe(false) + expect(result.reason).toBe('duplicate_alias') + }) + + test('validateForDrop should pass for non-duplicate aliases', () => { + const columnInfo: ColumnInfo = { + name: 'new_column', + dataType: 'string', + nullable: false, + sampleValues: ['alias1', 'alias2'], + } + + const target: DropTarget = { + path: 'item.terms.aliases.en', + type: 'alias', + acceptedTypes: ['string'], + isRequired: false, + } + + const existingAliases = [{ columnName: 'existing_column', dataType: 'string' }] + + const result = validateForDrop(columnInfo, target, existingAliases) + expect(result.isValid).toBe(true) + }) + + test('validateForDrop should not check duplicates for non-alias types', () => { + const columnInfo: ColumnInfo = { + name: 'existing_column', + dataType: 'string', + nullable: false, + sampleValues: ['label1', 'label2'], + } + + const target: DropTarget = { + path: 'item.terms.labels.en', + type: 'label', + acceptedTypes: ['string'], + isRequired: false, + } + + const existingAliases = [{ columnName: 'existing_column', dataType: 'string' }] + + const result = validateForDrop(columnInfo, target, existingAliases) + expect(result.isValid).toBe(true) + }) + }) +}) diff --git a/frontend/src/composables/useDropZoneStyling.ts b/frontend/src/composables/useDropZoneStyling.ts new file mode 100644 index 0000000..05c427d --- /dev/null +++ b/frontend/src/composables/useDropZoneStyling.ts @@ -0,0 +1,85 @@ +import { computed } from 'vue' +import { useDragDropStore } from '@frontend/stores/drag-drop.store' +import { useValidationCore } from '@frontend/composables/useValidationCore' +import type { DropTarget } from '@frontend/types/drag-drop' + +/** + * Common drop zone styling logic + */ +export const useDropZoneStyling = () => { + const dragDropStore = useDragDropStore() + const { validateForStyling } = useValidationCore() + + /** + * Get CSS classes for drop zone based on current drag state + */ + const getDropZoneClasses = (target: DropTarget) => { + const baseClasses = ['drop-zone', 'transition-colors', 'duration-200'] + + if (!dragDropStore.draggedColumn) { + return [...baseClasses, 'border-surface-200'] + } + + const validation = validateForStyling(dragDropStore.draggedColumn, target) + const isValidTarget = validation.isValid + const isHovered = dragDropStore.hoveredTarget === target.path + + if (isValidTarget) { + if (isHovered) { + return [...baseClasses, 'border-green-400', 'bg-green-50', 'border-2'] + } else { + return [...baseClasses, 'border-green-300', 'bg-green-25', 'border-dashed'] + } + } else { + if (isHovered) { + return [...baseClasses, 'border-red-400', 'bg-red-50', 'border-2'] + } else { + return [...baseClasses, 'border-surface-200', 'opacity-50'] + } + } + } + + /** + * Get CSS classes object for Vue class binding + */ + const getDropZoneClassObject = (target: DropTarget, isOverDropZone = false) => { + if (!dragDropStore.draggedColumn) { + return { + 'border-primary-400 bg-primary-50': isOverDropZone, + 'border-green-400 bg-green-50': false, + 'border-red-400 bg-red-50': false, + } + } + + const isValidTarget = validateForStyling(dragDropStore.draggedColumn, target).isValid + + return { + 'border-primary-400 bg-primary-50': isOverDropZone, + 'border-green-400 bg-green-50': dragDropStore.isDragging && isValidTarget, + 'border-red-400 bg-red-50': dragDropStore.isDragging && !isValidTarget, + } + } + + /** + * Check if current drag operation is valid for styling + */ + const isValidDragForStyling = computed(() => { + if (!dragDropStore.draggedColumn || !dragDropStore.hoveredTarget) { + return false + } + + // Find the target being hovered + const target = dragDropStore.availableTargets.find( + (t) => t.path === dragDropStore.hoveredTarget, + ) + if (!target) return false + + return validateForStyling(dragDropStore.draggedColumn, target).isValid + }) + + return { + getDropZoneClasses, + getDropZoneClassObject, + isValidDragForStyling, + } +} diff --git a/frontend/src/composables/useRealTimeValidation.ts b/frontend/src/composables/useRealTimeValidation.ts index 837b6f0..fe83458 100644 --- a/frontend/src/composables/useRealTimeValidation.ts +++ b/frontend/src/composables/useRealTimeValidation.ts @@ -2,12 +2,13 @@ import { ref, computed, watch } from 'vue' import { useDragDropStore } from '@frontend/stores/drag-drop.store' import { useValidationStore } from '@frontend/stores/validation.store' import { useValidationErrors } from '@frontend/composables/useValidationErrors' +import { useValidationCore } from '@frontend/composables/useValidationCore' import { useDataTypeCompatibility } from '@frontend/composables/useDataTypeCompatibility' import type { ColumnInfo, - WikibaseDataType, ValidationError, ValidationResult, + WikibaseDataType, } from '@frontend/types/wikibase-schema' import type { DropTarget, DropFeedback } from '@frontend/types/drag-drop' @@ -33,7 +34,8 @@ interface DragValidationResult { export const useRealTimeValidation = () => { const dragDropStore = useDragDropStore() const validationStore = useValidationStore() - const { createError, createWarning } = useValidationErrors() + const { createError } = useValidationErrors() + const { validateColumnForTarget } = useValidationCore() const { isDataTypeCompatible } = useDataTypeCompatibility() // Local reactive state @@ -63,116 +65,60 @@ export const useRealTimeValidation = () => { target: DropTarget, autoAddToStore = false, ): DragValidationResult => { - const errors: ValidationError[] = [] - const warnings: ValidationError[] = [] - // Clear previous errors for this path if auto-adding to store if (autoAddToStore) { validationStore.clearErrorsForPath(target.path, true) } - // 1. Data type compatibility check - if (!isDataTypeCompatible(columnInfo.dataType, target.acceptedTypes)) { + // Use core validation logic + const validation = validateColumnForTarget(columnInfo, target) + + if (!validation.isValid) { const error = createError( - 'INCOMPATIBLE_DATA_TYPE', + getErrorCodeFromReason(validation.reason || 'unknown'), target.path, { columnName: columnInfo.name, dataType: columnInfo.dataType, targetType: target.acceptedTypes.join(', '), }, - `Column type '${columnInfo.dataType}' is not compatible with target types: ${target.acceptedTypes.join(', ')}`, + validation.message || 'Validation failed', ) - errors.push(error) if (autoAddToStore) { validationStore.addError(error) } - } - - // 2. Nullable constraint check - if (target.isRequired && columnInfo.nullable) { - const error = createError( - 'MISSING_REQUIRED_MAPPING', - target.path, - { - columnName: columnInfo.name, - }, - 'Required field cannot accept nullable column', - ) - errors.push(error) - if (autoAddToStore) { - validationStore.addError(error) + return { + isValid: false, + errors: [error], + warnings: [], } } - // 3. Target-specific validation - const targetSpecificErrors = validateByTargetType(columnInfo, target) - errors.push(...targetSpecificErrors) - - if (autoAddToStore) { - targetSpecificErrors.forEach((error) => validationStore.addError(error)) - } - return { - isValid: errors.length === 0, - errors, - warnings, + isValid: validation.isValid, + errors: [], + warnings: [], } } /** - * Validate based on target type (labels, statements, etc.) + * Map validation reasons to error codes */ - const validateByTargetType = (columnInfo: ColumnInfo, target: DropTarget): ValidationError[] => { - const errors: ValidationError[] = [] - - switch (target.type) { - case 'label': - case 'alias': { - // Check length constraints for labels and aliases - const maxLength = target.type === 'label' ? 250 : 100 - const hasLongValues = columnInfo.sampleValues?.some((val) => val.length > maxLength) - - if (hasLongValues) { - errors.push( - createError( - 'INCOMPATIBLE_DATA_TYPE', - target.path, - { - columnName: columnInfo.name, - targetType: target.type, - }, - `${target.type} values should be shorter than ${maxLength} characters`, - ), - ) - } - break - } - - case 'statement': - case 'qualifier': - case 'reference': { - // These require property IDs - if (!target.propertyId) { - errors.push( - createError( - 'INVALID_PROPERTY_ID', - target.path, - { - columnName: columnInfo.name, - targetType: target.type, - }, - `${target.type} target must have a property ID`, - ), - ) - } - break - } + const getErrorCodeFromReason = (reason: string) => { + switch (reason) { + case 'incompatible_data_type': + return 'INCOMPATIBLE_DATA_TYPE' + case 'nullable_required_field': + return 'MISSING_REQUIRED_MAPPING' + case 'length_constraint': + return 'INCOMPATIBLE_DATA_TYPE' + case 'missing_property_id': + return 'INVALID_PROPERTY_ID' + default: + return 'INCOMPATIBLE_DATA_TYPE' } - - return errors } /** diff --git a/frontend/src/composables/useSchemaDropZone.ts b/frontend/src/composables/useSchemaDropZone.ts index 03b4f05..1b5688d 100644 --- a/frontend/src/composables/useSchemaDropZone.ts +++ b/frontend/src/composables/useSchemaDropZone.ts @@ -3,75 +3,66 @@ import type { ColumnInfo } from '@frontend/types/wikibase-schema' import { useDragDropStore } from '@frontend/stores/drag-drop.store' import { useSchemaStore } from '@frontend/stores/schema.store' import { useDragDropHandlers } from '@frontend/composables/useDragDropHandlers' +import { useValidationCore } from '@frontend/composables/useValidationCore' +import { useDropZoneStyling } from '@frontend/composables/useDropZoneStyling' /** - * Composable for handling schema drop zone functionality + * Simplified schema drop zone composable using shared validation and styling */ export const useSchemaDropZone = () => { // Configuration state const termType = ref<'label' | 'description' | 'alias'>('label') const languageCode = ref('en') + + // Stores and shared composables const dragDropStore = useDragDropStore() const schemaStore = useSchemaStore() - const { - createDragEnterHandler, - createDragLeaveHandler, - createDropHandler, - validateColumnCompatibility, - } = useDragDropHandlers() - - // Text-based fields (labels, descriptions, aliases) accept string types + const { createDragEnterHandler, createDragLeaveHandler, createDropHandler } = + useDragDropHandlers() + const { validateForStyling, validateForDrop } = useValidationCore() + const { getDropZoneClassObject } = useDropZoneStyling() + + // Text-based fields accept string types const acceptedTypes: WikibaseDataType[] = ['string'] - // Reactive state + // Local state const isOverDropZone = ref(false) - // Direct reactive references to drag store - const draggedColumn = computed(() => dragDropStore.draggedColumn) - const isDragging = computed(() => dragDropStore.isDragging) - - // Reactive validation using shared logic - const isColumnValid = computed(() => { - if (!draggedColumn.value) return false - - // First check basic type compatibility - if (!validateColumnCompatibility(draggedColumn.value, acceptedTypes)) { - return false - } - - // For aliases, also check for duplicates - if (termType.value === 'alias') { - const existingAliases = schemaStore.aliases[languageCode.value] || [] - const isDuplicate = existingAliases.some( - (alias) => - alias.columnName === draggedColumn.value!.name && - alias.dataType === draggedColumn.value!.dataType, - ) - return !isDuplicate - } + // Create target object for validation + const currentTarget = computed(() => ({ + type: termType.value, + path: `item.terms.${termType.value}s.${languageCode.value}`, + acceptedTypes, + language: languageCode.value, + isRequired: false, + })) - return true + const isValidForDrop = computed(() => { + if (!dragDropStore.draggedColumn) return false + const existingAliases = + termType.value === 'alias' ? schemaStore.aliases[languageCode.value] || [] : undefined + return validateForDrop(dragDropStore.draggedColumn, currentTarget.value, existingAliases) + .isValid }) - // Reactive CSS classes - const dropZoneClasses = computed(() => ({ - 'border-primary-400 bg-primary-50': isOverDropZone.value, - 'border-green-400 bg-green-50': isDragging.value && isColumnValid.value, - 'border-red-400 bg-red-50': isDragging.value && draggedColumn.value && !isColumnValid.value, - })) + // CSS classes using shared styling logic + const dropZoneClasses = computed(() => + getDropZoneClassObject(currentTarget.value, isOverDropZone.value), + ) - // Reactive drop states - const isValidDropState = computed(() => isDragging.value && isColumnValid.value) + // Drop states for external consumption + const isValidDropState = computed(() => dragDropStore.isDragging && isValidForDrop.value) const isInvalidDropState = computed( - () => isDragging.value && draggedColumn.value && !isColumnValid.value, + () => dragDropStore.isDragging && dragDropStore.draggedColumn && !isValidForDrop.value, ) - // Event handlers using shared logic with custom validation for aliases + // Event handlers const handleDragOver = (event: DragEvent): void => { event.preventDefault() if (event.dataTransfer) { - // Use the same validation logic as isColumnValid for consistent behavior - event.dataTransfer.dropEffect = isColumnValid.value ? 'copy' : 'none' + // Use drop validation for cursor behavior to include duplicate checking + event.dataTransfer.dropEffect = + dragDropStore.draggedColumn && isValidForDrop.value ? 'copy' : 'none' } } @@ -87,14 +78,17 @@ export const useSchemaDropZone = () => { acceptedTypes, (columnInfo) => { isOverDropZone.value = false - addColumnMapping(columnInfo) + // Only add if drop validation passes (includes duplicate checking) + if (isValidForDrop.value) { + addColumnMapping(columnInfo) + } }, () => { isOverDropZone.value = false }, ) - // Add column mapping action + // Simplified column mapping - duplicate checking handled by validation const addColumnMapping = (dataCol: ColumnInfo): void => { const columnMapping = { columnName: dataCol.name, @@ -106,18 +100,7 @@ export const useSchemaDropZone = () => { } else if (termType.value === 'description') { schemaStore.addDescriptionMapping(languageCode.value, columnMapping) } else if (termType.value === 'alias') { - // Check for duplicates before adding alias - const existingAliases = schemaStore.aliases[languageCode.value] || [] - const isDuplicate = existingAliases.some( - (alias) => - alias.columnName === columnMapping.columnName && - alias.dataType === columnMapping.dataType, - ) - - if (!isDuplicate) { - schemaStore.addAliasMapping(languageCode.value, columnMapping) - } - // Silently ignore duplicates - this is expected behavior + schemaStore.addAliasMapping(languageCode.value, columnMapping) } } @@ -131,11 +114,13 @@ export const useSchemaDropZone = () => { } return { - // Configuration state + // Configuration termType, languageCode, + setTermType, + setLanguageCode, - // Reactive state + // State isOverDropZone, isValidDropState, isInvalidDropState, @@ -147,9 +132,5 @@ export const useSchemaDropZone = () => { handleDragLeave, handleDrop, addColumnMapping, - - // Configuration methods - setTermType, - setLanguageCode, } } diff --git a/frontend/src/composables/useSchemaValidationUI.ts b/frontend/src/composables/useSchemaValidationUI.ts index 5b55dd9..a901f26 100644 --- a/frontend/src/composables/useSchemaValidationUI.ts +++ b/frontend/src/composables/useSchemaValidationUI.ts @@ -2,19 +2,22 @@ import { computed } from 'vue' import { useRealTimeValidation } from '@frontend/composables/useRealTimeValidation' import { useDragDropStore } from '@frontend/stores/drag-drop.store' import { useValidationStore } from '@frontend/stores/validation.store' +import { useDropZoneStyling } from '@frontend/composables/useDropZoneStyling' +import { useValidationCore } from '@frontend/composables/useValidationCore' import type { ColumnInfo } from '@frontend/types/wikibase-schema' import type { DropTarget } from '@frontend/types/drag-drop' /** - * Composable for integrating real-time validation with UI components - * Provides easy-to-use reactive properties and methods for schema validation UI + * Simplified UI composable that wraps shared validation and styling logic */ export const useSchemaValidationUI = () => { const realTimeValidation = useRealTimeValidation() const dragDropStore = useDragDropStore() const validationStore = useValidationStore() + const { getDropZoneClasses, isValidDragForStyling } = useDropZoneStyling() + const { validateColumnForTarget } = useValidationCore() - // Computed properties for UI state + // Reactive state from stores const isDragInProgress = computed(() => dragDropStore.isDragging) const hasValidationErrors = computed(() => validationStore.hasErrors) const hasValidationWarnings = computed(() => validationStore.hasWarnings) @@ -49,7 +52,7 @@ export const useSchemaValidationUI = () => { return realTimeValidation.getValidationFeedback(dragDropStore.draggedColumn, target) }) - // Get validation status for a specific path + // Simplified methods using shared logic const getPathValidationStatus = (path: string) => { return { hasErrors: validationStore.hasErrorsForPath(path), @@ -59,7 +62,6 @@ export const useSchemaValidationUI = () => { } } - // Get validation CSS classes for styling const getValidationClasses = (path: string) => { const status = getPathValidationStatus(path) @@ -72,7 +74,6 @@ export const useSchemaValidationUI = () => { } } - // Get validation icon for a path const getValidationIcon = (path: string) => { const status = getPathValidationStatus(path) @@ -93,68 +94,43 @@ export const useSchemaValidationUI = () => { } } - // Check if a column can be dropped on a target - const canDropColumn = (column: ColumnInfo, target: DropTarget): boolean => { - const validation = realTimeValidation.validateDragOperation(column, target) - return validation.isValid + const canDropColumn = (columnInfo: ColumnInfo, target: DropTarget): boolean => { + return validateColumnForTarget(columnInfo, target).isValid } - // Get drop zone classes based on current drag state - const getDropZoneClasses = (targetPath: string, acceptedTypes: string[]) => { - const baseClasses = ['drop-zone', 'transition-colors', 'duration-200'] - - if (!dragDropStore.draggedColumn) { - return [...baseClasses, 'border-surface-200'] + const getDropZoneClassesForPath = (targetPath: string) => { + const target = dragDropStore.availableTargets.find((t) => t.path === targetPath) + if (!target) { + return ['drop-zone', 'transition-colors', 'border-surface-200'] } + return getDropZoneClasses(target) + } - const isValidTarget = dragDropStore.validDropTargets.includes(targetPath) - const isHovered = dragDropStore.hoveredTarget === targetPath + const validateMapping = (columnInfo: ColumnInfo, target: DropTarget) => { + return realTimeValidation.validateDragOperation(columnInfo, target, true) + } - if (isValidTarget) { - if (isHovered) { - return [...baseClasses, 'border-green-400', 'bg-green-50', 'border-2'] - } else { - return [...baseClasses, 'border-green-300', 'bg-green-25', 'border-dashed'] - } - } else { - if (isHovered) { - return [...baseClasses, 'border-red-400', 'bg-red-50', 'border-2'] - } else { - return [...baseClasses, 'border-surface-200', 'opacity-50'] - } - } + const getMappingSuggestions = (columnInfo: ColumnInfo, target: DropTarget): string[] => { + return realTimeValidation.getValidationSuggestions(columnInfo, target) } - // Start real-time validation (typically called in component setup) + // Control methods const enableRealTimeValidation = () => { realTimeValidation.startRealTimeValidation() } - // Stop real-time validation (typically called in component cleanup) const disableRealTimeValidation = () => { realTimeValidation.stopRealTimeValidation() } - // Clear all validation errors const clearAllValidation = () => { validationStore.$reset() } - // Clear validation for a specific path const clearPathValidation = (path: string, exactMatch = false) => { validationStore.clearErrorsForPath(path, exactMatch) } - // Validate a specific mapping and add to store - const validateMapping = (column: ColumnInfo, target: DropTarget) => { - return realTimeValidation.validateDragOperation(column, target, true) - } - - // Get suggestions for improving a mapping - const getMappingSuggestions = (column: ColumnInfo, target: DropTarget): string[] => { - return realTimeValidation.getValidationSuggestions(column, target) - } - return { // Reactive state isDragInProgress, @@ -164,6 +140,7 @@ export const useSchemaValidationUI = () => { validationWarningCount, currentDragValidation, currentDragFeedback, + isValidDragForStyling, // Validation methods getPathValidationStatus, @@ -174,7 +151,7 @@ export const useSchemaValidationUI = () => { // UI helper methods getValidationClasses, getValidationIcon, - getDropZoneClasses, + getDropZoneClasses: getDropZoneClassesForPath, // Control methods enableRealTimeValidation, diff --git a/frontend/src/composables/useValidationCore.ts b/frontend/src/composables/useValidationCore.ts new file mode 100644 index 0000000..3095d85 --- /dev/null +++ b/frontend/src/composables/useValidationCore.ts @@ -0,0 +1,121 @@ +import { useDataTypeCompatibility } from '@frontend/composables/useDataTypeCompatibility' +import type { ColumnInfo } from '@frontend/types/wikibase-schema' +import type { DropTarget } from '@frontend/types/drag-drop' + +interface CoreValidationResult { + isValid: boolean + reason?: string + message?: string +} + +/** + * Core validation composable - single source of truth for all validation logic + */ +export const useValidationCore = () => { + const { isDataTypeCompatible } = useDataTypeCompatibility() + + /** + * Core validation function for column-target compatibility + */ + const validateColumnForTarget = ( + columnInfo: ColumnInfo, + target: DropTarget, + ): CoreValidationResult => { + // 1. Data type compatibility + if (!isDataTypeCompatible(columnInfo.dataType, target.acceptedTypes)) { + return { + isValid: false, + reason: 'incompatible_data_type', + message: `Column type '${columnInfo.dataType}' is not compatible with target types: ${target.acceptedTypes.join(', ')}`, + } + } + + // 2. Nullable constraints + if (target.isRequired && columnInfo.nullable) { + return { + isValid: false, + reason: 'nullable_required_field', + message: 'Required field cannot accept nullable column', + } + } + + // 3. Length constraints for labels and aliases + if (target.type === 'label' || target.type === 'alias') { + const maxLength = target.type === 'label' ? 250 : 100 + const hasLongValues = columnInfo.sampleValues?.some((val) => val.length > maxLength) + if (hasLongValues) { + return { + isValid: false, + reason: 'length_constraint', + message: `${target.type} values should be shorter than ${maxLength} characters`, + } + } + } + + // 4. Property requirements for statements, qualifiers, references + if (['statement', 'qualifier', 'reference'].includes(target.type) && !target.propertyId) { + return { + isValid: false, + reason: 'missing_property_id', + message: `${target.type} target must have a property ID`, + } + } + + return { isValid: true } + } + + /** + * Check if a column would be a duplicate alias + */ + const isAliasDuplicate = ( + columnInfo: ColumnInfo, + existingAliases: Array<{ columnName: string; dataType: string }>, + ): boolean => { + return existingAliases.some( + (alias) => alias.columnName === columnInfo.name && alias.dataType === columnInfo.dataType, + ) + } + + /** + * Validate column for styling purposes (consistent across all term types) + */ + const validateForStyling = (columnInfo: ColumnInfo, target: DropTarget): CoreValidationResult => { + // For styling, we don't check duplicates - aliases should show green even for duplicates + return validateColumnForTarget(columnInfo, target) + } + + /** + * Validate column for drop purposes (includes duplicate checking for aliases) + */ + const validateForDrop = ( + columnInfo: ColumnInfo, + target: DropTarget, + existingAliases?: Array<{ columnName: string; dataType: string }>, + ): CoreValidationResult => { + // First check basic validation + const basicValidation = validateColumnForTarget(columnInfo, target) + if (!basicValidation.isValid) { + return basicValidation + } + + // For aliases, also check duplicates + if (target.type === 'alias' && existingAliases) { + if (isAliasDuplicate(columnInfo, existingAliases)) { + return { + isValid: false, + reason: 'duplicate_alias', + message: 'This alias already exists', + } + } + } + + return { isValid: true } + } + + return { + validateColumnForTarget, + validateForStyling, + validateForDrop, + isAliasDuplicate, + } +} diff --git a/frontend/src/stores/schema.store.ts b/frontend/src/stores/schema.store.ts index 52f402e..6f7803e 100644 --- a/frontend/src/stores/schema.store.ts +++ b/frontend/src/stores/schema.store.ts @@ -54,8 +54,17 @@ export const useSchemaStore = defineStore('schema', () => { aliases.value[languageCode] = [] } - aliases.value[languageCode].push(columnMapping) - markDirty() + // Check for duplicates before adding + const existingAliases = aliases.value[languageCode] + const isDuplicate = existingAliases.some( + (alias) => + alias.columnName === columnMapping.columnName && alias.dataType === columnMapping.dataType, + ) + + if (!isDuplicate) { + aliases.value[languageCode].push(columnMapping) + markDirty() + } } const removeLabelMapping = (languageCode: string) => { From 6b2f57efb810d6997ce85f64d4a6e7ca9d4feac0 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sat, 2 Aug 2025 16:05:18 +0200 Subject: [PATCH 2/3] fix: typecheck and apply code review suggestions --- frontend/src/components/ValidationDisplay.vue | 14 ++++---------- frontend/src/composables/useDropZoneStyling.ts | 2 -- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/ValidationDisplay.vue b/frontend/src/components/ValidationDisplay.vue index 876c1c2..b8b54bf 100644 --- a/frontend/src/components/ValidationDisplay.vue +++ b/frontend/src/components/ValidationDisplay.vue @@ -54,9 +54,7 @@ const { hasValidationWarnings, validationErrorCount, validationWarningCount, - getPathValidationStatus, getValidationClasses, - getValidationIcon, clearPathValidation, clearAllValidation, validationStore, @@ -195,10 +193,6 @@ const clearAll = () => { } emit('allCleared') } - -const handleClearAll = () => { - clearAllValidation() -}