diff --git a/.circleci/config.yml b/.circleci/config.yml index 8bb0a9fb1..87549ac99 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -142,6 +142,66 @@ jobs: - run: name: Stop React App command: pkill -f npm + jest-tests: + docker: + - image: cimg/node:20.13 + resource_class: medium + steps: + - checkout + - restore_cache: + key: app-{{ checksum "app/package-lock.json" }} + - run: + name: Install Dependencies + command: cd app && npm install --legacy-peer-deps + - save_cache: + key: app-{{ checksum "app/package-lock.json" }} + paths: + - ./app/node_modules + - run: + name: Run Jest Tests + command: cd app && CI=true npm test -- --watchAll=false --coverage --coverageReporters=text --coverageReporters=lcov + - store_artifacts: + path: app/coverage + destination: coverage-report + cypress-e2e-tests: + docker: + - image: cimg/node:20.18-browsers + resource_class: large + steps: + - checkout + - restore_cache: + key: app-browsers-{{ checksum "app/package-lock.json" }} + - run: + name: Install Dependencies + command: cd app && npm install --legacy-peer-deps + - save_cache: + key: app-browsers-{{ checksum "app/package-lock.json" }} + paths: + - ./app/node_modules + - run: + name: Start React App in Background + command: cd app && DISABLE_ESLINT_PLUGIN=true npm start + background: true + - run: + name: Wait for React App + command: | + for i in {1..60}; do + if curl -s http://localhost:3000 > /dev/null; then + echo "React app is ready!" + exit 0 + fi + echo "Waiting for React app... ($i/60)" + sleep 2 + done + echo "React app failed to start" + exit 1 + - run: + name: Run Cypress Tests + command: cd app && CI=true npx cypress run + - store_artifacts: + path: app/cypress/videos + - store_artifacts: + path: app/cypress/screenshots workflows: wrolpi-api-tests: @@ -153,3 +213,5 @@ workflows: wrolpi-app-test: jobs: - react-app-start + - jest-tests + - cypress-e2e-tests diff --git a/.gitignore b/.gitignore index 1e7113026..172ad0194 100644 --- a/.gitignore +++ b/.gitignore @@ -125,7 +125,7 @@ mapnik.xml docker-compose.override.yml # test directory is used as media directory, we don't want to commit what a user downloads. -test +/test pg_data # Directories used to build images @@ -135,3 +135,5 @@ pg_data /pi-gen/*xz .DS_Store +app/cypress/screenshots +app/cypress/videos diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..1071ab6e2 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "postgres": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://postgres:wrolpi@localhost:5432/wrolpi" + ] + } + } +} diff --git a/alembic/versions/66407d145b76_.py b/alembic/versions/66407d145b76_.py new file mode 100644 index 000000000..bd2ea5b30 --- /dev/null +++ b/alembic/versions/66407d145b76_.py @@ -0,0 +1,87 @@ +"""Create collection table for domain collections + +Revision ID: 66407d145b76 +Revises: 4f03b9548f6e +Create Date: 2025-10-26 10:57:16.462524 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import Session + + +# revision identifiers, used by Alembic. +revision = '66407d145b76' +down_revision = '4f03b9548f6e' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + # Step 1: Create new collection table (keeping channel table separate for now) + op.create_table( + 'collection', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('kind', sa.String(), nullable=False), + sa.Column('directory', sa.Text(), nullable=True), + sa.Column('tag_id', sa.Integer(), nullable=True), + sa.Column('created_date', sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column('item_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('total_size', sa.Integer(), nullable=False, server_default='0'), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name='collection_tag_id_fkey'), + sa.UniqueConstraint('directory', name='uq_collection_directory') + ) + + # Create indexes for collection + op.create_index('idx_collection_kind', 'collection', ['kind'], unique=False) + op.create_index('idx_collection_item_count', 'collection', ['item_count'], unique=False) + op.create_index('idx_collection_total_size', 'collection', ['total_size'], unique=False) + + # Step 2: Create collection_item junction table + op.create_table( + 'collection_item', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.Column('file_group_id', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False, server_default='0'), + sa.Column('added_date', sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['file_group_id'], ['file_group.id'], ondelete='CASCADE'), + sa.UniqueConstraint('collection_id', 'file_group_id', name='uq_collection_file_group') + ) + + # Create indexes for collection_item + op.create_index('idx_collection_item_collection_id', 'collection_item', ['collection_id'], unique=False) + op.create_index('idx_collection_item_file_group_id', 'collection_item', ['file_group_id'], unique=False) + op.create_index('idx_collection_item_position', 'collection_item', ['position'], unique=False) + op.create_index('idx_collection_item_collection_position', 'collection_item', ['collection_id', 'position'], unique=False) + + # Ensure table owner in non-docker environments + if not DOCKERIZED: + session.execute(sa.text('ALTER TABLE public.collection OWNER TO wrolpi')) + session.execute(sa.text('ALTER TABLE public.collection_item OWNER TO wrolpi')) + + +def downgrade(): + # Drop collection_item table and its indexes + op.drop_index('idx_collection_item_collection_position', table_name='collection_item') + op.drop_index('idx_collection_item_position', table_name='collection_item') + op.drop_index('idx_collection_item_file_group_id', table_name='collection_item') + op.drop_index('idx_collection_item_collection_id', table_name='collection_item') + op.drop_table('collection_item') + + # Drop collection table and its indexes + op.drop_index('idx_collection_total_size', table_name='collection') + op.drop_index('idx_collection_item_count', table_name='collection') + op.drop_index('idx_collection_kind', table_name='collection') + op.drop_table('collection') diff --git a/alembic/versions/add_unique_collection_name_kind.py b/alembic/versions/add_unique_collection_name_kind.py new file mode 100644 index 000000000..10e966f57 --- /dev/null +++ b/alembic/versions/add_unique_collection_name_kind.py @@ -0,0 +1,95 @@ +"""Add unique constraint on collection (name, kind) + +This migration: +1. Removes duplicate collections (keeping the one with most items/archives) +2. Adds a unique constraint on (name, kind) to prevent future duplicates + +Revision ID: add_unique_collection_name_kind +Revises: migrate_download_to_collection +Create Date: 2025-11-27 +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text +from sqlalchemy.orm import Session + +# revision identifiers, used by Alembic. +revision = 'add_unique_collection_name_kind' +down_revision = 'migrate_download_to_collection' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + print("\n" + "=" * 60) + print("Add Unique Constraint on Collection (name, kind)") + print("=" * 60 + "\n") + + # Step 1: Find and remove duplicate collections + print("Step 1: Finding duplicate collections...") + + # Find duplicates - keep the one with the lowest id (first created) + duplicates = session.execute(text(""" + SELECT name, kind, array_agg(id ORDER BY id) as ids + FROM collection + GROUP BY name, kind + HAVING COUNT(*) > 1 + """)).fetchall() + + if duplicates: + print(f"Found {len(duplicates)} sets of duplicate collections") + for name, kind, ids in duplicates: + keep_id = ids[0] # Keep the first one (lowest id) + remove_ids = ids[1:] # Remove the rest + print(f" - '{name}' ({kind}): keeping id={keep_id}, removing ids={remove_ids}") + + # Move any archives from duplicate collections to the one we're keeping + for remove_id in remove_ids: + session.execute(text(""" + UPDATE archive SET collection_id = :keep_id + WHERE collection_id = :remove_id + """), {'keep_id': keep_id, 'remove_id': remove_id}) + + # Move any downloads from duplicate collections + session.execute(text(""" + UPDATE download SET collection_id = :keep_id + WHERE collection_id = :remove_id + """), {'keep_id': keep_id, 'remove_id': remove_id}) + + # Move any collection items + session.execute(text(""" + UPDATE collection_item SET collection_id = :keep_id + WHERE collection_id = :remove_id + """), {'keep_id': keep_id, 'remove_id': remove_id}) + + # Delete the duplicate collection + session.execute(text(""" + DELETE FROM collection WHERE id = :remove_id + """), {'remove_id': remove_id}) + + session.commit() + print("Duplicates removed\n") + else: + print("No duplicate collections found\n") + + # Step 2: Add unique constraint + print("Step 2: Adding unique constraint on (name, kind)...") + op.create_unique_constraint('uq_collection_name_kind', 'collection', ['name', 'kind']) + print("Done\n") + + print("=" * 60) + print("Migration Complete") + print("=" * 60 + "\n") + + if not DOCKERIZED: + session.execute(text('ALTER TABLE public.collection OWNER TO wrolpi')) + + +def downgrade(): + op.drop_constraint('uq_collection_name_kind', 'collection', type_='unique') diff --git a/alembic/versions/b43f70f369d0_remove_duplicate_channel_fields_.py b/alembic/versions/b43f70f369d0_remove_duplicate_channel_fields_.py new file mode 100644 index 000000000..cc0da2fd0 --- /dev/null +++ b/alembic/versions/b43f70f369d0_remove_duplicate_channel_fields_.py @@ -0,0 +1,73 @@ +"""Remove duplicate Channel fields delegated to Collection + +This migration removes fields from Channel that now delegate to Collection: +- name (now property: channel.name → collection.name) +- directory (now property: channel.directory → collection.directory) +- tag_id (now property: channel.tag_id → collection.tag_id) + +These fields were already synced to Collection in previous migration. + +Revision ID: b43f70f369d0 +Revises: ba98bd360b7a +Create Date: 2025-11-19 21:48:58.488850 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'b43f70f369d0' +down_revision = 'ba98bd360b7a' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +def upgrade(): + print("\n" + "="*60) + print("Removing Duplicate Fields from Channel") + print("="*60 + "\n") + + # Step 1: Drop foreign key constraint on tag_id + print("Step 1: Dropping tag_id foreign key constraint...") + op.drop_constraint('channel_tag_id_fkey', 'channel', type_='foreignkey') + print("✓ Dropped foreign key constraint\n") + + # Step 2: Drop columns (data already in Collection) + print("Step 2: Dropping duplicate columns...") + op.drop_column('channel', 'name') + print(" ✓ Dropped channel.name") + op.drop_column('channel', 'directory') + print(" ✓ Dropped channel.directory") + op.drop_column('channel', 'tag_id') + print(" ✓ Dropped channel.tag_id\n") + + print("="*60) + print("✓ Channel Cleanup Complete") + print(" Channels now delegate name/directory/tag to Collection") + print("="*60 + "\n") + + +def downgrade(): + # Re-add the columns + op.add_column('channel', sa.Column('name', sa.String(), nullable=True)) + op.add_column('channel', sa.Column('directory', sa.String(), nullable=True)) + op.add_column('channel', sa.Column('tag_id', sa.Integer(), nullable=True)) + + # Re-add foreign key + op.create_foreign_key('channel_tag_id_fkey', 'channel', 'tag', ['tag_id'], ['id'], ondelete='CASCADE') + + # Restore data from Collection + bind = op.get_bind() + bind.execute(text(""" + UPDATE channel + SET name = collection.name, + directory = collection.directory, + tag_id = collection.tag_id + FROM collection + WHERE channel.collection_id = collection.id + """)) \ No newline at end of file diff --git a/alembic/versions/ba98bd360b7a_add_collection_id_to_channel_model.py b/alembic/versions/ba98bd360b7a_add_collection_id_to_channel_model.py new file mode 100644 index 000000000..36117eac2 --- /dev/null +++ b/alembic/versions/ba98bd360b7a_add_collection_id_to_channel_model.py @@ -0,0 +1,130 @@ +"""Add collection_id to Channel model + +This migration: +1. Adds collection_id column to channel table +2. Creates Collection records for existing Channels +3. Links Channels to their Collections +4. Adds foreign key constraint + +Revision ID: ba98bd360b7a +Revises: migrate_domains_to_collections +Create Date: 2025-11-19 21:10:56.472836 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text +from sqlalchemy.orm import Session + +# revision identifiers, used by Alembic. +revision = 'ba98bd360b7a' +down_revision = 'migrate_domains_to_collections' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + print("\n" + "="*60) + print("Channel → Collection Migration") + print("="*60 + "\n") + + # Step 1: Add collection_id column to channel table (nullable initially) + print("Step 1: Adding collection_id column to channel table...") + op.add_column('channel', sa.Column('collection_id', sa.Integer(), nullable=True)) + print("✓ Added collection_id column\n") + + # Step 2: Create Collection records for each Channel + print("Step 2: Creating Collection records for existing Channels...") + + # Fetch all channels + result = session.execute(text(""" + SELECT id, name, directory, tag_id + FROM channel + ORDER BY name + """)) + + channels = [] + for row in result: + channels.append({ + 'id': row[0], + 'name': row[1], + 'directory': row[2], + 'tag_id': row[3], + }) + + print(f"Found {len(channels)} Channel records to migrate") + + if channels: + from wrolpi.collections import Collection + + for channel in channels: + channel_id = channel['id'] + channel_name = channel['name'] + + print(f" Processing Channel {channel_id}: {channel_name}") + + # Check if Collection already exists + existing = session.query(Collection).filter_by( + name=channel_name, + kind='channel' + ).first() + + if existing: + print(f" Collection already exists (id={existing.id})") + collection_id = existing.id + else: + # Create Collection + collection = Collection( + name=channel_name, + kind='channel', + directory=channel['directory'], # Channels always have directory + tag_id=channel['tag_id'], + ) + session.add(collection) + session.flush([collection]) + collection_id = collection.id + print(f" Created Collection id={collection_id}") + + # Link Channel to Collection + session.execute( + text("UPDATE channel SET collection_id = :collection_id WHERE id = :channel_id"), + {'collection_id': collection_id, 'channel_id': channel_id} + ) + + session.commit() + print(f"✓ Created Collections and linked Channels\n") + else: + print("No channels to migrate\n") + + # Step 3: Add foreign key constraint + print("Step 3: Adding foreign key constraint...") + op.create_foreign_key( + 'fk_channel_collection_id', + 'channel', + 'collection', + ['collection_id'], + ['id'], + ondelete='CASCADE' + ) + print("✓ Added foreign key constraint\n") + + print("="*60) + print("✓ Channel → Collection Migration Complete") + print("="*60 + "\n") + + if not DOCKERIZED: + session.execute(text('ALTER TABLE public.channel OWNER TO wrolpi')) + + +def downgrade(): + # Remove foreign key constraint + op.drop_constraint('fk_channel_collection_id', 'channel', type_='foreignkey') + + # Drop collection_id column + op.drop_column('channel', 'collection_id') diff --git a/alembic/versions/migrate_domains_to_collections.py b/alembic/versions/migrate_domains_to_collections.py new file mode 100644 index 000000000..b7430a9bc --- /dev/null +++ b/alembic/versions/migrate_domains_to_collections.py @@ -0,0 +1,381 @@ +"""Migrate Domain model to Collection model + +This migration: +1. Adds collection_id column to archive table +2. Runs the data migration to populate collections +3. Makes collection_id NOT NULL +4. Drops the old domain_id column and domains table + +Revision ID: migrate_domains_to_collections +Revises: 66407d145b76 +Create Date: 2025-10-29 + +""" +import os +from typing import Dict, List +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text +from sqlalchemy.orm import Session + +# revision identifiers, used by Alembic. +revision = 'migrate_domains_to_collections' +down_revision = '66407d145b76' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +class DomainMigrationStats: + """Track migration statistics.""" + def __init__(self): + self.domains_found = 0 + self.domains_migrated = 0 + self.collections_created = 0 + self.collection_items_created = 0 + self.archives_linked = 0 + self.errors: List[str] = [] + self.warnings: List[str] = [] + + def add_error(self, msg: str): + self.errors.append(msg) + print(f" ✗ ERROR: {msg}") + + def add_warning(self, msg: str): + self.warnings.append(msg) + print(f" ⚠ WARNING: {msg}") + + def print_summary(self, dry_run: bool = False): + """Print migration summary.""" + mode = "DRY-RUN" if dry_run else "ACTUAL" + print(f"\n{'='*60}") + print(f"Domain → Collection Migration Summary ({mode})") + print(f"{'='*60}") + print(f"Domains found: {self.domains_found}") + print(f"Domains migrated: {self.domains_migrated}") + print(f"Collections created: {self.collections_created}") + print(f"CollectionItems created: {self.collection_items_created}") + print(f"Archives linked: {self.archives_linked}") + + if self.warnings: + print(f"\nWarnings: {len(self.warnings)}") + for warning in self.warnings: + print(f" ⚠ {warning}") + + if self.errors: + print(f"\nErrors: {len(self.errors)}") + for error in self.errors: + print(f" ✗ {error}") + + if not self.errors: + print(f"\n✓ Migration completed successfully") + else: + print(f"\n✗ Migration completed with {len(self.errors)} errors") + + print(f"{'='*60}\n") + + +def get_domain_data(session: Session) -> List[Dict]: + """Fetch all Domain records from database.""" + result = session.execute(text("SELECT id, domain, directory FROM domains ORDER BY domain")) + domains = [] + for row in result: + domains.append({ + 'id': row[0], + 'domain': row[1], + 'directory': row[2], + }) + return domains + + +def get_archives_for_domain(session: Session, domain_id: int) -> List[Dict]: + """Get all Archive IDs for a given domain.""" + result = session.execute( + text("SELECT id, file_group_id FROM archive WHERE domain_id = :domain_id"), + {'domain_id': domain_id} + ) + archives = [] + for row in result: + if row[1]: # file_group_id must not be null + archives.append({ + 'archive_id': row[0], + 'file_group_id': row[1], + }) + return archives + + +def validate_domain_name(domain: str) -> bool: + """Validate domain name format - must contain at least one dot.""" + if not domain or not isinstance(domain, str): + return False + if domain.startswith('.') or domain.endswith('.'): + return False + return '.' in domain + + +def perform_domain_migration(session: Session, verbose: bool = True) -> DomainMigrationStats: + """ + Migrate all Domain records to Collection records. + + This function is integrated into the alembic migration. + """ + from wrolpi.collections import Collection, CollectionItem + + stats = DomainMigrationStats() + + # Step 1: Fetch all domains + print("Step 1: Fetching Domain records...") + domains = get_domain_data(session) + stats.domains_found = len(domains) + print(f"Found {len(domains)} Domain records") + + if not domains: + print("No domains to migrate") + return stats + + # Step 2: Create Collections from Domains + print("\nStep 2: Creating Collection records...") + domain_to_collection: Dict[int, int] = {} # domain_id -> collection_id + + for domain in domains: + domain_id = domain['id'] + domain_name = domain['domain'] + + if verbose: + print(f" Processing Domain {domain_id}: {domain_name}") + + # Validate domain name + if not validate_domain_name(domain_name): + stats.add_error(f"Invalid domain name: {repr(domain_name)} (skipping)") + continue + + # Check if Collection already exists + existing = session.query(Collection).filter_by( + name=domain_name, + kind='domain' + ).first() + + if existing: + stats.add_warning(f"Collection already exists for domain {repr(domain_name)} (id={existing.id})") + domain_to_collection[domain_id] = existing.id + stats.domains_migrated += 1 + continue + + # Create Collection + collection = Collection( + name=domain_name, + kind='domain', + directory=None, # Domains are unrestricted + ) + session.add(collection) + session.flush([collection]) + + domain_to_collection[domain_id] = collection.id + stats.collections_created += 1 + stats.domains_migrated += 1 + + if verbose: + print(f" Created Collection id={collection.id}") + + print(f"Created {stats.collections_created} Collection records") + + # Step 3: Update archive.collection_id for all Archives + print("\nStep 3: Updating archive.collection_id...") + + for domain in domains: + domain_id = domain['id'] + domain_name = domain['domain'] + + if domain_id not in domain_to_collection: + # Domain was skipped due to error + continue + + collection_id = domain_to_collection[domain_id] + + # Update all archives for this domain + result = session.execute( + text("UPDATE archive SET collection_id = :collection_id WHERE domain_id = :domain_id"), + {'collection_id': collection_id, 'domain_id': domain_id} + ) + updated_count = result.rowcount + + if verbose and updated_count > 0: + print(f" Domain {repr(domain_name)}: Updated {updated_count} archives") + + stats.archives_linked += updated_count + + print(f"Updated {stats.archives_linked} Archives with collection_id") + + # Step 4: Create CollectionItem records + print("\nStep 4: Creating CollectionItem records...") + + for domain in domains: + domain_id = domain['id'] + domain_name = domain['domain'] + + if domain_id not in domain_to_collection: + continue + + collection_id = domain_to_collection[domain_id] + + # Get all archives for this domain + archives = get_archives_for_domain(session, domain_id) + + if not archives: + if verbose: + print(f" Domain {repr(domain_name)}: No archives found") + continue + + if verbose: + print(f" Domain {repr(domain_name)}: {len(archives)} archives") + + for idx, archive in enumerate(archives, start=1): + file_group_id = archive['file_group_id'] + archive_id = archive['archive_id'] + + # Check if CollectionItem already exists + existing_item = session.query(CollectionItem).filter_by( + collection_id=collection_id, + file_group_id=file_group_id + ).first() + + if existing_item: + if verbose: + print(f" Archive {archive_id} already linked to collection") + continue + + # Create CollectionItem + item = CollectionItem( + collection_id=collection_id, + file_group_id=file_group_id, + position=idx + ) + session.add(item) + stats.collection_items_created += 1 + + session.flush() + print(f"Created {stats.collection_items_created} CollectionItem records") + + # Step 5: Skip config export during migration + # The config export requires a separate database session, but we're inside + # an alembic transaction. The application will export on startup instead. + print("\nStep 5: Skipping domains.yaml export during migration...") + print(" (Config will be exported on next application startup)") + + return stats + + +def upgrade(): + """ + Migrate Domains to Collections. + + This is a one-way migration - there is no downgrade path because: + - Domain data is transformed into Collection data + - Once migrated, the system uses Collections exclusively + - Downgrade would require reimplementing Domain model + """ + bind = op.get_bind() + session = Session(bind=bind) + + print("\n" + "="*60) + print("DOMAIN → COLLECTION MIGRATION") + print("="*60 + "\n") + + # Step 1: Add collection_id column to archive table (nullable initially) + print("Step 1: Adding collection_id column to archive table...") + op.add_column('archive', + sa.Column('collection_id', sa.Integer(), nullable=True)) + + # Add foreign key to collection table + op.create_foreign_key( + 'archive_collection_id_fkey', + 'archive', 'collection', + ['collection_id'], ['id'], + ondelete='CASCADE' + ) + print("✓ Added collection_id column\n") + + # Step 2: Run data migration script + print("Step 2: Running data migration script...") + print("This will:") + print(" - Create Collection records from Domain records") + print(" - Create CollectionItem records for Archives\n") + + try: + # Commit the schema changes before running migration + session.commit() + + # Run the data migration + stats = perform_domain_migration(session, verbose=True) + + # Print summary + stats.print_summary(dry_run=False) + + if stats.errors: + raise Exception(f"Migration completed with {len(stats.errors)} errors. See logs above.") + + except Exception as e: + print(f"\n✗ Migration failed: {e}") + raise + + # Step 3: Make collection_id NOT NULL + print("\nStep 3: Making collection_id NOT NULL...") + + # Check if any archives don't have a collection_id + result = session.execute(sa.text( + "SELECT COUNT(*) FROM archive WHERE collection_id IS NULL" + )) + null_count = result.scalar() + + if null_count > 0: + raise Exception( + f"Cannot make collection_id NOT NULL: {null_count} archives have NULL collection_id. " + f"The migration script should have assigned all archives to collections." + ) + + op.alter_column('archive', 'collection_id', nullable=False) + print("✓ collection_id is now NOT NULL\n") + + # Step 4: Drop old domain_id column and foreign key + print("Step 4: Dropping domain_id column and foreign key...") + op.drop_constraint('archive_domain_id_fkey', 'archive', type_='foreignkey') + op.drop_column('archive', 'domain_id') + print("✓ Dropped domain_id column\n") + + # Step 5: Drop domains table + print("Step 5: Dropping domains table...") + op.drop_table('domains') + print("✓ Dropped domains table\n") + + # Ensure table ownership in non-docker environments + if not DOCKERIZED: + print("Setting table ownership...") + session.execute(sa.text('ALTER TABLE public.archive OWNER TO wrolpi')) + session.commit() + + print("="*60) + print("MIGRATION COMPLETED SUCCESSFULLY") + print("="*60 + "\n") + + +def downgrade(): + """ + No downgrade path for this migration. + + This is intentional because: + 1. Domain → Collection is a one-way data transformation + 2. The Domain model is being removed from the codebase + 3. Rolling back would require: + - Recreating Domain model code + - Reversing the data transformation (Collection → Domain) + - Handling edge cases where Collections have features Domains didn't + + If you need to rollback, you should: + 1. Restore from a database backup taken before migration + 2. Re-index all files to rebuild Domain records + """ + raise NotImplementedError( + "This migration has no downgrade path. " + "To rollback, reset database and re-index files." + ) diff --git a/alembic/versions/migrate_download_channel_to_collection.py b/alembic/versions/migrate_download_channel_to_collection.py new file mode 100644 index 000000000..5e3282328 --- /dev/null +++ b/alembic/versions/migrate_download_channel_to_collection.py @@ -0,0 +1,110 @@ +"""Migrate Download.channel_id to Download.collection_id + +This migration: +1. Adds collection_id column to download table +2. Migrates existing channel_id references to collection_id via Channel.collection_id +3. Drops channel_id column and foreign key + +Revision ID: migrate_download_to_collection +Revises: b43f70f369d0 +Create Date: 2025-11-27 +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text +from sqlalchemy.orm import Session + +# revision identifiers, used by Alembic. +revision = 'migrate_download_to_collection' +down_revision = 'b43f70f369d0' +branch_labels = None +depends_on = None + +DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + print("\n" + "=" * 60) + print("Download channel_id -> collection_id Migration") + print("=" * 60 + "\n") + + # Step 1: Add collection_id column (nullable initially) + print("Step 1: Adding collection_id column to download table...") + op.add_column('download', sa.Column('collection_id', sa.Integer(), nullable=True)) + print("Done\n") + + # Step 2: Migrate data - lookup collection_id through channel + print("Step 2: Migrating channel_id to collection_id...") + result = session.execute(text(""" + UPDATE download + SET collection_id = channel.collection_id + FROM channel + WHERE download.channel_id = channel.id + AND download.channel_id IS NOT NULL + """)) + print(f"Updated {result.rowcount} download records\n") + + # Step 3: Add foreign key constraint + print("Step 3: Adding foreign key constraint...") + op.create_foreign_key( + 'fk_download_collection_id', + 'download', + 'collection', + ['collection_id'], + ['id'], + ondelete='SET NULL' + ) + print("Done\n") + + # Step 4: Create index for performance + print("Step 4: Creating index on collection_id...") + op.create_index('idx_download_collection_id', 'download', ['collection_id']) + print("Done\n") + + # Step 5: Drop old channel_id foreign key and column + print("Step 5: Dropping channel_id column...") + op.drop_constraint('download_channel_id_fkey', 'download', type_='foreignkey') + op.drop_column('download', 'channel_id') + print("Done\n") + + print("=" * 60) + print("Migration Complete") + print("=" * 60 + "\n") + + if not DOCKERIZED: + session.execute(text('ALTER TABLE public.download OWNER TO wrolpi')) + + +def downgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + # Add back channel_id column + op.add_column('download', sa.Column('channel_id', sa.Integer(), nullable=True)) + + # Migrate collection_id back to channel_id + session.execute(text(""" + UPDATE download + SET channel_id = channel.id + FROM channel + WHERE download.collection_id = channel.collection_id + AND download.collection_id IS NOT NULL + """)) + + # Add foreign key constraint + op.create_foreign_key( + 'download_channel_id_fkey', + 'download', + 'channel', + ['channel_id'], + ['id'] + ) + + # Drop collection_id + op.drop_index('idx_download_collection_id', 'download') + op.drop_constraint('fk_download_collection_id', 'download', type_='foreignkey') + op.drop_column('download', 'collection_id') diff --git a/app/cypress.config.js b/app/cypress.config.js index c092825ec..dc8bf255d 100644 --- a/app/cypress.config.js +++ b/app/cypress.config.js @@ -1,6 +1,14 @@ const { defineConfig } = require("cypress"); module.exports = defineConfig({ + e2e: { + // Use HTTP for CI, HTTPS for local development + baseUrl: process.env.CI ? 'http://localhost:3000' : 'https://localhost:8443', + specPattern: 'cypress/e2e/**/*.cy.js', + supportFile: 'cypress/support/e2e.js', + video: process.env.CI ? true : false, + screenshotOnRunFailure: true, + }, component: { devServer: { framework: "create-react-app", diff --git a/app/cypress/e2e/domains/domain-edit.cy.js b/app/cypress/e2e/domains/domain-edit.cy.js new file mode 100644 index 000000000..c7034866a --- /dev/null +++ b/app/cypress/e2e/domains/domain-edit.cy.js @@ -0,0 +1,270 @@ +describe('Domain Editing Workflow', () => { + beforeEach(() => { + // Suppress uncaught exceptions from the application (API errors are thrown as unhandled rejections) + cy.on('uncaught:exception', (err) => { + // Return false to prevent Cypress from failing the test + // These are expected API errors in error handling tests + return false; + }); + + // Mock directory search endpoint - DirectorySearch component makes this call automatically + cy.intercept('POST', '/api/files/search_directories', { + statusCode: 200, + body: {is_dir: true, directories: [], channel_directories: [], domain_directories: []} + }).as('searchDirectories'); + + // Mock tags endpoint - needed for TagsSelector component + cy.intercept('GET', '/api/tags', { + statusCode: 200, + body: { + tags: [ + {id: 1, name: 'News', color: '#ff0000'}, + {id: 2, name: 'Tech', color: '#00ff00'} + ] + } + }).as('getTags'); + + // Mock the collections list for domains + cy.intercept('GET', '/api/collections?kind=domain', { + statusCode: 200, + body: { + collections: [ + { + id: 1, + domain: 'example.com', + archive_count: 42, + size: 1024000, + tag_name: null, + directory: null, + can_be_tagged: false, + description: 'Example domain' + } + ], + totals: {collections: 1}, + metadata: { + kind: 'domain', + columns: [ + {key: 'domain', label: 'Domain', sortable: true}, + {key: 'archive_count', label: 'Archives', sortable: true, align: 'right'}, + {key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'}, + {key: 'tag_name', label: 'Tag', sortable: true}, + {key: 'actions', label: 'Manage', sortable: false, type: 'actions'}, + ], + fields: [ + {key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'}, + {key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'}, + {key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'}, + ], + routes: { + list: '/archive/domains', + edit: '/archive/domain/:id/edit', + search: '/archive', + }, + } + } + }).as('getDomains'); + }); + + it('completes full edit workflow: add directory and tag', () => { + // Mock collection details endpoint BEFORE navigation + cy.intercept('GET', '/api/collections/1', { + statusCode: 200, + body: { + collection: { + id: 1, + domain: 'example.com', + directory: '', + tag_name: null, + description: '', + can_be_tagged: false, + archive_count: 42, + size: 1024000 + } + } + }).as('getDomain'); + + // Start at domains list + cy.visit('/archive/domains'); + cy.wait('@getDomains'); + + // Click edit on first domain + cy.get('table tbody tr').first().within(() => { + cy.contains('Edit').click(); + }); + + // Should navigate to edit page + cy.url().should('include', '/archive/domain/1/edit'); + cy.wait('@getDomain'); + + // Page should load with domain name + cy.contains('h1', 'example.com').should('exist'); + + // Set directory - DirectorySearch component uses input inside field + cy.contains('label', 'Directory').parent().find('input').clear().type('archive/example.com'); + + // Set description - Semantic UI TextArea + cy.contains('label', 'Description').parent().find('textarea').clear().type('Updated example domain'); + + // Mock tags endpoint + cy.intercept('GET', '/api/tags', { + statusCode: 200, + body: { + tags: [ + {id: 1, name: 'News', color: '#ff0000'}, + {id: 2, name: 'Tech', color: '#00ff00'} + ] + } + }).as('getTags'); + + // Tag selector should now be enabled (because directory is set) + // This depends on the actual implementation of CollectionEditForm + + // Mock update endpoint + cy.intercept('PUT', '/api/collections/1', { + statusCode: 200, + body: { + success: true, + collection: { + id: 1, + name: 'example.com', + directory: 'archive/example.com', + tag_name: null, + description: 'Updated example domain', + can_be_tagged: true, + } + } + }).as('updateDomain'); + + // Save changes + cy.contains('button', 'Save').click(); + + cy.wait('@updateDomain'); + + // Should show success toast and stay on edit page (page refreshes data after save) + cy.contains('Domain Updated').should('be.visible'); + cy.url().should('include', '/archive/domain/1/edit'); + }); + + it('shows validation errors for invalid data', () => { + // Mock domain details + cy.intercept('GET', '/api/collections/1', { + statusCode: 200, + body: { + collection: { + id: 1, + domain: 'example.com', + directory: 'archive/example.com', + tag_name: null, + description: 'Example domain', + can_be_tagged: true, + archive_count: 42, + size: 1024000 + } + } + }).as('getDomain'); + + cy.visit('/archive/domain/1/edit'); + cy.wait('@getDomain'); + + // Try to save with invalid directory + cy.intercept('PUT', '/api/collections/1', { + statusCode: 400, + body: { + error: 'Invalid directory path', + cause: { + code: 'INVALID_DIRECTORY' + } + } + }).as('invalidUpdate'); + + cy.contains('label', 'Directory').parent().find('input').clear().type('/invalid/absolute/path'); + cy.contains('button', 'Save').click(); + + cy.wait('@invalidUpdate'); + + // Should show error message + cy.contains('Invalid directory path').should('be.visible'); + + // Should stay on edit page + cy.url().should('include', '/edit'); + }); + + it('allows navigating back without saving using Back button', () => { + cy.intercept('GET', '/api/collections/1', { + statusCode: 200, + body: { + collection: { + id: 1, + domain: 'example.com', + directory: '', + tag_name: null, + description: 'Example domain', + can_be_tagged: false, + archive_count: 42, + size: 1024000 + } + } + }).as('getDomain'); + + // Mock PUT endpoint but don't expect it to be called + cy.intercept('PUT', '/api/collections/1', { + statusCode: 200, + body: {success: true, domain: {}} + }).as('updateDomain'); + + cy.visit('/archive/domain/1/edit'); + cy.wait('@getDomain'); + + // Make some changes + cy.contains('label', 'Description').parent().find('textarea').clear().type('Changed description'); + + // Click Back button (the page uses BackButton, not Cancel) + cy.contains('button', 'Back').click(); + + // Should navigate back without saving + // Note: Since we don't have browser history in test, we just verify no save was made + cy.get('@updateDomain.all').should('have.length', 0); + }); + + it('shows form after domain data loads', () => { + cy.intercept('GET', '/api/collections/1', { + statusCode: 200, + body: { + collection: { + id: 1, + domain: 'example.com', + directory: null, + tag_name: null, + description: 'Example domain', + can_be_tagged: false + } + } + }).as('getDomain'); + + cy.visit('/archive/domain/1/edit'); + + cy.wait('@getDomain'); + + // Form should be visible with domain name in title + cy.contains('h1', 'example.com').should('exist'); + + // Form fields should be present + cy.contains('label', 'Directory').should('exist'); + cy.contains('label', 'Description').should('exist'); + }); + + it('handles 404 when domain not found', () => { + cy.intercept('GET', '/api/collections/999', { + statusCode: 404, + body: { + error: 'Domain collection with ID 999 not found' + } + }).as('domainNotFound'); + + cy.visit('/archive/domain/999/edit'); + cy.wait('@domainNotFound'); + + // Should show error message + cy.contains('not found').should('be.visible'); + }); +}); diff --git a/app/cypress/e2e/domains/domains-list.cy.js b/app/cypress/e2e/domains/domains-list.cy.js new file mode 100644 index 000000000..a6b3b25ac --- /dev/null +++ b/app/cypress/e2e/domains/domains-list.cy.js @@ -0,0 +1,154 @@ +describe('Domains List Page', () => { + beforeEach(() => { + // Mock the collections API response for domains + cy.intercept('GET', '/api/collections?kind=domain', { + statusCode: 200, + body: { + collections: [ + { + id: 1, + domain: 'example.com', + archive_count: 42, + size: 1024000, + tag_name: 'News', + directory: '/media/archives/example.com', + can_be_tagged: true, + description: 'Example domain' + }, + { + id: 2, + domain: 'test.org', + archive_count: 15, + size: 512000, + tag_name: null, + directory: null, + can_be_tagged: false, + description: null + } + ], + totals: {collections: 2}, + metadata: { + kind: 'domain', + columns: [ + {key: 'domain', label: 'Domain', sortable: true}, + {key: 'archive_count', label: 'Archives', sortable: true, align: 'right'}, + {key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'}, + {key: 'tag_name', label: 'Tag', sortable: true}, + {key: 'actions', label: 'Manage', sortable: false, type: 'actions'} + ], + fields: [ + {key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'}, + {key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'}, + {key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'} + ], + routes: { + list: '/archive/domains', + edit: '/archive/domain/:id/edit', + search: '/archive' + }, + messages: { + no_directory: 'Set a directory to enable tagging', + tag_will_move: 'Tagging will move files to a new directory' + } + } + } + }).as('getDomains'); + + // Visit the domains page + cy.visit('/archive/domains'); + cy.wait('@getDomains'); + }); + + it('displays domains page without errors', () => { + // DomainsPage doesn't have h1, check for the search input instead + cy.get('input[placeholder="Domain filter..."]').should('exist'); + }); + + it('renders CollectionTable component', () => { + // Check that the table exists + cy.get('table').should('exist'); + }); + + it('shows search input', () => { + cy.get('input[placeholder="Domain filter..."]').should('exist'); + }); + + it('shows all domains from API', () => { + cy.get('table tbody tr').should('have.length', 2); + }); + + it('displays domain names', () => { + cy.get('table tbody tr').first().should('contain', 'example.com'); + cy.get('table tbody tr').last().should('contain', 'test.org'); + }); + + it('displays Edit buttons in Manage column', () => { + cy.get('table tbody tr').first().within(() => { + cy.get('a').contains('Edit').should('exist'); + }); + }); + + it('Edit button has correct styling', () => { + cy.get('table tbody tr').first().within(() => { + cy.get('a').contains('Edit').should('have.class', 'ui'); + cy.get('a').contains('Edit').should('have.class', 'button'); + cy.get('a').contains('Edit').should('have.class', 'secondary'); + }); + }); + + it('navigates to domain edit page when edit clicked', () => { + // Mock the edit page's API calls to prevent "Failed to fetch" errors + cy.intercept('GET', '/api/collections/1', { + statusCode: 200, + body: { + collection: { + id: 1, + domain: 'example.com', + directory: '/media/archives/example.com', + tag_name: 'News', + description: 'Example domain' + } + } + }).as('getDomain'); + + // Mock directory search to prevent errors + cy.intercept('POST', '/api/files/search_directories', { + statusCode: 200, + body: {is_dir: true, directories: [], channel_directories: [], domain_directories: []} + }).as('searchDirs'); + + cy.get('table tbody tr').first().within(() => { + cy.get('a').contains('Edit').click(); + }); + cy.url().should('include', '/archive/domain/1/edit'); + }); + + it('filters domains with search', () => { + cy.get('input[placeholder="Domain filter..."]').type('example'); + cy.get('table tbody tr').should('have.length', 1); + cy.get('table tbody tr').should('contain', 'example.com'); + }); + + it('shows empty message when no domains', () => { + cy.intercept('GET', '/api/collections?kind=domain', { + statusCode: 200, + body: { + collections: [], + totals: {collections: 0}, + metadata: { + kind: 'domain', + columns: [] + } + } + }).as('getEmptyDomains'); + + cy.visit('/archive/domains'); + cy.wait('@getEmptyDomains'); + + cy.contains('No domains yet').should('exist'); + }); + + it('does not show "New Domain" button (domains are auto-created)', () => { + cy.contains('button', 'New Domain').should('not.exist'); + }); +}); diff --git a/app/cypress/e2e/forms/directory-search.cy.js b/app/cypress/e2e/forms/directory-search.cy.js new file mode 100644 index 000000000..4c53c7a40 --- /dev/null +++ b/app/cypress/e2e/forms/directory-search.cy.js @@ -0,0 +1,299 @@ +describe('DirectorySearch Integration Tests', () => { + const mockSearchResponse = { + is_dir: false, + directories: [ + {path: 'videos/nature'}, + {path: 'videos/tech'}, + {path: 'videos/cooking'} + ], + channel_directories: [ + {path: 'videos/channels/news', name: 'News Channel'}, + {path: 'videos/channels/tech', name: 'Tech Reviews'} + ], + domain_directories: [ + {path: 'archive/example.com', domain: 'example.com'}, + {path: 'archive/test.org', domain: 'test.org'} + ] + }; + + const mockEmptyResponse = { + is_dir: false, + directories: [], + channel_directories: [], + domain_directories: [] + }; + + const mockExistingDirResponse = { + is_dir: true, + directories: [ + {path: 'videos/nature/wildlife'}, + {path: 'videos/nature/ocean'} + ], + channel_directories: [], + domain_directories: [] + }; + + beforeEach(() => { + // Mock the directory search API + cy.intercept('POST', '/api/files/search_directories', (req) => { + const {path} = req.body; + + if (path === 'videos') { + req.reply({statusCode: 200, body: mockSearchResponse}); + } else if (path === 'empty') { + req.reply({statusCode: 200, body: mockEmptyResponse}); + } else if (path === 'videos/nature') { + req.reply({statusCode: 200, body: mockExistingDirResponse}); + } else { + req.reply({statusCode: 200, body: mockSearchResponse}); + } + }).as('searchDirectories'); + }); + + context('Standalone DirectorySearch Behavior', () => { + beforeEach(() => { + // Visit a page with DirectorySearch (using domain edit as example) + cy.intercept('GET', '/api/collections/1', { + statusCode: 200, + body: { + collection: { + id: 1, + name: 'example.com', + kind: 'domain', + directory: '', + description: '', + tag_name: null + } + } + }).as('getDomain'); + + cy.intercept('GET', '/api/collections?kind=domain', { + statusCode: 200, + body: { + collections: [], + totals: {collections: 0}, + metadata: { + kind: 'domain', + columns: [], + fields: [ + {key: 'directory', label: 'Directory', type: 'text', required: false} + ], + routes: {}, + messages: {} + } + } + }).as('getMetadata'); + + cy.visit('/archive/domain/1/edit'); + cy.wait('@getDomain'); + cy.wait('@getMetadata'); + }); + + it('types in search box and sees results', () => { + // Find the directory input + cy.contains('label', 'Directory') + .parent() + .find('input') + .type('videos'); + + cy.wait('@searchDirectories'); + + // Should see results in dropdown + cy.contains('videos/nature').should('be.visible'); + cy.contains('videos/tech').should('be.visible'); + }); + + it('clicks result and value updates', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .type('videos'); + + cy.wait('@searchDirectories'); + + // Click a result + cy.contains('videos/nature').click(); + + // Input should show selected value + cy.contains('label', 'Directory') + .parent() + .find('input') + .should('have.value', 'videos/nature'); + }); + + + it('categories are visually distinct', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .type('videos'); + + cy.wait('@searchDirectories'); + + // Check that categories exist + cy.contains('Directories').should('be.visible'); + cy.contains('Channels').should('be.visible'); + cy.contains('Domains').should('be.visible'); + }); + + it('result descriptions appear correctly', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .type('videos'); + + cy.wait('@searchDirectories'); + + // Channel should show name as description + cy.contains('News Channel').should('be.visible'); + + // Domain should show domain as description + cy.contains('example.com').should('be.visible'); + }); + + it('shows "New Directory" for new paths', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .type('empty'); + + cy.wait('@searchDirectories'); + + // Should show "New Directory" option + cy.contains('New Directory').should('be.visible'); + }); + + }); + + context('DestinationForm Workflow', () => { + beforeEach(() => { + // Set up domain edit page as a form with DestinationForm + cy.intercept('GET', '/api/collections/2', { + statusCode: 200, + body: { + collection: { + id: 2, + name: 'test.com', + kind: 'domain', + directory: 'archive/test.com', + description: '', + tag_name: null + } + } + }).as('getDomain'); + + cy.intercept('GET', '/api/collections?kind=domain', { + statusCode: 200, + body: { + collections: [], + totals: {collections: 0}, + metadata: { + kind: 'domain', + columns: [], + fields: [ + {key: 'directory', label: 'Directory', type: 'text', required: true} + ], + routes: {}, + messages: {} + } + } + }).as('getMetadata'); + + cy.visit('/archive/domain/2/edit'); + cy.wait('@getDomain'); + cy.wait('@getMetadata'); + }); + + it('starts with existing directory value', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .should('have.value', 'archive/test.com'); + }); + + it('types partial path and sees suggestions', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .clear() + .type('videos'); + + cy.wait('@searchDirectories'); + + // Should see suggestions + cy.contains('videos/nature').should('be.visible'); + }); + + it('selects channel directory and form updates', () => { + cy.contains('label', 'Directory') + .parent() + .find('input') + .clear() + .type('videos'); + + cy.wait('@searchDirectories'); + + // Select a channel directory + cy.contains('News Channel').click(); + + // Form should update + cy.contains('label', 'Directory') + .parent() + .find('input') + .should('have.value', 'videos/channels/news'); + }); + + + }); + + context('Real-World Scenarios', () => { + it('handles API returning empty results', () => { + cy.intercept('GET', '/api/collections/3', { + statusCode: 200, + body: { + collection: { + id: 3, + name: 'empty.com', + kind: 'domain', + directory: '', + description: '', + tag_name: null + } + } + }).as('getDomain'); + + cy.intercept('GET', '/api/collections?kind=domain', { + statusCode: 200, + body: { + collections: [], + totals: {collections: 0}, + metadata: { + kind: 'domain', + columns: [], + fields: [ + {key: 'directory', label: 'Directory', type: 'text', required: false} + ], + routes: {}, + messages: {} + } + } + }).as('getMetadata'); + + cy.visit('/archive/domain/3/edit'); + cy.wait('@getDomain'); + cy.wait('@getMetadata'); + + // Type path that returns empty results + cy.contains('label', 'Directory') + .parent() + .find('input') + .type('empty'); + + cy.wait('@searchDirectories'); + + // Should show "New Directory" option + cy.contains('New Directory').should('be.visible'); + }); + + }); +}); diff --git a/app/cypress/fixtures/channel-collections.json b/app/cypress/fixtures/channel-collections.json new file mode 100644 index 000000000..8e153947a --- /dev/null +++ b/app/cypress/fixtures/channel-collections.json @@ -0,0 +1,63 @@ +{ + "collections": [ + { + "id": 1, + "name": "My Tech Channel", + "video_count": 100, + "size": 5000000000, + "tag_name": "Tech", + "directory": "videos/channels/tech", + "download_frequency": "daily", + "url": "https://youtube.com/c/tech", + "rss_url": "https://youtube.com/feeds/tech" + }, + { + "id": 2, + "name": "News Channel", + "video_count": 50, + "size": 2500000000, + "tag_name": "News", + "directory": "videos/channels/news", + "download_frequency": "weekly", + "url": "https://youtube.com/c/news", + "rss_url": null + }, + { + "id": 3, + "name": "Science Channel", + "video_count": 75, + "size": 3750000000, + "tag_name": null, + "directory": "videos/channels/science", + "download_frequency": null, + "url": null, + "rss_url": null + } + ], + "totals": { + "channels": 3 + }, + "metadata": { + "kind": "channel", + "columns": [ + {"key": "name", "label": "Name", "sortable": true}, + {"key": "tag_name", "label": "Tag", "sortable": true}, + {"key": "video_count", "label": "Videos", "sortable": true}, + {"key": "download_frequency", "label": "Download Frequency", "sortable": true}, + {"key": "size", "label": "Size", "sortable": true, "format": "bytes"}, + {"key": "actions", "label": "Manage", "sortable": false, "type": "actions"} + ], + "fields": [ + {"key": "name", "label": "Channel Name", "type": "text", "required": true}, + {"key": "directory", "label": "Directory", "type": "path", "required": true}, + {"key": "url", "label": "URL", "type": "url", "optional": true}, + {"key": "tag_name", "label": "Tag", "type": "tag", "optional": true}, + {"key": "download_missing_data", "label": "Download Missing Data", "type": "boolean", "optional": true} + ], + "routes": { + "list": "/videos/channels", + "edit": "/videos/channel/:id/edit", + "search": "/videos/channel/:id/video" + } + } +} diff --git a/app/cypress/fixtures/domain-collections.json b/app/cypress/fixtures/domain-collections.json new file mode 100644 index 000000000..8de2e7b21 --- /dev/null +++ b/app/cypress/fixtures/domain-collections.json @@ -0,0 +1,62 @@ +{ + "collections": [ + { + "id": 1, + "domain": "example.com", + "archive_count": 42, + "size": 1024000, + "tag_name": "News", + "directory": "archive/example.com", + "can_be_tagged": true, + "description": "News from Example.com" + }, + { + "id": 2, + "domain": "test.org", + "archive_count": 15, + "size": 512000, + "tag_name": null, + "directory": null, + "can_be_tagged": false, + "description": "Test archives" + }, + { + "id": 3, + "domain": "demo.net", + "archive_count": 8, + "size": 256000, + "tag_name": "Tech", + "directory": "archive/demo.net", + "can_be_tagged": true, + "description": "Tech demos" + } + ], + "totals": { + "domains": 3 + }, + "metadata": { + "kind": "domain", + "columns": [ + {"key": "domain", "label": "Domain", "sortable": true}, + {"key": "archive_count", "label": "URLs", "sortable": true, "align": "right"}, + {"key": "size", "label": "Size", "sortable": true, "align": "right", "format": "bytes"}, + {"key": "tag_name", "label": "Tag", "sortable": true}, + {"key": "actions", "label": "Manage", "sortable": false, "type": "actions"} + ], + "fields": [ + {"key": "directory", "label": "Directory", "type": "text", "placeholder": "Optional directory path"}, + {"key": "tag_name", "label": "Tag", "type": "tag", "placeholder": "Select or create tag", "depends_on": "directory"}, + {"key": "description", "label": "Description", "type": "textarea", "placeholder": "Optional description"} + ], + "routes": { + "list": "/archive/domains", + "edit": "/archive/domain/:id/edit", + "search": "/archive", + "searchParam": "domain" + }, + "messages": { + "no_directory": "Set a directory to enable tagging", + "tag_will_move": "Tagging will move files to a new directory" + } + } +} diff --git a/app/cypress/support/e2e.js b/app/cypress/support/e2e.js new file mode 100644 index 000000000..a5094c495 --- /dev/null +++ b/app/cypress/support/e2e.js @@ -0,0 +1,9 @@ +// *********************************************************** +// This file is used to load the support and commands for e2e tests +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/app/package-lock.json b/app/package-lock.json index d7f8cc279..3ba7e02cc 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -29,8 +29,18 @@ "semantic-ui-react": "^2.1.4", "three": "^0.154", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -3728,6 +3738,33 @@ "node": ">=8" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "13.4.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", @@ -6365,6 +6402,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssdb": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz", @@ -13041,6 +13085,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz", @@ -13699,9 +13753,10 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -15751,6 +15806,20 @@ "node": ">=6.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -16985,6 +17054,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -18880,6 +18962,12 @@ } }, "dependencies": { + "@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, "@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -21357,6 +21445,28 @@ } } }, + "@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "dependencies": { + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + } + } + }, "@testing-library/react": { "version": "13.4.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", @@ -23310,6 +23420,12 @@ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssdb": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz", @@ -28143,6 +28259,12 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "mini-css-extract-plugin": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz", @@ -28605,9 +28727,9 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "picomatch": { "version": "2.3.1", @@ -29902,6 +30024,16 @@ "minimatch": "^3.0.5" } }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -30830,6 +30962,15 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==" }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/app/package.json b/app/package.json index f6bbf51c9..46181d619 100644 --- a/app/package.json +++ b/app/package.json @@ -27,9 +27,11 @@ }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "BROWSERSLIST_IGNORE_OLD_DATA=true react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "cy:run": "cypress run", + "cy:open": "cypress open" }, "eslintConfig": { "extends": [ @@ -47,5 +49,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1" } } diff --git a/app/src/Events.js b/app/src/Events.js index 300db707e..6dd72477c 100644 --- a/app/src/Events.js +++ b/app/src/Events.js @@ -105,6 +105,27 @@ function handleEvents(events) { eventToast('Archive Upload Failed!', message, 'error', 5000); } + if (event === 'screenshot_generated') { + eventToast( + 'Screenshot Generated', + message, + 'success', + 5000, + () => window.open(url, '_self')); + } + + if (event === 'screenshot_generation_failed') { + eventToast('Screenshot Generation Failed!', message, 'error', 5000); + } + + if (event === 'upgrade_started') { + eventToast('Upgrade Started', message, 'info', 10000); + // Redirect to maintenance page after a short delay + setTimeout(() => { + window.location.href = '/maintenance.html'; + }, 1000); + } + if (subject) { newestEvents[subject] = dt; } diff --git a/app/src/Tags.js b/app/src/Tags.js index bffabb37e..6276d4dcc 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -385,6 +385,11 @@ export function AddTagsButton({ const [loading, setLoading] = React.useState(false); const [localTags, setLocalTags] = React.useState(selectedTagNames); + // Sync localTags with selectedTagNames when it changes from parent + React.useEffect(() => { + setLocalTags(selectedTagNames); + }, [selectedTagNames]); + const active = anyTag || (selectedTagNames && selectedTagNames.length > 0); const handleOpen = (e) => { diff --git a/app/src/api.js b/app/src/api.js index 1396328b2..a29922e16 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1,7 +1,7 @@ import {emptyToNull} from "./components/Common"; import {toast} from "react-semantic-toasts-2"; import _ from "lodash"; -import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, Downloaders, OTP_API, VIDEOS_API, ZIM_API} from "./components/Vars"; +import {API_URI, ARCHIVES_API, COLLECTIONS_API, DEFAULT_LIMIT, Downloaders, OTP_API, VIDEOS_API, ZIM_API} from "./components/Vars"; function timeoutPromise(ms, promise) { // Create a timeout wrapper around a promise. If the timeout is reached, throw an error. Otherwise, return @@ -719,10 +719,10 @@ export async function searchArchives(offset, limit, domain, searchStr, order, ta } export async function fetchDomains() { - const response = await apiGet(`${ARCHIVES_API}/domains`); + const response = await apiGet(`${COLLECTIONS_API}?kind=domain`); if (response.ok) { let data = await response.json(); - return [data['domains'], data['totals']['domains']]; + return [data['collections'], data['totals']['collections'], data['metadata']]; } else { const message = await getErrorMessage(response, 'Unable to fetch Domains. See server logs.'); toast({ @@ -734,6 +734,137 @@ export async function fetchDomains() { } } +export async function fetchChannels() { + const response = await apiGet(`${COLLECTIONS_API}?kind=channel`); + if (response.ok) { + let data = await response.json(); + return [data['collections'], data['totals']['collections'], data['metadata']]; + } else { + const message = await getErrorMessage(response, 'Unable to fetch Channels. See server logs.'); + toast({ + type: 'error', + title: 'Channels Error', + description: message, + time: 5000, + }); + } +} + +export async function getDomain(domainId) { + const response = await apiGet(`${COLLECTIONS_API}/${domainId}`); + if (response.ok) { + const data = await response.json(); + return data['collection']; + } else { + const message = await getErrorMessage(response, 'Unable to fetch Domain. See server logs.'); + toast({ + type: 'error', + title: 'Domain Error', + description: message, + time: 5000, + }); + throw new Error(message); + } +} + +export async function updateDomain(domainId, updates) { + updates = emptyToNull(updates); + const response = await apiPut(`${COLLECTIONS_API}/${domainId}`, updates); + if (response.ok) { + const data = await response.json(); + return data['collection']; + } else { + const message = await getErrorMessage(response, 'Unable to update Domain. See server logs.'); + toast({ + type: 'error', + title: 'Domain Update Error', + description: message, + time: 5000, + }); + throw new Error(message); + } +} + +export async function getCollectionTagInfo(collectionId, tagName) { + const body = { + tag_name: tagName, + } + const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag_info`, body); + if (response.ok) { + const data = await response.json(); + return data; + } else { + const message = await getErrorMessage(response, 'Unable to get tag info. See server logs.'); + toast({ + type: 'error', + title: 'Tag Info Error', + description: message, + time: 5000, + }); + throw new Error(message); + } +} + +export async function tagDomain(domainId, tagName, directory) { + const body = { + tag_name: tagName, + } + if (directory) { + body['directory'] = directory; + } + const response = await apiPost(`${COLLECTIONS_API}/${domainId}/tag`, body); + if (response.ok) { + const data = await response.json(); + return data; + } else { + const message = await getErrorMessage(response, 'Unable to tag Domain. See server logs.'); + toast({ + type: 'error', + title: 'Domain Tag Error', + description: message, + time: 5000, + }); + throw new Error(message); + } +} + +export async function deleteDomain(domainId) { + const response = await apiDelete(`${COLLECTIONS_API}/${domainId}`); + if (response.status !== 204) { + const message = await getErrorMessage(response, 'Failed to delete domain.'); + toast({ + type: 'error', + title: 'Delete Failed', + description: message, + time: 5000, + }); + throw new Error(message); + } + return response; +} + +export async function refreshDomain(domainId) { + let url = `${COLLECTIONS_API}/${domainId}/refresh`; + const response = await apiPost(url); + if (!response.ok) { + const message = await getErrorMessage(response, "Failed to refresh this domain's directory"); + toast({ + type: 'error', + title: 'Failed to refresh', + description: message, + time: 5000, + }); + } else { + toast({ + type: 'success', + title: 'Domain refresh started', + description: 'The domain directory is being refreshed', + time: 3000, + }); + } + return response; +} + export async function getArchive(archiveId) { const response = await apiGet(`${ARCHIVES_API}/${archiveId}`); if (response.ok) { @@ -750,6 +881,28 @@ export async function getArchive(archiveId) { } } +export async function generateArchiveScreenshot(archiveId) { + const response = await apiPost(`${ARCHIVES_API}/${archiveId}/generate_screenshot`); + if (response.ok) { + toast({ + type: 'success', + title: 'Screenshot Generation Queued', + description: 'Screenshot generation has been queued. This may take a moment.', + time: 3000, + }); + return true; + } else { + const message = await getErrorMessage(response, 'Failed to queue screenshot generation.'); + toast({ + type: 'error', + title: 'Screenshot Generation Error', + description: message, + time: 5000, + }); + return false; + } +} + export async function postDownload(downloadData) { if (!downloadData.downloader) { toast({ @@ -1497,4 +1650,27 @@ export async function postRestart() { export async function postVideoFileFormat(video_file_format) { const body = {video_file_format}; return await apiPost(`${API_URI}/videos/file_format`, body); +} + +export async function checkUpgrade(force = false) { + const url = force ? `${API_URI}/upgrade/check?force=true` : `${API_URI}/upgrade/check`; + const response = await apiGet(url); + if (response.ok) { + return await response.json(); + } + return null; +} + +export async function triggerUpgrade() { + const response = await apiPost(`${API_URI}/upgrade/start`); + if (!response.ok) { + const message = await getErrorMessage(response, 'Failed to start upgrade. See server logs.'); + toast({ + type: 'error', + title: 'Upgrade Error', + description: message, + time: 5000, + }); + } + return response; } \ No newline at end of file diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index e6c04efe8..6b25c825b 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -9,10 +9,7 @@ import { GridRow, Image, Input, - PlaceholderHeader, - PlaceholderLine, TableCell, - TableRow } from "semantic-ui-react"; import { APIButton, @@ -35,19 +32,31 @@ import { textEllipsis, useTitle } from "./Common"; -import {deleteArchives, postDownload, tagFileGroup, untagFileGroup} from "../api"; -import {Link, Route, Routes, useNavigate, useParams} from "react-router-dom"; +import {deleteArchives, deleteDomain, generateArchiveScreenshot, getCollectionTagInfo, postDownload, refreshDomain, tagDomain, tagFileGroup, untagFileGroup} from "../api"; +import {CollectionTagModal} from "./collections/CollectionTagModal"; +import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-router-dom"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {useArchive, useDomains, useSearchArchives, useSearchOrder} from "../hooks/customHooks"; +import { + useArchive, + useCollectionMetadata, + useDomain, + useDomains, + useOneQuery, + useSearchArchives, + useSearchOrder +} from "../hooks/customHooks"; import {FileCards, FileRowTagIcon, FilesView} from "./Files"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import _ from "lodash"; import {Media, ThemeContext} from "../contexts/contexts"; import {Button, Card, CardIcon, darkTheme, Header, Loader, Placeholder, Popup, Segment, Tab, TabPane} from "./Theme"; import {SortableTable} from "./SortableTable"; -import {taggedImageLabel, TagsSelector} from "../Tags"; +import {taggedImageLabel, TagsSelector, TagsContext} from "../Tags"; import {toast} from "react-semantic-toasts-2"; import {API_ARCHIVE_UPLOAD_URI, Downloaders} from "./Vars"; +import {CollectionTable} from "./collections/CollectionTable"; +import {CollectionEditForm} from "./collections/CollectionEditForm"; +import {RecurringDownloadsTable} from "./admin/Downloads"; function archiveFileLink(path, directory = false) { if (path) { @@ -122,6 +131,14 @@ function ArchivePage() { } } + const localGenerateScreenshot = async () => { + const success = await generateArchiveScreenshot(data.id); + if (success) { + // Refresh the archive data after a short delay to show the new screenshot + setTimeout(() => fetchArchive(), 2000); + } + } + const updateButton = Delete ; + const generateScreenshotButton = !screenshotUrl ? + Generate Screenshot + : null; let historyList = ; if (history && history.length === 0) { @@ -254,6 +282,7 @@ function ArchivePage() { {readButton} {updateButton} {deleteButton} + {generateScreenshotButton} @@ -330,66 +359,219 @@ export function ArchiveCard({file}) { export function DomainsPage() { useTitle('Archive Domains'); - const [domains] = useDomains(); - const [searchStr, setSearchStr] = useState(''); + const [domains, total, metadata] = useDomains(); + const [searchStr, setSearchStr] = useOneQuery('domain'); + + // Header section matching ChannelsPage pattern + const header =
+ + + + setSearchStr('')} + onChange={setSearchStr} + onSubmit={null} + /> + + + {/* No "New Domain" button - domains are auto-created */} + + + +
; - if (domains === null) { - // Request is pending. + // Empty state + if (domains && domains.length === 0) { return <> - - - - - - + {header} + + No domains yet. Archive some webpages! + ; - } else if (domains === undefined) { - return Could not fetch domains - } else if (domains && domains.length === 0) { - return - No domains yet. - Archive some webpages! - ; } - let filteredDomains = domains; - if (searchStr) { - const re = new RegExp(_.escapeRegExp(searchStr), 'i'); - filteredDomains = domains.filter(i => re.test(i['domain'])); + // Error state + if (domains === undefined) { + return <> + {header} + Could not fetch Domains + ; } - const domainRow = ({domain, url_count, size}) => { - return - - - {domain} - - - {url_count} - {humanFileSize(size)} - + return <> + {header} + + ; +} + +export function DomainEditPage() { + const {domainId} = useParams(); + const navigate = useNavigate(); + const {domain, form, fetchDomain} = useDomain(parseInt(domainId)); + const {metadata} = useCollectionMetadata('domain'); + + // Modal state for tagging + const [tagEditModalOpen, setTagEditModalOpen] = useState(false); + + // Filter out tag_name field - we use the modal button instead of inline selector + const filteredMetadata = React.useMemo(() => { + if (!metadata) return metadata; + return { + ...metadata, + fields: metadata.fields.filter(field => field.key !== 'tag_name') + }; + }, [metadata]); + + useTitle(`Edit Domain: ${domain?.domain || '...'}`); + + // Wrap form.onSubmit to add toast and refresh domain data + React.useEffect(() => { + if (form && form.onSubmit) { + const originalOnSubmit = form.onSubmit; + form.onSubmit = async () => { + try { + await originalOnSubmit(); + toast({ + type: 'success', + title: 'Domain Updated', + description: 'Domain was successfully updated', + time: 3000, + }); + // Refresh domain data to show updated values + await fetchDomain(); + } catch (e) { + console.error('Failed to update domain:', e); + throw e; + } + }; + } + }, [form, fetchDomain]); + + // Handler for tag modal save + const handleTagSave = async (tagName, directory) => { + try { + await tagDomain(parseInt(domainId), tagName, directory); + toast({ + type: 'success', + title: 'Domain Tagged', + description: `Domain "${domain?.domain}" has been tagged with "${tagName}"`, + time: 3000, + }); + } catch (e) { + console.error('Failed to tag domain', e); + } finally { + setTimeout(async () => { + await fetchDomain(); + }, 500); + } + }; + + // Handler for fetching tag info + const handleGetTagInfo = async (tagName) => { + if (domain?.id) { + return await getCollectionTagInfo(domain.id, tagName); + } + return null; + }; + + const handleRefreshDomain = async (e) => { + if (e) { + e.preventDefault(); + } + await refreshDomain(parseInt(domainId)); + // Refresh domain data after completion + await fetchDomain(); + }; + + if (!form.ready) { + return Loading domain...; } - const headers = [ - {key: 'domain', text: 'Domain', sortBy: 'domain', width: 12}, - {key: 'archives', text: 'Archives', sortBy: 'url_count', width: 2}, - {key: 'Size', text: 'Size', sortBy: 'size', width: 2}, - ]; + // Handler for domain deletion + const handleDelete = async () => { + try { + let response = await deleteDomain(parseInt(domainId)); + if (response.status === 204) { + navigate('/archive/domains'); + } + } catch (e) { + console.error('Failed to delete domain', e); + } + }; + + const deleteButton = Delete; + + const refreshButton = domain?.directory ? ( + Refresh + ) : null; + + const tagButton = ; + + const actionButtons = <> + {deleteButton} + {refreshButton} + {tagButton} + ; return <> - setSearchStr(value)} + + - domainRow(i)} - rowKey='domain' - tableHeaders={headers} + + {/* Tag Modal */} + setTagEditModalOpen(false)} + currentTagName={domain?.tag_name} + originalDirectory={domain?.directory} + getTagInfo={handleGetTagInfo} + onSave={handleTagSave} + collectionName="Domain" /> + + {/* Downloads Segment */} + +
Downloads
+ +
; } @@ -613,9 +795,21 @@ export function ArchiveRowCells({file}) { } export function ArchiveRoute() { + const location = useLocation(); + const path = location.pathname; + const links = [ - {text: 'Archives', to: '/archive', end: true}, - {text: 'Domains', to: '/archive/domains'}, + { + text: 'Archives', + to: '/archive', + end: true, + isActive: () => path === '/archive' || /^\/archive\/\d+$/.test(path) + }, + { + text: 'Domains', + to: '/archive/domains', + isActive: () => path.startsWith('/archive/domain') + }, {text: 'Settings', to: '/archive/settings'}, ]; return @@ -623,6 +817,7 @@ export function ArchiveRoute() { }/> }/> + }/> }/> }/> diff --git a/app/src/components/Channels.js b/app/src/components/Channels.js index 2032ffb10..d5ba6905a 100644 --- a/app/src/components/Channels.js +++ b/app/src/components/Channels.js @@ -1,6 +1,7 @@ import React, {useState} from "react"; -import {Grid, Input, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react"; +import {Grid, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react"; import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo, updateChannel} from "../api"; +import {CollectionTagModal} from "./collections/CollectionTagModal"; import { APIButton, BackButton, @@ -12,7 +13,6 @@ import { secondsToFrequency, secondsToFullDuration, SimpleAccordion, - Toggle, useTitle, WROLModeMessage } from "./Common"; @@ -26,9 +26,7 @@ import { Header, Loader, Modal, - ModalActions, ModalContent, - ModalHeader, Segment, Statistic } from "./Theme"; @@ -36,9 +34,10 @@ import {Media, ThemeContext} from "../contexts/contexts"; import {SortableTable} from "./SortableTable"; import {toast} from "react-semantic-toasts-2"; import {RecurringDownloadsTable} from "./admin/Downloads"; -import {TagsContext, TagsSelector} from "../Tags"; +import {TagsContext} from "../Tags"; import {InputForm, ToggleForm} from "../hooks/useForm"; import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download"; +import {CollectionTable} from "./collections/CollectionTable"; function ChannelStatistics({statistics}) { @@ -86,20 +85,11 @@ export function ChannelPage({create, header}) { const {SingleTag} = React.useContext(TagsContext); const [tagEditModalOpen, setTagEditModalOpen] = useState(false); - const [newTagName, setNewTagName] = useState(null); - const [moveToTagDirectory, setMoveToTagDirectory] = useState(true); - const [newTagDirectory, setNewTagDirectory] = useState(''); const {channel, form, fetchChannel} = useChannel(channelId); useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`); - React.useEffect(() => { - if (channel && channel.tag_name !== newTagName) { - setNewTagName(channel.tag_name); - } - }, [channel]); - if (!create && !form.ready) { // Waiting for editing Channel to be fetched. return ; @@ -223,10 +213,10 @@ export function ChannelPage({create, header}) { setDownloadModalOpen(false); } - const handleTagEditChannel = async () => { + // Handler for tag modal save + const handleTagSave = async (tagName, directory) => { try { - await tagChannel(channelId, newTagName, moveToTagDirectory ? newTagDirectory : null); - setTagEditModalOpen(false); + await tagChannel(channelId, tagName, directory); } catch (e) { console.error('Failed to tag channel', e); } finally { @@ -235,69 +225,25 @@ export function ChannelPage({create, header}) { await fetchChannel(); }, 500); } - } + }; - const handleTagSelectMoveSuggestion = async (newTagName_) => { - setNewTagName(newTagName_); - try { - // Get suggested Tag directory for this Tag and Channel. - const videosDestination = await tagChannelInfo(channelId, newTagName_); - setNewTagDirectory(videosDestination); - } catch (e) { - console.error('Failed to tag channel', e); - } - } + // Handler for fetching tag info + const handleGetTagInfo = async (tagName) => { + return await tagChannelInfo(channelId, tagName); + }; let tagEditModal; if (!create) { // User is editing the Tag of the Channel. - tagEditModal = setTagEditModalOpen(false)} - closeIcon - > - {channel.tag_name ? 'Modify Tag' : 'Add Tag'} - - - - - handleTagSelectMoveSuggestion(null)} - /> - - - - - - - - - - setNewTagDirectory(value)} - disabled={!moveToTagDirectory} - /> - - - - - - - {moveToTagDirectory ? 'Move' : 'Save'} - - ; + currentTagName={channel.tag_name} + originalDirectory={channel.directory} + getTagInfo={handleGetTagInfo} + onSave={handleTagSave} + collectionName="Channel" + />; } let channelUrlRow; @@ -502,72 +448,13 @@ export function ChannelNewPage(props) { return } -function ChannelRow({channel}) { - const {SingleTag} = React.useContext(TagsContext); - - const videosTo = `/videos/channel/${channel.id}/video`; - - const {inverted} = React.useContext(ThemeContext); - const editTo = `/videos/channel/${channel.id}/edit`; - const buttonClass = `ui button secondary ${inverted}`; - - return - - {channel.name} - - - {channel.tag_name ? : null} - - - {channel.video_count} - - - {channel.url && channel.minimum_frequency ? secondsToFrequency(channel.minimum_frequency) : null} - - - {channel.size ? humanFileSize(channel.size) : null} - - - Edit - - ; -} - -function MobileChannelRow({channel}) { - const {SingleTag} = React.useContext(TagsContext); - - const editTo = `/videos/channel/${channel.id}/edit`; - const videosTo = `/videos/channel/${channel.id}/video`; - return - - -

- {channel.name} -

- {channel.tag_name ? : null} - -

- Videos: {channel.video_count} -

-
- -

- Edit -

-
-
; -} - - export function ChannelsPage() { useTitle('Channels'); - const {channels} = useChannels(); + const [channels, total, metadata] = useChannels(); const [searchStr, setSearchStr] = useOneQuery('name'); - // Hides Channels with few videos. - const [hideSmall, setHideSmall] = React.useState(true); - const enoughChannelsToHideSmall = channels && channels.length > 20; + // Header section matching DomainsPage pattern const header =
@@ -591,25 +478,7 @@ export function ChannelsPage() {
; - const headers = [ - {key: 'name', text: 'Name', sortBy: [i => i['name'].toLowerCase()], width: 8}, - {key: 'tag', text: 'Tag', sortBy: [i => i['tag_name'], i => i['name'].toLowerCase()], width: 2}, - {key: 'video_count', text: 'Videos', sortBy: [i => i['video_count'], i => i['name'].toLowerCase()], width: 2}, - { - key: 'download_frequency', - text: 'Download Frequency', - sortBy: [i => i['minimum_frequency'], i => i['name'].toLowerCase()], - width: 2 - }, - {key: 'size', text: 'Size', sortBy: [i => i['size'], i => i['name'].toLowerCase()], width: 2}, - {key: 'manage', text: 'Manage', width: 2}, - ]; - const mobileHeaders = [ - {key: 'name', text: 'Name', sortBy: [i => i['name'].toLowerCase()]}, - {key: 'video_count', text: 'Videos', sortBy: [i => i['video_count'], i => i['name'].toLowerCase()]}, - {key: 'manage', text: 'Manage'}, - ]; - + // Empty state if (channels && channels.length === 0) { return <> {header} @@ -617,65 +486,23 @@ export function ChannelsPage() { No channels exist yet! Create one. - - } else if (channels === undefined) { + ; + } + + // Error state + if (channels === undefined) { return <> {header} Could not fetch Channels - - } - - let filteredChannels = channels; - if (searchStr && Array.isArray(channels)) { - const re = new RegExp(_.escapeRegExp(searchStr), 'i'); - filteredChannels = channels.filter(i => re.test(i['name'])); - } else if (channels && hideSmall && enoughChannelsToHideSmall) { - // Get the top 80% of Channels. - let index80 = Math.floor(channels.length * 0.8); - const sortedChannels = channels.sort((a, b) => b.video_count - a.video_count); - let percentile = sortedChannels[index80].video_count; - filteredChannels = channels.filter(i => { - return i.video_count > percentile || i.name.toLowerCase() === 'wrolpi' - }); - if (filteredChannels.length < 21) { - // Filtering hid too many Channels, show them all. - filteredChannels = channels; - } + ; } return <> {header} - - } - /> - - - } - /> - - - - - - - - - + + ; } diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 4a1a83a5e..3f9e39108 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -303,6 +303,21 @@ export function secondsToTimestamp(seconds) { } export function humanFileSize(bytes, dp = 1) { + // Handle null/undefined + if (bytes == null) { + return '-'; + } + + // Convert string to number if needed + if (typeof bytes === 'string') { + bytes = parseFloat(bytes); + } + + // Handle invalid numbers + if (isNaN(bytes) || !isFinite(bytes)) { + return '-'; + } + const thresh = 1024; if (Math.abs(bytes) < thresh) { @@ -318,7 +333,6 @@ export function humanFileSize(bytes, dp = 1) { ++u; } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); - return bytes.toFixed(dp) + ' ' + units[u]; } @@ -596,10 +610,13 @@ export function TabLinks({links}) { return {links.map((link) => { + const active = link.isActive ? link.isActive() : isActive; + return active ? 'item active' : 'item'; + }} > {link.text} )} @@ -1224,10 +1241,18 @@ export function DirectorySearch({onSelect, value, disabled, required, ...props}) } } + const handleBlur = (e) => { + // When user leaves the field, commit the typed value to the form + if (onSelect && directoryName !== value) { + onSelect(directoryName); + } + } + return jest.fn(fn => { + fn.cancel = jest.fn(); + return fn; +})); + +// Mock useSearchDirectories hook to avoid async state updates that cause act() warnings +const mockSetDirectoryName = jest.fn(); +let mockHookState = { + directoryName: '', + directories: [], + channelDirectories: [], + domainDirectories: [], + isDir: false, + loading: false, +}; + +jest.mock('../hooks/customHooks', () => ({ + ...jest.requireActual('../hooks/customHooks'), + useSearchDirectories: (value) => { + // Return current mock state - tests control state via setMockHookState + return { + ...mockHookState, + setDirectoryName: (newValue) => { + mockHookState.directoryName = newValue; + mockSetDirectoryName(newValue); + }, + }; + }, +})); + +describe('DirectorySearch', () => { + const mockOnSelect = jest.fn(); + + const mockSearchResults = { + directories: [ + {path: 'videos/nature'}, + {path: 'videos/tech'} + ], + channelDirectories: [ + {path: 'videos/channels/news', name: 'News Channel'} + ], + domainDirectories: [ + {path: 'archive/example.com', domain: 'example.com'} + ], + }; + + // Helper to reset mock hook state with specific values + const setMockHookState = (overrides = {}) => { + mockHookState = { + directoryName: '', + directories: [], + channelDirectories: [], + domainDirectories: [], + isDir: false, + loading: false, + ...overrides, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock hook state with default search results + setMockHookState({ + ...mockSearchResults, + isDir: false, + }); + }); + + describe('Rendering', () => { + it('renders with placeholder text', () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + expect(input).toBeInTheDocument(); + }); + + it('shows initial value when provided', () => { + setMockHookState({ + ...mockSearchResults, + directoryName: 'videos/test', + }); + + render(); + + const input = screen.getByDisplayValue('videos/test'); + expect(input).toBeInTheDocument(); + }); + + it('applies disabled state correctly', () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + expect(input).toBeDisabled(); + }); + + it('displays with required indicator', () => { + const {container} = render( + + ); + + // Semantic UI doesn't add required attribute to Search input, + // but we verify the prop is passed + expect(container.querySelector('.ui.search')).toBeInTheDocument(); + }); + }); + + describe('Search Functionality', () => { + it('triggers setDirectoryName on value change', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.type(input, 'videos'); + + // Verify setDirectoryName was called (via mocked hook) + expect(mockSetDirectoryName).toHaveBeenCalled(); + // Debounce is mocked, so each character triggers a call + // Verify it was called 6 times (one per character in "videos") + expect(mockSetDirectoryName.mock.calls.length).toBe(6); + }); + + it('shows loading indicator when loading state is true', () => { + setMockHookState({ + ...mockSearchResults, + loading: true, + }); + + render(); + + const searchContainer = screen.getByPlaceholderText(/search directory names/i) + .closest('.ui.search'); + expect(searchContainer).toHaveClass('loading'); + }); + + it('hides loading indicator when loading state is false', () => { + setMockHookState({ + ...mockSearchResults, + loading: false, + }); + + render(); + + const searchContainer = screen.getByPlaceholderText(/search directory names/i) + .closest('.ui.search'); + expect(searchContainer).not.toHaveClass('loading'); + }); + + it('displays categorized results', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + // Click to open dropdown + await userEvent.click(input); + + await waitFor(() => { + // Should show category names + expect(screen.getAllByText(/Directories/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Channels/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Domains/i).length).toBeGreaterThan(0); + }); + }); + + it('shows "New Directory" when path doesn\'t exist (isDir=false)', async () => { + setMockHookState({ + directories: [], + channelDirectories: [], + domainDirectories: [], + isDir: false, + directoryName: 'new/path', + }); + + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.click(input); + + await waitFor(() => { + expect(screen.getByText(/New Directory/i)).toBeInTheDocument(); + }); + }); + + it('hides "New Directory" when path exists (isDir=true)', async () => { + setMockHookState({ + directories: [{path: 'videos/nature/wildlife'}], + channelDirectories: [], + domainDirectories: [], + isDir: true, + directoryName: 'videos/nature', + }); + + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.click(input); + + // "New Directory" should not appear when is_dir=true + expect(screen.queryByText(/New Directory/i)).not.toBeInTheDocument(); + }); + + it('debounces rapid typing (verifies setDirectoryName is called)', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + + // Type rapidly + await userEvent.type(input, 'abc', {delay: 10}); + + // Verify setDirectoryName was called + expect(mockSetDirectoryName).toHaveBeenCalled(); + }); + }); + + describe('User Interactions', () => { + it('calls onSelect when result is clicked', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.click(input); + + await waitFor(() => { + expect(screen.getByText('videos/nature')).toBeInTheDocument(); + }); + + // Click on a result + const result = screen.getByText('videos/nature'); + await userEvent.click(result); + + expect(mockOnSelect).toHaveBeenCalledWith('videos/nature'); + }); + + it('commits typed value on blur when directoryName differs from value', async () => { + // Set up mock state where directoryName differs from the prop value + // This simulates what happens after user types in the input + setMockHookState({ + ...mockSearchResults, + directoryName: 'typed/path', // User has typed this + }); + + // Render with empty value prop (different from directoryName) + const {container} = render(); + + // Find the Search component container and trigger blur on it + const searchComponent = container.querySelector('.ui.search'); + + // Blur the Search component - should trigger onBlur which calls onSelect + await act(async () => { + // Use fireEvent.blur which better simulates the Semantic UI Search blur behavior + const {fireEvent} = require('@testing-library/react'); + fireEvent.blur(searchComponent); + }); + + // Should call onSelect with directoryName from hook state + expect(mockOnSelect).toHaveBeenCalledWith('typed/path'); + }); + + it('does not call onSelect on blur if value unchanged', async () => { + setMockHookState({ + ...mockSearchResults, + directoryName: 'existing/path', + }); + + render(); + + const input = screen.getByDisplayValue('existing/path'); + + // Blur without changing value + await act(async () => { + input.blur(); + }); + + // Should not call onSelect since value didn't change + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + + it('disabled state prevents interactions', () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + + // Input should be disabled + expect(input).toBeDisabled(); + }); + + it('handles rapid selection changes', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.click(input); + + await waitFor(() => { + expect(screen.getByText('videos/nature')).toBeInTheDocument(); + }); + + // Click multiple results in succession + await userEvent.click(screen.getByText('videos/nature')); + await userEvent.click(screen.getByText('videos/tech')); + + // Should call onSelect for each selection + expect(mockOnSelect).toHaveBeenCalledWith('videos/nature'); + expect(mockOnSelect).toHaveBeenCalledWith('videos/tech'); + }); + }); + + describe('Hook Integration', () => { + it('calls setDirectoryName from hook on search change', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.type(input, 'archive'); + + expect(mockSetDirectoryName).toHaveBeenCalled(); + }); + + it('displays results from hook state', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + await userEvent.click(input); + + await waitFor(() => { + // Should display results from mock hook state + expect(screen.getByText('videos/nature')).toBeInTheDocument(); + expect(screen.getByText('videos/tech')).toBeInTheDocument(); + expect(screen.getByText('News Channel')).toBeInTheDocument(); + }); + }); + }); + + describe('Edge Cases', () => { + it('handles null/undefined initial value', () => { + setMockHookState({ + ...mockSearchResults, + directoryName: '', + }); + + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + expect(input).toHaveValue(''); + }); + + it('clears results display when empty', async () => { + setMockHookState({ + directories: [], + channelDirectories: [], + domainDirectories: [], + isDir: false, + directoryName: '', + }); + + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + expect(input).toHaveValue(''); + }); + + it('maintains value when component remounts', () => { + setMockHookState({ + ...mockSearchResults, + directoryName: 'videos/test', + }); + + const {rerender} = render( + + ); + + expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument(); + + // Remount with same value + rerender(); + + expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument(); + }); + + it('handles special characters in path', async () => { + render(); + + const input = screen.getByPlaceholderText(/search directory names/i); + + // Type path with special characters + const specialPath = 'videos/test-folder_2024/v1.0'; + await userEvent.type(input, specialPath); + + expect(mockSetDirectoryName).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/DomainEditPage.test.js b/app/src/components/DomainEditPage.test.js new file mode 100644 index 000000000..5fd819b88 --- /dev/null +++ b/app/src/components/DomainEditPage.test.js @@ -0,0 +1,446 @@ +import React from 'react'; +import {render, renderInDarkMode, screen, waitFor, createTestForm} from '../test-utils'; +import userEvent from '@testing-library/user-event'; +import {DomainEditPage} from './Archive'; +import {createMockDomain, createMockMetadata} from '../test-utils'; + +// Mock useParams to return domain ID +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({domainId: '1'}), + useNavigate: () => jest.fn(), +})); + +// Mock the useDomain hook +const mockUseDomain = jest.fn(); +const mockUseCollectionMetadata = jest.fn(); + +jest.mock('../hooks/customHooks', () => ({ + ...jest.requireActual('../hooks/customHooks'), + useDomain: (...args) => mockUseDomain(...args), + useCollectionMetadata: (...args) => mockUseCollectionMetadata(...args), +})); + +// Mock useTitle +jest.mock('./Common', () => ({ + ...jest.requireActual('./Common'), + useTitle: jest.fn(), +})); + +// Mock CollectionEditForm +jest.mock('./collections/CollectionEditForm', () => ({ + CollectionEditForm: ({form, metadata, title, actionButtons}) => ( +
+ {title &&

{title}

} + {form?.loading &&
Loading...
} + {form?.formData &&
Collection data loaded
} + {metadata &&
Metadata loaded
} + {actionButtons &&
{actionButtons}
} +
+ ), +})); + +// Mock CollectionTagModal +jest.mock('./collections/CollectionTagModal', () => ({ + CollectionTagModal: ({open, onClose, currentTagName, originalDirectory, getTagInfo, onSave, collectionName}) => { + if (!open) return null; + return ( +
+
{currentTagName ? 'Modify Tag' : 'Add Tag'}
+ + + +
+ ); + }, +})); + +// Mock API functions +const mockGetCollectionTagInfo = jest.fn(); +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getCollectionTagInfo: (...args) => mockGetCollectionTagInfo(...args), + tagDomain: jest.fn(), +})); + +describe('DomainEditPage', () => { + const mockMetadata = createMockMetadata(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseCollectionMetadata.mockReturnValue({metadata: mockMetadata}); + }); + + describe('Loading States', () => { + it('handles loading state while fetching domain', async () => { + // Start with loading state (no domain yet) + const form = createTestForm({}, { + overrides: {ready: false, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: null, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // Should show Semantic UI Loader with text + expect(screen.getByText(/loading domain/i)).toBeInTheDocument(); + + // Form should not be visible during initial load + expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument(); + }); + + it('shows form when domain is loaded', () => { + const mockDomain = createMockDomain({ + domain: 'test.com', + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // Should NOT show loading message + expect(screen.queryByText(/loading domain/i)).not.toBeInTheDocument(); + + // Should show domain name (may appear multiple times in header and form) + expect(screen.getAllByText(/test\.com/i).length).toBeGreaterThan(0); + }); + + it('passes loading state to form during submission', () => { + const mockDomain = createMockDomain({ + domain: 'example.com', + }); + + // Domain is loaded but form is submitting + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: true} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // Should show loading indicator in form (from mocked component) + // Multiple indicators due to Fresnel rendering for mobile and tablet+ + expect(screen.getAllByTestId('loading-indicator').length).toBeGreaterThan(0); + + // Should also show the domain name + expect(screen.getAllByText(/example\.com/i).length).toBeGreaterThan(0); + }); + }); + + describe('Error States', () => { + it('shows loader when form is not ready (fetch fails)', () => { + // When form.ready is false (e.g., fetch failed), show loader + const form = createTestForm({}, { + overrides: {ready: false, loading: false, error: new Error('Domain not found')} + }); + + mockUseDomain.mockReturnValue({ + domain: null, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // Should show loading screen when not ready + expect(screen.getByText(/loading domain/i)).toBeInTheDocument(); + + // Form should not be rendered + expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument(); + }); + + it('shows form when ready even if there was a submission error', () => { + // Form is ready (domain loaded) but submission may have failed + const mockDomain = createMockDomain({ + domain: 'example.com', + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false, error: new Error('Update failed')} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // Should show domain name (form is rendered) + // Multiple occurrences due to Fresnel rendering for mobile and tablet+ + expect(screen.getAllByText(/example\.com/i).length).toBeGreaterThan(0); + + // Should NOT show the initial loading screen + expect(screen.queryByText(/loading domain/i)).not.toBeInTheDocument(); + }); + }); + + describe('Page Title', () => { + it('sets page title with domain name', () => { + const {useTitle} = require('./Common'); + const mockDomain = createMockDomain({ + domain: 'example.com', + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // useTitle should be called with domain name + expect(useTitle).toHaveBeenCalledWith('Edit Domain: example.com'); + }); + + it('sets page title with placeholder while loading', () => { + const {useTitle} = require('./Common'); + + const form = createTestForm({}, { + overrides: {ready: false, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: null, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // useTitle should be called with placeholder + expect(useTitle).toHaveBeenCalledWith('Edit Domain: ...'); + }); + }); + + describe('Theme Integration', () => { + it('passes theme context to CollectionEditForm in dark mode', () => { + const mockDomain = createMockDomain({ + domain: 'example.com', + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + // Render in dark mode + renderInDarkMode(); + + // CollectionEditForm should be rendered + expect(screen.getByTestId('collection-edit-form')).toBeInTheDocument(); + + // The title should include the domain name + expect(screen.getAllByText(/example\.com/i).length).toBeGreaterThan(0); + }); + + it('renders properly in light mode', () => { + const mockDomain = createMockDomain({ + domain: 'test.com', + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + // Default render (light mode) + render(); + + // CollectionEditForm should be rendered + expect(screen.getByTestId('collection-edit-form')).toBeInTheDocument(); + + // The title should include the domain name + expect(screen.getAllByText(/test\.com/i).length).toBeGreaterThan(0); + }); + }); + + describe('Tag Modal and Directory Suggestions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetCollectionTagInfo.mockClear(); + }); + + it('suggests directory when tag is selected', async () => { + const mockDomain = createMockDomain({ + domain: 'example.com', + id: 1, + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + // Mock successful tag info response + mockGetCollectionTagInfo.mockResolvedValue({ + suggested_directory: 'archive/WROL/example.com', + conflict: false, + conflict_message: null, + }); + + render(); + + // Click the Tag button to open modal + const tagButton = screen.getByText('Tag'); + await userEvent.click(tagButton); + + // Wait for modal to open + await waitFor(() => { + expect(screen.getByText(/Modify Tag|Add Tag/i)).toBeInTheDocument(); + }); + + // Simulate selecting a tag (this would normally be done by TagsSelector) + // Since TagsSelector is a real component, we need to wait for the API call + // We'll verify the API was called when a tag would be selected + // This test validates the structure is in place + }); + + it('displays conflict warning in modal when directory conflict exists', async () => { + const mockDomain = createMockDomain({ + domain: 'example.com', + id: 1, + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + // Mock tag info response with conflict + mockGetCollectionTagInfo.mockResolvedValue({ + suggested_directory: 'archive/WROL/example.com', + conflict: true, + conflict_message: "A domain collection 'other.com' already uses this directory. Choose a different tag or directory.", + }); + + render(); + + // Open the modal + const tagButton = screen.getByText('Tag'); + await userEvent.click(tagButton); + + // The modal should be present + await waitFor(() => { + expect(screen.getByTestId('collection-tag-modal')).toBeInTheDocument(); + }); + + // Verify the modal structure includes the necessary inputs + expect(screen.getByTestId('directory-input')).toBeInTheDocument(); + }); + + it('clears conflict message when tag is changed', async () => { + const mockDomain = createMockDomain({ + domain: 'example.com', + id: 1, + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + render(); + + // Open modal + const tagButton = screen.getByText('Tag'); + await userEvent.click(tagButton); + + await waitFor(() => { + expect(screen.getByText(/Modify Tag|Add Tag/i)).toBeInTheDocument(); + }); + + // Modal should be open and ready for tag selection + // The actual tag selection and conflict clearing would be tested in integration tests + // This validates the structure is present + }); + + it('populates directory input with suggested directory', async () => { + const mockDomain = createMockDomain({ + domain: 'example.com', + id: 1, + directory: 'archive/example.com', + }); + + const form = createTestForm(mockDomain, { + overrides: {ready: true, loading: false} + }); + + mockUseDomain.mockReturnValue({ + domain: mockDomain, + form, + fetchDomain: jest.fn(), + }); + + mockGetCollectionTagInfo.mockResolvedValue({ + suggested_directory: 'archive/WROL/example.com', + conflict: false, + conflict_message: null, + }); + + render(); + + // Open modal + const tagButton = screen.getByText('Tag'); + await userEvent.click(tagButton); + + // Wait for modal to open + await waitFor(() => { + expect(screen.getByTestId('collection-tag-modal')).toBeInTheDocument(); + }); + + // Verify directory input field exists with original directory value + const directoryInput = screen.getByTestId('directory-input'); + expect(directoryInput).toBeInTheDocument(); + expect(directoryInput).toHaveValue('archive/example.com'); + }); + }); +}); diff --git a/app/src/components/DomainTagging.test.js b/app/src/components/DomainTagging.test.js new file mode 100644 index 000000000..220bf3463 --- /dev/null +++ b/app/src/components/DomainTagging.test.js @@ -0,0 +1,345 @@ +import React from 'react'; +import {render, screen, createTestForm} from '../test-utils'; +import {CollectionEditForm} from './collections/CollectionEditForm'; +import {DomainsPage} from './Archive'; +import {createMockDomain, createMockMetadata, createMockDomains} from '../test-utils'; + +// Mock the TagsSelector component and TagsContext +jest.mock('../Tags', () => ({ + TagsSelector: ({selectedTagNames, onChange, disabled}) => ( +
+ onChange(e.target.value ? [e.target.value] : [])} + disabled={disabled} + /> +
+ ), + TagsContext: { + _currentValue: { + SingleTag: ({name}) => {name} + } + }, +})); + +// Mock the DirectorySearch and DestinationForm components +jest.mock('./Common', () => ({ + ...jest.requireActual('./Common'), + DirectorySearch: ({value, onSelect, placeholder}) => ( + onSelect(e.target.value)} + placeholder={placeholder} + /> + ), + SearchInput: ({placeholder, searchStr, onChange, disabled}) => ( + onChange(e.target.value)} + disabled={disabled} + /> + ), + ErrorMessage: ({children}) =>
{children}
, + useTitle: jest.fn(), +})); + +// Mock DestinationForm (used for directory field) +jest.mock('./Download', () => ({ + DestinationForm: ({form, label, name}) => ( +
+ + form.setValue(name, e.target.value)} + /> +
+ ), +})); + +// Mock hooks for DomainsPage tests +const mockUseDomains = jest.fn(); +const mockUseOneQuery = jest.fn(() => ['', jest.fn()]); + +jest.mock('../hooks/customHooks', () => ({ + ...jest.requireActual('../hooks/customHooks'), + useDomains: (...args) => mockUseDomains(...args), + useOneQuery: (...args) => mockUseOneQuery(...args), +})); + +// Mock CollectionTable for DomainsPage tests +jest.mock('./collections/CollectionTable', () => ({ + CollectionTable: ({collections}) => ( +
+ + + {collections?.map((domain) => ( + + + + + ))} + +
{domain.domain} + {domain.tag_name || 'No tag'} +
+
+ ), +})); + +describe('Domain Tagging Logic', () => { + const mockMetadata = createMockMetadata(); + + describe('Tag Field Dependency on Directory', () => { + it('prevents tagging domain without directory', () => { + const domainWithoutDirectory = createMockDomain({ + directory: '', + tag_name: null, + can_be_tagged: false + }); + + const form = createTestForm(domainWithoutDirectory); + + render( + + ); + + // Should show dependency warning + expect(screen.getByText(/set a directory to enable tagging/i)).toBeInTheDocument(); + + // Tag selector should be disabled + const tagSelector = screen.getByTestId('tags-selector'); + expect(tagSelector).toHaveAttribute('data-disabled', 'true'); + + const tagInput = screen.getByTestId('tags-input'); + expect(tagInput).toBeDisabled(); + }); + + it('enables tagging when directory is set', () => { + const domainWithDirectory = createMockDomain({ + directory: 'archive/example.com', + tag_name: null, + can_be_tagged: true + }); + + const form = createTestForm(domainWithDirectory); + + render( + + ); + + // Dependency warning should not be shown + expect(screen.queryByText(/set a directory to enable tagging/i)).not.toBeInTheDocument(); + + // Tag selector should be enabled + const tagSelector = screen.getByTestId('tags-selector'); + expect(tagSelector).toHaveAttribute('data-disabled', 'false'); + + const tagInput = screen.getByTestId('tags-input'); + expect(tagInput).not.toBeDisabled(); + }); + + it('shows warning when directory is set (enabling tagging)', () => { + const domainWithDirectory = createMockDomain({ + directory: 'archive/example.com', + tag_name: null, + can_be_tagged: true + }); + + const form = createTestForm(domainWithDirectory); + + render( + + ); + + // Tag selector should be available (not disabled) + const tagSelector = screen.getByTestId('tags-selector'); + expect(tagSelector).toHaveAttribute('data-disabled', 'false'); + + // Dependency message should not appear + expect(screen.queryByText(/set a directory to enable tagging/i)).not.toBeInTheDocument(); + }); + }); + + describe('Tag Display in Domains List', () => { + const {useTitle} = require('./Common'); + + beforeEach(() => { + // Reset mocks + mockUseDomains.mockReset(); + mockUseOneQuery.mockReset(); + useTitle.mockReset(); + + // Re-setup default mocks + useTitle.mockImplementation(() => {}); + mockUseOneQuery.mockReturnValue(['', jest.fn()]); + }); + + it('displays tag in domains list after tagging', () => { + const mockDomains = [ + createMockDomain({ + id: 1, + domain: 'example.com', + tag_name: 'News', // Tagged domain + directory: 'archive/example.com', + can_be_tagged: true + }), + createMockDomain({ + id: 2, + domain: 'test.org', + tag_name: null, // Untagged domain + directory: '', + can_be_tagged: false + }), + ]; + + mockUseDomains.mockReturnValue([mockDomains, 2, mockMetadata]); + + render(); + + // Tagged domain should display its tag + expect(screen.getByTestId('domain-tag-1')).toHaveTextContent('News'); + + // Untagged domain should show "No tag" + expect(screen.getByTestId('domain-tag-2')).toHaveTextContent('No tag'); + }); + + it('displays multiple tagged domains correctly', () => { + const mockDomains = [ + createMockDomain({ + id: 1, + domain: 'news.com', + tag_name: 'News', + directory: 'archive/news.com', + can_be_tagged: true + }), + createMockDomain({ + id: 2, + domain: 'tech.com', + tag_name: 'Tech', + directory: 'archive/tech.com', + can_be_tagged: true + }), + createMockDomain({ + id: 3, + domain: 'science.com', + tag_name: 'Science', + directory: 'archive/science.com', + can_be_tagged: true + }), + ]; + + mockUseDomains.mockReturnValue([mockDomains, 3, mockMetadata]); + + render(); + + // All domains should display their respective tags + expect(screen.getByTestId('domain-tag-1')).toHaveTextContent('News'); + expect(screen.getByTestId('domain-tag-2')).toHaveTextContent('Tech'); + expect(screen.getByTestId('domain-tag-3')).toHaveTextContent('Science'); + }); + }); + + describe('Tag Warning Messages', () => { + it('shows "tagging will move files" warning when tag is set', () => { + const domainWithTag = createMockDomain({ + directory: 'archive/example.com', + tag_name: 'News', + can_be_tagged: true + }); + + const form = createTestForm(domainWithTag); + + render( + + ); + + // Should show move warning when tag is present + expect(screen.getByText(/tagging will move files/i)).toBeInTheDocument(); + }); + + it('does not show move warning when no tag is set', () => { + const domainWithoutTag = createMockDomain({ + directory: 'archive/example.com', + tag_name: null, + can_be_tagged: true + }); + + const form = createTestForm(domainWithoutTag); + + render( + + ); + + // Should not show move warning when tag is absent + expect(screen.queryByText(/tagging will move files/i)).not.toBeInTheDocument(); + }); + }); + + describe('Tag Clearing Submission Bug', () => { + it('should send empty string (not null) when clearing tag', async () => { + // Create a domain with a tag + const domainWithTag = createMockDomain({ + id: 1, + domain: 'example.com', + directory: 'archive/example.com', + tag_name: 'News', + can_be_tagged: true + }); + + // Create form with the domain data + const form = createTestForm(domainWithTag); + + // Simulate clearing the tag + form.setValue('tag_name', null); + + // Verify form has null + expect(form.formData.tag_name).toBe(null); + + // Mock updateDomain to track what it's called with + const mockUpdateDomain = jest.fn().mockResolvedValue({ok: true}); + + // Mock onSubmit to call updateDomain like useDomain does (with fix) + form.onSubmit = jest.fn(async () => { + const body = { + directory: form.formData.directory, + description: form.formData.description, + // FIX: Convert null to empty string - backend expects "" to clear tag + tag_name: form.formData.tag_name === null ? '' : form.formData.tag_name, + }; + return await mockUpdateDomain(1, body); + }); + + // Submit the form + await form.onSubmit(); + + // Verify updateDomain was called + expect(mockUpdateDomain).toHaveBeenCalledTimes(1); + + // Verify tag_name is correctly converted from null to "" for the API + expect(mockUpdateDomain).toHaveBeenCalledWith(1, { + directory: 'archive/example.com', + description: '', + tag_name: '', // Empty string clears the tag (null is converted) + }); + }); + }); +}); diff --git a/app/src/components/DomainsPage.test.js b/app/src/components/DomainsPage.test.js new file mode 100644 index 000000000..5ee7c5a0d --- /dev/null +++ b/app/src/components/DomainsPage.test.js @@ -0,0 +1,236 @@ +import React from 'react'; +import {render, screen, waitFor} from '../test-utils'; +import {DomainsPage} from './Archive'; +import {createMockDomains, createMockMetadata} from '../test-utils'; + +// Mock the custom hooks +jest.mock('../hooks/customHooks', () => ({ + ...jest.requireActual('../hooks/customHooks'), + useDomains: jest.fn(), + useOneQuery: jest.fn(), +})); + +// Mock CollectionTable component +jest.mock('./collections/CollectionTable', () => ({ + CollectionTable: ({collections, metadata, searchStr}) => ( +
+
{collections?.length || 0}
+
{searchStr}
+ {collections?.map((domain) => ( +
+ {domain.domain} + +
+ ))} +
+ ), +})); + +// Mock SearchInput component and useTitle +jest.mock('./Common', () => ({ + ...jest.requireActual('./Common'), + SearchInput: ({placeholder, searchStr, onChange, disabled}) => ( + onChange(e.target.value)} + disabled={disabled} + /> + ), + ErrorMessage: ({children}) =>
{children}
, + useTitle: jest.fn(), +})); + +describe('DomainsPage', () => { + const {useDomains, useOneQuery} = require('../hooks/customHooks'); + const {useTitle} = require('./Common'); + const mockMetadata = createMockMetadata(); + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + + // Default mock implementations + useTitle.mockImplementation(() => {}); + useOneQuery.mockReturnValue(['', jest.fn()]); + }); + + describe('Page Rendering', () => { + it('displays domains page without errors', () => { + const mockDomains = createMockDomains(3); + useDomains.mockReturnValue([mockDomains, 3, mockMetadata]); + + render(); + + // Page should render without crashing + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + expect(screen.getByTestId('collection-table')).toBeInTheDocument(); + }); + + it('renders CollectionTable component', () => { + const mockDomains = createMockDomains(2); + useDomains.mockReturnValue([mockDomains, 2, mockMetadata]); + + render(); + + expect(screen.getByTestId('collection-table')).toBeInTheDocument(); + }); + + it('shows search input', () => { + const mockDomains = createMockDomains(1); + useDomains.mockReturnValue([mockDomains, 1, mockMetadata]); + + render(); + + const searchInput = screen.getByTestId('search-input'); + expect(searchInput).toBeInTheDocument(); + expect(searchInput).toHaveAttribute('placeholder', 'Domain filter...'); + }); + }); + + describe('Domain Display', () => { + it('shows all domains from API', () => { + const mockDomains = createMockDomains(5); + useDomains.mockReturnValue([mockDomains, 5, mockMetadata]); + + render(); + + // Should render all 5 domains + expect(screen.getByTestId('collection-count')).toHaveTextContent('5'); + + mockDomains.forEach((domain) => { + expect(screen.getByTestId(`domain-${domain.id}`)).toBeInTheDocument(); + }); + }); + + it('displays domain names', () => { + const mockDomains = [ + {id: 1, domain: 'example1.com', archive_count: 10, size: 1000}, + {id: 2, domain: 'example2.com', archive_count: 20, size: 2000}, + {id: 3, domain: 'example3.com', archive_count: 30, size: 3000}, + ]; + useDomains.mockReturnValue([mockDomains, 3, mockMetadata]); + + render(); + + expect(screen.getByTestId('domain-name-1')).toHaveTextContent('example1.com'); + expect(screen.getByTestId('domain-name-2')).toHaveTextContent('example2.com'); + expect(screen.getByTestId('domain-name-3')).toHaveTextContent('example3.com'); + }); + + it('displays Edit buttons in Manage column', () => { + const mockDomains = createMockDomains(3); + useDomains.mockReturnValue([mockDomains, 3, mockMetadata]); + + render(); + + // Each domain should have an Edit button + mockDomains.forEach((domain) => { + expect(screen.getByTestId(`edit-button-${domain.id}`)).toBeInTheDocument(); + }); + }); + + it('Edit button has correct styling', () => { + const mockDomains = createMockDomains(1); + useDomains.mockReturnValue([mockDomains, 1, mockMetadata]); + + render(); + + const editButton = screen.getByTestId('edit-button-1'); + expect(editButton).toHaveClass('ui'); + expect(editButton).toHaveClass('mini'); + expect(editButton).toHaveClass('primary'); + expect(editButton).toHaveClass('button'); + }); + }); + + describe('Empty and Error States', () => { + it('shows "No items yet" message when no domains', () => { + // Empty array indicates no domains + useDomains.mockReturnValue([[], 0, mockMetadata]); + + render(); + + // Should show empty state message + expect(screen.getByText(/no domains yet/i)).toBeInTheDocument(); + expect(screen.getByText(/archive some webpages/i)).toBeInTheDocument(); + + // Should not show table + expect(screen.queryByTestId('collection-table')).not.toBeInTheDocument(); + }); + + it('shows error message when fetch fails', () => { + // undefined indicates error state + useDomains.mockReturnValue([undefined, 0, mockMetadata]); + + render(); + + // Should show error message + expect(screen.getByTestId('error-message')).toBeInTheDocument(); + expect(screen.getByText(/could not fetch domains/i)).toBeInTheDocument(); + + // Should not show table + expect(screen.queryByTestId('collection-table')).not.toBeInTheDocument(); + }); + + it('does not show "New Domain" button', () => { + const mockDomains = createMockDomains(2); + useDomains.mockReturnValue([mockDomains, 2, mockMetadata]); + + render(); + + // Domains are auto-created, so there should be no "New" button + expect(screen.queryByRole('button', {name: /new/i})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /create/i})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /add/i})).not.toBeInTheDocument(); + }); + }); + + describe('Search Integration', () => { + it('disables search when no domains', () => { + useDomains.mockReturnValue([[], 0, mockMetadata]); + + render(); + + const searchInput = screen.getByTestId('search-input'); + expect(searchInput).toBeDisabled(); + }); + + it('enables search when domains exist', () => { + const mockDomains = createMockDomains(3); + useDomains.mockReturnValue([mockDomains, 3, mockMetadata]); + + render(); + + const searchInput = screen.getByTestId('search-input'); + expect(searchInput).not.toBeDisabled(); + }); + + it('passes search string to CollectionTable', () => { + const mockDomains = createMockDomains(2); + useDomains.mockReturnValue([mockDomains, 2, mockMetadata]); + + const mockSetSearchStr = jest.fn(); + useOneQuery.mockReturnValue(['example', mockSetSearchStr]); + + render(); + + // Search string should be passed to table + expect(screen.getByTestId('search-filter')).toHaveTextContent('example'); + }); + }); + + describe('Page Title', () => { + it('sets page title correctly', () => { + const mockDomains = createMockDomains(1); + useDomains.mockReturnValue([mockDomains, 1, mockMetadata]); + + render(); + + expect(useTitle).toHaveBeenCalledWith('Archive Domains'); + }); + }); +}); diff --git a/app/src/components/Download.test.js b/app/src/components/Download.test.js new file mode 100644 index 000000000..6c09dbefa --- /dev/null +++ b/app/src/components/Download.test.js @@ -0,0 +1,253 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {DestinationForm} from './Download'; +import {createTestForm} from '../test-utils'; + +// Mock DirectorySearch component - simplified to avoid useState/useEffect warnings +jest.mock('./Common', () => { + const React = require('react'); + return { + ...jest.requireActual('./Common'), + DirectorySearch: ({value, onSelect, disabled, required, id}) => ( +
+ onSelect(e.target.value)} + disabled={disabled} + required={required} + id={id} + /> +
+ ), + RequiredAsterisk: () => React.createElement('span', null, '*'), + InfoPopup: ({content}) => React.createElement('span', {'data-testid': 'info-popup'}, content), + }; +}); + +describe('DestinationForm', () => { + describe('Form Integration', () => { + it('renders DirectorySearch with form value', () => { + const form = createTestForm( + {destination: 'videos/test'}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveValue('videos/test'); + }); + + it('calls form onChange when directory is selected', async () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + + // Type a new directory + await userEvent.type(input, 'archive/new'); + + // Form data should be updated + await waitFor(() => { + expect(form.formData.destination).toBe('archive/new'); + }); + }); + + + it('displays required indicator when required=true', () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveAttribute('required'); + }); + }); + + describe('Props Handling', () => { + it('uses custom label when provided', () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + expect(screen.getByText(/custom folder/i)).toBeInTheDocument(); + }); + + it('uses default label when not provided', () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + expect(screen.getByText(/destination/i)).toBeInTheDocument(); + }); + + it('uses custom name/path when provided', () => { + const form = createTestForm( + {output_dir: 'videos/test'}, + {overrides: {ready: true, loading: false}} + ); + + render( + + ); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveValue('videos/test'); + }); + + it('shows info popup when infoContent provided', () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render( + + ); + + expect(screen.getByText(/this is helpful information/i)).toBeInTheDocument(); + }); + }); + + describe('useForm Integration', () => { + it('gets correct props from form.getCustomProps', () => { + const form = createTestForm( + {destination: 'videos/initial'}, + {overrides: {ready: true, loading: false}} + ); + + const getCustomPropsSpy = jest.spyOn(form, 'getCustomProps'); + + render(); + + expect(getCustomPropsSpy).toHaveBeenCalledWith({ + name: 'destination', + path: 'destination', + required: true + }); + }); + + it('updates form data on selection', async () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + + // Select a directory + await userEvent.type(input, 'videos/new-folder'); + + // Form should be updated + await waitFor(() => { + expect(form.formData.destination).toBe('videos/new-folder'); + }); + }); + + }); + + describe('Edge Cases', () => { + + it('works with nested form paths', () => { + const form = createTestForm( + {config: {output: {destination: 'videos/nested'}}}, + {overrides: {ready: true, loading: false}} + ); + + render( + + ); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveValue('videos/nested'); + }); + + it('handles concurrent field updates', async () => { + const form = createTestForm( + {destination: '', title: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + + // Simulate rapid updates + await userEvent.type(input, 'videos/a'); + form.setValue('title', 'Test Title'); + await userEvent.type(input, 'bc'); + + // Destination should have full value + await waitFor(() => { + expect(form.formData.destination).toBe('videos/abc'); + }); + + // Title should also be set + expect(form.formData.title).toBe('Test Title'); + }); + + it('handles empty string as initial value', () => { + const form = createTestForm( + {destination: ''}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveValue(''); + }); + + it('handles null as initial value', () => { + const form = createTestForm( + {destination: null}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveValue(''); + }); + + it('handles undefined as initial value', () => { + const form = createTestForm( + {}, + {overrides: {ready: true, loading: false}} + ); + + render(); + + const input = screen.getByTestId('directory-search-input'); + expect(input).toHaveValue(''); + }); + }); +}); diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js index b138ea432..5d4fa5462 100644 --- a/app/src/components/Nav.js +++ b/app/src/components/Nav.js @@ -186,9 +186,24 @@ export function NavBar() { /> } + // Upgrade available notification - only show on native (non-Docker) installs + let upgradeIcon; + if (status?.update_available && !status?.dockerized) { + const commitsBehind = status.commits_behind || 0; + const branch = status.git_branch || 'unknown'; + const icon = + + ; + upgradeIcon = ; + } + const icons = {apiDownIcon} {processingIcon} + {upgradeIcon} {powerIcon} {warningIcon} diff --git a/app/src/components/Vars.js b/app/src/components/Vars.js index 8a44c5dfd..d07b7b979 100644 --- a/app/src/components/Vars.js +++ b/app/src/components/Vars.js @@ -1,6 +1,7 @@ export const API_URI = process.env && process.env.REACT_APP_API_URI ? process.env.REACT_APP_API_URI : `https://${window.location.host}/api`; export const VIDEOS_API = `${API_URI}/videos`; export const ARCHIVES_API = `${API_URI}/archive`; +export const COLLECTIONS_API = `${API_URI}/collections`; export const OTP_API = `${API_URI}/otp`; export const ZIM_API = `${API_URI}/zim`; export const DEFAULT_LIMIT = 20; diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js index e89f28902..a47633b13 100644 --- a/app/src/components/admin/Settings.js +++ b/app/src/components/admin/Settings.js @@ -1,5 +1,6 @@ import React from "react"; -import {postRestart, postShutdown} from "../../api"; +import {ThemeContext} from "../../contexts/contexts"; +import {checkUpgrade, postRestart, postShutdown, triggerUpgrade} from "../../api"; import { Button, Divider, @@ -30,7 +31,7 @@ import QRCode from "react-qr-code"; import {useConfigs, useDockerized} from "../../hooks/customHooks"; import {toast} from "react-semantic-toasts-2"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; -import {SettingsContext} from "../../contexts/contexts"; +import {SettingsContext, StatusContext} from "../../contexts/contexts"; import {ConfigsTable} from "./Configs"; import {semanticUIColorMap} from "../Vars"; @@ -85,6 +86,89 @@ export function ShutdownButton() { } +function UpgradeSegment() { + const {status, fetchStatus} = React.useContext(StatusContext); + const dockerized = useDockerized(); + const [upgrading, setUpgrading] = React.useState(false); + const [checking, setChecking] = React.useState(false); + + const handleCheckUpgrade = async () => { + setChecking(true); + try { + await checkUpgrade(true); // Force a fresh check + toast({ + type: 'info', + title: 'Update Check Complete', + description: 'Checked for updates from git remote.', + time: 3000, + }); + } finally { + setChecking(false); + await fetchStatus(); + } + }; + + const handleUpgrade = async () => { + setUpgrading(true); + try { + const response = await triggerUpgrade(); + if (response.ok) { + // Redirect to maintenance page + window.location.href = '/maintenance.html'; + } + } catch (e) { + setUpgrading(false); + } + }; + + // Not available in Docker + if (dockerized) { + return +
System Upgrade
+

Upgrades are not available in Docker environments. Please upgrade your Docker images manually.

+
; + } + + // No update available + if (!status?.update_available) { + return +
System Upgrade
+

Your WROLPi is up to date.

+

Version: v{status?.version} on branch {status?.git_branch || 'unknown'}

+ + {checking ? 'Checking...' : 'Check for Updates'} + +
; + } + + // Update available + return +
+ + Upgrade Available +
+

Branch: {status?.git_branch}

+

Current commit: {status?.current_commit}

+

Latest commit: {status?.latest_commit}

+

{status?.commits_behind} commit(s) behind

+ + + {upgrading ? 'Starting Upgrade...' : 'Upgrade Now'} + +
; +} + export function RestartButton() { const dockerized = useDockerized(); @@ -529,6 +613,8 @@ export function SettingsPage() { {configsSegment} + +
Browser Settings
Show All Hints diff --git a/app/src/components/collections/CollectionEditForm.js b/app/src/components/collections/CollectionEditForm.js new file mode 100644 index 000000000..b6ffc0b6f --- /dev/null +++ b/app/src/components/collections/CollectionEditForm.js @@ -0,0 +1,152 @@ +import React from 'react'; +import {Button, Form, Grid, Message, TextArea} from 'semantic-ui-react'; +import {Header, Segment} from '../Theme'; +import {TagsSelector, TagsContext} from '../../Tags'; +import {WROLModeMessage} from '../Common'; +import {DestinationForm} from '../Download'; +import {InputForm} from '../../hooks/useForm'; + +/** + * Reusable form component for editing collections (Domains, Channels, etc). + * + * @param {Object} form - Form object from useForm hook + * @param {Object} metadata - Backend-provided metadata containing fields configuration + * @param {Function} onCancel - Optional callback when cancel is clicked + * @param {String} title - Page title to display in header + * @param {String} wrolModeContent - Content to show in WROL mode message (optional) + * @param {React.ReactNode} actionButtons - Optional additional action buttons to display in the button row + * @param {String} appliedTagName - Optional tag name to display (similar to ChannelEditPage pattern) + */ +export function CollectionEditForm({ + form, + metadata, + onCancel, + title, + wrolModeContent, + actionButtons, + appliedTagName +}) { + const {SingleTag} = React.useContext(TagsContext); + if (!metadata) { + return + No metadata available + ; + } + + const handleSubmit = (e) => { + e.preventDefault(); + form.onSubmit(); + }; + + const renderField = (field) => { + const value = form.formData[field.key] || ''; + const disabled = field.depends_on && !form.formData[field.depends_on]; + + switch (field.type) { + case 'text': + if (field.key === 'directory') { + // Use DestinationForm for directory picker + return ; + } + // Use InputForm for regular text fields + return ; + + case 'textarea': + // Textarea doesn't have a form component, use manual Field + const [textareaProps] = form.getCustomProps({name: field.key, path: field.key, required: field.required}); + return + +