diff --git a/e2e/labels.spec.ts b/e2e/labels.spec.ts new file mode 100644 index 0000000..25cd194 --- /dev/null +++ b/e2e/labels.spec.ts @@ -0,0 +1,233 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for Labels page functionality. + * Tests the complete workflow: create label → add properties → add relationships → save → delete + */ + +test('labels page loads and displays empty state', async ({ page, baseURL }) => { + const consoleMessages: { type: string; text: string }[] = []; + page.on('console', (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + + // Navigate to Labels page + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Verify page loads + await expect(page).toHaveTitle(/SciDK - Labels/i, { timeout: 10_000 }); + + // Check for new label button + await expect(page.getByTestId('new-label-btn')).toBeVisible(); + + // Check for label list + await expect(page.getByTestId('label-list')).toBeVisible(); + + // No console errors + const errors = consoleMessages.filter((m) => m.type === 'error'); + expect(errors.length).toBe(0); +}); + +test('labels navigation link is visible in header', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + + await page.goto(base); + await page.waitForLoadState('networkidle'); + + // Check that Labels link exists in navigation + const labelsLink = page.getByTestId('nav-labels'); + await expect(labelsLink).toBeVisible(); + + // Click it and verify we navigate to labels page + await labelsLink.click(); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveTitle(/SciDK - Labels/i); +}); + +test('complete label workflow: create → edit → delete', async ({ page, baseURL }) => { + const consoleMessages: { type: string; text: string }[] = []; + page.on('console', (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Step 1: Click "New Label" button + await page.getByTestId('new-label-btn').click(); + + // Step 2: Enter label name + const labelNameInput = page.getByTestId('label-name'); + await expect(labelNameInput).toBeVisible(); + await labelNameInput.fill('E2ETestLabel'); + + // Step 3: Add a property + await page.getByTestId('add-property-btn').click(); + + // Fill property details + const propertyRows = page.getByTestId('property-row'); + const firstPropertyRow = propertyRows.first(); + await firstPropertyRow.getByTestId('property-name').fill('testProperty'); + await firstPropertyRow.getByTestId('property-type').selectOption('string'); + await firstPropertyRow.getByTestId('property-required').check(); + + // Step 4: Add another property + await page.getByTestId('add-property-btn').click(); + const secondPropertyRow = propertyRows.nth(1); + await secondPropertyRow.getByTestId('property-name').fill('count'); + await secondPropertyRow.getByTestId('property-type').selectOption('number'); + + // Step 5: Save the label + await page.getByTestId('save-label-btn').click(); + + // Wait for save to complete (look for toast or list update) + await page.waitForTimeout(1000); + + // Step 6: Verify label appears in list + const labelItems = page.getByTestId('label-item'); + await expect(labelItems.first()).toBeVisible(); + const labelText = await labelItems.first().textContent(); + expect(labelText).toContain('E2ETestLabel'); + expect(labelText).toContain('2 properties'); + + // Step 7: Click on the label to edit it + await labelItems.first().click(); + await page.waitForTimeout(500); + + // Verify editor is populated + await expect(labelNameInput).toHaveValue('E2ETestLabel'); + const editPropertyRows = page.getByTestId('property-row'); + await expect(editPropertyRows).toHaveCount(2); + + // Step 8: Delete the label + const deleteBtn = page.getByTestId('delete-label-btn'); + await expect(deleteBtn).toBeVisible(); + + // Handle confirmation dialog + page.on('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + await dialog.accept(); + }); + + await deleteBtn.click(); + await page.waitForTimeout(1000); + + // Verify label is removed from list + const remainingLabels = await page.getByTestId('label-item').count(); + // Should be 0 or not include our test label + const listContent = await page.getByTestId('label-list').textContent(); + expect(listContent).not.toContain('E2ETestLabel'); + + // No console errors + const errors = consoleMessages.filter((m) => m.type === 'error'); + expect(errors.length).toBe(0); +}); + +test('can add and remove multiple properties', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Create new label + await page.getByTestId('new-label-btn').click(); + await page.getByTestId('label-name').fill('MultiPropLabel'); + + // Add 3 properties + for (let i = 0; i < 3; i++) { + await page.getByTestId('add-property-btn').click(); + const rows = page.getByTestId('property-row'); + const currentRow = rows.nth(i); + await currentRow.getByTestId('property-name').fill(`prop${i + 1}`); + } + + // Verify 3 properties exist + await expect(page.getByTestId('property-row')).toHaveCount(3); + + // Remove the second property + const removeButtons = page.getByTestId('remove-property-btn'); + await removeButtons.nth(1).click(); + + // Verify only 2 properties remain + await expect(page.getByTestId('property-row')).toHaveCount(2); + + // Save label + await page.getByTestId('save-label-btn').click(); + await page.waitForTimeout(1000); + + // Verify saved + const labelItems = page.getByTestId('label-item'); + const labelText = await labelItems.first().textContent(); + expect(labelText).toContain('MultiPropLabel'); + expect(labelText).toContain('2 properties'); + + // Cleanup: delete the label + await labelItems.first().click(); + page.on('dialog', async (dialog) => await dialog.accept()); + await page.getByTestId('delete-label-btn').click(); + await page.waitForTimeout(500); +}); + +test('can create label with relationships', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // First create a target label + await page.getByTestId('new-label-btn').click(); + await page.getByTestId('label-name').fill('TargetLabel'); + await page.getByTestId('save-label-btn').click(); + await page.waitForTimeout(1000); + + // Now create a label with relationship + await page.getByTestId('new-label-btn').click(); + await page.getByTestId('label-name').fill('SourceLabel'); + + // Add relationship + await page.getByTestId('add-relationship-btn').click(); + const relationshipRow = page.getByTestId('relationship-row').first(); + await relationshipRow.getByTestId('relationship-type').fill('LINKS_TO'); + await relationshipRow.getByTestId('relationship-target').selectOption('TargetLabel'); + + // Save + await page.getByTestId('save-label-btn').click(); + await page.waitForTimeout(1000); + + // Verify + const labelItems = page.getByTestId('label-item'); + const sourceLabel = labelItems.filter({ hasText: 'SourceLabel' }); + const labelText = await sourceLabel.textContent(); + expect(labelText).toContain('1 relationship'); + + // Cleanup + page.on('dialog', async (dialog) => await dialog.accept()); + for (const labelName of ['SourceLabel', 'TargetLabel']) { + const item = labelItems.filter({ hasText: labelName }); + await item.click(); + await page.waitForTimeout(300); + await page.getByTestId('delete-label-btn').click(); + await page.waitForTimeout(500); + } +}); + +test('validation: cannot save label without name', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Create new label but don't enter name + await page.getByTestId('new-label-btn').click(); + + // Try to save without name + await page.getByTestId('save-label-btn').click(); + + // Should see error message (implementation shows error inline) + // The label name input should still be visible and empty + const labelNameInput = page.getByTestId('label-name'); + await expect(labelNameInput).toBeVisible(); + const value = await labelNameInput.inputValue(); + expect(value).toBe(''); +}); diff --git a/scidk/core/migrations.py b/scidk/core/migrations.py index 2a26073..983cbed 100644 --- a/scidk/core/migrations.py +++ b/scidk/core/migrations.py @@ -248,6 +248,23 @@ def migrate(conn: Optional[sqlite3.Connection] = None) -> int: _set_version(conn, 4) version = 4 + # v5: label_definitions for graph schema management + if version < 5: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS label_definitions ( + name TEXT PRIMARY KEY, + properties TEXT, + relationships TEXT, + created_at REAL, + updated_at REAL + ); + """ + ) + conn.commit() + _set_version(conn, 5) + version = 5 + return version finally: if own: diff --git a/scidk/services/label_service.py b/scidk/services/label_service.py new file mode 100644 index 0000000..ca01eb7 --- /dev/null +++ b/scidk/services/label_service.py @@ -0,0 +1,350 @@ +""" +Label service for managing graph schema definitions. + +This service provides operations for: +- CRUD operations on label definitions (stored in SQLite) +- Push/pull schema synchronization with Neo4j +- Schema introspection and validation +""" +from __future__ import annotations +from typing import Dict, List, Any, Optional +import json +import time +import sqlite3 + + +class LabelService: + """Service for managing label definitions and Neo4j schema sync.""" + + def __init__(self, app): + self.app = app + from ..core import path_index_sqlite as pix + self.conn = pix.connect() + + def list_labels(self) -> List[Dict[str, Any]]: + """ + Get all label definitions from SQLite. + + Returns: + List of label definition dicts with keys: name, properties, relationships, created_at, updated_at + """ + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT name, properties, relationships, created_at, updated_at + FROM label_definitions + ORDER BY name + """ + ) + rows = cursor.fetchall() + + labels = [] + for row in rows: + name, props_json, rels_json, created_at, updated_at = row + labels.append({ + 'name': name, + 'properties': json.loads(props_json) if props_json else [], + 'relationships': json.loads(rels_json) if rels_json else [], + 'created_at': created_at, + 'updated_at': updated_at + }) + return labels + + def get_label(self, name: str) -> Optional[Dict[str, Any]]: + """ + Get a specific label definition by name. + + Args: + name: Label name + + Returns: + Label definition dict or None if not found + """ + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT name, properties, relationships, created_at, updated_at + FROM label_definitions + WHERE name = ? + """, + (name,) + ) + row = cursor.fetchone() + + if not row: + return None + + name, props_json, rels_json, created_at, updated_at = row + return { + 'name': name, + 'properties': json.loads(props_json) if props_json else [], + 'relationships': json.loads(rels_json) if rels_json else [], + 'created_at': created_at, + 'updated_at': updated_at + } + + def save_label(self, definition: Dict[str, Any]) -> Dict[str, Any]: + """ + Create or update a label definition. + + Args: + definition: Dict with keys: name, properties (list), relationships (list) + + Returns: + Updated label definition + """ + name = definition.get('name', '').strip() + if not name: + raise ValueError("Label name is required") + + properties = definition.get('properties', []) + relationships = definition.get('relationships', []) + + # Validate property structure + for prop in properties: + if not isinstance(prop, dict) or 'name' not in prop or 'type' not in prop: + raise ValueError(f"Invalid property structure: {prop}") + + # Validate relationship structure + for rel in relationships: + if not isinstance(rel, dict) or 'type' not in rel or 'target_label' not in rel: + raise ValueError(f"Invalid relationship structure: {rel}") + + props_json = json.dumps(properties) + rels_json = json.dumps(relationships) + now = time.time() + + # Check if label exists + existing = self.get_label(name) + + cursor = self.conn.cursor() + if existing: + # Update + cursor.execute( + """ + UPDATE label_definitions + SET properties = ?, relationships = ?, updated_at = ? + WHERE name = ? + """, + (props_json, rels_json, now, name) + ) + created_at = existing['created_at'] + else: + # Insert + cursor.execute( + """ + INSERT INTO label_definitions (name, properties, relationships, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + (name, props_json, rels_json, now, now) + ) + created_at = now + + self.conn.commit() + + return { + 'name': name, + 'properties': properties, + 'relationships': relationships, + 'created_at': created_at, + 'updated_at': now + } + + def delete_label(self, name: str) -> bool: + """ + Delete a label definition. + + Args: + name: Label name + + Returns: + True if deleted, False if not found + """ + cursor = self.conn.cursor() + cursor.execute("DELETE FROM label_definitions WHERE name = ?", (name,)) + self.conn.commit() + return cursor.rowcount > 0 + + def push_to_neo4j(self, name: str) -> Dict[str, Any]: + """ + Push label definition to Neo4j (create constraints/indexes). + + Args: + name: Label name + + Returns: + Dict with status and details + """ + label_def = self.get_label(name) + if not label_def: + raise ValueError(f"Label '{name}' not found") + + try: + from .neo4j_client import get_neo4j_client + neo4j_client = get_neo4j_client() + + if not neo4j_client: + raise Exception("Neo4j client not configured") + + # Create constraints for required properties + constraints_created = [] + indexes_created = [] + + for prop in label_def.get('properties', []): + prop_name = prop.get('name') + required = prop.get('required', False) + + if required and prop_name: + # Create unique constraint + try: + constraint_name = f"constraint_{name}_{prop_name}" + query = f""" + CREATE CONSTRAINT {constraint_name} IF NOT EXISTS + FOR (n:{name}) + REQUIRE n.{prop_name} IS UNIQUE + """ + neo4j_client.execute_write(query) + constraints_created.append(prop_name) + except Exception as e: + # Constraint might already exist, continue + pass + + return { + 'status': 'success', + 'label': name, + 'constraints_created': constraints_created, + 'indexes_created': indexes_created + } + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } + + def pull_from_neo4j(self) -> Dict[str, Any]: + """ + Pull label schema from Neo4j and import as label definitions. + + Returns: + Dict with status and imported labels + """ + try: + from .neo4j_client import get_neo4j_client + neo4j_client = get_neo4j_client() + + if not neo4j_client: + raise Exception("Neo4j client not configured") + + # Query for node labels and their properties + query = """ + CALL db.schema.nodeTypeProperties() + YIELD nodeType, propertyName, propertyTypes + RETURN nodeType, propertyName, propertyTypes + """ + + results = neo4j_client.execute_read(query) + + # Group by label + labels_map = {} + for record in results: + node_type = record.get('nodeType') + if not node_type or not node_type.startswith(':'): + continue + + label_name = node_type[1:] # Remove leading ':' + prop_name = record.get('propertyName') + prop_types = record.get('propertyTypes', []) + + if label_name not in labels_map: + labels_map[label_name] = [] + + # Map Neo4j types to our property types + prop_type = 'string' + if prop_types: + first_type = prop_types[0].lower() + if 'int' in first_type or 'long' in first_type: + prop_type = 'number' + elif 'bool' in first_type: + prop_type = 'boolean' + elif 'date' in first_type: + prop_type = 'date' + elif 'datetime' in first_type or 'localdatetime' in first_type: + prop_type = 'datetime' + + labels_map[label_name].append({ + 'name': prop_name, + 'type': prop_type, + 'required': False # Can't determine from schema introspection + }) + + # Save imported labels + imported = [] + for label_name, properties in labels_map.items(): + try: + self.save_label({ + 'name': label_name, + 'properties': properties, + 'relationships': [] + }) + imported.append(label_name) + except Exception as e: + # Continue with other labels + pass + + return { + 'status': 'success', + 'imported_labels': imported, + 'count': len(imported) + } + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } + + def get_neo4j_schema(self) -> Dict[str, Any]: + """ + Get current Neo4j schema information. + + Returns: + Dict with schema details + """ + try: + from .neo4j_client import get_neo4j_client + neo4j_client = get_neo4j_client() + + if not neo4j_client: + return { + 'status': 'error', + 'error': 'Neo4j client not configured' + } + + # Get labels + labels_query = "CALL db.labels() YIELD label RETURN label" + labels_results = neo4j_client.execute_read(labels_query) + labels = [r.get('label') for r in labels_results] + + # Get relationship types + rels_query = "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType" + rels_results = neo4j_client.execute_read(rels_query) + rel_types = [r.get('relationshipType') for r in rels_results] + + # Get constraints + constraints_query = "SHOW CONSTRAINTS YIELD name, type RETURN name, type" + try: + constraints_results = neo4j_client.execute_read(constraints_query) + constraints = [{'name': r.get('name'), 'type': r.get('type')} for r in constraints_results] + except: + constraints = [] + + return { + 'status': 'success', + 'labels': labels, + 'relationship_types': rel_types, + 'constraints': constraints + } + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } diff --git a/scidk/ui/templates/base.html b/scidk/ui/templates/base.html index dd569bd..f012c6f 100644 --- a/scidk/ui/templates/base.html +++ b/scidk/ui/templates/base.html @@ -35,6 +35,7 @@
Define and manage graph schema labels with properties and relationships.
+ +