diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c795d81a..843a317d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,11 +8,12 @@ Django web app for with React frontend. PostgreSQL data - unless explicitly told otherwise, don't run `git commit`, let me review changes. - avoid nested imports unless specifically used to avoid circular imports or delay heavy imports. - avoid arbitrary time.sleep() calls in tests; use proper waits on a specific condition instead. +- the current year is 2025 (not 2024), for web-searches ## Tech Stack **Backend**: Django, Python, DRF, GraphQL (graphene-django), PostgreSQL, Celery + Redis -**Frontend**: React, Webpack, pnpm monorepo (packages: spectra, blast use Vite) +**Frontend**: React, vite, pnpm monorepo (packages: spectra, blast use Vite) **Science**: BioPython, NumPy, Pandas, SciPy, Matplotlib ## Key Overrides @@ -29,7 +30,7 @@ uv sync # Install/update Python deps pnpm install # Install Node deps # Development -pnpm dev # Start webpack + Django dev server +pnpm dev # Start vite + Django dev server uv run backend/manage.py shell_plus # Interactive shell with auto-imports # Testing diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cc0e2009..ffca4710 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,7 +1,7 @@ # FPbase Copilot Instructions ## Project Overview -FPbase is a Django/React monorepo for the Fluorescent Protein Database (fpbase.org). The backend uses Django 4.x with GraphQL and REST APIs, while the frontend is a hybrid of server-rendered Django templates with embedded React apps built via Webpack and Vite. +FPbase is a Django/React monorepo for the Fluorescent Protein Database (fpbase.org). The backend uses Django 4.x with GraphQL and REST APIs, while the frontend is a hybrid of server-rendered Django templates with embedded React apps built via Vite. ## Architecture @@ -12,12 +12,12 @@ FPbase is a Django/React monorepo for the Fluorescent Protein Database (fpbase.o - `references/`: Publication and citation management - `fpseq/`: Bioinformatics sequence alignment and analysis using Biopython - `favit/`: User favorites system -- **`frontend/`**: Webpack-bundled assets integrated via `django-webpack-loader` +- **`frontend/`**: vite-bundled assets integrated via `django-vite` - **`packages/`**: Standalone Vite apps (`blast/`, `spectra/`) embedded in Django templates ### Key Technologies - **Backend**: Django, Django REST Framework, Graphene (GraphQL), Celery (Redis), PostgreSQL -- **Frontend**: React, Material-UI, Webpack, Vite +- **Frontend**: React, Material-UI, Vite - **Search**: Algolia for protein/organism search - **Bioinformatics**: Biopython (sequence alignment), BLAST (local binaries in `backend/bin/`) - **Deployment**: Heroku, AWS S3 (media), Sentry (error tracking) @@ -50,7 +50,6 @@ Django settings split across `backend/config/settings/`: - `base.py`: Shared configuration - `local.py`: Development (DEBUG=True, dummy cache, console email) - `production.py`: Production (Heroku, AWS S3, real cache) -- `test.py`: Testing (uses `MockWebpackLoader` to skip frontend builds) Environment variables loaded via `django-environ` from `.env` file (set `DJANGO_READ_DOT_ENV_FILE=True`). @@ -91,11 +90,6 @@ Core models in `models/`: ## Frontend Integration -### Webpack + Django Templates -- Webpack builds to `frontend/dist/` with stats tracked in `webpack-stats.json` -- Django loads bundles via `{% load webpack_loader %}` template tags -- Hot reload available with `HOT_RELOAD=1` env var -- Entry points in `frontend/src/`: `index.js`, `spectra-viewer.js`, `blast-app.js`, etc. ### Vite Apps (packages/) - Standalone React apps (`@fpbase/blast`, `@fpbase/spectra`) embedded as iframes or via CDN @@ -110,7 +104,6 @@ Core models in `models/`: ## Testing Patterns - Tests in `*/tests/` directories (pytest) - Use `@pytest.mark.django_db` for database access -- Frontend-dependent tests use `@pytest.mark.usefixtures("uses_frontend", "use_real_webpack_loader")` - Factory fixtures preferred over manual object creation ## Custom Middleware diff --git a/.gitignore b/.gitignore index 55b655f6..19564dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -362,9 +362,6 @@ _data .pytest_cache /fpseq/addgene.py - -webpack-stats.json - /.sentryclirc .vscode/ diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index dcb5cce2..64f0747f 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -226,16 +226,16 @@ "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] -INSTALLED_APPS.append("webpack_loader") - -WEBPACK_LOADER = { - "DEFAULT": { - "CACHE": not DEBUG, - "BUNDLE_DIR_NAME": "/", - "STATS_FILE": str(ROOT_DIR.parent / "frontend" / "dist" / "webpack-stats.json"), - "POLL_INTERVAL": 0.1, - "TIMEOUT": None, - "IGNORE": [r".*\.hot-update.js", r".+\.map"], +INSTALLED_APPS.append("django_vite") + +DJANGO_VITE = { + "default": { + "dev_mode": DEBUG, + "dev_server_protocol": "http", + "dev_server_host": "localhost", + "dev_server_port": 5173, + "static_url_prefix": "", + "manifest_path": str(ROOT_DIR.parent / "frontend" / "dist" / "manifest.json"), } } @@ -336,7 +336,6 @@ # See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 -# CANONICAL_URL = env('CANONICAL_URL', default='https://www.fpbase.org') CANONICAL_URL = env("CANONICAL_URL", default=None) diff --git a/backend/config/settings/local.py b/backend/config/settings/local.py index 6147984f..76d1c092 100644 --- a/backend/config/settings/local.py +++ b/backend/config/settings/local.py @@ -1,7 +1,5 @@ """Local settings for FPbase project.""" -import os - import structlog from .base import * # noqa @@ -9,7 +7,7 @@ # STATIC FILES - Add backend static directory for development # ------------------------------------------------------------------------------ # Include backend/fpbase/static so Django can serve microscope.js and other -# static files that don't go through webpack +# static files that don't go through vite STATICFILES_DIRS = [*STATICFILES_DIRS, str(ROOT_DIR / "fpbase" / "static")] # DEBUG @@ -19,6 +17,10 @@ CRISPY_FAIL_SILENTLY = not DEBUG +# DJANGO_VITE - Enable dev mode for local development +# ------------------------------------------------------------------------------ +DJANGO_VITE["default"]["dev_mode"] = True + # CSRF_COOKIE_HTTPONLY = True # SECRET CONFIGURATION @@ -83,7 +85,6 @@ ] # Structlog Configuration for Local Development -# Reconfigure to add dev-specific processors (set_exc_info for better tracebacks) structlog.configure( processors=[ *STRUCTLOG_SHARED_PROCESSORS, @@ -127,74 +128,13 @@ "level": "INFO", }, "loggers": { - # Application loggers - DEBUG in local - "fpbase": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, - "proteins": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, - "references": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, - "favit": { - "handlers": ["console"], - "level": "DEBUG", + "django.server": { + "level": "WARNING", # Hide normal requests, use structlog instead "propagate": False, }, - "celery": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, - # Django framework - "django": { - "handlers": ["console"], - "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), - "propagate": False, - }, - "django.db.backends": { - "handlers": ["console"], - "level": "INFO", # Set to DEBUG to see SQL queries - "propagate": False, - }, - # django-structlog - "django_structlog": { - "handlers": ["console"], - "level": "INFO", + "debug_toolbar": { + "level": "WARNING", # Hide debug toolbar noise "propagate": False, }, }, } - -# Optional: Desktop logging for specific debugging -if os.getenv("DESKTOP_LOG"): - from pathlib import Path - - LOGGING["handlers"]["file"] = { - "level": "DEBUG", - "class": "logging.FileHandler", - "filename": str(Path.home() / "Desktop/fpbase.log"), - "formatter": "colored", - } - LOGGING["loggers"].update( - { - "django.template": { - "handlers": ["file"], - "level": "INFO", - "propagate": True, - }, - "django.utils": { - "handlers": ["file"], - "level": "INFO", - "propagate": True, - }, - } - ) - LOGGING["loggers"]["django"]["handlers"].append("file") diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py index 3dcd3d3c..6d0d1b5a 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -9,6 +9,7 @@ """ +import re import ssl import sentry_sdk @@ -113,6 +114,14 @@ # ------------------------ WHITENOISE_MAX_AGE = 600 + +# http://whitenoise.evans.io/en/stable/django.html#WHITENOISE_IMMUTABLE_FILE_TEST +# https://github.com/MrBin99/django-vite?tab=readme-ov-file#whitenoise +def WHITENOISE_IMMUTABLE_FILE_TEST(path, url): + # Match vite (rollup)-generated hashes, à la, `some_file-CSliV9zW.js` + return re.match(r"^.+[.-][0-9a-zA-Z_-]{8,12}\..+$", url) + + # EMAIL # ------------------------------------------------------------------------------ DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL", default="FPbase ") diff --git a/backend/config/settings/test.py b/backend/config/settings/test.py index 9420ed9f..9a675a27 100644 --- a/backend/config/settings/test.py +++ b/backend/config/settings/test.py @@ -15,6 +15,11 @@ DEBUG = False TEMPLATES[0]["OPTIONS"]["debug"] = True +# ALLOWED_HOSTS +# ------------------------------------------------------------------------------ +# Allow all hosts for testing (live_server uses random ports) +ALLOWED_HOSTS = ["*"] + # SECRET CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key @@ -94,3 +99,12 @@ ], ], ] + + +# django-vite in test mode uses manifest (never dev server) +DJANGO_VITE = { + "default": { + "dev_mode": False, + "manifest_path": str(ROOT_DIR.parent / "frontend" / "dist" / "manifest.json"), + } +} diff --git a/backend/fpbase/static/js/README.md b/backend/fpbase/static/js/README.md index 5fb4eb70..c1e78e98 100644 --- a/backend/fpbase/static/js/README.md +++ b/backend/fpbase/static/js/README.md @@ -1,6 +1,6 @@ # Legacy Static JavaScript -This directory contains legacy JavaScript that is **isolated from the main webpack bundle** and loaded separately via Django static files. +This directory contains legacy JavaScript that is **isolated from the main vite bundle** and loaded separately via Django static files. ## Contents diff --git a/backend/fpbase/templates/500.html b/backend/fpbase/templates/500.html index c0995e27..85c5a51b 100644 --- a/backend/fpbase/templates/500.html +++ b/backend/fpbase/templates/500.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% load webpack_static from webpack_loader %} {% block title %}Server Error{% endblock %} @@ -12,8 +11,6 @@

Looks like something went wrong :(

{% block javascript %} - - {% if sentry_event_id %} + + + + + + + + {% vite_asset 'src/index.js' defer='defer' %} + + {% block extrahead %} {% endblock extrahead %} - {% block bodyopen %} - {% endblock bodyopen %} + {% block bodyopen %}{% endblock bodyopen %} {% block body %}
@@ -95,7 +104,6 @@
- {% block messages %} {% if messages %}
@@ -110,8 +118,7 @@ {% endif %} {% endblock messages %} - {% block content %} - {% endblock content %} + {% block content %}{% endblock content %}
@@ -138,14 +145,7 @@ {% endblock body %} - - {% render_bundle 'main' 'js' %} - - - - {% block javascript %}{% endblock %} + {% block javascript %}{% endblock %} diff --git a/backend/fpbase/templates/pages/test_autocomplete.html b/backend/fpbase/templates/pages/test_autocomplete.html index 03b0c89c..f895b688 100644 --- a/backend/fpbase/templates/pages/test_autocomplete.html +++ b/backend/fpbase/templates/pages/test_autocomplete.html @@ -1,6 +1,4 @@ {% extends "base.html" %} -{% load webpack_static from webpack_loader %} - {% block content %}
diff --git a/backend/fpbase/tests/__init__.py b/backend/fpbase/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/fpbase/users/tests/__init__.py b/backend/fpbase/users/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/fpbase/users/tests/factories.py b/backend/fpbase/users/tests/factories.py deleted file mode 100644 index e49b2dee..00000000 --- a/backend/fpbase/users/tests/factories.py +++ /dev/null @@ -1,11 +0,0 @@ -import factory.declarations - - -class UserFactory(factory.django.DjangoModelFactory): - username = factory.declarations.Sequence(lambda n: f"user-{n}") - email = factory.declarations.Sequence(lambda n: f"user-{n}@example.com") - password = factory.declarations.PostGenerationMethodCall("set_password", "password") - - class Meta: - model = "users.User" - django_get_or_create = ("username",) diff --git a/backend/proteins/templates/compare.html b/backend/proteins/templates/compare.html index 224e38d5..ec9aa309 100644 --- a/backend/proteins/templates/compare.html +++ b/backend/proteins/templates/compare.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load humanize %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% block title %}{% endblock %} {% block meta-description %}{% endblock %} @@ -89,6 +89,6 @@

Nothing to compare!

{% block javascript %} {% if spectra_ids %} - {% render_bundle 'simpleSpectraViewer' %} + {% vite_asset 'src/simple-spectra-viewer.js' %} {% endif %} {% endblock javascript %} diff --git a/backend/proteins/templates/fret.html b/backend/proteins/templates/fret.html index 53b2c334..9f6f21bc 100644 --- a/backend/proteins/templates/fret.html +++ b/backend/proteins/templates/fret.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% load favit_tags %} {% load webp_picture from protein_tags %} +{% load django_vite %} {% block title %}FPbase FRET Calculator{% endblock %} {% block meta-description %}An interactive fluorescence spectra viewer and Förster radius calculator to visualize @@ -29,18 +30,6 @@

FPbase FRET Calculator

- -
@@ -223,13 +212,13 @@
Helpful References
@@ -239,18 +228,16 @@
Helpful References
+ {% endblock content %} {% block javascript %} - - +{% vite_asset 'src/fret.js' %} - + +{# FRET initialization handled automatically by the fret.js bundle #} {% endblock javascript %} diff --git a/backend/proteins/templates/ichart.html b/backend/proteins/templates/ichart.html index 525ea416..60c1c802 100644 --- a/backend/proteins/templates/ichart.html +++ b/backend/proteins/templates/ichart.html @@ -101,30 +101,44 @@ {% block javascript %} {% endblock %} diff --git a/backend/proteins/templates/lineage.html b/backend/proteins/templates/lineage.html index b608b3c9..38c79faa 100644 --- a/backend/proteins/templates/lineage.html +++ b/backend/proteins/templates/lineage.html @@ -29,24 +29,36 @@

Fluorescent Protein Lineages

{% block javascript %} diff --git a/backend/proteins/templates/proteins/_structure_section.html b/backend/proteins/templates/proteins/_structure_section.html index 15e8c1c0..c71755f3 100644 --- a/backend/proteins/templates/proteins/_structure_section.html +++ b/backend/proteins/templates/proteins/_structure_section.html @@ -1,7 +1,10 @@

Structure

-
+
-
+
@@ -23,16 +26,13 @@
Deposited: , -
+
Chromophore{% if protein.chromophore %} ({{protein.chromophore}}){% endif %}:

-
diff --git a/backend/proteins/templates/proteins/blast.html b/backend/proteins/templates/proteins/blast.html index dded26b3..cf64fd9b 100644 --- a/backend/proteins/templates/proteins/blast.html +++ b/backend/proteins/templates/proteins/blast.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load static %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% block title %}FPbase Fluorescent Protein Sequence BLAST{% endblock %} {% block meta-description %}Search the database at FPbase for fluorescent protein sequences similar to a query amino acid or nucleotide sequence.{% endblock %} @@ -26,6 +26,6 @@

Fluorescent Protein BLAST

-{% render_bundle 'blast' %} +{% vite_asset 'src/blast-app.js' %} {% endblock content %} diff --git a/backend/proteins/templates/proteins/microscope_embed.html b/backend/proteins/templates/proteins/microscope_embed.html index 30cf320e..e45309ec 100644 --- a/backend/proteins/templates/proteins/microscope_embed.html +++ b/backend/proteins/templates/proteins/microscope_embed.html @@ -1,6 +1,6 @@ {% load static %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} @@ -21,8 +21,12 @@ {# Load only CSS from main bundle (not JS which includes D3 v7) #} - {% render_bundle 'main' 'css' %} - + {% vite_asset 'src/index.js' %} + + + + + @@ -40,9 +44,7 @@

{{microscope}}

{% include 'proteins/_microscope_include.html' with embeddable=False %}
- {% render_bundle 'embedscope' 'js' %} - - + {% vite_asset 'src/embedscope.js' %} -{% render_bundle 'microscopeForm' %} +{% vite_asset 'src/microscope-form.js' %} {% if object %}

Update microscope: {{object}}

diff --git a/backend/proteins/templates/proteins/microscope_list.html b/backend/proteins/templates/proteins/microscope_list.html index e023a5a3..6741c9f8 100644 --- a/backend/proteins/templates/proteins/microscope_list.html +++ b/backend/proteins/templates/proteins/microscope_list.html @@ -51,7 +51,7 @@

Example microscopes:

  • {{object}} {{object.description}} + title='fluorophore efficiency report'>
  • {% endfor %} @@ -71,7 +71,7 @@

    Recently created microscopes:

    class="fa fa-trash-alt small" style="margin-bottom: 2px">{% endif%} - + {% empty %} {% if owner %} @@ -94,12 +94,12 @@

    Microscopes you manage

    text-muted ml-2'>{{object.description}} – {{object.created |date:"M d, Y"}} {% if request.user.username == owner %} - + {% endif%} - + {% endfor %} diff --git a/backend/proteins/templates/proteins/organism_detail.html b/backend/proteins/templates/proteins/organism_detail.html index 0f10b402..a7bee7ae 100644 --- a/backend/proteins/templates/proteins/organism_detail.html +++ b/backend/proteins/templates/proteins/organism_detail.html @@ -37,23 +37,36 @@
    Proteins derived from {{ object }}
    {% block javascript %} diff --git a/backend/proteins/templates/proteins/protein_detail.html b/backend/proteins/templates/proteins/protein_detail.html index 451deb0e..be37c650 100644 --- a/backend/proteins/templates/proteins/protein_detail.html +++ b/backend/proteins/templates/proteins/protein_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load i18n favit_tags %} {% load flag_object from protein_tags %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% block title %}{{ protein.name|safe }} :: Fluorescent Protein Database {% endblock %} {% block meta-description %}{{ protein.description }}{% endblock %} @@ -15,6 +15,9 @@ {% block extrahead %} {% include 'proteins/_structured_data.html' with protein=protein %} {% if protein.pdb %} + {# Preconnect to PDB data sources for faster loading #} + + {% endif %} {% endblock extrahead %} @@ -180,7 +183,7 @@

    {{ protein.name

    -

    FPbase ID: +

    FPbase ID: {{ protein.uuid }}

    @@ -283,7 +286,7 @@

    {{ protein.name | safe }} Sequence

    {% if protein.seq_validated %} {% else %} - + {% endif %} {% endif %} @@ -292,9 +295,19 @@

    {{ protein.name | safe }} Sequence

    {% endif %} {% if protein.lineage and protein.lineage.parent %} -

    {{protein.name | safe}} was derived from {{protein.lineage.parent.protein}} with the following mutations: {{protein.lineage.rootmut}}
    - {% if protein.lineage.root_node != protein.lineage.parent and protein.lineage.mutation != protein.lineage.rootmut %} - amino acid numbers relative to {{protein.lineage.root_node}}. show relative to {{protein.lineage.parent.protein}} +

    {{protein.name | safe}} was derived from + {{protein.lineage.parent.protein}} + with the following mutations: + + {{protein.lineage.rootmut}}
    + {% if protein.lineage.root_node != protein.lineage.parent and protein.lineage.mutation != protein.lineage.rootmut %} + + amino acid numbers relative to + + {{protein.lineage.root_node}}. + + show relative to {{protein.lineage.parent.protein}} + {% endif %}

    {% if protein.has_spectra %} - {% render_bundle 'simpleSpectraViewer' %} - {% endif %} - - {% if protein.pdb %} - {% render_bundle 'litemol' attrs='defer' %} - + {% vite_asset 'src/simple-spectra-viewer.js' %} {% endif %} - -{% endblock javascript %} + +{% endblock %} diff --git a/backend/proteins/templates/proteins/scope_report.html b/backend/proteins/templates/proteins/scope_report.html index a4c767e9..e6cd35c7 100644 --- a/backend/proteins/templates/proteins/scope_report.html +++ b/backend/proteins/templates/proteins/scope_report.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load static %} +{% load django_vite %} {% block title %}FPbase efficiency report for {{object}}{% endblock %} {% block meta-description %}Graphical and tabular representation of excitation and collection efficiency for each optical configuration on the scope, with each fluorophore in the database {% endblock %} @@ -115,6 +116,7 @@
    Efficiency considerations
    {% endblock content %} {% block javascript %} +{% vite_asset 'src/scope-report.js' %} diff --git a/backend/proteins/templates/proteins/spectrum_form.html b/backend/proteins/templates/proteins/spectrum_form.html index b249e9c3..e131c9d2 100644 --- a/backend/proteins/templates/proteins/spectrum_form.html +++ b/backend/proteins/templates/proteins/spectrum_form.html @@ -128,6 +128,16 @@
    } }); + // Helper to wait for Bootstrap plugins to be available + function waitForBootstrap(callback) { + if (typeof $.fn.tab !== 'undefined') { + callback(); + } else { + // Retry after a short delay + setTimeout(function() { waitForBootstrap(callback); }, 50); + } + } + // Create tabs immediately when DOM is ready $(document).ready(function() { // Find the file and data fields in the crispy form and wrap them with tabs @@ -168,7 +178,11 @@
    // Manually initialize Bootstrap tabs $('#data-source-tabs a[data-toggle="tab"]').on('click', function (e) { e.preventDefault(); - $(this).tab('show'); + var $this = $(this); + // Wait for Bootstrap to be available before calling .tab() + waitForBootstrap(function() { + $this.tab('show'); + }); }); }); @@ -342,7 +356,12 @@
    } }, error: function(xhr) { - var response = JSON.parse(xhr.responseText || '{}'); + var response = {}; + try { + response = JSON.parse(xhr.responseText || '{}'); + } catch(e) { + // Ignore parse errors + } var errorMsg = response.error || 'Failed to generate preview'; var details = response.details || ''; var formErrors = response.form_errors || {}; @@ -359,7 +378,8 @@
    var preview = response.preview; // Clear any previous validation error alerts since preview was successful - $('.alert-danger.alert-dismissible').alert('close'); + // Use remove() instead of Bootstrap's alert('close') for better compatibility + $('.alert-danger.alert-dismissible').remove(); // Update message based on whether file was processed var message = response.message; @@ -411,7 +431,9 @@
    function hidePreview() { if (currentPreviewData) { // Switch to manual data tab - $("#manual-tab").tab('show'); + waitForBootstrap(function() { + $("#manual-tab").tab('show'); + }); // Clear the file input completely var fileInput = $("#id_file"); @@ -501,6 +523,8 @@
    // Initialize submit button text $(document).ready(function() { updateSubmitButton(); + // Mark form as ready for E2E tests + $("#spectrum-submit-form").attr("data-form-ready", "true"); }); diff --git a/backend/proteins/templates/spectra.html b/backend/proteins/templates/spectra.html index 86da919d..9b222e93 100644 --- a/backend/proteins/templates/spectra.html +++ b/backend/proteins/templates/spectra.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load favit_tags %} {% load static %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% block title %}FPbase Fluorescence Spectra Viewer{% endblock %} {% block meta-description %}An interactive fluorescence spectra viewer to evaluate the spectral properties of fluorescent proteins, organic dyes, filters, and detectors. Calculate collection efficiency or bleedthrough probabilities in your microscope and explore combinations of filters and dyes.{% endblock %} @@ -19,7 +19,7 @@ {% block content %}

    FPbase Spectra Viewer

    {% include "proteins/_loading_logo.html" %}
    -{% render_bundle 'spectraViewer' %} +{% vite_asset 'src/spectra-viewer.js' %} {% endblock content %} {% block footer %} diff --git a/backend/proteins/templates/spectra_graph.html b/backend/proteins/templates/spectra_graph.html index f5b03c37..5b54a2a1 100644 --- a/backend/proteins/templates/spectra_graph.html +++ b/backend/proteins/templates/spectra_graph.html @@ -1,6 +1,6 @@ {% load static %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% block ga %} @@ -24,5 +24,5 @@
    - {% render_bundle 'simpleSpectraViewer' %} + {% vite_asset 'src/simple-spectra-viewer.js' %} diff --git a/backend/proteins/templates/table.html b/backend/proteins/templates/table.html index 4bc88b20..ebb31dd7 100644 --- a/backend/proteins/templates/table.html +++ b/backend/proteins/templates/table.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% block title %}FPbase :: Table of Fluorescent Protein Properties{% endblock %} {% block meta-description %}A comprehensive, sortable, searchable table of all the fluorescent proteins in the fluorescent protein database.{% endblock %} @@ -29,7 +29,7 @@

    Fluorescent Protein Table

    -{% render_bundle 'proteinTable' %} +{% vite_asset 'src/protein-table.js' %} {% endblock %} diff --git a/backend/proteins/tests/__init__.py b/backend/proteins/tests/__init__.py deleted file mode 100644 index eed836da..00000000 --- a/backend/proteins/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.1.0" -__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".")]) diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index f4f79001..5107c60d 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -1,5 +1,6 @@ import contextlib import io +import json import logging import operator from functools import reduce @@ -9,6 +10,7 @@ import django.forms.formsets import reversion from django.apps import apps +from django.conf import settings from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required @@ -37,7 +39,6 @@ from fpbase.util import is_ajax, uncache_protein_page from proteins.extrest.entrez import get_cached_gbseqs from proteins.extrest.ga import cached_ga_popular -from proteins.forms.forms import BaseStateFormSet from proteins.util.helpers import link_excerpts, most_favorited from proteins.util.maintain import check_lineages, suggested_switch_type from proteins.util.spectra import spectra2csv @@ -57,6 +58,8 @@ if TYPE_CHECKING: import maxminddb + from proteins.forms.forms import BaseStateFormSet + logger = logging.getLogger(__name__) @@ -211,11 +214,14 @@ class ProteinDetailView(DetailView): .select_related("primary_reference") ) - @method_decorator(cache_page(60 * 30)) - @method_decorator(vary_on_cookie) def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) + # Only enable caching in production (when DEBUG=False) + if not settings.DEBUG: + dispatch = method_decorator(cache_page(60 * 30))(dispatch) + dispatch = method_decorator(vary_on_cookie)(dispatch) + def version_view(self, request, version, *args, **kwargs): try: with transaction.atomic(using=version.db): @@ -312,6 +318,10 @@ def get_context_data(self, **kwargs): except Exception: data["country_code"] = "" + # Serialize PDB IDs as JSON for JavaScript + if self.object.pdb: + data["pdb_ids_json"] = json.dumps(self.object.pdb) + return data diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 144eb4f9..4152ec1f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,13 +1,25 @@ -from unittest.mock import patch +import unittest.mock import pytest -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def _mock_django_vite_for_unit_tests(): - # Mock out the webpack loader to use a fake loader for unit tests - # we do this here rather than config.settings.test to not interfere with e2e tests. - from webpack_loader import loaders, utils + """Mock django-vite asset loading for unit tests that don't need frontend assets. + + This fixture prevents django-vite from trying to load the manifest.json file + when running unit tests without the frontend build. It returns empty strings + for all asset requests, which is sufficient for tests that don't actually + render templates or care about frontend assets. + + E2E tests (in tests_e2e/) skip this mock by having their own conftest.py + that builds the frontend assets before tests run. + """ + + # Mock the generate_vite_asset method on the DjangoViteAssetLoader + with unittest.mock.patch("django_vite.templatetags.django_vite.DjangoViteAssetLoader.instance") as mock_loader: + mock_instance = unittest.mock.MagicMock() + mock_instance.generate_vite_asset.return_value = "" + mock_loader.return_value = mock_instance - with patch.object(utils, "get_loader", return_value=loaders.FakeWebpackLoader("DEFAULT", {})): yield diff --git a/backend/tests_e2e/conftest.py b/backend/tests_e2e/conftest.py index e2dabbf4..f5582cf5 100644 --- a/backend/tests_e2e/conftest.py +++ b/backend/tests_e2e/conftest.py @@ -75,28 +75,6 @@ pytestmark = pytest.mark.django_db(transaction=True) -def _frontend_assets_need_rebuild(stats_file) -> bool: - """Check if frontend assets need to be rebuilt.""" - if not stats_file.is_file(): - return True - - assets = json.loads(stats_file.read_bytes()) - if assets.get("status") != "done" or not assets.get("chunks") or ("localhost" in assets.get("publicPath", "")): - return True - - # Stats are valid - check if source files are newer - stats_mtime = stats_file.stat().st_mtime - frontend_src = Path(__file__).parent.parent.parent / "frontend" / "src" - sources = list(frontend_src.rglob("*")) - packages = Path(__file__).parent.parent.parent / "packages" - sources += list(packages.rglob("src/**/*")) - if any(f.stat().st_mtime > stats_mtime for f in sources if f.is_file() and not f.name.startswith(".")): - return True - - # Everything is up to date - return False - - def pytest_configure(config: pytest.Config) -> None: """Build frontend assets before test collection when running e2e tests. @@ -121,8 +99,7 @@ def _color_text(text: str, color_code: int) -> str: return f"\033[{color_code}m{text}\033[0m" return text - manifest_file = Path(django.conf.settings.WEBPACK_LOADER["DEFAULT"]["STATS_FILE"]) - + manifest_file = Path(django.conf.settings.DJANGO_VITE["default"]["manifest_path"]) lock_file = manifest_file.parent / ".build.lock" lock_file.parent.mkdir(parents=True, exist_ok=True) @@ -148,10 +125,45 @@ def _color_text(text: str, color_code: int) -> str: django.conf.settings.STATICFILES_DIRS.append(str(manifest_file.parent)) + # Clear django-vite's cached manifest so it reloads the new one + # This is necessary because Django may have already loaded the old manifest + import django_vite.core.asset_loader + + django_vite.core.asset_loader.DjangoViteAssetLoader._instance = None else: print(_color_text("✅ Frontend assets are up to date", GREEN), flush=True) +def _frontend_assets_need_rebuild(manifest_file) -> bool: + """Check if frontend assets need to be rebuilt.""" + if not manifest_file.is_file(): + return True + + # Check if manifest is valid JSON + try: + manifest = json.loads(manifest_file.read_bytes()) + if not manifest: + return True + except (json.JSONDecodeError, ValueError): + return True + + # Manifest is valid - check if source files are newer + manifest_mtime = manifest_file.stat().st_mtime + frontend_src = Path(__file__).parent.parent.parent / "frontend" / "src" + sources = list(frontend_src.rglob("*")) + packages = Path(__file__).parent.parent.parent / "packages" + sources += list(packages.rglob("src/**/*")) + if any(f.stat().st_mtime > manifest_mtime for f in sources if f.is_file() and not f.name.startswith(".")): + return True + + # Everything is up to date + return False + + +# Frontend assets are built in pytest_configure hook (runs before Django import) +# No fixture needed here since assets are guaranteed to exist before workers start + + @pytest.fixture def page(page: Page) -> Iterator[Page]: """Configure Playwright page fixture with FPbase defaults. @@ -172,7 +184,10 @@ def page(page: Page) -> Iterator[Page]: def _apply_source_maps_to_stack(stack_trace: str) -> str: - """Apply inline source maps to transform bundle paths into source file locations.""" + """Apply inline source maps to transform bundle paths into source file locations. + + Works with both Webpack and Vite inline source maps. + """ static_dir = Path(__file__).parent.parent.parent / "frontend" / "dist" def replace_location(match: re.Match) -> str: @@ -196,7 +211,16 @@ def replace_location(match: re.Match) -> str: token: sourcemap.objects.Token token = source_map.lookup(line=int(line_str) - 1, column=int(column_str)) if token and token.src: - src = token.src.replace("file:///", "").replace("webpack://fpbase/", "").lstrip("./") + # Clean up source paths from both Webpack and Vite + src = ( + token.src.replace("file:///", "") + .replace("webpack://fpbase/", "") # Webpack format + .replace("../../", "") # Vite relative paths from dist/assets/ + .lstrip("./") + ) + # Remove leading "../" patterns that might remain + while src.startswith("../"): + src = src[3:] return f"{src}:{token.src_line + 1}:{token.src_col}" except Exception: pass @@ -268,8 +292,15 @@ def on_console(self, msg: ConsoleMessage) -> None: def on_page_error(self, error: Exception) -> None: """Collect uncaught JavaScript exceptions (ReferenceError, TypeError, etc).""" - if not self._should_ignore(str(error)): - self.page_errors.append(error) + error_text = str(error) + if not self._should_ignore(error_text): + # Apply source maps to stack traces in page errors + if hasattr(error, "stack") and error.stack: + # Create a wrapper since error.stack is read-only + mapped_error = SimpleNamespace(stack=_apply_source_maps_to_stack(error.stack), message=str(error)) + self.page_errors.append(mapped_error) + else: + self.page_errors.append(error) def on_request_failed(self, request: Request) -> None: """Collect failed network requests (DNS errors, connection refused, etc).""" diff --git a/backend/tests_e2e/test_e2e.py b/backend/tests_e2e/test_e2e.py index 6b218c88..1fd5efd1 100644 --- a/backend/tests_e2e/test_e2e.py +++ b/backend/tests_e2e/test_e2e.py @@ -106,6 +106,8 @@ def test_spectrum_submission_preview_manual_data( url = f"{live_server.url}{reverse('proteins:submit-spectra')}" auth_page.goto(url) expect(auth_page).to_have_url(url) + # Wait for form to be fully initialized + expect(auth_page.locator("#spectrum-submit-form[data-form-ready='true']")).to_be_attached() auth_page.locator("#id_category").select_option(Spectrum.PROTEIN) auth_page.locator("#id_subtype").select_option(Spectrum.EX) @@ -126,9 +128,9 @@ def test_spectrum_submission_preview_manual_data( # Submit for preview auth_page.locator('input[type="submit"]').click() - # Wait for preview section to appear (auto-waiting) + # Wait for preview section to appear (AJAX request) preview_section = auth_page.locator("#spectrum-preview-section") - expect(preview_section).to_be_visible() + expect(preview_section).to_be_visible(timeout=10000) svg = auth_page.locator("#spectrum-preview-chart svg") expect(svg).to_be_visible() @@ -483,7 +485,8 @@ def test_advanced_search(live_server: LiveServer, page: Page, assert_snapshot: C url = f"{live_server.url}{reverse('proteins:search')}" page.goto(url) expect(page).to_have_url(url) - # Wait for search form to be ready + # Wait for search form to be ready (initSearch has completed) + expect(page.locator("#query_builder[data-search-ready='true']")).to_be_attached() expect(page.locator("#filter-select-0")).to_be_visible() assert_snapshot(page) @@ -502,13 +505,15 @@ def test_advanced_search(live_server: LiveServer, page: Page, assert_snapshot: C # Submit search page.locator('button[type="submit"]').first.click() - # page.wait_for_load_state("networkidle") + + # Wait for results page to load and JS to initialize + expect(page.locator("#query_builder[data-search-ready='true']")).to_be_attached() lozenges = page.locator("#ldisplay") expect(lozenges).to_be_visible() assert_snapshot(page) - # click on table display + # Click on table display button by clicking its label page.locator("label:has(#tbutton)").click() table = page.locator("#tdisplay") expect(table).to_be_visible() diff --git a/frontend/package.json b/frontend/package.json index 1d54ba42..7298615b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,64 +11,44 @@ "dependencies": { "@fpbase/blast": "workspace:*", "@fpbase/spectra": "workspace:*", - "@popperjs/core": "^2.4.0", "@sentry/browser": "^10.22.0", "algoliasearch": "^3.35.1", "autocomplete.js": "^0.36.0", - "d3": "^7.9.0", + "d3-array": "^3.2.4", + "d3-axis": "^3.0.0", + "d3-color": "^3.1.0", + "d3-drag": "^3.0.0", + "d3-hierarchy": "^3.1.2", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "highcharts": "^12.4.0", - "jquery": "^3.7.0", "nouislider": "^14.7.0", "process": "^0.11.10", "progressbar.js": "^1.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "regenerator-runtime": "^0.13.5", "select2": "^4.0.13", - "select2-theme-bootstrap4": "^0.2.0-beta.6", - "url": "^0.11.0" + "select2-theme-bootstrap4": "^0.2.0-beta.6" }, "devDependencies": { - "@babel/core": "^7.12.7", - "@babel/plugin-syntax-dynamic-import": "^7.7.4", - "@babel/preset-env": "^7.7.7", - "@babel/preset-react": "^7.7.4", - "@babel/preset-typescript": "^7.28.5", "@sentry/cli": "^2.57.0", - "@sentry/webpack-plugin": "^4.5.0", + "@sentry/vite-plugin": "^4.6.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.0", "autoprefixer": "^10.4.21", - "babel-loader": "^10.0.0", - "babel-plugin-transform-imports": "^2.0.0", "bootstrap": "^4.6.2", - "clean-webpack-plugin": "^4.0.0", - "copy-webpack-plugin": "^13.0.1", - "css-loader": "^7.1.2", - "css-minimizer-webpack-plugin": "^7.0.2", "cssnano": "^7.1.1", - "mini-css-extract-plugin": "^2.9.4", - "postcss-loader": "^8.2.0", + "rollup-plugin-visualizer": "^6.0.5", "sass": "1.93.2", - "sass-loader": "16.0.6", - "terser-webpack-plugin": "^5.3.14", - "webpack": "^5.102.1", - "webpack-bundle-analyzer": "^4.9.0", - "webpack-bundle-tracker": "3.2.1", - "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.2" + "vite": "^7.1.12", + "vite-plugin-static-copy": "^3.1.4" }, "scripts": { - "start": "HOT_RELOAD=1 webpack-dev-server --mode development", - "build": "NODE_ENV=production webpack --mode production", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "clean": "pnpm exec -- rm -rf dist" - }, - "browserslist": { - "production": [ - "defaults" - ], - "development": [ - "last 1 version" - ] } } diff --git a/frontend/src/blast-app.js b/frontend/src/blast-app.js index 82b649d5..73fae19c 100644 --- a/frontend/src/blast-app.js +++ b/frontend/src/blast-app.js @@ -1,3 +1,9 @@ +// Vite modulepreload polyfill (must be first) +import "vite/modulepreload-polyfill" + +// React Fast Refresh preamble (must be before any React imports when using Django/backend integration) +import "@vitejs/plugin-react/preamble" + // Initialize Sentry first to catch errors during module loading import "./js/sentry-init.js" diff --git a/frontend/src/css/_select2-bootstrap.scss b/frontend/src/css/_select2-bootstrap.scss deleted file mode 100644 index 652786a1..00000000 --- a/frontend/src/css/_select2-bootstrap.scss +++ /dev/null @@ -1,874 +0,0 @@ -// -// Functions -// -------------------------------------------------- - -/** - * We need a clone of bootstrap color-yiq mixin so we can get the same value for color - */ -@function bs4-color-yiq($color) { - $r: red($color); - $g: green($color); - $b: blue($color); - - $yiq: (($r * 299) + ($g * 587) + ($b * 114)) / 1000; - - @if ($yiq >= 150) { - @return "#111"; - } @else { - @return "#fff"; - } -} - -// -// Variables -// -------------------------------------------------- - -// Variables directly translating Bootstrap variables -// ------------------------- - -$s2bs-enable-shadows: $enable-shadows !default; -$s2bs-border-radius-base: $border-radius !default; -$s2bs-border-radius-large: $border-radius-lg !default; -$s2bs-border-radius-small: $border-radius-sm !default; -$s2bs-btn-default-bg: $input-group-addon-bg !default; // or $gray-200 -$s2bs-btn-default-border: theme-color("secondary") !default; -$s2bs-btn-default-color: bs4-color-yiq($s2bs-btn-default-bg) !default; -$s2bs-caret-width-base: .25rem !default; // 4px -$s2bs-caret-width-large: .3125rem !default; // 5px - -$s2bs-cursor-disabled: not-allowed !default; -$s2bs-dropdown-header-color: $dropdown-header-color !default; -$s2bs-dropdown-link-active-bg: $dropdown-link-active-bg !default; -$s2bs-dropdown-link-active-color: $dropdown-link-active-color !default; -$s2bs-dropdown-link-disabled-color: $dropdown-link-disabled-color !default; -$s2bs-dropdown-link-hover-bg: $dropdown-link-hover-bg !default; -$s2bs-dropdown-link-hover-color: $dropdown-link-hover-color !default; -$s2bs-font-size-base: $font-size-base !default; -$s2bs-font-size-large: $font-size-lg !default; -$s2bs-font-size-small: $font-size-sm !default; -$s2bs-padding-base-horizontal: $input-btn-padding-x !default; -$s2bs-padding-large-horizontal: $input-btn-padding-x-lg !default; -$s2bs-padding-small-horizontal: $input-btn-padding-x-sm !default; -$s2bs-padding-base-vertical: $input-btn-padding-y !default; -$s2bs-padding-large-vertical: $input-btn-padding-y-lg !default; -$s2bs-padding-small-vertical: $input-btn-padding-y-sm !default; -$s2bs-line-height-base: $input-btn-line-height !default; -$s2bs-line-height-large: $input-btn-line-height !default; -$s2bs-line-height-small: $input-btn-line-height !default; -$s2bs-input-bg: $input-bg !default; -$s2bs-input-bg-disabled: $input-disabled-bg !default; -$s2bs-input-color: $input-color !default; -$s2bs-input-color-placeholder: $input-placeholder-color !default; -$s2bs-input-border: $input-border-color !default; -$s2bs-input-border-focus: $input-focus-border-color !default; -$s2bs-input-border-radius: $input-border-radius !default; -$s2bs-input-height-base: calc(#{$input-btn-padding-y * 2 + $input-btn-line-height * $font-size-base} + #{$border-width * 2}) !default; -$s2bs-input-height-large: calc(#{$input-btn-padding-y-lg * 2 + $input-btn-line-height * $font-size-lg} + #{$border-width * 2}) !default; -$s2bs-input-height-small: calc(#{$input-btn-padding-y-sm * 2 + $input-btn-line-height * $font-size-sm} + #{$border-width * 2}) !default; -$s2bs-state-warning-text: theme-color("warning") !default; -$s2bs-state-danger-text: $form-feedback-invalid-color !default; -$s2bs-state-success-text: $form-feedback-valid-color !default; - -// Theme-specific variables -// ------------------------- - -$s2bs-dropdown-arrow-color: $s2bs-input-color-placeholder !default; -$s2bs-dropdown-box-shadow: 0 6px 12px rgba(0,0,0,.175) !default; -$s2bs-dropdown-box-shadow-above: 0px -3px 12px rgba(0,0,0,.175) !default; -$s2bs-clear-selection-color: $s2bs-dropdown-arrow-color !default; -$s2bs-clear-selection-hover-color: $s2bs-btn-default-color !default; -$s2bs-remove-choice-color: $s2bs-input-color-placeholder !default; -$s2bs-remove-choice-hover-color: $s2bs-btn-default-color !default; -$s2bs-selection-choice-border-radius: $s2bs-border-radius-base !default; -$s2bs-dropdown-header-padding-vertical: $s2bs-padding-base-vertical !default; -$s2bs-dropdown-header-font-size: $s2bs-font-size-small !default; - -// Form control variables -// ------------------------- -$s2bs-form-control-box-shadow: $input-box-shadow !default; -$s2bs-form-control-focus-box-shadow: $input-focus-box-shadow !default; -$s2bs-form-control-transition: $input-transition !default; - -// -// Mixins -// -------------------------------------------------- - -// @see https://github.com/twbs/bootstrap/blob/v4.0.0/scss/_forms.scss#L8 -@mixin bootstrap-input-defaults { - @include box-shadow($s2bs-form-control-box-shadow); - @include border-radius($s2bs-input-border-radius); - @include transition($s2bs-form-control-transition); - background-color: $s2bs-input-bg; - border: 1px solid $s2bs-input-border; - color: $s2bs-input-color; - font-size: $s2bs-font-size-base; -} - -// @see https://getbootstrap.com/docs/4.0/components/forms/#validation -// @see https://github.com/twbs/bootstrap-sass/blob/master/assets/stylesheets/bootstrap/_forms.scss#L388 -@mixin validation-state-focus($color) { - $shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten($color, 20%); - - .select2-dropdown, - .select2-selection { - border-color: $color; - } - - .select2-container--focus .select2-selection, - .select2-container--open .select2-selection { - @include box-shadow($shadow); - border-color: darken($color, 10%); - - &:focus { - box-shadow: 0 0 0 .2rem rgba($color,.25); - } - } - - &.select2-drop-active { - border-color: darken($color, 10%); - - &.select2-drop.select2-drop-above { - border-top-color: darken($color, 10%); - } - } -} - -// dropdown arrow when dropdown is open -@mixin dropdown-arrow { - .select2-selection--single { - /** - * Make the dropdown arrow point up while the dropdown is visible. - */ - - .select2-selection__arrow b { - border-color: transparent transparent $s2bs-dropdown-arrow-color transparent; - border-width: 0 $s2bs-caret-width-large $s2bs-caret-width-large $s2bs-caret-width-large; - } - } -} - - - - - -.select2-container--bootstrap { - display: block; - - - - - /*------------------------------------*\ - #COMMON STYLES - \*------------------------------------*/ - - .select2-selection { - @include bootstrap-input-defaults; - outline: 0; - - &.form-control { - @include border-radius($s2bs-border-radius-base); - } - } - - - - /** - * Search field in the Select2 dropdown. - */ - - .select2-search--dropdown { - .select2-search__field { - @include bootstrap-input-defaults; - } - } - - /** - * No outline for all search fields - in the dropdown - * and inline in multi Select2s. - */ - - .select2-search__field { - outline: 0; - - &::-webkit-input-placeholder { - color: $s2bs-input-color-placeholder; - } - - /* Firefox 18- */ - &:-moz-placeholder { - color: $s2bs-input-color-placeholder; - } - - /** - * Firefox 19+ - * - * @see http://stackoverflow.com/questions/24236240/color-for-styled-placeholder-text-is-muted-in-firefox - */ - &::-moz-placeholder { - color: $s2bs-input-color-placeholder; - opacity: 1; - } - - &:-ms-input-placeholder { - color: $s2bs-input-color-placeholder; - } - } - - /** - * Adjust Select2's choices hover and selected styles to match - * Bootstrap 4's default dropdown styles. - * - * @see https://getbootstrap.com/docs/4.0/components/dropdowns/ - */ - - .select2-results__option { - padding: $s2bs-padding-base-vertical $s2bs-padding-base-horizontal; - - &[role=group] { - padding: 0; - } - - /** - * Disabled results. - * - * @see https://select2.github.io/examples.html#disabled-results - */ - - &[aria-disabled=true] { - color: $s2bs-dropdown-link-disabled-color; - cursor: $s2bs-cursor-disabled; - } - - /** - * Hover state. - */ - - &[aria-selected=true] { - background-color: $s2bs-dropdown-link-hover-bg; - color: $s2bs-dropdown-link-hover-color; - } - - /** - * Selected state. - */ - - &--highlighted[aria-selected] { - background-color: $s2bs-dropdown-link-active-bg; - color: $s2bs-dropdown-link-active-color; - } - - .select2-results__option { - padding: $s2bs-padding-base-vertical $s2bs-padding-base-horizontal; - - .select2-results__group { - padding-left: 0; - } - - .select2-results__option { - margin-left: -$s2bs-padding-base-horizontal; - padding-left: $s2bs-padding-base-horizontal*2; - - .select2-results__option { - margin-left: -$s2bs-padding-base-horizontal*2; - padding-left: $s2bs-padding-base-horizontal*3; - - .select2-results__option { - margin-left: -$s2bs-padding-base-horizontal*3; - padding-left: $s2bs-padding-base-horizontal*4; - - .select2-results__option { - margin-left: -$s2bs-padding-base-horizontal*4; - padding-left: $s2bs-padding-base-horizontal*5; - - .select2-results__option { - margin-left: -$s2bs-padding-base-horizontal*5; - padding-left: $s2bs-padding-base-horizontal*6; - } - } - } - } - } - } - } - - .select2-results__group { - color: $s2bs-dropdown-header-color; - display: block; - padding: $s2bs-dropdown-header-padding-vertical $s2bs-padding-base-horizontal; - font-size: $s2bs-dropdown-header-font-size; - line-height: $s2bs-line-height-base; - white-space: nowrap; - } - - &.select2-container--focus, - &.select2-container--open { - .select2-selection { - border-color: $s2bs-input-border-focus; - - @if $s2bs-enable-shadows { - box-shadow: $s2bs-form-control-box-shadow, $s2bs-form-control-focus-box-shadow; - } @else { - box-shadow: $s2bs-form-control-focus-box-shadow; - } - } - } - - &.select2-container--open { - /** - * Make the dropdown arrow point up while the dropdown is visible. - */ - - .select2-selection .select2-selection__arrow b { - border-color: transparent transparent $s2bs-dropdown-arrow-color transparent; - border-width: 0 $s2bs-caret-width-base $s2bs-caret-width-base $s2bs-caret-width-base; - } - - /** - * Handle border radii of the container when the dropdown is showing. - */ - - &.select2-container--below { - .select2-selection { - @include border-bottom-radius(0); - border-bottom-color: transparent; - box-shadow: none; - } - } - - &.select2-container--above { - .select2-selection { - @include border-top-radius(0); - border-top-color: transparent; - box-shadow: none; - } - } - } - - /** - * Clear the selection. - */ - - .select2-selection__clear { - color: $s2bs-clear-selection-color; - cursor: pointer; - float: right; - font-weight: bold; - margin-right: 10px; - - &:hover { - color: $s2bs-clear-selection-hover-color; - } - } - - /** - * Address disabled Select2 styles. - * - * @see https://select2.github.io/examples.html#disabled - * @see hhttps://getbootstrap.com/docs/4.0/components/forms/#disabled-forms - */ - - &.select2-container--disabled { - .select2-selection { - border-color: $s2bs-input-border; - @include box-shadow(none); - } - - .select2-selection, - .select2-search__field { - cursor: $s2bs-cursor-disabled; - } - - .select2-selection, - .select2-selection--multiple .select2-selection__choice { - background-color: $s2bs-input-bg-disabled; - } - - .select2-selection__clear, - .select2-selection--multiple .select2-selection__choice__remove { - display: none; - } - } - - - - - - /*------------------------------------*\ - #DROPDOWN - \*------------------------------------*/ - - /** - * Dropdown border color and box-shadow. - */ - - .select2-dropdown { - @include box-shadow($s2bs-dropdown-box-shadow); - border-color: $s2bs-input-border-focus; - overflow-x: hidden; - margin-top: -1px; - - &--above { - @include box-shadow($s2bs-dropdown-box-shadow-above); - margin-top: 1px; - } - } - - /** - * Limit the dropdown height. - */ - - .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; - } - - - - - - /*------------------------------------*\ - #SINGLE SELECT2 - \*------------------------------------*/ - - .select2-selection--single { - height: $s2bs-input-height-base; - line-height: $s2bs-line-height-base; - padding: $s2bs-padding-base-vertical ($s2bs-padding-base-horizontal + $s2bs-caret-width-base*3) $s2bs-padding-base-vertical $s2bs-padding-base-horizontal; - - /** - * Adjust the single Select2's dropdown arrow button appearance. - */ - - .select2-selection__arrow { - position: absolute; - bottom: 0; - right: $s2bs-padding-base-horizontal; - top: 0; - width: $s2bs-caret-width-base; - - b { - border-color: $s2bs-dropdown-arrow-color transparent transparent transparent; - border-style: solid; - border-width: $s2bs-caret-width-base $s2bs-caret-width-base 0 $s2bs-caret-width-base; - height: 0; - left: 0; - margin-left: -$s2bs-caret-width-base; - margin-top: -$s2bs-caret-width-base/2; - position: absolute; - top: 50%; - width: 0; - } - } - - .select2-selection__rendered { - color: $s2bs-input-color; - padding: 0; - } - - .select2-selection__placeholder { - color: $s2bs-input-color-placeholder; - } - } - - - - - - /*------------------------------------*\ - #MULTIPLE SELECT2 - \*------------------------------------*/ - - .select2-selection--multiple { - min-height: $s2bs-input-height-base; - padding: 0; - height: auto; - - .select2-selection__rendered { - box-sizing: border-box; - display: block; - line-height: $s2bs-line-height-base; - list-style: none; - margin: 0; - overflow: hidden; - padding: 0; - width: 100%; - text-overflow: ellipsis; - white-space: nowrap; - } - - .select2-selection__placeholder { - color: $s2bs-input-color-placeholder; - float: left; - margin-top: 5px; - } - - /** - * Make Multi Select2's choices match Bootstrap 4's default button styles. - */ - - .select2-selection__choice { - color: $s2bs-input-color; - background: $s2bs-btn-default-bg; - border: 1px solid $s2bs-btn-default-border; - border-radius: $s2bs-selection-choice-border-radius; - cursor: default; - float: left; - margin: calc(#{$s2bs-padding-base-vertical} - 1px) 0 0 $s2bs-padding-base-horizontal/2; - padding: 0 $s2bs-padding-base-vertical; - } - - /** - * Minus 2px borders. - */ - - .select2-search--inline { - .select2-search__field { - background: transparent; - padding: 0 $s2bs-padding-base-horizontal; - height: $s2bs-input-height-base; - line-height: $s2bs-line-height-base; - margin: -$border-width 0; // Compensate for input borders included in height - min-width: 5em; - } - } - - .select2-selection__choice__remove { - color: $s2bs-remove-choice-color; - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: $s2bs-padding-base-vertical / 2; - - &:hover { - color: $s2bs-remove-choice-hover-color; - } - } - - /** - * Clear the selection. - */ - - .select2-selection__clear { - margin-top: $s2bs-padding-base-vertical; - } - } - - - - - - /** - * Address Bootstrap control sizing classes - * - * 1. Reset Bootstrap defaults. - * 2. Adjust the dropdown arrow button icon position. - * - * @see https://getbootstrap.com/docs/4.0/components/forms/#sizing - */ - - /* 1 */ - .select2-selection--single.form-control-sm, - .input-group-sm & .select2-selection--single, - .form-group-sm & .select2-selection--single { - @include border-radius($s2bs-border-radius-small); - font-size: $s2bs-font-size-small; - height: $s2bs-input-height-small; - line-height: $s2bs-line-height-small; - padding: $s2bs-padding-small-vertical $s2bs-padding-small-horizontal + $s2bs-caret-width-base*3 $s2bs-padding-small-vertical $s2bs-padding-small-horizontal; - - /* 2 */ - .select2-selection__arrow b { - margin-left: -$s2bs-padding-small-vertical; - } - } - - .select2-selection--multiple.form-control-sm, - .input-group-sm & .select2-selection--multiple, - .form-group-sm & .select2-selection--multiple { - @include border-radius($s2bs-border-radius-small); - min-height: $s2bs-input-height-small; - - .select2-selection__choice { - font-size: $s2bs-font-size-small; - line-height: $s2bs-line-height-small; - margin: calc(#{$s2bs-padding-small-vertical} - 1px) 0 0 $s2bs-padding-small-horizontal/2; - padding: 0 $s2bs-padding-small-vertical; - } - - .select2-search--inline .select2-search__field { - padding: 0 $s2bs-padding-small-horizontal; - font-size: $s2bs-font-size-small; - height: $s2bs-input-height-small; - line-height: $s2bs-line-height-small; - } - - .select2-selection__clear { - margin-top: $s2bs-padding-small-vertical; - } - } - - .select2-selection--single.form-control-lg, - .input-group-lg & .select2-selection--single, - .form-group-lg & .select2-selection--single { - @include border-radius($s2bs-border-radius-large); - font-size: $s2bs-font-size-large; - height: $s2bs-input-height-large; - line-height: $s2bs-line-height-large; - padding: $s2bs-padding-large-vertical $s2bs-padding-large-horizontal + $s2bs-caret-width-large*3 $s2bs-padding-large-vertical $s2bs-padding-large-horizontal; - - /* 1 */ - .select2-selection__arrow { - width: $s2bs-caret-width-large; - - b { - border-width: $s2bs-caret-width-large $s2bs-caret-width-large 0 $s2bs-caret-width-large; - margin-left: -$s2bs-caret-width-large; - margin-left: -$s2bs-padding-large-vertical; - margin-top: -$s2bs-caret-width-large/2; - } - } - } - - .select2-selection--multiple.form-control-lg, - .input-group-lg & .select2-selection--multiple, - .form-group-lg & .select2-selection--multiple { - min-height: $s2bs-input-height-large; - border-radius: $s2bs-border-radius-large; - - .select2-selection__choice { - font-size: $s2bs-font-size-large; - line-height: $s2bs-line-height-large; - border-radius: $s2bs-selection-choice-border-radius; - margin: calc(#{$s2bs-padding-large-vertical} - 1px) 0 0 $s2bs-padding-large-horizontal/2; - padding: 0 $s2bs-padding-large-vertical; - } - - .select2-search--inline .select2-search__field { - padding: 0 $s2bs-padding-large-horizontal; - font-size: $s2bs-font-size-large; - height: $s2bs-input-height-large; - line-height: $s2bs-line-height-large; - } - - .select2-selection__clear { - margin-top: $s2bs-padding-large-vertical; - } - } - - .select2-selection.form-control-lg.select2-container--open { - @include dropdown-arrow; - } - - .input-group-lg & .select2-selection { - &.select2-container--open { - @include dropdown-arrow; - } - } - - - - - - /*------------------------------------*\ - #RTL SUPPORT - \*------------------------------------*/ - - &[dir="rtl"] { - - /** - * Single Select2 - * - * 1. Makes sure that .select2-selection__placeholder is positioned - * correctly. - */ - - .select2-selection--single { - padding-left: $s2bs-padding-base-horizontal + $s2bs-caret-width-base*3; - padding-right: $s2bs-padding-base-horizontal; - - .select2-selection__rendered { - padding-right: 0; - padding-left: 0; - text-align: right; /* 1 */ - } - - .select2-selection__clear { - float: left; - } - - .select2-selection__arrow { - left: $s2bs-padding-base-horizontal; - right: auto; - - b { - margin-left: 0; - } - } - } - - /** - * Multiple Select2 - */ - - .select2-selection--multiple { - .select2-selection__choice, - .select2-selection__placeholder, - .select2-search--inline { - float: right; - } - - .select2-selection__choice { - margin-left: 0; - margin-right: $s2bs-padding-base-horizontal/2; - } - - .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; - } - } - } - - .select2-dropdown[dir="rtl"] { - .select2-results__options { - text-align: right; - } - } -} - - - - - -/*------------------------------------*\ - #ADDITIONAL GOODIES -\*------------------------------------*/ - -/** - * Address Bootstrap's validation states - * - * If a Select2 widget parent has one of Bootstrap's validation state modifier - * classes, adjust Select2's border colors and focus states accordingly. - * You may apply said classes to the Select2 dropdown (body > .select2-container) - * via JavaScript match Bootstraps' to make its styles match. - * - * @see https://getbootstrap.com/docs/4.0/components/forms/#validation - */ - -.is-valid { - @include validation-state-focus($s2bs-state-success-text); -} - -.is-invalid { - @include validation-state-focus($s2bs-state-danger-text); -} - -/* Validation classes on parent element. Preserved Bootstrap 3 validation classes */ - -.has-warning { - @include validation-state-focus($s2bs-state-warning-text); -} - -.has-error { - @include validation-state-focus($s2bs-state-danger-text); -} - -.has-success { - @include validation-state-focus($s2bs-state-success-text); -} - -/** - * Select2 widgets in Bootstrap Input Groups - * - * @see https://getbootstrap.com/docs/4.0/components/input-group/ - * @see https://github.com/twbs/bootstrap/blob/v4.0.0-beta.2/scss/_input-group.scss - */ - -/** - * Reset rounded corners - */ - -.input-group > .select2-hidden-accessible { - &:first-child + .select2-container--bootstrap > .selection > .select2-selection, - &:first-child + .select2-container--bootstrap > .selection > .select2-selection.form-control { - @include border-right-radius(0); - } - - &:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection, - &:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection.form-control { - border-radius: 0; - } - - &:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection, - &:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection.form-control { - @include border-left-radius(0); - } -} - -.input-group > .select2-container--bootstrap { - flex: 1 1 auto; - position: relative; - z-index: 2; - width: 1%; - margin-bottom: 0; - - > .selection { - display: flex; - flex: 1 1 auto; - - > .select2-selection.form-control { - float: none; - } - } - - /** - * Adjust z-index like Bootstrap does to show the focus-box-shadow - * above appended buttons in .input-group and .form-group. - */ - - &.select2-container--open, /* .form-group */ - &.select2-container--focus /* .input-group */ { - z-index: 3; - } - - /** - * Adjust alignment of Bootstrap buttons in Bootstrap Input Groups to address - * Multi Select2's height which - depending on how many elements have been selected - - * may grow taller than its initial size. - * - * @see https://github.com/twbs/bootstrap/blob/v4.0.0-beta.2/scss/_input-group.scss - */ - - &, - .input-group-append, - .input-group-prepend, - .input-group-append .btn, - .input-group-prepend .btn { - vertical-align: top; - } -} - -/** - * Temporary fix for https://github.com/select2/select2-bootstrap-theme/issues/9 - * - * Provides `!important` for certain properties of the class applied to the - * original `