├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pylintrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Docker.dev ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── app.py ├── config ├── spa_config.ini ├── spa_config.plusnet.ini └── spa_config.test.ini ├── dash_spa ├── __init__.py ├── _version.py ├── callback.py ├── components │ ├── AIO_base.py │ ├── __init__.py │ ├── alert.py │ ├── button_container_aoi.py │ ├── dropdown_aio.py │ ├── dropdown_button_aoi.py │ ├── dropdown_folder_aoi.py │ ├── footer.py │ ├── icons.py │ ├── navbar.py │ ├── notyf.py │ ├── spa_location.py │ └── table │ │ ├── __init__.py │ │ ├── context.py │ │ ├── pagination_aoi.py │ │ ├── pagination_view_aoi.py │ │ ├── search.py │ │ └── table_aio.py ├── context_state.py ├── decorators.py ├── exceptions.py ├── logging.py ├── plugins │ ├── __init__.py │ └── dash_logging.py ├── session │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ ├── backend_factory.py │ │ ├── diskcache.py │ │ ├── postgres.py │ │ ├── redis.py │ │ └── session_backend.py │ ├── session_cookie.py │ └── session_data.py ├── spa_config.py ├── spa_context.py ├── spa_current_app.py ├── spa_current_user.py ├── spa_form.py ├── spa_globals.py ├── spa_pages.py └── utils │ ├── __init__.py │ ├── caller.py │ ├── dataclass.py │ ├── dumps_layout.py │ ├── json_coder.py │ ├── notify_dict.py │ ├── syncronised.py │ └── time.py ├── dash_spa_admin ├── __init__.py ├── admin_navbar.py ├── admin_page.py ├── decorators.py ├── exceptions.py ├── login_manager.py ├── synchronised_cache.py ├── template_mailer.py └── views │ ├── __init__.py │ ├── admin_register_view.py │ ├── common.py │ ├── forgot_code_view.py │ ├── forgot_password_view.py │ ├── forgot_view.py │ ├── login_view.py │ ├── logout_view.py │ ├── register_verify_view.py │ ├── register_view.py │ └── users_view.py ├── docs ├── README.TEST.md └── img │ ├── admin-views.png │ ├── dash-spa.png │ ├── flightdeck-1.png │ ├── global.png │ ├── multi-page-example.png │ ├── register.png │ ├── sidebar.png │ ├── signin.png │ ├── solar.png │ ├── tables-1.png │ ├── tables-2.png │ ├── ticker.png │ ├── todo.png │ └── veggy.png ├── examples ├── __init__.py ├── button_dropdown │ ├── __init__.py │ ├── app.py │ ├── assets │ │ └── volt-min.css │ └── pages │ │ ├── __init__.py │ │ ├── button_page.py │ │ ├── dropdown_simple_page.py │ │ └── icons.py ├── button_test_context_state │ ├── __init__.py │ ├── app.py │ └── pages │ │ └── button_page.py ├── button_test_redux │ ├── __init__.py │ ├── app.py │ └── pages │ │ └── button_page.py ├── button_test_simple │ ├── __init__.py │ ├── app.py │ └── pages │ │ └── button_page.py ├── context │ ├── __init__.py │ ├── app.py │ └── pages │ │ ├── button_toolbar.py │ │ └── toolbar_page.py ├── cra │ ├── app.py │ ├── assets │ │ ├── app.css │ │ ├── favicon.ico │ │ ├── index.css │ │ └── logo.svg │ └── pages │ │ └── cra_example.py ├── forms │ ├── __init__.py │ ├── app.py │ └── pages │ │ ├── common.py │ │ ├── login_page.py │ │ └── welcome.py ├── multipage │ ├── __init__.py │ ├── app.py │ ├── assets │ │ └── multipage.css │ └── pages │ │ ├── __init__.py │ │ ├── page1.py │ │ ├── page2.py │ │ └── wellcome.py ├── notifications │ ├── __init__.py │ ├── app.py │ └── pages │ │ ├── containers.py │ │ └── notification_test.py ├── sidebar │ ├── __init__.py │ ├── app.py │ ├── assets │ │ └── img │ │ │ └── brand │ │ │ ├── dark.svg │ │ │ └── light.svg │ ├── pages │ │ ├── 500_page.py │ │ ├── bootstrap_tables_page.py │ │ ├── common │ │ │ ├── __init__.py │ │ │ ├── background_image.py │ │ │ ├── jumbotron.py │ │ │ ├── mobile_nav.py │ │ │ └── sidebar.py │ │ ├── containers.py │ │ ├── dashboard_page.py │ │ ├── forgot_password_page.py │ │ ├── icons │ │ │ ├── __init__.py │ │ │ ├── hero.py │ │ │ └── social.py │ │ ├── lock_page.py │ │ ├── not_found_404.py │ │ ├── reset_password_page.py │ │ ├── settings_page.py │ │ ├── signin_page.py │ │ ├── signup_page.py │ │ ├── transactions_page.py │ │ └── welcome.py │ └── themes.py └── veggy │ ├── app.py │ ├── assets │ └── veggy.css │ └── pages │ ├── veggy │ ├── __init__.py │ ├── cart.py │ ├── context.py │ ├── footer.py │ ├── header.py │ ├── modal.py │ ├── product.py │ ├── search.py │ └── stepper_input.py │ └── veggy_page.py ├── landing-page.md ├── main.py ├── pages ├── 500_page.py ├── __init__.py ├── buttons │ └── button_toolbar.py ├── buttons_page.py ├── data │ ├── README.md │ ├── customers.csv │ ├── global-warming.csv │ ├── solar.csv │ └── subscriptions.csv ├── default_container.py ├── global_warming_page.py ├── life_expectancy.py ├── not_found_404.py ├── state_solar_page.py ├── table_example.py ├── terms_and_conditions.py ├── ticker.py ├── transactions │ ├── __init__.py │ ├── icons.py │ ├── table.py │ └── table_header.py ├── transactions_page.py ├── user │ └── profile.py └── welcome.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── server.py ├── tests ├── __init__.py ├── admin │ ├── __init__.py │ ├── admin_login_test.py │ ├── admin_register_test.py │ ├── conftest.py │ ├── forgot_password_test.py │ ├── mailer_test.py │ └── test_login_manager.py ├── components │ ├── __init__.py │ ├── alert_test.py │ ├── app_factory.py │ ├── notify_test.py │ └── table │ │ ├── __init__.py │ │ └── test_table_filter.py ├── conftest.py ├── examples │ ├── __init__.py │ ├── dropdown_test.py │ └── multipage_test.py └── spa │ ├── __init__.py │ ├── config │ ├── __init__.py │ ├── config_test.py │ └── test.ini │ ├── context │ ├── __init__.py │ ├── conftest.py │ ├── context_provider_inititial_state_test.py │ ├── context_provider_test.py │ ├── context_single_button.py │ ├── context_state_test.py │ ├── context_use_initial_state.py │ ├── context_use_list_test.py │ └── context_use_simple_test.py │ └── session │ ├── __init__.py │ ├── diskcache_basic_test.py │ ├── redis_basic_test.py │ └── session_test.py ├── tox.ini ├── usage.py ├── waitress_server.py └── wsgi.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test [ubuntu, windows, macOS] 2 | 3 | on: 4 | push: 5 | # branches: [ "master" ] 6 | pull_request: 7 | # branches: [ "master" ] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, windows-latest, macos-latest] 14 | python-version: [3.8, 3.9] 15 | 16 | defaults: 17 | run: 18 | shell: bash 19 | 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install Poetry 30 | uses: snok/install-poetry@v1 31 | with: 32 | version: 1.3.2 33 | virtualenvs-create: true 34 | virtualenvs-in-project: true 35 | 36 | - name: Install dependencies 37 | run: poetry install --no-interaction --no-root 38 | 39 | - name: Install Flake8 40 | run: pip install flake8 41 | 42 | - name: Lint with Flake8 43 | run: | 44 | # stop the build if there are Python syntax errors or undefined names 45 | flake8 --exclude .venv . --count --select=E9,F63,F7,F82 --show-source --statistics 46 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 47 | flake8 --exclude .venv . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 48 | 49 | - name: Run pytest 50 | run: poetry run pytest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | /.vscode/.ropeproject 34 | /.pytest_cache 35 | 36 | /tmp 37 | /db.sqlite 38 | /tests/admin/test_db.sqlite 39 | /.devcontainer 40 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # https://stackoverflow.com/a/66767754 4 | 5 | init-hook='import sys; sys.path.append(".")' 6 | 7 | [FORMAT] 8 | 9 | # Maximum number of characters on a single line. 10 | max-line-length=100 11 | 12 | 13 | [MESSAGES CONTROL] 14 | 15 | # Enable the message, report, category or checker with the given id(s). You can 16 | # either give multiple identifier separated by comma (,) or put this option 17 | # multiple time. 18 | #enable= 19 | 20 | # Disable the message, report, category or checker with the given id(s). You 21 | # can either give multiple identifier separated by comma (,) or put this option 22 | # multiple time (only on the command line, not in the configuration file where 23 | # it should appear only once). 24 | #disable= 25 | disable=broad-except, 26 | dangerous-default-value, 27 | global-statement, 28 | multiple-statements, 29 | protected-access, 30 | redefined-outer-name, 31 | unused-argument, 32 | unused-variable, 33 | using-constant-test, 34 | 35 | attribute-defined-outside-init, 36 | bad-inline-option, 37 | bare-except, 38 | blacklisted-name, 39 | consider-using-enumerate, 40 | deprecated-pragma, 41 | duplicate-code, 42 | file-ignored, 43 | global-variable-not-assigned, 44 | invalid-name, 45 | len-as-condition, 46 | line-too-long, 47 | locally-disabled, 48 | missing-docstring, 49 | no-member, 50 | raw-checker-failed, 51 | redefined-builtin, 52 | suppressed-message, 53 | too-few-public-methods, 54 | too-many-arguments, 55 | too-many-branches, 56 | too-many-function-args, 57 | too-many-instance-attributes, 58 | too-many-locals, 59 | too-many-statements, 60 | useless-suppression, 61 | wrong-import-order -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | 8 | "python.terminal.activateEnvironment": true, 9 | "python.linting.pylintEnabled": true, 10 | 11 | "files.watcherExclude": { 12 | "**/.venv/**": true, 13 | "**/node_modules/**": true, 14 | "**/__pycache__/*/**": true, 15 | "**/tmp/*/**": true 16 | }, 17 | "remote.localPortHost": "allInterfaces", 18 | "python.formatting.provider": "black", 19 | "python.formatting.blackPath": "black", 20 | "cSpell.words": [ 21 | "fmxw", 22 | "thead", 23 | "trows" 24 | ], 25 | "todo-tree.tree.showBadges": true, 26 | "python.linting.enabled": true 27 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.5] - August 28th, 2022 2 | 3 | ### Fix 4 | 5 | - Rework current_user mock to remove flask_login package dependency. 6 | - Minimise the number of packages in loaded by install dash-spa. Now have dash-spa[admin] to load admin package 7 | 8 | ## [1.1.4] - August 27th, 2022 9 | 10 | ### Added 11 | 12 | - Added session backend storage test 13 | 14 | ## [1.1.3] - August 24th, 2022 15 | 16 | ### Added 17 | 18 | - Add dash request pathname to Flask request object 19 | 20 | ## [1.1.2] - August 24th, 2022 21 | 22 | ### Fixed 23 | 24 | - Fix problem of session manager not setting session cookie correctly. 25 | 26 | ## [1.1.0] - August 23th, 2022 27 | 28 | ### Added 29 | 30 | - Upgraded to Dash 2.6.1 31 | - Added sidebar example 32 | - Wrapped SPA_LOCATION in a LocalProxy 33 | - CSS dynamic styles now working 34 | - Added Veggy example 35 | - Added Alerts & Notifications example 36 | - Added add_external_stylesheets() and add_external_scripts(). This allows any module to add additional js and css to improve Dash component modularity 37 | - Added session persistence to ContextState 38 | - Added session context 39 | 40 | ## [1.0.1] - May 5th, 2022 41 | 42 | This is complete rewrite. The previous Flask style blueprints have been abandoned. DashSPA now uses the Dash/Pages plugin. 43 | 44 | ### Added 45 | 46 | - ContextState now handles lists & dicts 47 | - Added sync lock to SessionCookieManager 48 | - Added PostgresSessionBackend - Not tested yet! 49 | - Added session cookies Getting session cookie inititialisation to work 50 | - Added Session redis 51 | - Transaction table now tracks the address bar query-string values 52 | - Table search & pagination working 53 | - Moved to using @dataclass for ContextState 54 | - Added context/state pattern 55 | - Added table search 56 | - Added table paginator 57 | - Added table component 58 | - Added table size dropdown 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### dash-spa 2 | 3 | pip install poetry 4 | poetry install --no-root 5 | 6 | #### Testing 7 | 8 | To run the tests (in Docker container): 9 | 10 | pytest 11 | 12 | #### Build & Publish 13 | 14 | rm -rf dist && poetry build 15 | 16 | poetry publish 17 | 18 | Or upload to local package repository: 19 | 20 | poetry publish -r pypicloud -------------------------------------------------------------------------------- /Docker.dev: -------------------------------------------------------------------------------- 1 | FROM python:3.8-buster 2 | 3 | RUN useradd -ms /bin/bash vscode 4 | 5 | # Install nodejs 12 6 | # https://computingforgeeks.com/how-to-install-nodejs-on-ubuntu-debian-linux-mint/ 7 | 8 | RUN apt update && \ 9 | apt -y install curl dirmngr apt-transport-https lsb-release ca-certificates && \ 10 | curl -sL https://deb.nodesource.com/setup_12.x | bash - && \ 11 | apt -y install nodejs 12 | 13 | # RUN apt-get update && apt-get install -y build-essential g++ libx11-dev libxkbfile-dev libsecret-1-dev 14 | 15 | ENV CHROME_VERSION 114.0.5735.90 16 | 17 | RUN mkdir -p /tmp/chrome \ 18 | && cd /tmp/chrome \ 19 | && wget -q http://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}-1_amd64.deb 20 | 21 | RUN cd /tmp/chrome \ 22 | && ls \ 23 | && dpkg -i google-chrome*.deb || true \ 24 | && apt update \ 25 | && apt --fix-broken install -y 26 | 27 | # Install chromedriver 28 | 29 | RUN mkdir -p /tmp/ && \ 30 | cd /tmp/ && \ 31 | wget -q -O /tmp/chromedriver.zip https://chromedriver.storage.googleapis.com/${CHROME_VERSION}/chromedriver_linux64.zip && \ 32 | unzip /tmp/chromedriver.zip chromedriver -d /usr/bin/ && \ 33 | # clean up the container "layer", after we are done 34 | rm /tmp/chromedriver.zip 35 | 36 | USER vscode 37 | 38 | ENV PATH="/home/vscode/.local/bin:${PATH}" 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uwsgi-nginx-flask:python3.8 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | # CMD [ "/bin/bash" ] 10 | ENTRYPOINT ["python", "waitress_server.py"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Steve Jones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include landing-page.md 3 | include LICENSE 4 | include requirements.txt 5 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import dash_spa as spa 3 | from dash_bootstrap_components.themes import BOOTSTRAP 4 | # from dash_spa import spa_pages 5 | 6 | external_stylesheets = [ 7 | "https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css", 8 | BOOTSTRAP, 9 | # "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css", 10 | "https://cdnjs.cloudflare.com/ajax/libs/chartist/0.11.4/chartist.min.css", 11 | "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.2.0/css/all.min.css" 12 | ] 13 | 14 | external_scripts = [ 15 | # "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.bundle.min.js", 16 | ] 17 | 18 | 19 | logging_opt = spa.config.get('logging') 20 | 21 | def create_dash() -> spa.DashSPA: 22 | 23 | flask_options = spa.config.get('flask') 24 | options = spa.config.get('logging') 25 | 26 | server = flask.Flask(__name__) 27 | server.config['SECRET_KEY'] = flask_options.SECRET_KEY 28 | 29 | plugins=[] 30 | 31 | if logging_opt.get('DASH_LOGGING', default_value=False): 32 | plugins.append(spa.dash_logging) 33 | 34 | 35 | app = spa.DashSPA(__name__, 36 | plugins=plugins, 37 | prevent_initial_callbacks=True, 38 | suppress_callback_exceptions=True, 39 | external_stylesheets=external_stylesheets, 40 | external_scripts=external_scripts, server=server 41 | ) 42 | 43 | app.logger.setLevel(options.level) 44 | 45 | return app 46 | 47 | -------------------------------------------------------------------------------- /config/spa_config.ini: -------------------------------------------------------------------------------- 1 | [logging] 2 | level=WARN 3 | DASH_LOGGING=False 4 | 5 | [flask] 6 | SECRET_KEY=my secret flask password 7 | URL_PREFIX=api 8 | 9 | 10 | [session_storage] 11 | ; backend = diskcache | redis 12 | backend=diskcache 13 | 14 | expire_days=30 15 | diskcache_folder=tmp/cache/spa_sessions 16 | 17 | [session_storage.redis] 18 | host=redis-server 19 | ;host=172.172.0.128 20 | ;port=NNNN defaults to 6379 21 | 22 | [login_manager] 23 | enabled=True 24 | database_uri=sqlite:///db.sqlite 25 | verify_users=False 26 | 27 | [login_manager.mail] 28 | sender=admin@joes.com 29 | host=smtp.gmail.com 30 | port=465 31 | secure=True 32 | 33 | ; Not good idea to hard code user & password here. Instead 34 | ; create ENV variables and reference them, eg: 35 | ; 36 | ; user=${SPA_MAIL_USER} 37 | ; password=${SPA_MAIL_PASSWORD} 38 | 39 | user=bigjoe 40 | password=1234 41 | -------------------------------------------------------------------------------- /config/spa_config.plusnet.ini: -------------------------------------------------------------------------------- 1 | ; The following settings will override any values defined 2 | ; in the file 'spa_config.ini' when the following ENV variable 3 | ; is defined: 4 | ; 5 | ; DASH_SPA_ENV = "plusnet" 6 | 7 | [login_manager.mail] 8 | host=relay.plus.net 9 | port=587 10 | secure=False 11 | -------------------------------------------------------------------------------- /config/spa_config.test.ini: -------------------------------------------------------------------------------- 1 | ; The following settings will override any values defined 2 | ; in the file 'spa_config.ini' when the following ENV variable 3 | ; is defined: 4 | ; 5 | ; DASH_SPA_ENV = "test" 6 | 7 | [logging] 8 | level=INFO 9 | 10 | [login_manager] 11 | database_uri=sqlite:///tests/admin/test_db.sqlite 12 | test=True 13 | verify_users=True 14 | 15 | 16 | -------------------------------------------------------------------------------- /dash_spa/__init__.py: -------------------------------------------------------------------------------- 1 | """dash-pages-spa - Dash Pages SPA Framework""" 2 | 3 | from ._version import __version__ 4 | 5 | from ensurepip import version 6 | 7 | 8 | from dash.exceptions import PreventUpdate 9 | from .spa_config import config 10 | from dash_prefix import prefix, match, isTriggered, trigger_index, NOUPDATE, copy_factory, component_id 11 | from .spa_pages import register_page, page_container, page_container_append, location, url_for, page_for, get_page, register_container 12 | from .spa_pages import add_style, page_id, add_external_scripts, add_external_stylesheets, DashSPA 13 | from .spa_form import SpaForm 14 | from .spa_current_user import current_user 15 | from .spa_current_app import current_app 16 | from .decorators import login_required 17 | from .callback import callback 18 | 19 | from .plugins import dash_logging 20 | from .session import session_data 21 | -------------------------------------------------------------------------------- /dash_spa/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.5' 2 | __author__ = 'Steve Jones ' 3 | __all__ = [] 4 | -------------------------------------------------------------------------------- /dash_spa/callback.py: -------------------------------------------------------------------------------- 1 | from dash import callback as dash_callback 2 | from .spa_current_app import current_app 3 | 4 | 5 | def callback(*_args, **_kwargs): 6 | """Wrapper for standard Dash callback decorator. Dismisses 7 | the callback and handler if the server is active. This does not mean the 8 | callback will not work, just that is does not need to be presented to 9 | the Dash subsystem again. 10 | """ 11 | def callback_stub(*_args, **_kwargs): 12 | pass 13 | 14 | if current_app and current_app.is_live: 15 | # log.info('Dismiss @callback decorator %s server has started', caller_location()) 16 | return callback_stub 17 | 18 | return dash_callback(*_args, **_kwargs) 19 | -------------------------------------------------------------------------------- /dash_spa/components/AIO_base.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | import uuid 3 | 4 | class AIOPrefix: 5 | 6 | def __init__(self, component_id): 7 | self.component_id=component_id 8 | self.aio_id = str(uuid.uuid4()) 9 | 10 | def id(self, subcomponent_id): 11 | return { 12 | 'component': self.component_id, 13 | 'subcomponent': subcomponent_id, 14 | 'aio_id': self.aio_id 15 | } 16 | -------------------------------------------------------------------------------- /dash_spa/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .AIO_base import AIOPrefix 2 | 3 | from .dropdown_aio import DropdownAIO 4 | 5 | from .navbar import NavBar, NavbarBrand, NavbarLink, NavbarDropdown 6 | from .footer import Footer 7 | 8 | from .alert import Alert, SPA_ALERT 9 | from .notyf import Notyf, SPA_NOTIFY 10 | from .spa_location import SPA_LOCATION 11 | 12 | from .table import TableAIO, TableAIOPaginator, TableAIOPaginatorView, TableContext 13 | 14 | from .dropdown_aio import DropdownAIO 15 | from .button_container_aoi import ButtonContainerAIO 16 | 17 | # TODO: Make sure all components have no hardcoded className spec -------------------------------------------------------------------------------- /dash_spa/components/button_container_aoi.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import List 3 | from dash_redux import ReduxStore 4 | from dash_spa.logging import log 5 | from dash import html, ALL 6 | from dash.development.base_component import Component 7 | from dash_spa import callback, match, prefix, trigger_index, NOUPDATE 8 | 9 | 10 | class Dict2Obj: 11 | def __init__(self, d=dict) -> object: 12 | if d is not None: 13 | for key, value in d.items(): 14 | setattr(self, key, value) 15 | 16 | 17 | class ButtonContainerAIO(html.Div): 18 | """Manage a container of buttons. This is an abstract base class. 19 | 20 | The ButtonContainerAIO constructor calls the render_button() method 21 | passing in the list of elements. It then wraps each button in the 22 | returned button list in a callback match ALL container. The button_match 23 | attribute can then be used in by the super-class to action button callback 24 | events. 25 | 26 | Args: 27 | elements (List): List of elements to be passed to the render() method. 28 | current (int): Index of the selected button 29 | className (str, optional): Container className. Defaults to None. 30 | id (_type_, optional): Container ID. 31 | 32 | Attributes: 33 | 34 | button_match: Match ALL button id to be used in callbacks 35 | 36 | Abstract Methods: 37 | 38 | render_buttons(self, elements) 39 | 40 | """ 41 | 42 | def __init__(self, elements: List, current:int, className: str = None, id=None): 43 | 44 | assert id, "The ButtonContainerAIO must have an id" 45 | 46 | pid = prefix(id) 47 | self._elements = elements 48 | 49 | self.button_match = match({'type': pid('li'), 'idx': ALL}) 50 | 51 | def _render_buttons(current): 52 | 53 | # Get the user rendered button list 54 | 55 | buttons = self.render_buttons(elements) 56 | 57 | def _render_button(index, text): 58 | btn = buttons[index] 59 | return html.Div(btn, id=self.button_match.idx(index)) 60 | 61 | return [_render_button(index, text) for index, text in enumerate(elements)] 62 | 63 | buttons = _render_buttons(current) 64 | 65 | super().__init__(buttons, className=className) 66 | 67 | @abstractmethod 68 | def render_buttons(self, elements:List) -> List[Component]: 69 | """Return a list of button components derived from the supplied 70 | element list. 71 | 72 | Args: 73 | elements (List): elements to be rendered 74 | 75 | Returns: 76 | List[Component]: Elements rendered as buttons 77 | """ 78 | 79 | return [] 80 | 81 | -------------------------------------------------------------------------------- /dash_spa/components/dropdown_aio.py: -------------------------------------------------------------------------------- 1 | import time 2 | from dash import html, MATCH 3 | from dash.development.base_component import Component 4 | import dash_holoniq_components as dhc 5 | 6 | from dash_spa import callback 7 | from dash_spa.logging import log 8 | 9 | from dash_prefix import prefix 10 | 11 | class DropdownAIO(html.Div): 12 | """Button and container. The container is shown when the 13 | button is clicked. The container is hidden when focus is lost. 14 | 15 | The button **must** be a DropdownAIO.Button 16 | 17 | Args: 18 | button (DropdownAIO.Button): Button, when clicked displays the container 19 | container (Component): Container to be shown/hidden 20 | id (str): The container prefix. 21 | """ 22 | 23 | Button = dhc.Button 24 | 25 | 26 | def __init__(self, button:dhc.Button, container:Component, id, classname_modifier='show'): 27 | 28 | pid = prefix(id) 29 | 30 | # log.info('DropdownAIO pid=%s', id) 31 | 32 | button.id = pid('btn') 33 | container.id = pid('container') 34 | 35 | @callback(container.output.className, button.input.n_clicks, 36 | button.input.focus, button.state.id, container.state.className, 37 | prevent_initial_call=True 38 | ) 39 | def show_dropdown(button_clicks, button_focus, id, className): 40 | 41 | # log.info('%s button_clicks=%s, button_focus=%s',id, button_clicks, button_focus) 42 | 43 | if not button_clicks: 44 | return className 45 | 46 | classNames = className.split() 47 | 48 | if classname_modifier in classNames: 49 | if button_focus is False: 50 | classNames.remove(classname_modifier) 51 | 52 | # Delay hiding the container. If we don't do this click 53 | # event from elements in the container are lost 54 | # TODO: Add a configurable delay to dhc.Button 55 | 56 | time.sleep(300/1000) 57 | else: 58 | classNames.append(classname_modifier) 59 | 60 | className = ' '.join(classNames) 61 | 62 | #log.info("DropdownAIO className='%s'", className) 63 | 64 | return className 65 | 66 | super().__init__(html.Div([button, container], className='dropdown')) 67 | -------------------------------------------------------------------------------- /dash_spa/components/dropdown_button_aoi.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from .icons import DOWN_ARROW, PLUS 3 | from .dropdown_aio import DropdownAIO 4 | 5 | def dropdownLink(title, icon, href='#'): 6 | return dcc.Link([ 7 | icon, 8 | title 9 | ], className='dropdown-item d-flex align-items-center', href=href) 10 | 11 | class DropdownButtonAIO(DropdownAIO): 12 | 13 | """Button with supplied icon and down arrow. When clicked a drop-down 14 | selection of entries is revealed. 15 | 16 | Args: 17 | dropdownEntries (list): The dropdown entries 18 | buttonText (str): The button text 19 | buttonIcon (Svg, optional): Optional button icon. Defaults to PLUS. 20 | buttonColor (str, optional): BS5 button colour. Defaults to 'secondary'. 21 | downArrow (bool, optional): Show down arrow. Defaults to False. 22 | 23 | Example: 24 | 25 | DropdownButtonAIO([ 26 | dropdownLink("Add User", USER_ADD), 27 | 28 | dropdownLink("Add Widget", WIDGET), 29 | 30 | dropdownLink("Upload Files", UPLOAD), 31 | 32 | dropdownLink("Preview Security", SECURITY), 33 | 34 | dropdownLink("Upgrade to Pro", FIRE_DANGER), 35 | 36 | ], "New Task", buttonColor="gray-800") 37 | 38 | """ 39 | 40 | container_className = '' 41 | 42 | def __init__(self, dropdownEntries, buttonText, buttonIcon=PLUS, 43 | buttonColor='secondary', downArrow=False, id=None): 44 | 45 | button = DropdownAIO.Button([ 46 | buttonIcon, 47 | buttonText, 48 | DOWN_ARROW if downArrow else None 49 | ], className=self.button_className(buttonColor)) 50 | 51 | # Drop down container 52 | 53 | container = html.Div( 54 | dropdownEntries, 55 | className=self.container_className) 56 | 57 | super().__init__(button, container, id=id) 58 | 59 | 60 | def button_className(self, buttonColor): 61 | return f'btn btn-{buttonColor}' 62 | -------------------------------------------------------------------------------- /dash_spa/components/footer.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from .navbar import NavbarBase 3 | 4 | 5 | class Footer(NavbarBase): 6 | 7 | xstyle = ''' 8 | .spa_footer { 9 | background-color: #c6c4c4; 10 | } 11 | ''' 12 | 13 | def __init__(self, title=None): 14 | super().__init__() 15 | self.title=title 16 | 17 | def layout(self): 18 | text = self.title 19 | if text: 20 | return html.Div([ 21 | html.P(text, id='footer', className='text-center font-italic', style={'marginTop': 10}) 22 | ], className='spa_footer') 23 | else: 24 | return None 25 | -------------------------------------------------------------------------------- /dash_spa/components/icons.py: -------------------------------------------------------------------------------- 1 | from dash_svg import Svg, Path 2 | 3 | from dash_spa import add_style 4 | 5 | 6 | icon_style = """ 7 | .icon { 8 | height: 2rem; 9 | } 10 | .icon.icon-xxs { 11 | height: 1rem; 12 | } 13 | .icon.icon-xs { 14 | height: 1.25rem; 15 | } 16 | .icon.icon-sm { 17 | height: 1.5rem; 18 | } 19 | """ 20 | 21 | add_style(icon_style) 22 | 23 | # heroicons: https://heroicons.dev/ 24 | 25 | ARROW = Svg([ 26 | Path(fillRule='evenodd', d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z', clipRule='evenodd') 27 | ], className='icon icon-sm', fill='currentColor', viewBox='0 0 20 20', xmlns='http://www.w3.org/2000/svg') 28 | 29 | 30 | DOWN_ARROW = Svg([ 31 | Path(fillRule='evenodd', d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z', clipRule='evenodd') 32 | ], className='icon icon-xs ms-1', fill='currentColor', viewBox='0 0 20 20', xmlns='http://www.w3.org/2000/svg') 33 | 34 | PLUS = Svg([ 35 | Path(strokeLinecap='round', strokeLinejoin='round', strokeWidth='2', d='M12 6v6m0 0v6m0-6h6m-6 0H6') 36 | ], className='icon icon-xs me-2', fill='none', stroke='currentColor', viewBox='0 0 24 24', xmlns='http://www.w3.org/2000/svg') 37 | 38 | SEARCH = Svg([ 39 | Path(fillRule='evenodd', d='M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z', clipRule='evenodd') 40 | ], className='icon icon-xs', xmlns='http://www.w3.org/2000/svg', viewBox='0 0 20 20', fill='currentColor') 41 | -------------------------------------------------------------------------------- /dash_spa/components/spa_location.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from werkzeug.local import LocalProxy 3 | from urllib.parse import urlparse 4 | from dash._callback import GLOBAL_CALLBACK_MAP 5 | from dash_spa import page_container_append, callback, NOUPDATE, current_app 6 | from dash_spa.logging import log 7 | from dash_redux import ReduxStore 8 | import dash_holoniq_components as dhc 9 | 10 | # Use ReduxStore many-to-one capability to allow any event in any page to 11 | # update the browser location. Use SPA_LOCATION.update callback: 12 | # 13 | # from dash_spa import SPA_LOCATION 14 | # 15 | # @SPA_LOCATION.update(ticker_dropdown.input.value) 16 | # def _update_loc(value, store): 17 | # ... 18 | # return { 'href': href } 19 | # 20 | # See pages/ticker.py for working example 21 | 22 | class LocationStore(ReduxStore): 23 | 24 | def update(self, *_args, **_kwargs): 25 | 26 | def callback_stub(self, *_args, **_kwargs): 27 | pass 28 | 29 | if app and app.got_first_request: 30 | return callback_stub 31 | 32 | return super().update(*_args, **_kwargs) 33 | 34 | 35 | def _create_location(): 36 | 37 | if not current_app: 38 | return None 39 | 40 | if 'spa_location_store.data' not in GLOBAL_CALLBACK_MAP: 41 | _create_location.singleton = LocationStore(id='spa_location_store', data=None, storage_type='session') 42 | 43 | _location = dhc.Location(id='spa_location', refresh=False) 44 | 45 | @callback(_location.output.href, _location.state.href, _create_location.singleton.input.data, prevent_initial_call=True) 46 | def _location_update(url, data): 47 | if data and 'href' in data: 48 | url = urlparse(url) 49 | href = data['href'] 50 | new_url = urlparse(href) 51 | if url.path != new_url.path or url.query != new_url.query: 52 | # log.info('location update, href=%s', href) 53 | return href 54 | 55 | return NOUPDATE 56 | 57 | page_container_append(_location) 58 | page_container_append(_create_location.singleton) 59 | 60 | return _create_location.singleton 61 | 62 | 63 | SPA_LOCATION = LocalProxy(_create_location) 64 | -------------------------------------------------------------------------------- /dash_spa/components/table/__init__.py: -------------------------------------------------------------------------------- 1 | from .pagination_aoi import TableAIOPaginator 2 | from .pagination_view_aoi import TableAIOPaginatorView 3 | from .table_aio import TableAIO 4 | from .context import TableContext, TableState 5 | from .search import SearchAIO, filter_dash, filter_str 6 | -------------------------------------------------------------------------------- /dash_spa/components/table/context.py: -------------------------------------------------------------------------------- 1 | from dash_spa.spa_context import createContext, ContextState, dataclass 2 | 3 | @dataclass 4 | class TableState(ContextState): 5 | current_page: int = 1 6 | page_size: int = 10 7 | last_page: int = 1 8 | table_rows: int = 0 9 | search_term: str = None 10 | 11 | TableContext = createContext(TableState) -------------------------------------------------------------------------------- /dash_spa/components/table/pagination_view_aoi.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from .context import TableContext 3 | 4 | class TableAIOPaginatorView(html.Div): 5 | """Manages and updates the view component of the associated 6 | TableAIOPaginator. The TableAIOPaginatorView callback is triggered when the 7 | store component value changes. The callback calls the supplied 8 | 'content' function. The function return value is rendered as the child 9 | element of the TableAIOPaginatorView 10 | 11 | Args: 12 | paginator (TableAIOPaginator): The associated paginator 13 | className (str): the className of the component 14 | 15 | Returns: 16 | html.Div: The view component 17 | 18 | Example: 19 | ``` 20 | 21 | paginator = TableAIOPaginator(["Previous", 1, 2, 3, 4, 5, "Next"], 5, 25) 22 | viewer = TableAIOPaginatorView(paginator, render_content, className='fw-normal small mt-4 mt-lg-0' ) 23 | ``` 24 | 25 | Markup: 26 | ``` 27 |
28 | Showing page 4 out of 25 pages 29 |
30 | ``` 31 | """ 32 | def __init__(self, className='fw-normal small mt-4 mt-lg-0'): 33 | state = TableContext.getState() 34 | content = ["Showing page ",html.B(state.current_page)," out of ",html.B(state.last_page)," pages"] 35 | super().__init__(content, className=className) 36 | -------------------------------------------------------------------------------- /dash_spa/components/table/table_aio.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from math import ceil 3 | from typing import List, Dict, Any 4 | from dash import html 5 | 6 | 7 | import dash_spa as spa 8 | from dash_spa.logging import log 9 | 10 | from .context import TableContext, TableState 11 | 12 | TableData = List[Dict[str, Any]] 13 | TableColumns = List[Dict[str, Any]] 14 | 15 | class TableAIO(html.Table): 16 | """Generic SPA Table 17 | 18 | Args: 19 | data (TableData): The table data (an array of dict) 20 | columns (TableColumns): Column names dictionary 21 | page (int, optional): Initial page size. Defaults to 1. 22 | page_size (int, optional): Initial page size. Defaults to 100. 23 | id (str, optional): The table id. If None one will be assigned. 24 | 25 | Notes: 26 | 27 | The table needs to be initialised with a *page_size* that is the 28 | largest that will ever be displayed 29 | 30 | """ 31 | 32 | TABLE_CLASS_NAME = 'table table-hover' 33 | 34 | def __init__(self, data: TableData, columns: TableColumns, page = 1, page_size: int = 100, id: str = None, **kwargs): 35 | 36 | # TODO: This shouldn't be here! 37 | 38 | initial_state = TableState(page, page_size, ceil(len(data) / page_size), len(data)) 39 | state, _ = TableContext.useState(initial_state=initial_state) 40 | 41 | self._prefix = pid = spa.prefix(id) 42 | self._data = data 43 | 44 | # log.info('TableAIO id=%s', pid()) 45 | 46 | thead = self.tableHead(columns) 47 | trows = self.tableRows(data, page=state.current_page, page_size=page_size) 48 | tbody = html.Tbody(trows, id=pid('table')) 49 | 50 | super().__init__([thead,tbody], className=TableAIO.TABLE_CLASS_NAME, **kwargs) 51 | 52 | def tableHead(self, columns: TableColumns): 53 | row = html.Tr([html.Th(col['name'], className='border-gray-200') for col in columns]) 54 | return html.Thead(row) 55 | 56 | def tableRows(self, row_data, page=1, page_size = None): 57 | if page_size: 58 | low = (page -1) * page_size 59 | high = (page) * page_size 60 | high = high if high < len(row_data) else len(row_data) 61 | row_data = row_data[low:high] 62 | 63 | rows = [] 64 | for index, args in enumerate(row_data): 65 | row = self.tableRow(index, args) 66 | rows.append(row) 67 | 68 | return rows 69 | 70 | @abstractmethod 71 | def tableRow(self, row_index, args): 72 | return None 73 | 74 | -------------------------------------------------------------------------------- /dash_spa/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from .spa_current_app import current_app 3 | 4 | from .exceptions import InvalidAccess 5 | from .spa_current_user import current_user 6 | 7 | def login_required(func): 8 | @wraps(func) 9 | def decorated_function(*args, **kwargs): 10 | 11 | if current_app and current_app.got_first_request: 12 | if current_user is None or current_user.is_anonymous: 13 | raise InvalidAccess('You must be logged in to access this page') 14 | return func(*args, **kwargs) 15 | return decorated_function 16 | -------------------------------------------------------------------------------- /dash_spa/exceptions.py: -------------------------------------------------------------------------------- 1 | from dash.exceptions import DashException 2 | 3 | class InvalidUsageException(DashException): 4 | pass 5 | 6 | 7 | class InvalidAccess(Exception): 8 | def __init__(self, message): 9 | self.message = message 10 | super().__init__(self.message) 11 | -------------------------------------------------------------------------------- /dash_spa/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from .spa_config import config 3 | 4 | # pylint: disable=unused-import 5 | from logging import CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET, FATAL, WARN 6 | 7 | options = config.get('logging') 8 | 9 | def getOptionsLevel(default='WARN'): 10 | return options.get('level', default_value=default) 11 | 12 | logging.basicConfig( 13 | level = getOptionsLevel(), 14 | # format = '%(levelname)s %(asctime)s.%(msecs)03d %(module)10s/%(lineno)-5d %(message)s' 15 | format = '%(levelname)s %(module)13s/%(lineno)-5d %(message)s' 16 | ) 17 | 18 | log = logging.getLogger("dash_spa") 19 | 20 | def getLogger(name=None): 21 | return logging.getLogger(name) 22 | 23 | def setLevel(level): 24 | log.setLevel(level=level) 25 | -------------------------------------------------------------------------------- /dash_spa/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/dash_spa/plugins/__init__.py -------------------------------------------------------------------------------- /dash_spa/session/__init__.py: -------------------------------------------------------------------------------- 1 | from .session_data import session_data, SessionContext, session_context 2 | from .backends.backend_factory import SessionBackendFactory 3 | -------------------------------------------------------------------------------- /dash_spa/session/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/dash_spa/session/backends/__init__.py -------------------------------------------------------------------------------- /dash_spa/session/backends/backend_factory.py: -------------------------------------------------------------------------------- 1 | from dash_spa.spa_config import config, ConfigurationError 2 | 3 | from .diskcache import SessionDiskCache 4 | from .redis import RedisSessionBackend 5 | from .postgres import PostgresSessionBackend 6 | 7 | from ..session_cookie import session_manager 8 | 9 | options = config.get('session_storage') 10 | 11 | class SessionBackendFactory: 12 | 13 | user_sessions = {} 14 | 15 | @staticmethod 16 | def get_cache(): 17 | 18 | session_id = session_manager.get_session_id() 19 | 20 | def create_session(session_id): 21 | cache_type = options.get('backend', 'diskcache') 22 | 23 | if cache_type == 'diskcache': return SessionDiskCache(session_id) 24 | if cache_type == 'redis': return RedisSessionBackend(session_id) 25 | if cache_type == 'postgres': return PostgresSessionBackend(session_id) 26 | 27 | raise ConfigurationError(f"Unsupported backend {cache_type}") 28 | 29 | if not session_id in SessionBackendFactory.user_sessions: 30 | SessionBackendFactory.user_sessions[session_id] = create_session(session_id) 31 | 32 | return SessionBackendFactory.user_sessions[session_id] 33 | 34 | 35 | -------------------------------------------------------------------------------- /dash_spa/session/backends/redis.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from _plotly_utils.utils import PlotlyJSONEncoder 5 | from dash_spa.spa_config import config 6 | from .session_backend import SessionBackend 7 | 8 | options = config.get('session_storage.redis') 9 | 10 | # https://github.com/T4rk1n 11 | # https://github.com/plotly/dash-labs/blob/sessions/dash_labs/session/__init__.py 12 | 13 | class RedisSessionBackend(SessionBackend): 14 | """ 15 | Session backend using redis. 16 | """ 17 | 18 | def __init__(self, session_id): 19 | self.session_id = session_id 20 | 21 | port = options.port or 6379 22 | host = options.host or 'localhost' 23 | db = options.db or 0 24 | 25 | expire = options.expire or None 26 | 27 | connection_kwargs = {} 28 | 29 | try: 30 | # pylint: disable=import-outside-toplevel 31 | import redis 32 | except ImportError as err: 33 | raise ImportError( 34 | "Diskcache is not installed, install it with " 35 | "`pip install redis`" 36 | ) from err 37 | 38 | self.pool = redis.ConnectionPool(host=host, port=port, db=db, **connection_kwargs) 39 | self.r = redis.Redis(connection_pool=self.pool) 40 | self.expire = expire 41 | 42 | def _session_key(self): 43 | return f"dash_spa/session/{self.session_id}" 44 | 45 | def get(self, obj_key: str): 46 | value = self.r.hget(self._session_key(), obj_key) 47 | if value: 48 | return json.loads(value) 49 | else: 50 | return {} 51 | 52 | def set(self, obj_key: str, value: Any): 53 | self.r.hset(self._session_key(), obj_key, json.dumps(value, cls=PlotlyJSONEncoder)) 54 | if self.expire: 55 | self.r.expire(self._session_key(), self.expire) 56 | 57 | def remove(self, obj_key): 58 | """ Remove given key and update the store""" 59 | self.r.hdel(self._session_key(), obj_key) 60 | -------------------------------------------------------------------------------- /dash_spa/session/backends/session_backend.py: -------------------------------------------------------------------------------- 1 | 2 | class SessionBackend: 3 | 4 | def get(self, obj_key) -> dict: 5 | raise NotImplementedError 6 | 7 | def set(self, obj_key, value: dict): 8 | raise NotImplementedError 9 | 10 | def remove(self, obj_key): 11 | raise NotImplementedError 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /dash_spa/spa_current_app.py: -------------------------------------------------------------------------------- 1 | from werkzeug.local import LocalProxy 2 | from dash import get_app 3 | from .spa_pages import DashSPA 4 | 5 | def _get_current_app() -> DashSPA: 6 | try: 7 | return get_app() 8 | except Exception: 9 | pass 10 | return None 11 | 12 | current_app = LocalProxy(_get_current_app) 13 | -------------------------------------------------------------------------------- /dash_spa/spa_current_user.py: -------------------------------------------------------------------------------- 1 | from werkzeug.local import LocalProxy 2 | from flask import session 3 | 4 | def _current_user(): 5 | 6 | class CurrentUser: 7 | """Stub that allows DashSPA to think it's got a Flask_Login 8 | login manager when it hasn't. Without a login manager the 9 | stub mocks the equivalent of an anonymous user. With a login 10 | manager installed requests are handled by the flask_login 11 | current_user instance as it would be normally. 12 | 13 | To enable the DashSPA login manager add the following lines 14 | to your start-up code: 15 | ``` 16 | from dash_spa import DashSPA 17 | from dash_spa_admin import AdminLoginManager 18 | 19 | app = DashSPA( __name__, ...) 20 | 21 | ... 22 | 23 | login_manager = AdminLoginManager(app.server) 24 | login_manager.init_app(app.server) 25 | ``` 26 | 27 | """ 28 | 29 | def __init__(self, current_user): 30 | self.current_user = current_user 31 | 32 | @property 33 | def is_authenticated(self): 34 | try: 35 | session.permanent = True 36 | return self.current_user.is_authenticated 37 | except: 38 | return False 39 | 40 | @property 41 | def is_active(self): 42 | try: 43 | return self.current_user.is_active 44 | except: 45 | pass 46 | return False 47 | 48 | def get_id(self): 49 | try: 50 | return self.current_user.get_id 51 | except: 52 | pass 53 | return 54 | 55 | @property 56 | def is_anonymous(self): 57 | try: 58 | return self.current_user.is_anonymous 59 | except: 60 | pass 61 | return True 62 | 63 | @property 64 | def role(self): 65 | try: 66 | return self.current_user.role 67 | except: 68 | pass 69 | return None 70 | 71 | @property 72 | def name(self): 73 | try: 74 | return self.current_user.name 75 | except: 76 | pass 77 | return "Guest" 78 | 79 | try: 80 | # pylint: disable=import-outside-toplevel 81 | from flask_login import current_user as flask_current_user 82 | return CurrentUser(flask_current_user) 83 | except: 84 | pass 85 | return CurrentUser(None) 86 | 87 | current_user = LocalProxy(_current_user) 88 | 89 | def set_current_user(flask_currentuser): 90 | global current_user 91 | current_user = flask_currentuser 92 | -------------------------------------------------------------------------------- /dash_spa/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .time import time_ms 2 | from .syncronised import synchronized 3 | -------------------------------------------------------------------------------- /dash_spa/utils/caller.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from inspect import getframeinfo, stack 3 | 4 | # https://stackoverflow.com/a/24439444/489239 5 | 6 | def caller_location(depth:int=1): 7 | caller = getframeinfo(stack()[depth+1][0]) 8 | str = f"{caller.filename}/{caller.lineno}" 9 | return str 10 | 11 | 12 | def caller_nested(): 13 | caller = getframeinfo(stack()[2][0]) 14 | return caller.code_context[0].startswith(' ') 15 | 16 | def caller_hash(depth:int=1, prefix:str='#') -> str: 17 | """Return hash derived from the the call stack filename and location 18 | 19 | Args: 20 | depth (int, optional): The depth in the call stack. Defaults to 1 (callers caller). 21 | prefix (str, optional): Prefix for returned hash. Defaults to '#'. 22 | 23 | Returns: 24 | str: _description_ 25 | """ 26 | caller = getframeinfo(stack()[depth+1][0]) 27 | str = f"{caller.filename}/{caller.lineno}" 28 | _hash = hash(str) 29 | _hash += sys.maxsize + 1 30 | return prefix + hex(_hash)[2:] 31 | 32 | -------------------------------------------------------------------------------- /dash_spa/utils/dataclass.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from dataclasses import _is_dataclass_instance, fields 3 | 4 | # Modified version of /python3.8/dataclasses.py asdict. The 5 | # dataclasses version in unable to handle missing fields 6 | 7 | def asdict(obj, *, dict_factory=dict): 8 | """Return the fields of a dataclass instance as a new dictionary mapping 9 | field names to field values. 10 | 11 | Example usage: 12 | 13 | @dataclass 14 | class C: 15 | x: int 16 | y: int 17 | 18 | c = C(1, 2) 19 | assert asdict(c) == {'x': 1, 'y': 2} 20 | 21 | If given, 'dict_factory' will be used instead of built-in dict. 22 | The function applies recursively to field values that are 23 | dataclass instances. This will also look into built-in containers: 24 | tuples, lists, and dicts. 25 | """ 26 | if not _is_dataclass_instance(obj): 27 | raise TypeError("asdict() should be called on dataclass instances") 28 | return _asdict_inner(obj, dict_factory) 29 | 30 | 31 | def _asdict_inner(obj, dict_factory): 32 | if _is_dataclass_instance(obj): 33 | result = [] 34 | for f in fields(obj): 35 | 36 | # Fix: Handle missing fields 37 | 38 | if hasattr(obj, f.name): 39 | value = _asdict_inner(getattr(obj, f.name), dict_factory) 40 | result.append((f.name, value)) 41 | 42 | return dict_factory(result) 43 | elif isinstance(obj, tuple) and hasattr(obj, '_fields'): 44 | # obj is a namedtuple. Recurse into it, but the returned 45 | # object is another namedtuple of the same type. This is 46 | # similar to how other list- or tuple-derived classes are 47 | # treated (see below), but we just need to create them 48 | # differently because a namedtuple's __init__ needs to be 49 | # called differently (see bpo-34363). 50 | 51 | # I'm not using namedtuple's _asdict() 52 | # method, because: 53 | # - it does not recurse in to the namedtuple fields and 54 | # convert them to dicts (using dict_factory). 55 | # - I don't actually want to return a dict here. The main 56 | # use case here is json.dumps, and it handles converting 57 | # namedtuples to lists. Admittedly we're losing some 58 | # information here when we produce a json list instead of a 59 | # dict. Note that if we returned dicts here instead of 60 | # namedtuples, we could no longer call asdict() on a data 61 | # structure where a namedtuple was used as a dict key. 62 | 63 | return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj]) 64 | elif isinstance(obj, (list, tuple)): 65 | # Assume we can create an object of this type by passing in a 66 | # generator (which is not true for namedtuples, handled 67 | # above). 68 | return type(obj)(_asdict_inner(v, dict_factory) for v in obj) 69 | elif isinstance(obj, dict): 70 | return type(obj)((_asdict_inner(k, dict_factory), 71 | _asdict_inner(v, dict_factory)) 72 | for k, v in obj.items()) 73 | else: 74 | return copy.deepcopy(obj) 75 | -------------------------------------------------------------------------------- /dash_spa/utils/dumps_layout.py: -------------------------------------------------------------------------------- 1 | import re 2 | import plotly 3 | import json 4 | 5 | 6 | def json_layout(layout): 7 | """Dump the given component layout""" 8 | 9 | # ReduxStore elements use a hash() to generate a component 10 | # id. This is a problem when testing because the hash() seed changes 11 | # on each test run. Here we pick out the hash based ids' and replace 12 | # them with an index. 13 | 14 | id_list = [] 15 | 16 | def replace_id(match): 17 | val = match.group(0).split(': ')[1] 18 | if val not in id_list: 19 | id_list.append(val) 20 | return f"\"idx\": \"PYTEST_REPLACEMENT_ID_{id_list.index(val)}\"" 21 | 22 | # "idx": "2438d368d91cd816" 23 | 24 | json_str = json.dumps(layout, indent=2, cls=plotly.utils.PlotlyJSONEncoder) 25 | json_str = re.sub(r'\"idx\": \"[0-9a-f]+\"', replace_id, json_str) 26 | 27 | # json_str = re.sub(r': true', ': True', json_str) 28 | # json_str = re.sub(r': false', ': False', json_str) 29 | json_str = re.sub(r': null', ': []', json_str) 30 | 31 | return json_str 32 | 33 | 34 | def dumps_layout(layout): 35 | json_str = json_layout(layout) 36 | return json.loads(json_str) 37 | -------------------------------------------------------------------------------- /dash_spa/utils/json_coder.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | import json 3 | 4 | 5 | def json_encode(o): 6 | if isinstance(o, (date, datetime)): 7 | return o.isoformat() 8 | 9 | def json_decode(json_dict): 10 | for (key, value) in json_dict.items(): 11 | try: 12 | json_dict[key] = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f") 13 | except Exception: 14 | pass 15 | return json_dict 16 | 17 | def clone(obj): 18 | s = json.dumps(obj, default=json_encode) 19 | return json.loads(s, object_hook=json_decode) -------------------------------------------------------------------------------- /dash_spa/utils/notify_dict.py: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/a/5186698/489239 2 | 3 | class NotifyDict(dict): 4 | __slots__ = ["callback"] 5 | 6 | def __init__(self, callback, *args, **kwargs): 7 | self.callback = callback 8 | dict.__init__(self, *args, **kwargs) 9 | 10 | 11 | def _wrap(method): 12 | def wrapper(self, *args, **kwargs): 13 | result = method(self, *args, **kwargs) 14 | self.callback() 15 | return result 16 | return wrapper 17 | 18 | 19 | __delitem__ = _wrap(dict.__delitem__) 20 | __setitem__ = _wrap(dict.__setitem__) 21 | clear = _wrap(dict.clear) 22 | pop = _wrap(dict.pop) 23 | popitem = _wrap(dict.popitem) 24 | setdefault = _wrap(dict.setdefault) 25 | update = _wrap(dict.update) -------------------------------------------------------------------------------- /dash_spa/utils/syncronised.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | def synchronized(lock): 4 | @wraps 5 | def _wrapper(wrapped, args, kwargs): 6 | with lock: 7 | return wrapped(*args, **kwargs) 8 | return _wrapper 9 | -------------------------------------------------------------------------------- /dash_spa/utils/time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | def time_ms(): 4 | tnow = datetime.datetime.timestamp(datetime.datetime.now()) 5 | return int(tnow * 1000) 6 | -------------------------------------------------------------------------------- /dash_spa_admin/__init__.py: -------------------------------------------------------------------------------- 1 | from .login_manager import AdminLoginManager 2 | from .admin_navbar import AdminNavbarComponent 3 | -------------------------------------------------------------------------------- /dash_spa_admin/admin_navbar.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from flask import current_app as app 3 | from dash_spa import current_user 4 | from dash import html 5 | import dash_bootstrap_components as dbc 6 | 7 | from .views.common import LOGIN_ENDPOINT, LOGOUT_ENDPOINT, USERS_ENDPOINT, REGISTER_ADMIN_ENDPOINT 8 | 9 | class AdminNavbarComponent: 10 | 11 | def __init__(self): 12 | pass 13 | 14 | @abstractmethod 15 | def menu_items(self): 16 | """User defined components for NavBar My Account dropdown""" 17 | return [ 18 | dbc.DropdownMenuItem("Messages", href="#"), 19 | dbc.DropdownMenuItem("Settings", href="#"), 20 | ] 21 | 22 | def account_dropdown(self): 23 | 24 | children = self.menu_items() 25 | 26 | if app.login_manager.isAdmin(): 27 | users_link = dbc.DropdownMenuItem("Users", href=app.login_manager.path_for(USERS_ENDPOINT)) 28 | children.append(users_link) 29 | 30 | children.append(html.Div(className='dropdown-divider')) 31 | children.append( 32 | dbc.DropdownMenuItem([html.I(className='fa fa-sign-in'), ' Sign out'], href=app.login_manager.path_for(LOGOUT_ENDPOINT)) 33 | ) 34 | 35 | menu = dbc.DropdownMenu( 36 | children=children, 37 | nav=True, 38 | in_navbar=True, 39 | label="My Account", 40 | right=True 41 | ) 42 | 43 | icon = html.I(className="fa fa fa-user", style={'position': 'relative','left': '4px'}) 44 | 45 | return html.Div(['', icon, menu], style={'padding': '0'}, className='d-flex align-items-center nav-link') 46 | 47 | def signin_link(self): 48 | try: 49 | if app.login_manager.user_count() > 0: 50 | href=app.login_manager.path_for(LOGIN_ENDPOINT) 51 | else: 52 | href=app.login_manager.path_for(REGISTER_ADMIN_ENDPOINT) 53 | return dbc.NavItem(dbc.NavLink([html.I(className='fa fa-sign-in'), ' Sign in'], href=href)) 54 | except: 55 | return html.Div('') 56 | 57 | 58 | 59 | def layout(self, **kwargs): 60 | if current_user and not current_user.is_anonymous: 61 | return self.account_dropdown() 62 | return self.signin_link() 63 | -------------------------------------------------------------------------------- /dash_spa_admin/admin_page.py: -------------------------------------------------------------------------------- 1 | from dash_spa import prefix, SpaForm, register_page 2 | 3 | from .views import loginForm, forgotForm, forgotCodeForm, forgotPasswordForm 4 | from .views import registerForm, logoutView, usersView, adminRegistrationForm 5 | from .views import registerVerifyForm 6 | 7 | from .views.common import (LOGIN_ENDPOINT, LOGOUT_ENDPOINT, 8 | REGISTER_ENDPOINT, REGISTER_ADMIN_ENDPOINT, 9 | REGISTER_VERIFY_ENDPOINT, 10 | FORGOT_ENDPOINT, FORGOT_CODE_ENDPOINT, FORGOT_PASSWORD_ENDPOINT, 11 | USERS_ENDPOINT) 12 | 13 | class AdminPage: 14 | 15 | def __init__(self, login_manager): 16 | pfx = prefix('spa_admin') 17 | 18 | database_uri = login_manager.database_uri() 19 | 20 | class ViewContext: 21 | 22 | def path_for(self, endpoint, args=None): 23 | return login_manager.path_for(endpoint, args) 24 | 25 | def SpaForm(self, id): 26 | id = id.split('.')[-1] 27 | return SpaForm(pfx(id)) 28 | 29 | ctx = ViewContext() 30 | 31 | views = { 32 | LOGIN_ENDPOINT: loginForm(ctx), 33 | LOGOUT_ENDPOINT: logoutView(ctx), 34 | 35 | FORGOT_ENDPOINT: forgotForm(ctx), 36 | FORGOT_CODE_ENDPOINT : forgotCodeForm(ctx), 37 | FORGOT_PASSWORD_ENDPOINT : forgotPasswordForm(ctx), 38 | 39 | REGISTER_ENDPOINT: registerForm(ctx), 40 | REGISTER_ADMIN_ENDPOINT: adminRegistrationForm(ctx), 41 | REGISTER_VERIFY_ENDPOINT: registerVerifyForm(ctx), 42 | 43 | USERS_ENDPOINT: usersView(ctx, database_uri) 44 | 45 | } 46 | 47 | # container = html.Div(loginForm, id=pfx('container')) 48 | 49 | # @callback(container.output.children, spa.location.input.pathname) 50 | # def admin_cb(pathname): 51 | # if pathname in views: 52 | # return views[pathname] 53 | # return NOUPDATE 54 | 55 | # def layout(view_id=LOGIN_ENDPOINT, **kwargs): 56 | # log.info('layout "%s"', view_id) 57 | # if view_id in views: 58 | # return views[view_id] 59 | # raise InvalidPath 60 | 61 | # register_page('dash_spa_admin.page', path_template=f'{login_manager.slug}/', layout=layout) 62 | 63 | for id, layout in views.items(): 64 | register_page( 65 | f'dash_spa_admin.{id}', 66 | path=f'{login_manager.slug}/{id}', 67 | layout=layout 68 | ) 69 | -------------------------------------------------------------------------------- /dash_spa_admin/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import current_app as app 3 | from dash_spa.exceptions import InvalidAccess 4 | 5 | # TODO: Move this to dash_spa and test role via Flask current user 6 | 7 | def role_required(role_name): 8 | """Layout function decorator. Raise InvalidAccess exception if current user 9 | does not have the role required 10 | 11 | Args: 12 | role_name (str): "admin" or "user" 13 | 14 | Raises: 15 | 16 | InvalidAccess exception 17 | 18 | ``` 19 | @role_required('admin') 20 | def layout(): 21 | return "Secrets known only to admin..." 22 | 23 | ``` 24 | """ 25 | def decorator(func): 26 | @wraps(func) 27 | def authorize(*args, **kwargs): 28 | if app and app.got_first_request: 29 | if not app.login_manager.isAdmin(): 30 | raise InvalidAccess(f'You must have "{role_name}" role to access this route') 31 | return func(*args, **kwargs) 32 | return authorize 33 | return decorator 34 | -------------------------------------------------------------------------------- /dash_spa_admin/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class InvalidPath(Exception): 3 | def __init__(self, message): 4 | self.message = message 5 | super().__init__(self.message) 6 | -------------------------------------------------------------------------------- /dash_spa_admin/synchronised_cache.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from cachetools import TTLCache 3 | import time 4 | from dash_spa.utils import synchronized 5 | 6 | 7 | class SynchronisedTTLCache: 8 | """Synchronised TTL cache 9 | 10 | Args: 11 | maxsize (_type_): _description_ 12 | ttl (int): Time to live, seconds 13 | timer (_type_, optional): _description_. Defaults to time.monotonic. 14 | getsizeof (_type_, optional): _description_. Defaults to None. 15 | """ 16 | 17 | FOREVER = 100 * 365 * 24 * 60 * 60 18 | 19 | # https://rszalski.github.io/magicmethods/ 20 | 21 | def __init__(self, maxsize, ttl, timer=time.monotonic, getsizeof=None): 22 | self._cache = TTLCache(maxsize=maxsize, ttl=ttl, timer=timer, getsizeof=getsizeof) 23 | self._lock = threading.Lock() 24 | 25 | # pylint: disable=invalid-length-returned 26 | def __len__(self): 27 | @synchronized(self._lock) 28 | def inner(): 29 | return self._cache.__len__ 30 | return inner() 31 | 32 | def __getitem__(self, key): 33 | @synchronized(self._lock) 34 | def inner(): 35 | return self._cache.__getitem__(key) 36 | return inner() 37 | 38 | def __setitem__(self, key, value): 39 | @synchronized(self._lock) 40 | def inner(): 41 | return self._cache.__setitem__(key, value) 42 | return inner() 43 | 44 | def __delitem__(self, key): 45 | @synchronized(self._lock) 46 | def inner(): 47 | return self._cache.__delitem__(key) 48 | return inner() 49 | 50 | def keys(self): 51 | @synchronized(self._lock) 52 | def inner(): 53 | return self._cache.keys() 54 | return inner() 55 | 56 | def __iter__(self): 57 | @synchronized(self._lock) 58 | def inner(): 59 | return self._cache.__iter__() 60 | return inner() 61 | 62 | def __reversed__(self): 63 | @synchronized(self._lock) 64 | def inner(): 65 | # pylint: disable=not-callable 66 | return self._cache.__reversed__() 67 | return inner() 68 | -------------------------------------------------------------------------------- /dash_spa_admin/template_mailer.py: -------------------------------------------------------------------------------- 1 | import pystache 2 | import smtplib 3 | from email.mime.text import MIMEText 4 | 5 | from dash_spa import config 6 | from dash_spa.logging import log 7 | 8 | header_template = """ 9 | Subject: {{subject}} 10 | To: {{receiver}} 11 | From: {{sender}} 12 | """ 13 | 14 | class TemplateMailer: 15 | 16 | def __init__(self, template, args): 17 | self.options = config.get('login_manager.mail') 18 | self.args = args 19 | self.email_msg = pystache.render(template, args) 20 | 21 | @property 22 | def smt_transport(self): 23 | options = self.options 24 | if options.secure: 25 | return smtplib.SMTP_SSL(options.host, options.port) 26 | 27 | return smtplib.SMTP(options.host, options.port) 28 | 29 | def send(self, receiver, subject, test_mode=True): 30 | options = self.options 31 | sender = options.sender 32 | 33 | # When testing no email is sent. Instead we just print the 34 | # email that would have been sent 35 | 36 | if test_mode: 37 | args = locals() 38 | email = pystache.render(header_template + self.email_msg, args) 39 | print('Test mode: email send is dissabled.\nThe following email would have been sent:\n') 40 | print(email) 41 | return email 42 | 43 | try: 44 | msg = MIMEText(self.email_msg) 45 | msg['Subject'] = subject 46 | msg['From'] = sender 47 | msg['To'] = receiver 48 | 49 | log.info('sending email to %s ...', receiver) 50 | with self.smt_transport as server: 51 | # server.ehlo() 52 | # server.starttls() 53 | server.login(options.user, options.password) 54 | server.send_message(msg) 55 | log.info('Done') 56 | 57 | except Exception as ex: 58 | log.info('email send failed %s', ex) 59 | 60 | return None 61 | -------------------------------------------------------------------------------- /dash_spa_admin/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .forgot_code_view import forgotCodeForm 2 | from .forgot_password_view import forgotPasswordForm 3 | from .forgot_view import forgotForm 4 | 5 | from .login_view import loginForm 6 | from .register_view import registerForm 7 | from .admin_register_view import adminRegistrationForm 8 | 9 | from .logout_view import logoutView 10 | from .users_view import usersView 11 | 12 | from .register_verify_view import registerVerifyForm -------------------------------------------------------------------------------- /dash_spa_admin/views/admin_register_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from dash import html 3 | 4 | from dash_spa import isTriggered, location, callback 5 | from dash_spa.logging import log 6 | 7 | from .common import form_layout, email_valid, REGISTER_ADMIN_ENDPOINT, LOGIN_ENDPOINT 8 | 9 | def adminRegistrationForm(ctx): 10 | 11 | frm = ctx.SpaForm(__name__) 12 | 13 | log.info('__name__=%s', __name__) 14 | 15 | def get_name(value=None): 16 | return frm.Input('Name', name='name', id='name', placeholder="Enter name", value=value) 17 | 18 | def get_email(value=None): 19 | return frm.Input('Email', id='email', name='email', type='email', placeholder="Enter email", value=value) 20 | 21 | flash = frm.Alert(id='flash') 22 | name = get_name() 23 | email = get_email() 24 | password = frm.PasswordInput("Password", name='password', id="password", placeholder="Enter password") 25 | confirm_password = frm.PasswordInput('Re-enter password', 26 | name="confirm_password", id='confirm_password', placeholder="Re-enter password", 27 | feedback="Password fields are not the same, re-enter them") 28 | button = frm.Button('Create Admin', type='submit', id='btn') 29 | redirect = frm.Location(id='redirect', refresh=True) 30 | 31 | form = frm.Form([ 32 | flash, 33 | name, 34 | email, 35 | password, 36 | confirm_password, 37 | button, 38 | ], id='admin_register') 39 | 40 | 41 | @callback(redirect.output.href, flash.output.children, form.input.form_data) 42 | def _form_submit(values): 43 | redirect = frm.NOUPDATE 44 | error = "" 45 | 46 | log.info('_form_submit values=%s', values) 47 | 48 | if isTriggered(form.input.form_data): 49 | name = values['name'] 50 | email = values['email'] 51 | password = values['password'] 52 | confirm_password = values['confirm_password'] 53 | if not (name and password and confirm_password and email): 54 | error = 'You must enter all fields' 55 | elif not email_valid(email): 56 | error = 'Invalid email' 57 | elif password != confirm_password: 58 | error = 'Password mismatch' 59 | else: 60 | app.login_manager.add_user(name, email, password, role=['admin']) 61 | redirect = ctx.path_for(LOGIN_ENDPOINT) 62 | 63 | 64 | return redirect, error 65 | 66 | # Make name & email fields persistent. 67 | 68 | store = frm.Store(form, fields=['name', 'email'], storage_type='local') 69 | 70 | @callback(form.output.children, location.input.pathname, store.state.data) 71 | def _form_update(pathname, data): 72 | if pathname == ctx.path_for(REGISTER_ADMIN_ENDPOINT) and data is not None: 73 | name = get_name(data['name']) 74 | email = get_email(data['email']) 75 | return [flash, name, email, password, confirm_password, button] 76 | else: 77 | return frm.NOUPDATE 78 | 79 | 80 | _form = form_layout('Create Admin', form) 81 | return html.Div([_form, redirect, store]) 82 | -------------------------------------------------------------------------------- /dash_spa_admin/views/common.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from collections import namedtuple 3 | import re 4 | from dash import html 5 | 6 | class USER(IntEnum): 7 | NONE = 0 8 | USER_ALLREADY_EXISTS = 1 9 | EMAIL_SENT = 2 10 | VALIDATED = 3 11 | VALIDATION_FAILED = 4 12 | 13 | LOGIN_ENDPOINT = 'login' 14 | LOGOUT_ENDPOINT = 'logout' 15 | 16 | REGISTER_ENDPOINT = 'register' 17 | REGISTER_ADMIN_ENDPOINT = 'register_admin' 18 | REGISTER_VERIFY_ENDPOINT = 'register_verify' 19 | 20 | FORGOT_ENDPOINT = 'forgot' 21 | FORGOT_CODE_ENDPOINT = 'forgotcode' 22 | FORGOT_PASSWORD_ENDPOINT = 'forgotpassword' 23 | 24 | USERS_ENDPOINT = 'users' 25 | 26 | def form_values(dt): 27 | obj = namedtuple("FormFields", dt.keys())(*dt.values()) 28 | return obj 29 | 30 | def email_valid(email): 31 | return re.match(r"[^@]+@[^@]+\.[^@]+", email) 32 | 33 | def form_layout(title, form): 34 | return html.Div([ 35 | html.Div([ 36 | html.Div([ 37 | html.Div([ 38 | html.H4(title, className="card-title"), 39 | form, 40 | ], className='card-body') 41 | ], className="card fat") 42 | ], className="col-6 mx-auto") 43 | ], className="row align-items-center h-100") 44 | -------------------------------------------------------------------------------- /dash_spa_admin/views/forgot_code_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from dash import html 3 | 4 | from dash_spa import isTriggered, location, callback 5 | from dash_spa.logging import log 6 | 7 | from .common import form_layout, email_valid, FORGOT_PASSWORD_ENDPOINT 8 | 9 | """ 10 | The user has been sent an email containing the forgot password verification 11 | code. Allow the user to enter the code. If it verifies we redirect to 12 | the `forgot2` endpoint. 13 | """ 14 | 15 | def forgotCodeForm(ctx): 16 | 17 | frm = ctx.SpaForm(__name__) 18 | 19 | flash = frm.Alert(id='flash') 20 | code = frm.Input(name='code', id='code', placeholder="verification code", prompt="Check your email in-box") 21 | redirect = frm.Location(id='redirect', refresh=True) 22 | 23 | form = frm.Form([ 24 | flash, code, frm.Button('Enter Verification Code', id='btn', type='submit') 25 | ], id='forgot') 26 | 27 | @callback(redirect.output.href, flash.output.children, form.input.form_data, location.state.href) 28 | def _form_submit(values, href): 29 | redirect = frm.NOUPDATE 30 | error = frm.NOUPDATE 31 | 32 | if isTriggered(form.input.form_data): 33 | code = values['code'] 34 | qs = frm.querystring_args(href) 35 | email = qs['email'] 36 | if not app.login_manager.forgot_code_valid(code, email): 37 | error = 'Invalid vaildation code, please re-enter' 38 | else: 39 | args = {'code': code.upper(), 'email': email} 40 | redirect = ctx.path_for(FORGOT_PASSWORD_ENDPOINT, args=args) 41 | 42 | return redirect, error 43 | 44 | _form = form_layout('Password Reset', form) 45 | 46 | return html.Div([_form, redirect]) 47 | -------------------------------------------------------------------------------- /dash_spa_admin/views/forgot_password_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from dash import html 3 | 4 | from dash_spa import isTriggered, callback, location 5 | from dash_spa.logging import log 6 | 7 | from .common import form_layout, LOGIN_ENDPOINT 8 | 9 | """ 10 | The user has confirmed his email, allow user to change the account 11 | password 12 | """ 13 | 14 | def forgotPasswordForm(ctx): 15 | 16 | frm = ctx.SpaForm(__name__) 17 | 18 | flash = frm.Alert(id='flash') 19 | password = frm.PasswordInput("Password", name='password', id='password', prompt="Make sure your password is strong and easy to remember", placeholder="Enter password") 20 | confirm_password = frm.PasswordInput('Re-enter password', name="confirm_password", id='confirm_password', placeholder="Re-enter password", feedback="Password fields are not the same, re-enter them") 21 | redirect = frm.Location(id='redirect', refresh=True) 22 | 23 | form = frm.Form([ 24 | flash, password, confirm_password, frm.Button('Update Password', id='btn', type='submit') 25 | ], id='forgot') 26 | 27 | @callback(redirect.output.href, flash.output.children, form.input.form_data, location.state.href) 28 | def _form_submit(values, href): 29 | redirect = frm.NOUPDATE 30 | error = frm.NOUPDATE 31 | 32 | if isTriggered(form.input.form_data): 33 | qs = frm.querystring_args(href) 34 | if qs is not None and 'code' in qs and 'email' in qs: 35 | code = qs['code'] 36 | email = qs['email'] 37 | if app.login_manager.forgot_code_valid(code, email): 38 | password = values['password'] 39 | confirm_password = values['confirm_password'] 40 | if password != confirm_password: 41 | error = 'Password mismatch' 42 | else: 43 | if app.login_manager.change_password(email, password): 44 | redirect = ctx.path_for(LOGIN_ENDPOINT) 45 | else: 46 | error = 'Update failed, try again' 47 | 48 | return redirect, error 49 | 50 | 51 | _form = form_layout('Password Reset', form) 52 | return html.Div([_form, redirect]) 53 | -------------------------------------------------------------------------------- /dash_spa_admin/views/forgot_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from dash import html 3 | 4 | from dash_spa import isTriggered, callback 5 | from dash_spa.logging import log 6 | 7 | from .common import form_layout, email_valid, FORGOT_CODE_ENDPOINT 8 | 9 | """ 10 | Display email form and wait input. If the user enters a valid email 11 | the login_manager will send an forgot validation email to the user. We 12 | redirect to the `forgot-code` endpoint 13 | """ 14 | 15 | def forgotForm(ctx): 16 | 17 | frm = ctx.SpaForm(__name__) 18 | 19 | flash = frm.Alert(id='flash') 20 | email = frm.Input(name='email', type='email', id='email', placeholder="Enter email", feedback="Your email is invalid") 21 | redirect = frm.Location(id='redirect', refresh=True) 22 | 23 | form = frm.Form([ 24 | flash, email, frm.Button('Reset Request', id='btn', type='submit') 25 | ], id='forgot') 26 | 27 | @callback(redirect.output.href, flash.output.children, form.input.form_data) 28 | def _form_submit(values): 29 | redirect = frm.NOUPDATE 30 | error = frm.NOUPDATE 31 | 32 | if isTriggered(form.input.form_data): 33 | email = values['email'] 34 | if not email_valid(email): 35 | error = 'Invalid email' 36 | elif not app.login_manager.forgot(email): 37 | error = 'You do not have an account on this site' 38 | else: 39 | 40 | # An email has been sent to the user, redirect to await 41 | # entry of the forgot password validation code 42 | 43 | redirect = ctx.path_for(FORGOT_CODE_ENDPOINT, {'email': email}) 44 | 45 | return redirect, error 46 | 47 | _form = form_layout('Password Reset', form) 48 | return html.Div([_form, redirect]) 49 | -------------------------------------------------------------------------------- /dash_spa_admin/views/login_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from dash import html, dcc 3 | from dash.exceptions import PreventUpdate 4 | 5 | from dash_spa import isTriggered, callback, location, url_for 6 | from dash_spa.logging import log 7 | 8 | from .common import form_layout, LOGIN_ENDPOINT, FORGOT_ENDPOINT, REGISTER_ENDPOINT 9 | 10 | def loginForm(ctx): 11 | 12 | def registerLink(): 13 | return html.Div([ 14 | "Don't have an account? ", 15 | dcc.Link("Create one", href=ctx.path_for(REGISTER_ENDPOINT)) 16 | ], className="mt-4 text-center") 17 | 18 | frm = ctx.SpaForm(__name__) 19 | 20 | flash = frm.Alert(id='flash') 21 | email = frm.Input('Email', id='email', name='email', type='email', placeholder="Enter email") 22 | 23 | password = frm.PasswordInput("Password", name='password', id="password", placeholder="Enter password") 24 | password.children.insert(1, dcc.Link('Forgot Password?', href=ctx.path_for(FORGOT_ENDPOINT), className="float-end")) 25 | 26 | remember = frm.Checkbox("Remember me", id='remember', name='remember', checked=True) 27 | button = frm.Button('Sign In', type='submit', id='btn') 28 | redirect = frm.Location(id='redirect', refresh=True) 29 | 30 | form = frm.Form([ 31 | flash, 32 | email, 33 | password, 34 | remember, html.Br(), 35 | button, 36 | registerLink() 37 | ], id='login') 38 | 39 | 40 | @callback(redirect.output.href, flash.output.children, form.input.form_data) 41 | def _form_submit(values): 42 | redirect = frm.NOUPDATE 43 | error = "" 44 | 45 | log.info('_form_submit values=%s', values) 46 | 47 | if isTriggered(form.input.form_data): 48 | email = values['email'] 49 | password = values['password'] 50 | remember = values['remember'] 51 | valid = app.login_manager.login(email, password, remember) 52 | if valid: 53 | # TODO: Make this configurable 54 | redirect = url_for('pages.user.profile') 55 | else: 56 | error = 'Please check your login details and try again.' 57 | 58 | 59 | return redirect, error 60 | 61 | # Make email field perestant. 62 | 63 | store = frm.Store(form, fields=[email.name, remember.name], storage_type='session') 64 | 65 | @callback(email.output.value, remember.output.value, location.input.pathname, store.state.data) 66 | def _form_update(pathname, data): 67 | if pathname == ctx.path_for(LOGIN_ENDPOINT) and data is not None: 68 | return data[email.name], data[remember.name] 69 | 70 | raise PreventUpdate 71 | 72 | _form = form_layout('Sign in', form) 73 | 74 | return html.Div([_form, redirect, store]) 75 | -------------------------------------------------------------------------------- /dash_spa_admin/views/logout_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | import dash_holoniq_components as dhc 3 | 4 | from dash_spa import callback, NOUPDATE 5 | from dash_spa.logging import log 6 | 7 | from .common import LOGOUT_ENDPOINT 8 | 9 | 10 | def logoutView(ctx): 11 | 12 | redirect = dhc.Location(id='redirect', refresh=True) 13 | 14 | @callback(redirect.output.href, redirect.input.pathname) 15 | def _logout_cb(pathname): 16 | log.info('_logout_cb pathname=%s',pathname) 17 | if pathname == ctx.path_for(LOGOUT_ENDPOINT): 18 | app.login_manager.logout_user() 19 | return '/' 20 | return NOUPDATE 21 | 22 | return redirect 23 | -------------------------------------------------------------------------------- /dash_spa_admin/views/register_verify_view.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from dash import html, dcc 3 | 4 | from dash_spa import isTriggered, callback, location 5 | from dash_spa.logging import log 6 | 7 | from .common import form_layout, REGISTER_ENDPOINT, LOGIN_ENDPOINT 8 | 9 | """ 10 | The user has been sent an email containing the registration verification 11 | code. Allow the user to enter the code. If it verifies we redirect to 12 | the LOGIN_ENDPOINT endpoint. 13 | """ 14 | 15 | def registerVerifyForm(ctx): 16 | 17 | frm = ctx.SpaForm(__name__) 18 | 19 | def registerLink(): 20 | return html.Div([ 21 | "Don't have an account? ", 22 | dcc.Link("Create one", href=ctx.path_for(REGISTER_ENDPOINT)) 23 | ], className="mt-4 text-center") 24 | 25 | flash = frm.Alert(id='flash') 26 | code = frm.Input(name='code', id='code', placeholder="verification code", prompt="Check your email in-box") 27 | button = frm.Button('Submit', type='submit', id='btn') 28 | redirect = frm.Location(id='redirect', refresh=True) 29 | 30 | form = frm.Form([ 31 | flash, 32 | code, html.Br(), 33 | button, 34 | registerLink() 35 | ], id='verify') 36 | 37 | @callback(redirect.output.href, flash.output.children, form.input.form_data, location.state.href) 38 | def _button_click(values, href): 39 | redirect = frm.NOUPDATE 40 | error = frm.NOUPDATE 41 | 42 | if isTriggered(form.input.form_data): 43 | qs = frm.querystring_args(href) 44 | if qs is not None and 'email' in qs: 45 | code = values['code'] 46 | email = qs['email'] 47 | if app.login_manager.validate(email, code): 48 | redirect = ctx.path_for(LOGIN_ENDPOINT) 49 | else: 50 | error = 'invalid code, please re-enter' 51 | return redirect, error 52 | 53 | _form = form_layout('Verify', form) 54 | return html.Div([_form, redirect]) -------------------------------------------------------------------------------- /docs/README.TEST.md: -------------------------------------------------------------------------------- 1 | [Dash constructor](dash.py#2023) 2 | [init_app()](dash_spa.py#114) 3 | before_first_request(self.validate_pages) 4 | [init_app()](dash.py#505) 5 | before_first_request(self._setup_server) 6 | *** get_app works from here on *** 7 | [dash.enable_pages()](dash.py#2023) 8 | import_layouts_from_pages() 9 | - imports all pages 10 | - register_page() 11 | - any static callbacks in pages files are registered (Providers) 12 | 13 | @self.server.before_first_request 14 | def router() 15 | 16 | [app.run_server()](server.py#35) 17 | [run()](dash_spa.py#138) 18 | 19 | 20 | flask.try_trigger_before_first_request_functions: 21 | [validate_pages()](dash_pages.py#161) 22 | [_setup_server()](dash.py#1271) 23 | GLOBAL_CALLBACK_LIST.clear() 24 | 25 | 26 | [dash.router()](dash.py#2026) 27 | -------------------------------------------------------------------------------- /docs/img/admin-views.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/admin-views.png -------------------------------------------------------------------------------- /docs/img/dash-spa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/dash-spa.png -------------------------------------------------------------------------------- /docs/img/flightdeck-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/flightdeck-1.png -------------------------------------------------------------------------------- /docs/img/global.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/global.png -------------------------------------------------------------------------------- /docs/img/multi-page-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/multi-page-example.png -------------------------------------------------------------------------------- /docs/img/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/register.png -------------------------------------------------------------------------------- /docs/img/sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/sidebar.png -------------------------------------------------------------------------------- /docs/img/signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/signin.png -------------------------------------------------------------------------------- /docs/img/solar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/solar.png -------------------------------------------------------------------------------- /docs/img/tables-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/tables-1.png -------------------------------------------------------------------------------- /docs/img/tables-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/tables-2.png -------------------------------------------------------------------------------- /docs/img/ticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/ticker.png -------------------------------------------------------------------------------- /docs/img/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/todo.png -------------------------------------------------------------------------------- /docs/img/veggy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/docs/img/veggy.png -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/__init__.py -------------------------------------------------------------------------------- /examples/button_dropdown/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/button_dropdown/__init__.py -------------------------------------------------------------------------------- /examples/button_dropdown/app.py: -------------------------------------------------------------------------------- 1 | import dash_bootstrap_components as dbc 2 | from dash_spa import page_container, DashSPA 3 | from dash_spa.logging import setLevel 4 | 5 | from server import serve_app 6 | 7 | def create_dash(): 8 | app = DashSPA( __name__, 9 | prevent_initial_callbacks=True, 10 | suppress_callback_exceptions=True, 11 | external_stylesheets=[dbc.themes.BOOTSTRAP] 12 | ) 13 | 14 | app.server.config['SECRET_KEY'] = "A secret key" 15 | 16 | return app 17 | 18 | def create_app(dash_factory) -> DashSPA: 19 | app = dash_factory() 20 | app.layout = page_container 21 | return app 22 | 23 | # python -m examples.button_dropdown.app 24 | 25 | if __name__ == "__main__": 26 | setLevel("INFO") 27 | app = create_app(create_dash) 28 | serve_app(app, path='/dropdown', debug=False) -------------------------------------------------------------------------------- /examples/button_dropdown/pages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/button_dropdown/pages/__init__.py -------------------------------------------------------------------------------- /examples/button_dropdown/pages/button_page.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dash import html 4 | from dash_spa import register_page, prefix, trigger_index 5 | 6 | from dash_spa.spa_context import createContext, ContextState, dataclass 7 | from dash_spa.components import DropdownAIO, ButtonContainerAIO 8 | 9 | from .icons import ICON 10 | 11 | page = register_page(__name__, path='/', title="Button Test", short_name='Buttons') 12 | 13 | # See assets.css for icon and text styling 14 | 15 | @dataclass 16 | class MyAppState(ContextState): 17 | page_size: int = 10 18 | 19 | MyAppContext: MyAppState = createContext(MyAppState) 20 | 21 | class PageSizeSelect(ButtonContainerAIO): 22 | 23 | className ='dropdown-menu dropdown-menu-xs dropdown-menu-end pb-0' 24 | 25 | def __init__(self, page_sizes: List, current:int, id): 26 | super().__init__(page_sizes, current, id=id, className=PageSizeSelect.className) 27 | 28 | state = MyAppContext.getState() 29 | 30 | @MyAppContext.On(self.button_match.input.n_clicks) 31 | def page_select(clicks): 32 | index = trigger_index() 33 | if index is not None and clicks[index]: 34 | state.page_size = int(page_sizes[index]) 35 | 36 | 37 | def render_buttons(self, elements): 38 | state = MyAppContext.getState() 39 | 40 | def render_button(text): 41 | if int(text) == state.page_size: 42 | element = html.Div([text, ICON.TICK], className='dropdown-item d-flex align-items-center fw-bold') 43 | else: 44 | element = html.Div(text, className='dropdown-item fw-bold') 45 | 46 | if text == elements[-1]: 47 | element.className += ' rounded-bottom' 48 | return element 49 | 50 | return [render_button(text) for text in elements] 51 | 52 | def page_size_dropdown(id) -> html.Div: 53 | pid = prefix(id) 54 | 55 | button = DropdownAIO.Button([ 56 | ICON.GEAR,html.Span("Toggle Dropdown", className='visually-hidden') 57 | ], className='btn btn-link text-dark dropdown-toggle dropdown-toggle-split m-0 p-1') 58 | 59 | container = PageSizeSelect(["10", "20", "30"], 0, id=pid('settings_container')) 60 | dropdown = DropdownAIO(button, container, id=pid('settings_dropdown')) 61 | 62 | return html.Div(dropdown) 63 | 64 | @MyAppContext.Provider() 65 | def layout(): 66 | state = MyAppContext.getState() 67 | size_dropdown = page_size_dropdown('test') 68 | h4 = html.H4(f"Page size is {state.page_size}", id="test_h4") 69 | return html.Div([h4, size_dropdown]) 70 | -------------------------------------------------------------------------------- /examples/button_dropdown/pages/dropdown_simple_page.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dash import html 4 | from dash_spa import register_page 5 | from dash_spa.components.dropdown_button_aoi import DropdownButtonAIO, dropdownLink 6 | 7 | 8 | from .icons import ICON 9 | 10 | page = register_page(__name__, path='/dropdown', title="Dropdown Test", short_name='Dropdown') 11 | 12 | class MyDropdown(DropdownButtonAIO): 13 | 14 | container_className = 'dropdown-menu dashboard-dropdown dropdown-menu-start mt-2 py-1' 15 | 16 | def button_className(self, buttonColor): 17 | return f'btn btn-{buttonColor} d-inline-flex align-items-center me-2 dropdown-toggle' 18 | 19 | 20 | def newButton(): 21 | return MyDropdown([ 22 | dropdownLink("Document", ICON.DOCUMENT), 23 | dropdownLink("Message", ICON.MESSAGE.ME2), 24 | dropdownLink("Product", ICON.UPLOAD), 25 | dropdownLink("My Plan", ICON.FIRE.ME2_DANGER), 26 | ], "New") 27 | 28 | 29 | def buttonBar(lhs=[], rhs=[]): 30 | lhs = lhs if isinstance(lhs, list) else [lhs] 31 | rhs = rhs if isinstance(rhs, list) else [rhs] 32 | return html.Div([ 33 | *lhs, 34 | html.Div(rhs, className='d-flex flex-md-nowrap align-items-center') 35 | ], className='d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center py-4') 36 | 37 | 38 | layout = html.Div([ 39 | buttonBar( 40 | lhs=newButton(), 41 | ), 42 | ], style={"min-height": "100%"}) 43 | -------------------------------------------------------------------------------- /examples/button_test_context_state/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/button_test_context_state/__init__.py -------------------------------------------------------------------------------- /examples/button_test_context_state/app.py: -------------------------------------------------------------------------------- 1 | from dash import Dash, html 2 | import dash_bootstrap_components as dbc 3 | 4 | from dash_spa import page_container, DashSPA 5 | from dash_spa.logging import setLevel 6 | 7 | from server import serve_app 8 | 9 | def create_dash(): 10 | app = DashSPA( __name__, 11 | prevent_initial_callbacks=True, 12 | suppress_callback_exceptions=True, 13 | external_stylesheets=[dbc.themes.BOOTSTRAP]) 14 | 15 | app.server.config['SECRET_KEY'] = "A secret key" 16 | 17 | return app 18 | 19 | 20 | def create_app(dash_factory) -> DashSPA: 21 | app = dash_factory() 22 | app.layout = page_container 23 | return app 24 | 25 | # python -m examples.button_test_context_state.app 26 | 27 | if __name__ == "__main__": 28 | setLevel("INFO") 29 | app = create_app(create_dash) 30 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/button_test_context_state/pages/button_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | from dash_spa.spa_context import createContext, ContextState, dataclass 4 | from dash_spa.logging import log 5 | 6 | page = register_page(__name__, path='/', title="Button Test", short_name='Buttons') 7 | 8 | @dataclass 9 | class ButtonState(ContextState): 10 | clicks: int = 1000 11 | 12 | ButtonContext = createContext(ButtonState) 13 | 14 | def myButton(id): 15 | state = ButtonContext.getState() 16 | btn = html.Button("Button", id=id) 17 | 18 | @ButtonContext.On(btn.input.n_clicks) 19 | def btn_click(clicks): 20 | log.info('btn_click()') 21 | state.clicks += 1 22 | 23 | return btn 24 | 25 | @ButtonContext.Provider() 26 | def layout(): 27 | state = ButtonContext.getState() 28 | btn = myButton('test1') 29 | div = html.Div(f"Button pressed {state.clicks} times!") 30 | return html.Div([btn, div]) 31 | -------------------------------------------------------------------------------- /examples/button_test_redux/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/button_test_redux/__init__.py -------------------------------------------------------------------------------- /examples/button_test_redux/app.py: -------------------------------------------------------------------------------- 1 | from dash import Dash, html 2 | import dash_bootstrap_components as dbc 3 | 4 | from dash_spa import page_container, DashSPA, dash_logging 5 | from dash_spa.logging import setLevel 6 | 7 | from server import serve_app 8 | 9 | def create_dash(): 10 | app = DashSPA( __name__, 11 | # plugins=[dash_logging], 12 | prevent_initial_callbacks=True, 13 | suppress_callback_exceptions=True, 14 | external_stylesheets=[dbc.themes.BOOTSTRAP]) 15 | return app 16 | 17 | 18 | def create_app(dash_factory) -> DashSPA: 19 | app = dash_factory() 20 | def layout(): 21 | return html.Div([ 22 | html.Div([ 23 | html.Div([ 24 | html.Div([], className="col-md-1"), 25 | html.Div(page_container, id='page-content', className="col-md-10"), 26 | html.Div([], className="col-md-1") 27 | ], className='row') 28 | ], className="container-fluid"), 29 | ]) 30 | 31 | app.layout = page_container 32 | return app 33 | 34 | # python -m examples.button_test_redux.app 35 | 36 | if __name__ == "__main__": 37 | setLevel("INFO") 38 | app = create_app(create_dash) 39 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/button_test_redux/pages/button_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import prefix, register_page, callback, NOUPDATE 3 | from dash_spa.logging import log 4 | from dash_redux import ReduxStore 5 | 6 | register_page(__name__, path='/', title="Button Test", short_name='Buttons') 7 | 8 | # Example demonstrates basic use of ReduxStore to manage several instances 9 | # of a button_toolbar(). Button clicks update the redux store. The toolbar 10 | # state is reported locally within toolbar UI. In addition the the last 11 | # button clicked in any of the toolbars is reported. 12 | # 13 | # Since the ReduxStore default storage type is "session" the button state 14 | # is persistent. This would make a useful pattern for a shopping trolley. 15 | # 16 | # See examples/context/pages/context_pattern.py for a far better 17 | # generic toolbar supporting any number of buttons 18 | 19 | pfx = prefix("redux_test") 20 | 21 | store = ReduxStore(id=pfx('store'), data={}) 22 | 23 | def button_toolbar(toolbar_title: str): 24 | pfx = prefix(toolbar_title) 25 | 26 | def store_ref(title, store): 27 | if not title in store: 28 | store[title] = {'btn1': 0, 'btn2': 0} 29 | return store[title] 30 | 31 | title = html.H4(f"Button {toolbar_title}") 32 | btn1 = html.Button("Button1", id=pfx('btn1')) 33 | btn2 = html.Button("Button2", id=pfx("btn2")) 34 | container = html.Div("", id=pfx('container')) 35 | 36 | @store.update(btn1.input.n_clicks) 37 | def btn1_update(clicks, store): 38 | log.info('btn1_update - clicks = %s', clicks) 39 | tb_store = store_ref(toolbar_title, store) 40 | if clicks: 41 | tb_store['btn1'] += 1 42 | store['last'] = f"{toolbar_title}.btn1(clicks={tb_store['btn1']})" 43 | 44 | return store 45 | 46 | @store.update(btn2.input.n_clicks) 47 | def btn2_update(clicks, store): 48 | log.info('btn2_update - clicks = %s', clicks) 49 | tb_store = store_ref(toolbar_title, store) 50 | if clicks: 51 | tb_store['btn2'] += 1 52 | store['last'] = f"{toolbar_title}.btn2(clicks={tb_store['btn2']})" 53 | return store 54 | 55 | # Report toolbar state locally 56 | 57 | @callback(container.output.children, store.input.data) 58 | def btn_message(store): 59 | log.info('btn_message - store = %s', store) 60 | tb_store = store_ref(toolbar_title, store) 61 | msg = f"btn1 {tb_store['btn1']} clicks, btn2 {tb_store['btn2']} clicks" 62 | return msg 63 | 64 | return html.Div([title, btn1, btn2, container, html.Br()]) 65 | 66 | def layout(): 67 | 68 | tb1 = button_toolbar('Toolbar1') 69 | tb2 = button_toolbar('Toolbar2') 70 | tb3 = button_toolbar('Toolbar3') 71 | 72 | # Report last button clicked 73 | 74 | report = html.H3("", id=pfx('report')) 75 | 76 | @callback(report.output.children, store.input.data) 77 | def cb_update(store): 78 | if 'last' in store: 79 | return f"Button {store['last']} clicked last" 80 | else: 81 | return NOUPDATE 82 | 83 | # Return page layout 84 | 85 | return html.Div([store, tb1, tb2, tb3, report]) 86 | -------------------------------------------------------------------------------- /examples/button_test_simple/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/button_test_simple/__init__.py -------------------------------------------------------------------------------- /examples/button_test_simple/app.py: -------------------------------------------------------------------------------- 1 | from dash import Dash, html 2 | import dash_bootstrap_components as dbc 3 | 4 | from dash_spa import page_container, DashSPA 5 | from dash_spa.logging import setLevel 6 | 7 | from server import serve_app 8 | 9 | def create_dash(): 10 | app = DashSPA( __name__, 11 | prevent_initial_callbacks=True, 12 | suppress_callback_exceptions=True, 13 | external_stylesheets=[dbc.themes.BOOTSTRAP]) 14 | return app 15 | 16 | 17 | def create_app(dash_factory) -> DashSPA: 18 | app = dash_factory() 19 | app.layout = page_container 20 | return app 21 | 22 | # python -m examples.button_test_simple.app 23 | 24 | if __name__ == "__main__": 25 | setLevel("INFO") 26 | app = create_app(create_dash) 27 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/button_test_simple/pages/button_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page, callback 3 | from dash_spa.logging import log 4 | 5 | # Use sta 6 | 7 | page = register_page(__name__, path='/', title="Button Test", short_name='Buttons') 8 | 9 | def page_layout(): 10 | btn = html.Button("Button1", id=page.pfx('btn')) 11 | h2 = html.H2("", id=page.pfx('h2')) 12 | 13 | @callback(h2.output.children, btn.input.n_clicks, prevent_initial_call=True) 14 | def btn_cb(clicks): 15 | log.info('clicks = %s', clicks) 16 | return f"Button clicked {clicks} times!" 17 | return html.Div([btn, h2]) 18 | 19 | 20 | layout = page_layout 21 | -------------------------------------------------------------------------------- /examples/context/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/context/__init__.py -------------------------------------------------------------------------------- /examples/context/app.py: -------------------------------------------------------------------------------- 1 | from dash import Dash, html 2 | import dash_bootstrap_components as dbc 3 | from dash_spa import page_container, DashSPA 4 | from server import serve_app 5 | 6 | 7 | def create_dash(): 8 | app = DashSPA( __name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) 9 | app.server.config['SECRET_KEY'] = "A secret key" 10 | return app 11 | 12 | 13 | def create_app(dash_factory) -> DashSPA: 14 | app = dash_factory() 15 | def layout(): 16 | return html.Div([ 17 | html.Div([ 18 | html.Div([ 19 | html.Div([], className="col-md-1"), 20 | html.Div(page_container, id='page-content', className="col-md-10"), 21 | html.Div([], className="col-md-1") 22 | ], className='row') 23 | ], className="container-fluid"), 24 | ]) 25 | 26 | app.layout = layout 27 | return app 28 | 29 | # python -m examples.context.app 30 | 31 | if __name__ == "__main__": 32 | app = create_app(create_dash) 33 | serve_app(app, debug=False) 34 | -------------------------------------------------------------------------------- /examples/context/pages/button_toolbar.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from dash import html, ALL 3 | from dash_spa import prefix 4 | from dash_spa.logging import log 5 | from dash_spa.spa_context import createContext, ContextState, dataclass 6 | import dash_spa as spa 7 | 8 | 9 | @dataclass 10 | class TButton(ContextState): 11 | name: str = '' 12 | clicks: int = 0 13 | 14 | @dataclass 15 | class TBState(ContextState): 16 | title: str = "" 17 | buttons: List[TButton] = None 18 | 19 | def __post_init__(self): 20 | self.buttons = [TButton(name, 1000) for name in self.buttons] 21 | 22 | ToolbarContext: Dict[str, TBState] = createContext() 23 | 24 | def button_toolbar(id, initial_state:TBState): 25 | pid = prefix(f'button_toolbar_{id}') 26 | 27 | state, _ = ToolbarContext.useState(id, initial_state=initial_state) 28 | 29 | log.info("button_toolbar state.id=%s (%s)", pid(), state.cid()) 30 | 31 | button_match = spa.match({'type': pid('btn'), 'idx': ALL}) 32 | 33 | btns = html.Div([ 34 | html.Button(btn.name, id=button_match.idx(idx), type="button", className='btn btn-secondary me-1') 35 | for idx, btn in enumerate(state.buttons) 36 | ] 37 | ) 38 | 39 | msg = [f"{btn.name} pressed {btn.clicks} times" for btn in state.buttons] 40 | container = html.H5(", ".join(msg)) 41 | 42 | @ToolbarContext.On(button_match.input.n_clicks) 43 | def btn_update(clicks): 44 | log.info("btn_update state.cid=%s", state.cid()) 45 | index = spa.trigger_index() 46 | state.buttons[index].clicks += 1 47 | 48 | title = html.H4(f"Toolbar {state.title}") 49 | 50 | return html.Div([title, btns, container], style={'background-color': '#e6e6e6'}) 51 | -------------------------------------------------------------------------------- /examples/context/pages/toolbar_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | from dash_spa.logging import log 4 | 5 | from .button_toolbar import ToolbarContext, button_toolbar, TBState 6 | 7 | page = register_page(__name__, path='/', title="Toolbar Page", short_name='Toolbar') 8 | 9 | # Example of using the DashSPA context pattern 10 | # that allows a components state change to be easily passed 11 | # between components and trigger UI updates. 12 | # 13 | # The example creates two panels, each with two instances of 14 | # a toolbar component. The buttons in each toolbar update the common 15 | # ButtonContext for the panel. Because the layout_page() method 16 | # is decorated with ButtonContext.Provider it will be 17 | # called to refresh the UI whenever the context changes. 18 | 19 | 20 | def tb_report(tb: TBState): 21 | """reports single toolbar state""" 22 | 23 | msg = [f'{btn.name}={btn.clicks}' for btn in tb.buttons] 24 | return html.H4(f'{tb.title}: {", ".join(msg)}') 25 | 26 | @ToolbarContext.Provider() 27 | def toolbar_panel_layout(): 28 | 29 | log.info('top_panel_layout()') 30 | 31 | # Create some toolbars 32 | 33 | main_toolbar = button_toolbar(page.id("top_main"), TBState("main", ['close', "exit", 'refresh'])) 34 | page_toolbar = button_toolbar(page.id("top_page"), TBState("page", ['next', "prev", 'top', 'bottom'])) 35 | 36 | state = ToolbarContext.getState() 37 | 38 | title = html.H3('Toolbar Component Example') 39 | 40 | report = html.Div([tb_report(tb) for tb in state.items()], style={'background-color': '#e6e6e6'}) 41 | report.children.insert(0, html.H3('Toolbar Report')) 42 | 43 | return html.Div([title, main_toolbar, page_toolbar, report]) 44 | 45 | 46 | def layout(): 47 | log.info('layout()') 48 | top = toolbar_panel_layout() 49 | return html.Div([top]) 50 | -------------------------------------------------------------------------------- /examples/cra/app.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash_spa import page_container, DashSPA 3 | from server import serve_app 4 | 5 | app = DashSPA( __name__) 6 | 7 | # python -m examples.cra.app 8 | 9 | if __name__ == "__main__": 10 | app.layout=page_container 11 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/cra/assets/app.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/cra/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/cra/assets/favicon.ico -------------------------------------------------------------------------------- /examples/cra/assets/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/cra/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/cra/pages/cra_example.py: -------------------------------------------------------------------------------- 1 | from dash import html, get_asset_url 2 | import dash_spa as spa 3 | 4 | # Python/Dash clone of node.js project template that's normally created using Create React App (CRA) 5 | 6 | spa.register_page(__name__, path='', title="Dash CRA Example") 7 | 8 | def layout(): 9 | return html.Div([ 10 | html.Header([ 11 | html.Img(src=get_asset_url("logo.svg"), className="App-logo", alt="logo"), 12 | html.P(["Edit ", html.Code("usage.py"), " and save to reload."]), 13 | html.A("Learn Dash", className="App-link", href="https://dash.plotly.com/", target="_blank", rel="noopener noreferrer") 14 | ], className="App-header") 15 | ], className="App") 16 | -------------------------------------------------------------------------------- /examples/forms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/forms/__init__.py -------------------------------------------------------------------------------- /examples/forms/app.py: -------------------------------------------------------------------------------- 1 | from dash_spa.logging import log 2 | import dash 3 | from dash import html 4 | import dash_bootstrap_components as dbc 5 | 6 | from dash_spa import page_container, DashSPA 7 | from server import serve_app 8 | 9 | from .pages.common import store 10 | 11 | external_stylesheets = [ 12 | dbc.themes.BOOTSTRAP, 13 | "https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" 14 | ] 15 | 16 | app = DashSPA(__name__, external_stylesheets=external_stylesheets) 17 | 18 | def layout(): 19 | """Top level layout, all pages are rendered in the container 20 | """ 21 | 22 | log.info('top level layout()') 23 | 24 | return html.Div([ 25 | store, 26 | html.Div([ 27 | html.Div([ 28 | html.Div([], className="col-md-1"), 29 | html.Div(page_container, id='page-content', className="col-md-10"), 30 | html.Div([], className="col-md-1") 31 | ], className='row') 32 | ], className="container-fluid") 33 | ]) 34 | 35 | # python -m examples.forms.app 36 | 37 | if __name__ == "__main__": 38 | log.info('__main__') 39 | app.layout = layout() 40 | serve_app(app, path='/login') -------------------------------------------------------------------------------- /examples/forms/pages/common.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | 3 | def form_container(title, form): 4 | return html.Div([ 5 | html.Div([ 6 | html.Div([ 7 | html.Div([ 8 | html.H4(title, className="card-title"), 9 | form, 10 | ], className='card-body') 11 | ], className="card fat") 12 | ], className="col-6 mx-auto") 13 | ], className="row align-items-center h-100") 14 | 15 | store = dcc.Store(id='user_details', storage_type='session') 16 | -------------------------------------------------------------------------------- /examples/forms/pages/login_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | from dash_spa import register_page, SpaForm, isTriggered, callback 4 | 5 | from .common import form_container, store 6 | 7 | register_page(__name__, path='/login' , title='Login') 8 | 9 | frm = SpaForm('loginFrm') 10 | 11 | def terms_check_box(): 12 | return frm.Checkbox(label=[ 13 | "I agree to the ", 14 | html.A("Terms and Conditions", href="#") 15 | ], id='terms', name='terms') 16 | 17 | flash = frm.Alert(id='flash') 18 | email = frm.Input('Email', id='email', name='email', type='email', placeholder="Enter email") 19 | password = frm.PasswordInput("Password", name='password', id="password", placeholder="Enter password") 20 | remember = frm.Checkbox("Remember me", id='remember', name='remember', checked=True) 21 | terms = terms_check_box() 22 | button = frm.Button('Sign In', type='submit', id='btn') 23 | redirect = frm.Location(id='redirect', refresh=True) 24 | 25 | form = frm.Form([ 26 | flash, 27 | email, 28 | password, 29 | remember, 30 | terms, html.Br(), 31 | button, 32 | ], id='login') 33 | 34 | 35 | @callback(redirect.output.href, store.output.data, flash.output.children, form.input.form_data) 36 | def _form_submit(values): 37 | redirect = frm.NOUPDATE 38 | store = frm.NOUPDATE 39 | error = "" 40 | 41 | if isTriggered(form.input.form_data) and values: 42 | _password = values['password'] 43 | valid = _password == '1234' 44 | if valid: 45 | store = values.copy() 46 | redirect = '/welcome' 47 | else: 48 | error = 'Please check your login details and try again (hint, try 1234)' 49 | 50 | 51 | return redirect, store, error 52 | 53 | _form = form_container('Sign in', form) 54 | 55 | layout = html.Div([_form, redirect]) 56 | -------------------------------------------------------------------------------- /examples/forms/pages/welcome.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page, callback 3 | 4 | from .common import store 5 | 6 | register_page(__name__, path='/welcome', title='Welcome') 7 | 8 | def big_center(text): 9 | return html.H2(text, className='display-3 text-center') 10 | 11 | 12 | def page_content(name='Guest'): 13 | 14 | return html.Header([ 15 | big_center("DashSPA Welcomes"), 16 | big_center(name) 17 | ], className='jumbotron my-4') 18 | 19 | 20 | layout = html.Div(page_content(), id='container') 21 | 22 | @callback(layout.output.children, store.input.data) 23 | def _page_cb(data): 24 | 25 | if data: 26 | return page_content(data['email']) 27 | else: 28 | return page_content() 29 | -------------------------------------------------------------------------------- /examples/multipage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/multipage/__init__.py -------------------------------------------------------------------------------- /examples/multipage/app.py: -------------------------------------------------------------------------------- 1 | from dash import Dash, html 2 | import dash_bootstrap_components as dbc 3 | from dash_spa import logging 4 | 5 | from dash_spa import DashSPA, page_container 6 | from dash_spa.components import NavBar, NavbarBrand, NavbarLink, Footer 7 | from server import serve_app 8 | 9 | from .pages import page1, page2 10 | 11 | def create_dash(): 12 | app = DashSPA( __name__,external_stylesheets=[dbc.themes.BOOTSTRAP]) 13 | return app 14 | 15 | def create_app(dash_factory) -> Dash: 16 | app = dash_factory() 17 | 18 | NAV_BAR_ITEMS = { 19 | 'brand' : NavbarBrand(' DashSPA','/'), 20 | 'left' : [ 21 | NavbarLink(page1, id='nav-page1'), 22 | NavbarLink(page2, id='nav-page2'), 23 | ] 24 | } 25 | 26 | navbar = NavBar(NAV_BAR_ITEMS) 27 | footer = Footer('SPA/Examples') 28 | 29 | def layout(): 30 | return html.Div([ 31 | html.Header([ 32 | navbar.layout(), 33 | html.Br() 34 | ]), 35 | html.Main([ 36 | html.Div([ 37 | html.Div([ 38 | html.Div(page_container, className="col-md-12"), 39 | ], className='row') 40 | ], className='container d-flex flex-column flex-grow-1'), 41 | ], role='main', className='d-flex'), 42 | html.Footer(footer.layout(), className='footer mt-auto') 43 | ], className='body') 44 | 45 | app.layout = layout 46 | return app 47 | 48 | # python -m examples.multipage.app 49 | 50 | if __name__ == "__main__": 51 | # logging.setLevel("INFO") 52 | app = create_app(create_dash) 53 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/multipage/assets/multipage.css: -------------------------------------------------------------------------------- 1 | .card-footer { 2 | background-color: maroon; 3 | } 4 | 5 | .body { 6 | display: flex; 7 | flex-direction: column; 8 | margin: 0; 9 | min-height: 100vh; 10 | } 11 | 12 | header, footer { 13 | flex-grow: 0; 14 | } 15 | 16 | footer { 17 | background-color: #dedede; 18 | } 19 | 20 | main { 21 | flex-grow: 1; 22 | } -------------------------------------------------------------------------------- /examples/multipage/pages/__init__.py: -------------------------------------------------------------------------------- 1 | from .page1 import page1 2 | from .page2 import page2 -------------------------------------------------------------------------------- /examples/multipage/pages/page1.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | page1 = register_page(__name__, path='/page1', title='Page1') 5 | 6 | def big_center(text, id=None): 7 | className='display-3 text-center' 8 | return html.H2(text, id=id, className=className) if id else html.H2(text, className=className) 9 | 10 | 11 | def layout(): 12 | return html.Div([ 13 | big_center('Multi-page Example'), 14 | big_center('+'), 15 | big_center('Page 1', id="page"), 16 | ]) 17 | -------------------------------------------------------------------------------- /examples/multipage/pages/page2.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | page2 = register_page(__name__, path='/page2', title='Page2') 5 | 6 | def big_center(text, id=None): 7 | className='display-3 text-center' 8 | return html.H2(text, id=id, className=className) if id else html.H2(text, className=className) 9 | 10 | 11 | def layout(): 12 | return html.Div([ 13 | big_center('Multi-page Example'), 14 | big_center('+'), 15 | big_center('Page 2', id="page"), 16 | ]) 17 | -------------------------------------------------------------------------------- /examples/multipage/pages/wellcome.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | welcome_page = register_page(__name__, path='/', title='Welcome') 5 | 6 | header_text = """ 7 | DashSPA is a minimal framework XXXX and component suite that allows you to build complex 8 | Dash based single-page applications with ease. The demo application includes 9 | several well known Dash examples that have been pasted into the SPA framework 10 | to show how easy it is to transition to SPA. 11 | 12 | The framework, component suite and demo are 100% Python 13 | """ 14 | 15 | def jumbotron_header(title, text): 16 | return html.Header([ 17 | html.H1(title, className='display-4 text-center'), 18 | html.P(text), 19 | ], className='jumbotron my-4') 20 | 21 | 22 | def card(title, text): 23 | return html.Div([ 24 | html.Div([ 25 | html.Img(alt=''), 26 | html.Div([ 27 | html.H4(title, className='card-title'), 28 | html.P(text, className='card-text') 29 | ], className='card-body'), 30 | html.Div([ 31 | # spa.ButtonLink('Find Out More!', href=spa.url_for(link)).layout 32 | ], className='card-footer') 33 | ], className='card h-100') 34 | ], className='col-lg-3 col-md-6 mb-4') 35 | 36 | 37 | def layout(): 38 | return html.Div([ 39 | jumbotron_header('Welcome to DashSPA', header_text), 40 | html.Div([ 41 | card('Pages', 'Support for Dash Pages, '), 42 | card('Navbar', 'Includes an optional NAVBAR, configured by a simple dictionary'), 43 | card('Forms', 'Easy creation of interactive forms'), 44 | card('Admin', 'Admin blueprint that supports user registration, email authentication and login authorization') 45 | ], className='row text-center'), 46 | ], className='container') 47 | 48 | -------------------------------------------------------------------------------- /examples/notifications/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/notifications/__init__.py -------------------------------------------------------------------------------- /examples/notifications/app.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | from dash_bootstrap_components.themes import BOOTSTRAP 4 | from dash_spa import page_container, DashSPA 5 | from server import serve_app 6 | 7 | external_stylesheets = [ 8 | BOOTSTRAP, 9 | ] 10 | 11 | def create_dash(): 12 | app = DashSPA( __name__, 13 | prevent_initial_callbacks=True, 14 | suppress_callback_exceptions=True, 15 | external_stylesheets=external_stylesheets) 16 | return app 17 | 18 | 19 | def create_app(dash_factory) -> DashSPA: 20 | app = dash_factory() 21 | def layout(): 22 | return html.Div([ 23 | html.Div([ 24 | html.Div([ 25 | html.Div([], className="col-md-1"), 26 | html.Div(page_container, id='page-content', className="col-md-10"), 27 | html.Div([], className="col-md-1") 28 | ], className='row') 29 | ], className="container-fluid"), 30 | ]) 31 | 32 | app.layout = layout 33 | return app 34 | 35 | # python -m examples.notifications.app 36 | 37 | if __name__ == "__main__": 38 | app = create_app(create_dash) 39 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/notifications/pages/containers.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from dash import html 3 | import dash_spa as spa 4 | from pages import NAVBAR_PAGES 5 | from dash_spa.logging import log 6 | from dash_spa_admin import AdminNavbarComponent 7 | from dash_spa.exceptions import InvalidAccess 8 | 9 | def default_container(page, layout, **kwargs): 10 | 11 | content = layout(**kwargs) if callable(layout) else layout 12 | 13 | return html.Div([ 14 | html.Br(), 15 | html.Div( 16 | html.Div([ 17 | html.Div(content, className="col-10"), 18 | html.Div(className="col-2") 19 | ], className='row'), 20 | className='container') 21 | ]) 22 | 23 | 24 | spa.register_container(default_container) 25 | -------------------------------------------------------------------------------- /examples/sidebar/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/examples/sidebar/__init__.py -------------------------------------------------------------------------------- /examples/sidebar/app.py: -------------------------------------------------------------------------------- 1 | from dash_spa.logging import setLevel 2 | from dash_spa import page_container, DashSPA 3 | 4 | from .themes import VOLT 5 | from server import serve_app 6 | 7 | 8 | external_stylesheets = [ 9 | VOLT, 10 | "https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" 11 | ] 12 | 13 | app = DashSPA(__name__, external_stylesheets=external_stylesheets) 14 | 15 | # python -m examples.sidebar.app 16 | 17 | if __name__ == "__main__": 18 | setLevel("INFO") 19 | app.layout = page_container 20 | app.server.config['SECRET_KEY'] = "A secret key" 21 | serve_app(app) -------------------------------------------------------------------------------- /examples/sidebar/assets/img/brand/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /examples/sidebar/assets/img/brand/light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/sidebar/pages/500_page.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from dash_spa import register_page 3 | from .icons import ICON 4 | 5 | register_page(__name__, path="/pages/500", title="Dash/Flightdeck - 500", container='full_page') 6 | 7 | layout = html.Div([ 8 | html.Main([ 9 | html.Section([ 10 | html.Div([ 11 | html.Div([ 12 | html.Div([ 13 | html.H1([ 14 | "Something has gone", 15 | html.Span(" seriously ", className='text-primary'), 16 | "wrong" 17 | ], className='mt-5'), 18 | html.P("It's always time for a coffee break. We should be back by the time you finish your coffee.", className='lead my-4'), 19 | dcc.Link([ICON.ARROW_NARROW_LEFT, "Back to homepage" 20 | ], href='dashboard', className='btn btn-gray-800 d-inline-flex align-items-center justify-content-center mb-4') 21 | ], className='col-12 col-lg-5 order-2 order-lg-1 text-center text-lg-left'), 22 | html.Div([ 23 | html.Img(src='/assets/img/illustrations/500.svg') 24 | ], className='col-12 col-lg-7 order-1 order-lg-2 text-center d-flex align-items-center justify-content-center') 25 | ], className='row align-items-center') 26 | ], className='container') 27 | ], className='vh-100 d-flex align-items-center justify-content-center') 28 | ]) 29 | ]) 30 | -------------------------------------------------------------------------------- /examples/sidebar/pages/bootstrap_tables_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | from .common import jumbotron_content 5 | 6 | layout = jumbotron_content('Bootstrap Tables', 'Page') 7 | 8 | register_page(__name__, path="/pages/tables/bootstrap-tables", title="Dash/Flightdeck - Bootstrap Tables") 9 | -------------------------------------------------------------------------------- /examples/sidebar/pages/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .background_image import background_img 2 | from .sidebar import sideBar 3 | from .mobile_nav import mobileNavBar 4 | from .jumbotron import jumbotron_content 5 | 6 | -------------------------------------------------------------------------------- /examples/sidebar/pages/common/background_image.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def background_img(url, width=0, height=0): 4 | if width or height: 5 | return { 6 | "background": f'rgba(0, 0, 0, 0) url("{url}") repeat scroll 0% 0%', 7 | "background-size" : f'{height}px {width}px' 8 | } 9 | else: 10 | return {"background": f'rgba(0, 0, 0, 0) url("{url}") repeat scroll 0% 0%'} 11 | -------------------------------------------------------------------------------- /examples/sidebar/pages/common/jumbotron.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | def big_center(text): 4 | return html.H2(text, className='display-3 text-center') 5 | 6 | def jumbotron_content(header, message=""): 7 | 8 | return html.Div( 9 | html.Header([ 10 | big_center(header), 11 | big_center(message) 12 | ], className='jumbotron my-4') 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /examples/sidebar/pages/common/mobile_nav.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | from ..icons.hero import ICON 4 | 5 | def mobileNavBar(): 6 | """ Mobile only navbar - Volt logo & burger button """ 7 | return html.Nav([ 8 | html.A([ 9 | html.Img(className='navbar-brand-dark', src='../../assets/img/brand/light.svg', alt='Volt logo'), 10 | html.Img(className='navbar-brand-light', src='../../assets/img/brand/dark.svg', alt='Volt logo') 11 | ], className='navbar-brand me-lg-5', href='../../index'), 12 | html.Div([ 13 | html.Button([ 14 | 15 | # Burger button 16 | 17 | html.Span(className='navbar-toggler-icon') 18 | 19 | ], className='navbar-toggler d-lg-none collapsed', type='button') 20 | ], className='d-flex align-items-center') 21 | ], className='navbar navbar-dark navbar-theme-primary px-4 col-12 d-lg-none') 22 | 23 | 24 | def mobileSidebarHeader(): 25 | """ Mobile only sidebar header""" 26 | return html.Div([ 27 | html.Div([ 28 | # Snip avatar 29 | html.Div([ 30 | # Snip Hi, Jane 31 | html.A([ 32 | html.Img(className='icon icon-sm', src='../../assets/img/icons/sign_out.svg', height='20', width='20', alt='upgrade'), 33 | "Sign Out" 34 | ], href='../../pages/examples/sign-in', className='btn btn-secondary btn-sm d-inline-flex align-items-center') 35 | ], className='d-block') 36 | ], className='d-flex align-items-center'), 37 | 38 | # Sidebar close [X] icon 39 | 40 | html.Div([ 41 | html.A([ 42 | ICON.CROSS, 43 | ], href='#sidebarMenu') 44 | ], className='collapse-close d-md-none') 45 | 46 | ], className='user-card d-flex d-md-none align-items-center justify-content-between justify-content-md-center pb-4') 47 | -------------------------------------------------------------------------------- /examples/sidebar/pages/containers.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from dash import html 3 | import dash_spa as spa 4 | from dash_spa.exceptions import InvalidAccess 5 | from dash_spa.logging import log 6 | 7 | from .common import sideBar, mobileNavBar 8 | 9 | #sidebar_instance = sideBar() 10 | 11 | def default_container(page, layout, **_kwargs): 12 | """Default page content container. All pages are wrapped by this content unless 13 | registered with container=None or container='some_other_container' 14 | 15 | Args: 16 | layout (Component or callable): layout to be wrapped 17 | 18 | Returns: 19 | layout wrapped by container markup 20 | """ 21 | 22 | # log.info("*************** default_container page=%s *********************", page['module']) 23 | 24 | try: 25 | 26 | try: 27 | content = layout(**_kwargs) if callable(layout) else layout 28 | except InvalidAccess: 29 | 30 | # To force the user to the login page uncomment the following lines 31 | # 32 | # page = spa.page_for('dash_spa_admin.page') 33 | # content = page.layout() 34 | 35 | page = spa.page_for('pages.not_found_404') 36 | return page.layout() 37 | 38 | return html.Div([ 39 | #mobileNavBar(), 40 | sideBar(), 41 | content 42 | ]) 43 | 44 | except Exception: 45 | log.warning(traceback.format_exc()) 46 | page = spa.page_for('pages.not_found_404') 47 | return page.layout() 48 | 49 | 50 | spa.register_container(default_container) 51 | 52 | 53 | def full_page_container(page, layout, **kwargs): 54 | """Full page container""" 55 | 56 | try: 57 | content = layout(**kwargs) if callable(layout) else layout 58 | except InvalidAccess: 59 | 60 | # To force the user to the login page uncomment the following lines 61 | # 62 | # page = spa.page_for('dash_spa_admin.page') 63 | # content = page.layout() 64 | 65 | page = spa.page_for('pages.not_found_404') 66 | content = page.layout() 67 | 68 | return content 69 | 70 | spa.register_container(full_page_container, name='full_page') 71 | -------------------------------------------------------------------------------- /examples/sidebar/pages/dashboard_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | from .common import jumbotron_content 5 | 6 | layout = jumbotron_content('Dashboard','Page') 7 | 8 | register_page(__name__, path="/pages/dashboard", title="Dash/Flightdeck - Dashboard") 9 | -------------------------------------------------------------------------------- /examples/sidebar/pages/forgot_password_page.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from dash_spa import register_page 3 | from .icons import ICON 4 | 5 | register_page(__name__, path="/pages/forgot-password", title="Dash/Flightdeck - Forgot password", container='full_page') 6 | 7 | layout = html.Div([ 8 | # NOTICE: You can use the _analytics.html partial to include production code specific code & trackers 9 | html.Main([ 10 | # Section 11 | html.Section([ 12 | html.Div([ 13 | html.Div([ 14 | html.P([ 15 | dcc.Link([ ICON.ARROW_NARROW_LEFT,"Back to log in" 16 | ], href='./sign-in', className='d-flex align-items-center justify-content-center') 17 | ], className='text-center'), 18 | html.Div([ 19 | html.Div([ 20 | html.H1("Forgot your password?", className='h3'), 21 | html.P("Don't fret! Just type in your email and we will send you a code to reset your password!", className='mb-4'), 22 | html.Form([ 23 | # Form 24 | html.Div([ 25 | html.Label("Your Email", htmlFor='email'), 26 | html.Div([ 27 | dcc.Input(type='email', className='form-control', id='email', placeholder='john@company.com', required='') 28 | ], className='input-group') 29 | ], className='mb-4'), 30 | # End of Form 31 | html.Div([ 32 | html.Button("Recover password", type='submit', className='btn btn-gray-800') 33 | ], className='d-grid') 34 | ], action='#') 35 | ], className='signin-inner my-3 my-lg-0 bg-white shadow border-0 rounded p-4 p-lg-5 w-100 fmxw-500') 36 | ], className='col-12 d-flex align-items-center justify-content-center') 37 | ], className='row justify-content-center form-bg-image') 38 | ], className='container') 39 | ], className='vh-lg-100 mt-5 mt-lg-0 bg-soft d-flex align-items-center') 40 | ]) 41 | ]) 42 | -------------------------------------------------------------------------------- /examples/sidebar/pages/icons/__init__.py: -------------------------------------------------------------------------------- 1 | from .hero import ICON 2 | from .social import FACEBOOK, TWITTER, YOUTUBE, GITHUB, PAYPAL, BEHANCE, GOOGLE, YAHOO -------------------------------------------------------------------------------- /examples/sidebar/pages/lock_page.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from dash_spa import register_page 3 | from .common import background_img 4 | from .icons import ICON 5 | 6 | register_page(__name__, path="/pages/lock", title="Dash/Flightdeck - Lock", container='full_page') 7 | 8 | layout = html.Div([ 9 | # NOTICE: You can use the _analytics.html partial to include production code specific code & trackers 10 | html.Main([ 11 | # Section 12 | html.Section([ 13 | html.Div([ 14 | dcc.Link([ICON.ARROW_NARROW_LEFT, "Back to homepage" 15 | ], href='dashboard', className='d-flex align-items-center justify-content-center mb-4'), 16 | html.Div([ 17 | html.Div([ 18 | html.Div([ 19 | html.Div([ 20 | html.Div([ 21 | html.Img(className='rounded-circle', alt='Image placeholder', src='/assets/img/team/profile-picture-3.jpg') 22 | ], className='avatar avatar-lg mx-auto mb-3'), 23 | html.H1("Bonnie Green", className='h3'), 24 | html.P("Better to be safe than sorry.", className='text-gray') 25 | ], className='text-center text-md-center mb-4 mt-md-0'), 26 | html.Form([ 27 | # Form 28 | html.Div([ 29 | html.Label("Your Password", htmlFor='password'), 30 | html.Div([ 31 | html.Span(ICON.LOCK_CLOSED, className='input-group-text', id='basic-addon2'), 32 | dcc.Input(type='password', placeholder='Password', className='form-control', id='password', required='') 33 | ], className='input-group') 34 | ], className='form-group mb-4'), 35 | # End of Form 36 | html.Div([ 37 | html.Button("Unlock", type='submit', className='btn btn-gray-800') 38 | ], className='d-grid mt-3') 39 | ], className='mt-5') 40 | ], className='bg-white shadow border-0 rounded p-4 p-lg-5 w-100 fmxw-500') 41 | ], className='col-12 d-flex align-items-center justify-content-center') 42 | ], className='row justify-content-center form-bg-image', style=background_img("/assets/img/illustrations/signin.svg")) 43 | ], className='container') 44 | ], className='vh-lg-100 mt-5 mt-lg-0 bg-soft d-flex align-items-center') 45 | ]) 46 | ]) 47 | -------------------------------------------------------------------------------- /examples/sidebar/pages/not_found_404.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from dash_spa import register_page 3 | from .icons import ICON 4 | 5 | register_page(__name__, path="/pages/404", title="Dash/Flightdeck - 404", container='full_page') 6 | 7 | layout = html.Div([ 8 | # NOTICE: You can use the _analytics.html partial to include production code specific code & trackers 9 | html.Main([ 10 | html.Section([ 11 | html.Div([ 12 | html.Div([ 13 | html.Div([ 14 | html.Div([ 15 | html.Img(className='img-fluid w-75', src='/assets/img/illustrations/404.svg', alt='404 not found'), 16 | html.H1([ 17 | "Page not", 18 | html.Span(" found", className='fw-bolder text-primary') 19 | ], className='mt-5'), 20 | html.P("Oops! Looks like you followed a bad link. If you think this is a problem with us, please tell us.", className='lead my-4'), 21 | dcc.Link([ICON.ARROW_NARROW_LEFT, "Back to homepage" 22 | ], href='dashboard', className='btn btn-gray-800 d-inline-flex align-items-center justify-content-center mb-4') 23 | ]) 24 | ], className='col-12 text-center d-flex align-items-center justify-content-center') 25 | ], className='row') 26 | ], className='container') 27 | ], className='vh-100 d-flex align-items-center justify-content-center') 28 | ]) 29 | ]) 30 | -------------------------------------------------------------------------------- /examples/sidebar/pages/settings_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | from .common import jumbotron_content 5 | 6 | layout = jumbotron_content('Settings', 'Page') 7 | 8 | register_page(__name__, path="/pages/settings", title="Dash/Flightdeck - Settings") 9 | -------------------------------------------------------------------------------- /examples/sidebar/pages/transactions_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | from .common import jumbotron_content 5 | 6 | layout = jumbotron_content('Transactions', 'Page') 7 | 8 | page = register_page(__name__, path="/pages/transactions", title="Dash/Flightdeck - Transactions") 9 | 10 | -------------------------------------------------------------------------------- /examples/sidebar/pages/welcome.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | from .common import jumbotron_content 4 | 5 | register_page(__name__, path='/', title='Welcome') 6 | 7 | layout = jumbotron_content("DashSPA Welcomes", 'Guest') 8 | 9 | -------------------------------------------------------------------------------- /examples/sidebar/themes.py: -------------------------------------------------------------------------------- 1 | # Bootstrap 5 theme created by themesberg.com. The theme has been forked 2 | # and made available via github/jsdelivr 3 | 4 | # https://github.com/stevej2608/volt-bootstrap-5-dashboard/tree/volt-custom-themes 5 | # https://www.jsdelivr.com/?docs=gh 6 | 7 | _VOLT_BASE = "https://cdn.jsdelivr.net/gh/stevej2608/volt-bootstrap-5-dashboard@1.4.2/dist/css/" 8 | 9 | # VOLT standard - dark with hints of mustard 10 | # 11 | # https://demo.themesberg.com/volt/pages/dashboard/dashboard.html 12 | 13 | VOLT = _VOLT_BASE + "volt.min.css" 14 | 15 | # VOLT_BOOTSTRAP - Volt CSS with standard bootstrap colouring 16 | # Alternative, custom coloured, VOLT themes can easily be 17 | # created, see: 18 | # 19 | # https://github.com/stevej2608/volt-bootstrap-5-dashboard/tree/volt-custom-themes 20 | 21 | VOLT_BOOTSTRAP = _VOLT_BASE + "volt.bootstrap.min.css" -------------------------------------------------------------------------------- /examples/veggy/app.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from dash_spa import page_container, DashSPA 4 | from server import serve_app 5 | 6 | external_stylesheets = [ 7 | "https://fonts.googleapis.com/css?family=Roboto:300,400,700", 8 | "https://res.cloudinary.com/sivadass/image/upload/v1494699523/icons/bare-tree.png", 9 | ] 10 | 11 | server = flask.Flask(__name__) 12 | server.config['SECRET_KEY'] = "Veggy Store Secret Key" 13 | 14 | app = DashSPA( __name__, 15 | server = server, 16 | external_stylesheets=external_stylesheets 17 | ) 18 | 19 | # python -m examples.veggy.app 20 | 21 | if __name__ == "__main__": 22 | app.layout=page_container 23 | serve_app(app, debug=False) -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/__init__.py: -------------------------------------------------------------------------------- 1 | from .product import productList 2 | from .cart import CartContext, TCartItem 3 | from .header import header 4 | from .footer import footer 5 | from .modal import modal 6 | from .context import TCartItem, TCartState, CartContext -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/context.py: -------------------------------------------------------------------------------- 1 | from dash_spa.spa_context import createContext, ContextState, dataclass 2 | 3 | @dataclass 4 | class TCartItem(ContextState): 5 | id: str = None 6 | count: int = 0 7 | price: float = 0.0 8 | name: str = '' 9 | image: str = '' 10 | 11 | 12 | @dataclass 13 | class TCartState(ContextState): 14 | isCartOpen: bool = False 15 | items: list = None 16 | search_term: str = '' 17 | 18 | def __post_init__(self): 19 | self.items = [] 20 | super().__post_init__() 21 | 22 | CartContext = createContext(TCartState) 23 | -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/footer.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | WS = "\n" 4 | 5 | def footer(): 6 | return html.Footer([ 7 | html.P([ 8 | html.A("View Source on Github", href='https://github.com/sivadass/react-shopping-cart', target='_blank'), 9 | html.Span("/"), 10 | html.A("Need any help?", href='mailto:contact@sivadass.in', target='_blank'), 11 | html.Span("/"), 12 | html.A("Say Hi on Twitter", href='https://twitter.com/NSivadass', target='_blank'), 13 | html.Span("/"), 14 | html.A("Read My Blog", href='https://sivadass.in', target='_blank') 15 | ], className='footer-links'), 16 | html.P(["© 2017", WS, html.Strong("Veggy"), WS, "- Organic Green Store" ]) 17 | ]) -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/header.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | 3 | from .cart import cart 4 | from .search import search 5 | 6 | VEGGY_IMG = 'https://res.cloudinary.com/sivadass/image/upload/v1493547373/dummy-logo/Veggy.png' 7 | 8 | def brand(): 9 | return html.Div([ 10 | html.Img(className='logo', src=VEGGY_IMG, alt='Veggy Brand Logo') 11 | ], className='brand') 12 | 13 | def header(): 14 | return html.Header([ 15 | html.Div([ 16 | brand(), 17 | search(), 18 | cart(), 19 | ], className='container') 20 | ]) -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/modal.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | 3 | def modal(): 4 | return html.Div([ 5 | html.Div([ 6 | html.Button("×", type='button', className='close'), 7 | html.Div([ 8 | html.Div([ 9 | html.Img() 10 | ], className='quick-view-image'), 11 | html.Div([ 12 | html.Span(className='product-name'), 13 | html.Span(className='product-price') 14 | ], className='quick-view-details') 15 | ], className='quick-view') 16 | ], className='modal') 17 | ], className='modal-wrapper') 18 | -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/product.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from dash import html 3 | from dash_spa import prefix 4 | from dash_spa.components.table import filter_str 5 | 6 | from .cart import CartContext, TCartItem 7 | from .stepper_input import StepperInput 8 | 9 | try: 10 | df = pd.read_json('https://res.cloudinary.com/sivadass/raw/upload/v1535817394/json/products.json') 11 | except Exception: 12 | print("Unable to read 'product.json' from cloudinary, no internet connection?") 13 | exit(0) 14 | 15 | 16 | def ProductCard(index, data: list): 17 | 18 | pid = prefix(f'product_card_{index}') 19 | 20 | id, name, price, image, category = data.values() 21 | 22 | state = CartContext.getState() 23 | 24 | stepper = StepperInput(id=pid('stepper')) 25 | add_btn = html.Button("ADD TO CART", className='', type='button', id=pid('add_btn')) 26 | 27 | @CartContext.On(add_btn.input.n_clicks, stepper.state.value) 28 | def update_cb(clicks, value): 29 | nonlocal state, id 30 | try: 31 | value = int(value) 32 | if state.items is None: 33 | state.items = [] 34 | 35 | for item in state.items: 36 | if item.id == id: 37 | item.count += value 38 | return 39 | 40 | new_item = TCartItem(id, value, price, name, image) 41 | state.items.append(new_item) 42 | except Exception: 43 | pass 44 | 45 | return html.Div([ 46 | html.Div([ 47 | html.Img(src=image, alt=name) 48 | ], className='product-image'), 49 | html.H4(name, className='product-name'), 50 | html.P(price, className='product-price'), 51 | stepper, 52 | html.Div(add_btn, className='product-action') 53 | ], className='product') 54 | 55 | 56 | 57 | def productList(): 58 | state = CartContext.getState() 59 | 60 | df1 = filter_str(df, state.search_term) 61 | 62 | product_data = df1.to_dict('records') 63 | products = [ProductCard(index, data) for index, data in enumerate(product_data)] 64 | return html.Div([ 65 | html.Div(products, className='products') 66 | ], className='products-wrapper') -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/search.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from .context import CartContext 3 | 4 | SEARCH_IMG = 'https://res.cloudinary.com/sivadass/image/upload/v1494756966/icons/search-green.png' 5 | BACK_ARROW_IMG = 'https://res.cloudinary.com/sivadass/image/upload/v1494756030/icons/back.png' 6 | 7 | 8 | def search(): 9 | 10 | search_input = dcc.Input(type='search', placeholder='Search for Vegetables and Fruits', id='search', className='search-keyword') 11 | state = CartContext.getState() 12 | 13 | @CartContext.On(search_input.input.value) 14 | def input_cb(value): 15 | state.search_term = value 16 | 17 | return html.Div([ 18 | html.A([ 19 | html.Img(src=SEARCH_IMG, alt='search') 20 | ], className='mobile-search', href='#'), 21 | html.Form([ 22 | html.A([ 23 | html.Img(src=BACK_ARROW_IMG, alt='back') 24 | ], className='back-button', href='#'), 25 | search_input, 26 | html.Button(className='search-button', type='submit') 27 | ], action='#', method='get', className='search-form') 28 | ], className='search') 29 | -------------------------------------------------------------------------------- /examples/veggy/pages/veggy/stepper_input.py: -------------------------------------------------------------------------------- 1 | from dash import html, dcc 2 | from dash_spa import prefix, callback, isTriggered, copy_factory 3 | 4 | class StepperInput(html.Div): 5 | def __init__(self, id): 6 | pid = prefix(id) 7 | 8 | add_btn = html.A("+", className='increment', id=pid('add')) 9 | remove_btn = html.A("–", className='decrement', id=pid('remove')) 10 | input_number = dcc.Input(type='number', className='quantity', value='1', id=pid('input')) 11 | 12 | @callback(input_number.output.value, 13 | add_btn.input.n_clicks, 14 | remove_btn.input.n_clicks, 15 | input_number.state.value, 16 | prevent_initial_call=True) 17 | def add_cb(add_clicks, remove_clicks, value): 18 | try: 19 | count = int(value) 20 | if isTriggered(add_btn.input.n_clicks): 21 | count += 1 22 | elif isTriggered(remove_btn.input.n_clicks): 23 | if count > 1: count -= 1 24 | return str(count) 25 | except Exception: 26 | return value 27 | 28 | super().__init__([remove_btn, input_number, add_btn], className='stepper-input') 29 | copy_factory(input_number, self) 30 | -------------------------------------------------------------------------------- /examples/veggy/pages/veggy_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | from .veggy import productList, header, footer, modal, CartContext 5 | 6 | register_page(__name__, path='/', title="Veggy", short_name='Veggy') 7 | 8 | @CartContext.Provider() 9 | def layout(): 10 | return html.Main([ 11 | html.Div([ 12 | header(), 13 | productList(), 14 | footer(), 15 | modal() 16 | ], className='container') 17 | ]) 18 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from app import create_dash 2 | from usage import create_spa 3 | 4 | def create_app(): 5 | app = create_spa(create_dash) 6 | return app.dash.server 7 | 8 | app = create_app() 9 | 10 | if __name__ == "__main__": 11 | # Only for debugging while developing 12 | app.run(host="0.0.0.0", debug=True, port=8050) 13 | -------------------------------------------------------------------------------- /pages/500_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | from dash_spa import register_page 4 | 5 | register_page(__name__, path="/pages/500", title="Dash - 500", container=None) 6 | 7 | layout = html.Div([ 8 | html.Div([ 9 | html.H1("500", className='display-1 fw-bold'), 10 | html.P([ 11 | html.Span("Server Error! ", className='text-danger'), 12 | "Something has gone seriously wrong.", 13 | ], className='fs-3'), 14 | html.A("Go Home", href='/', className='btn btn-secondary') 15 | ], className='text-center') 16 | ], className='d-flex align-items-center justify-content-center vh-100') 17 | -------------------------------------------------------------------------------- /pages/__init__.py: -------------------------------------------------------------------------------- 1 | BUTTONS_PAGE_SLUG = '/buttons' 2 | SOLAR_SLUG = '/solar' 3 | GLOBAL_WARMING_SLUG = '/global-warming' 4 | TICKER_SLUG = '/ticker' 5 | USER_SLUG='/user' 6 | LIFE_EXPECTANCY_SLUG='/life' 7 | TABLE_EXAMPLE_SLUG = '/table' 8 | TERMS_AND_CONDITIONS_SLUG='/terms' 9 | TRANSACTIONS_SLUG = "/pages/transactions" 10 | 11 | NAVBAR_PAGES = (TABLE_EXAMPLE_SLUG, TRANSACTIONS_SLUG, SOLAR_SLUG, GLOBAL_WARMING_SLUG, TICKER_SLUG, LIFE_EXPECTANCY_SLUG, BUTTONS_PAGE_SLUG) 12 | -------------------------------------------------------------------------------- /pages/buttons/button_toolbar.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from dash import html, ALL 3 | from dash_spa import prefix 4 | from dash_spa.logging import log 5 | from dash_spa.spa_context import createContext, ContextState, dataclass 6 | import dash_spa as spa 7 | 8 | 9 | @dataclass 10 | class TButton(ContextState): 11 | name: str = '' 12 | clicks: int = 0 13 | 14 | @dataclass 15 | class TBState(ContextState): 16 | title: str = "" 17 | buttons: List[TButton] = None 18 | 19 | def __post_init__(self): 20 | self.buttons = [TButton(name, 0) for name in self.buttons] 21 | 22 | ToolbarContext: Dict[str, TBState] = createContext() 23 | 24 | def button_toolbar(id, initial_state:TBState): 25 | pid = prefix(f'button_toolbar_{id}') 26 | 27 | state, _ = ToolbarContext.useState(id, initial_state=initial_state) 28 | 29 | log.info("button_toolbar state.id=%s (%s)", pid(), state.cid()) 30 | 31 | button_match = spa.match({'type': pid('btn'), 'idx': ALL}) 32 | 33 | btns = html.Div([ 34 | html.Button(btn.name, id=button_match.idx(idx), type="button", className='btn btn-secondary me-1') 35 | for idx, btn in enumerate(state.buttons) 36 | ] 37 | ) 38 | 39 | msg = [f"{btn.name} pressed {btn.clicks} times" for btn in state.buttons] 40 | container = html.H5(", ".join(msg)) 41 | 42 | @ToolbarContext.On(button_match.input.n_clicks) 43 | def btn_update(clicks): 44 | log.info("btn_update state.cid=%s", state.cid()) 45 | index = spa.trigger_index() 46 | state.buttons[index].clicks += 1 47 | 48 | title = html.H4(f"Toolbar {state.title}") 49 | 50 | return html.Div([title, btns, container], style={'background-color': '#e6e6e6'}) 51 | -------------------------------------------------------------------------------- /pages/buttons_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | from dash_spa.logging import log 4 | 5 | from pages import BUTTONS_PAGE_SLUG 6 | 7 | from .buttons.button_toolbar import ToolbarContext, button_toolbar, TBState 8 | 9 | page = register_page(__name__, path=BUTTONS_PAGE_SLUG, title="Button Toolbar Page", short_name='Buttons') 10 | 11 | # Example of using the DashSPA context pattern 12 | # that allows a components state change to be easily passed 13 | # between components and trigger UI updates. 14 | # 15 | # The example creates two panels, each with two instances of 16 | # a toolbar component. The buttons in each toolbar update the common 17 | # ButtonContext for the panel. Because the layout_page() method 18 | # is decorated with ButtonContext.Provider it will be 19 | # called to refresh the UI whenever the context changes. 20 | 21 | 22 | def tb_report(tb: TBState): 23 | """reports single toolbar state""" 24 | 25 | msg = [f'{btn.name}={btn.clicks}' for btn in tb.buttons] 26 | return html.H4(f'{tb.title}: {", ".join(msg)}') 27 | 28 | @ToolbarContext.Provider() 29 | def top_panel_layout(): 30 | 31 | log.info('top_panel_layout()') 32 | 33 | # Create some toolbars 34 | 35 | main_toolbar = button_toolbar(page.id("top_main"), TBState("main", ['close', "exit", 'refresh'])) 36 | page_toolbar = button_toolbar(page.id("top_page"), TBState("page", ['next', "prev", 'top', 'bottom'])) 37 | 38 | state = ToolbarContext.getState() 39 | 40 | title = html.H3('Toolbar Component Example (with persistent state)') 41 | 42 | report = html.Div([tb_report(tb) for tb in state.items()], style={'background-color': '#e6e6e6'}) 43 | report.children.insert(0, html.H3('Toolbar Report')) 44 | 45 | return html.Div([title, main_toolbar, page_toolbar, report]) 46 | 47 | 48 | @ToolbarContext.Provider(persistent=False) 49 | def bottom_panel_layout(): 50 | 51 | log.info('bottom_panel_layout()') 52 | 53 | # Create some toolbars 54 | 55 | main_toolbar = button_toolbar(page.id("bottom_main"), TBState("main", ['close', "exit", 'refresh'])) 56 | page_toolbar = button_toolbar(page.id("bottom_page"), TBState("page", ['next', "prev", 'top', 'bottom'])) 57 | 58 | state = ToolbarContext.getState() 59 | 60 | title = html.H3('Toolbar Component Example (without persistent state)') 61 | 62 | report = html.Div([tb_report(tb) for tb in state.items()], style={'background-color': '#e6e6e6'}) 63 | report.children.insert(0, html.H3('Toolbar Report')) 64 | 65 | 66 | return html.Div([title, main_toolbar, page_toolbar, report]) 67 | 68 | def layout(): 69 | log.info('layout()') 70 | top = top_panel_layout() 71 | bottom = bottom_panel_layout() 72 | return html.Div([top, bottom]) 73 | -------------------------------------------------------------------------------- /pages/data/README.md: -------------------------------------------------------------------------------- 1 | 2 | * [json-generator.com](https://json-generator.com/#) 3 | * [json-to-csv.com](https://www.convertcsv.com/json-to-csv.htm) 4 | 5 | *subscriptions.csv* 6 | ``` 7 | [ 8 | '{{repeat(500, 500)}}', 9 | { 10 | ID: '{{integer(45000, 46000)}}', 11 | Bill_For: '{{random("Platinum Subscription Plan", "Gold Subscription Plan", "Flexible Subscription Plan")}}', 12 | Issue_Date: '{{date(new Date(2020, 0, 1), new Date(), "dd MMM YYYY")}}', 13 | Due_Date: '{{date(new Date(2021, 0, 1), new Date(), "dd MMM YYYY")}}', 14 | Total: '{{floating(200, 1000, 2, "$0,0.00")}}', 15 | Status: '{{random("Paid", "Due", "Cancelled")}}', 16 | Action: '-' 17 | 18 | } 19 | ] 20 | ``` 21 | 22 | *customers.csv* 23 | ``` 24 | [ 25 | '{{repeat(300, 300)}}', 26 | { 27 | index: '{{index()}}', 28 | isActive: '{{bool()}}', 29 | balance: '{{floating(1000, 4000, 2, "$0,0.00")}}', 30 | age: '{{integer(20, 40)}}', 31 | eyeColor: '{{random("blue", "brown", "green")}}', 32 | name: '{{firstName()}} {{surname()}}', 33 | gender: '{{gender()}}', 34 | company: '{{company().toUpperCase()}}', 35 | email: '{{email()}}', 36 | phone: '+1 {{phone()}}', 37 | address: '{{integer(100, 999)}} {{street()}}, {{city()}}, {{state()}}, {{integer(100, 10000)}}', 38 | registered: '{{date(new Date(2014, 0, 1), new Date(), "YYYY-MM-ddThh:mm:ss Z")}}', 39 | } 40 | ] 41 | ``` -------------------------------------------------------------------------------- /pages/data/solar.csv: -------------------------------------------------------------------------------- 1 | State,Number of Solar Plants,Installed Capacity (MW),Average MW Per Plant,Generation (GWh) 2 | California,289,4395,15.3,10826 3 | Arizona,48,1078,22.5,2550 4 | Nevada,11,238,21.6,557 5 | New Mexico,33,261,7.9,590 6 | Colorado,20,118,5.9,235 7 | Texas,12,187,15.6,354 8 | North Carolina,148,669,4.5,1162 9 | New York,13,53,4.1,84 10 | -------------------------------------------------------------------------------- /pages/global_warming_page.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from dash import html, dcc 3 | import dash_bootstrap_components as dbc 4 | 5 | from dash_spa import register_page 6 | 7 | from pages import GLOBAL_WARMING_SLUG, SOLAR_SLUG 8 | 9 | register_page(__name__, path=GLOBAL_WARMING_SLUG, title="Dash - Global Warming", short_name='Warming') 10 | 11 | global_md = """\ 12 | ### Global Warming 13 | Global Temperature Time Series. Data are included from the GISS 14 | Surface Temperature (GISTEMP) analysis and the global component 15 | of Climate at a Glance (GCAG). Two datasets are provided: 16 | 17 | * Global monthly mean 18 | * Annual mean temperature anomalies in degrees Celsius from 1880 to the present 19 | 20 | """ 21 | 22 | data = pd.read_csv("pages/data/global-warming.csv") 23 | 24 | layout = html.Div([ 25 | dbc.Row([ 26 | dbc.Col([ 27 | dcc.Markdown(global_md), 28 | html.A("View details", href='https://datahub.io/core/global-temp#readme', className="btn btn-secondary"), 29 | ], md=3), 30 | dbc.Col([ 31 | 32 | # https://dash.plot.ly/dash-core-components/graph 33 | 34 | dcc.Graph( 35 | figure={ 36 | "data": [{ 37 | "y": data['Mean'].tolist(), 38 | "x": data['Year'].tolist() 39 | }], 40 | 'layout': { 41 | 'title': 'Global Temperature Change (°C)', 42 | 'xaxis': {'title': 'Year'} 43 | 44 | } 45 | }, 46 | config={'displayModeBar': False}, 47 | 48 | ), 49 | ], md=9), 50 | 51 | ]), 52 | dbc.Row([ 53 | dbc.Col([ 54 | dcc.Link("State Solar", href=SOLAR_SLUG, className="btn btn-primary float-end") 55 | ], md=12) 56 | 57 | ]) 58 | ]) 59 | -------------------------------------------------------------------------------- /pages/life_expectancy.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import pandas as pd 3 | from dash import html, dcc, dash_table 4 | from dash.exceptions import PreventUpdate 5 | 6 | from dash_spa import register_page, callback 7 | 8 | from pages import LIFE_EXPECTANCY_SLUG 9 | 10 | page = register_page(__name__, path=LIFE_EXPECTANCY_SLUG, title="Dash - Life Expectancy", short_name='Life Expectancy') 11 | 12 | # Share Data Between Callbacks 13 | # https://dash.plotly.com/dash-core-components/store 14 | 15 | 16 | df = pd.read_csv('https://raw.githubusercontent.com/plotly/datasets/master/gapminderDataFiveYear.csv') 17 | 18 | store = dcc.Store(id=page.id('memory-output')) 19 | 20 | countries_dropdown = dcc.Dropdown(df.country.unique(), 21 | ['Canada', 'United States'], 22 | id=page.id('countries_dropdown'), 23 | multi=True) 24 | 25 | life_dropdown = dcc.Dropdown({'lifeExp': 'Life expectancy', 'gdpPercap': 'GDP per capita'}, 26 | 'lifeExp', 27 | id=page.id('life_dropdown')) 28 | 29 | graph = dcc.Graph(id='graph') 30 | 31 | table = dash_table.DataTable( 32 | id=page.id('table'), 33 | columns=[{'name': i, 'id': i} for i in df.columns]) 34 | 35 | 36 | layout = html.Div([store, countries_dropdown, life_dropdown, html.Div([graph, table])]) 37 | 38 | 39 | @callback(store.output.data, countries_dropdown.input.value) 40 | def filter_countries(countries_selected): 41 | if not countries_selected: 42 | # Return all the rows on initial load/no country selected. 43 | return df.to_dict('records') 44 | 45 | filtered = df.query('country in @countries_selected') 46 | 47 | return filtered.to_dict('records') 48 | 49 | 50 | @callback(table.output.data,store.input.data) 51 | def on_data_set_table(data): 52 | if data is None: 53 | raise PreventUpdate 54 | 55 | return data 56 | 57 | @callback(graph.output.figure, store.input.data, life_dropdown.input.value) 58 | def on_data_set_graph(data, field): 59 | if data is None: 60 | raise PreventUpdate 61 | 62 | aggregation = collections.defaultdict( 63 | lambda: collections.defaultdict(list) 64 | ) 65 | 66 | for row in data: 67 | 68 | a = aggregation[row['country']] 69 | 70 | a['name'] = row['country'] 71 | a['mode'] = 'lines+markers' 72 | 73 | a['x'].append(row[field]) 74 | a['y'].append(row['year']) 75 | 76 | return { 77 | 'data': list(aggregation.values()) 78 | } 79 | -------------------------------------------------------------------------------- /pages/not_found_404.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | from dash_spa import register_page 4 | 5 | register_page(__name__, path="/pages/404", title="Dash - 404", container=None) 6 | 7 | layout = html.Div([ 8 | html.Div([ 9 | html.H1("404", className='display-1 fw-bold'), 10 | html.P([ 11 | html.Span("Opps! ", className='text-danger'), 12 | "Page not found." 13 | ], className='fs-3'), 14 | html.P("The page you're looking for doesn't exist.", className='lead'), 15 | html.A("Go Home", href='/', className='btn btn-secondary') 16 | ], className='text-center') 17 | ], className='d-flex align-items-center justify-content-center vh-100') 18 | -------------------------------------------------------------------------------- /pages/state_solar_page.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import dash_bootstrap_components as dbc 3 | from dash import dcc, html 4 | from dash_spa import register_page 5 | 6 | from pages import GLOBAL_WARMING_SLUG, SOLAR_SLUG 7 | 8 | register_page(__name__, path=SOLAR_SLUG, title="Dash Solar", short_name='Solar') 9 | 10 | 11 | global_md = """\ 12 | ### Global Warming 13 | Global Temperature Time Series. Data are included from the GISS 14 | Surface Temperature (GISTEMP) analysis and the global component 15 | of Climate at a Glance (GCAG). Two datasets are provided: 16 | 17 | * Global monthly mean 18 | * Annual mean temperature anomalies in degrees Celsius from 1880 to the present 19 | 20 | """ 21 | 22 | # Taken from Dash example, see: 23 | # https://dash.plot.ly/datatable 24 | 25 | df = pd.read_csv('pages/data/solar.csv') 26 | 27 | layout = html.Div([ 28 | html.Div([ 29 | html.Div([], className="col-md-2"), 30 | html.Div([ 31 | html.H2('US Solar Capacity'), 32 | html.Br(), 33 | dbc.Table.from_dataframe(df, striped=True, bordered=True, hover=True), 34 | ], className="col-md-8"), 35 | html.Div([], className="col-md-2") 36 | ], className='row'), 37 | 38 | dbc.Row([ 39 | dbc.Col([ 40 | dcc.Link("Global Warming", href=GLOBAL_WARMING_SLUG, className="btn btn-primary float-end") 41 | ], md=12) 42 | 43 | ]) 44 | 45 | ], className="container-fluid") 46 | -------------------------------------------------------------------------------- /pages/table_example.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | import pandas as pd 3 | 4 | from dash_spa import register_page, prefix 5 | from dash_spa.components import DropdownAIO, TableContext, TableAIO, TableAIOPaginator, TableAIOPaginatorView 6 | from dash_spa.logging import log 7 | 8 | from pages import TABLE_EXAMPLE_SLUG 9 | 10 | register_page(__name__, path=TABLE_EXAMPLE_SLUG, title="Table Example", short_name='Table') 11 | 12 | 13 | 14 | # Example Of creating a custom table with paginator 15 | 16 | df = pd.read_csv('pages/data/subscriptions.csv') 17 | 18 | class CustomTable(TableAIO): 19 | 20 | def tableAction(self, row): 21 | 22 | pid = prefix('table_example_row_action') 23 | 24 | button = DropdownAIO.Button([ 25 | html.Span(html.Span(className='fas fa-ellipsis-h icon-dark'), className='icon icon-sm'), 26 | html.Span("Toggle Dropdown", className='visually-hidden') 27 | ], className='btn btn-link text-dark dropdown-toggle-split m-0 p-0') 28 | 29 | # Action column dropdown bottom-left. Ripped from the Volt transactions table using Firefox debug tools 30 | 31 | style={"position": "absolute", 32 | "inset": "0px 0px auto auto", 33 | "margin": "0px", 34 | "transform": "translate3d(0px, 25.3333px, 0px)" 35 | } 36 | 37 | container = html.Div([ 38 | html.A([html.Span(className='fas fa-eye me-2'), "View Details" ], className='dropdown-item rounded-top', href='#'), 39 | html.A([html.Span(className='fas fa-edit me-2'), "Edit"], className='dropdown-item', href='#'), 40 | html.A([html.Span(className='fas fa-trash-alt me-2'), "Remove" ], className='dropdown-item text-danger rounded-bottom', href='#') 41 | ], className='dropdown-menu py-0', style=style) 42 | 43 | return html.Div(DropdownAIO(button, container, id=pid(row)), className='btn-group') 44 | 45 | 46 | def tableRow(self, row_index, args): 47 | 48 | cid, bill, issue_date, due_date, total, status, action, = args.values() 49 | action = self.tableAction(row_index) 50 | 51 | return html.Tr([ 52 | html.Td(html.A(cid, href='#', className='fw-bold')), 53 | html.Td(html.Span(bill, className='fw-normal')), 54 | html.Td(html.Span(issue_date, className='fw-normal')), 55 | html.Td(html.Span(due_date, className='fw-normal')), 56 | html.Td(html.Span(total, className='fw-bold')), 57 | html.Td(html.Span(status, className='fw-bold text-warning')), 58 | html.Td(action) 59 | ]) 60 | 61 | 62 | @TableContext.Provider() 63 | def layout(): 64 | log.info('layout - example_table') 65 | table = CustomTable( 66 | data=df.to_dict('records'), 67 | page_size = 8, 68 | columns=[{'id': c, 'name': c} for c in df.columns], 69 | id="table_example") 70 | 71 | paginator = TableAIOPaginator(className='pagination mb-0', id="table_example_paginator") 72 | viewer = TableAIOPaginatorView() 73 | 74 | paginator_row = html.Div([ 75 | paginator, 76 | viewer 77 | ], className='card-footer px-3 border-0 d-flex flex-column flex-lg-row align-items-center justify-content-between') 78 | 79 | return html.Div([ 80 | html.Div(table, className='card card-body border-0 shadow table-wrapper table-responsive'), 81 | paginator_row 82 | ]) 83 | -------------------------------------------------------------------------------- /pages/terms_and_conditions.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | from pages import TERMS_AND_CONDITIONS_SLUG 5 | 6 | register_page(__name__, path=TERMS_AND_CONDITIONS_SLUG, title="Dash Solar", short_name='Licence') 7 | 8 | layout = html.Div([ 9 | html.H2('Terms and Conditions'), 10 | html.P("Aliquip excepteur anim aliqua amet velit qui aute."), 11 | html.P("Amet in excepteur adipisicing duis ad est amet aliquip duis et et non. Do laborum aliqua deserunt ipsum pariatur exercitation ipsum nulla mollit ullamco nisi consequat esse veniam. Esse exercitation aliqua sunt magna duis occaecat sunt ipsum officia laborum. Ex labore nisi dolor quis quis ex elit quis laboris ut non tempor. Mollit deserunt eiusmod est adipisicing do aliqua nulla sint qui."), 12 | html.P("Excepteur sint amet incididunt culpa irure consectetur exercitation ad sunt. Sunt aliqua anim aliqua non dolor do. Nostrud nisi commodo dolor sint quis reprehenderit mollit. Eu nulla sunt eu sint excepteur nulla officia commodo eiusmod eu tempor."), 13 | html.P("Eu excepteur esse nostrud fugiat voluptate nostrud cupidatat amet tempor velit mollit sint do voluptate. Exercitation excepteur Lorem adipisicing enim. Consectetur excepteur ex ullamco quis exercitation aliquip cupidatat excepteur laboris.") 14 | ]) 15 | -------------------------------------------------------------------------------- /pages/transactions/__init__.py: -------------------------------------------------------------------------------- 1 | from .table import create_table 2 | from .table_header import create_header -------------------------------------------------------------------------------- /pages/transactions/icons.py: -------------------------------------------------------------------------------- 1 | from dash_svg import Svg, Path 2 | 3 | class ICON: 4 | 5 | GEAR = Svg([ 6 | Path(fillRule='evenodd', d='M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z', clipRule='evenodd') 7 | ], className='icon icon-sm', fill='currentColor', viewBox='0 0 20 20', xmlns='http://www.w3.org/2000/svg') 8 | 9 | PLUS = Svg([ 10 | Path(strokeLinecap='round', strokeLinejoin='round', strokeWidth='2', d='M12 6v6m0 0v6m0-6h6m-6 0H6') 11 | ], className='icon icon-xs me-2', fill='none', stroke='currentColor', viewBox='0 0 24 24', xmlns='http://www.w3.org/2000/svg') 12 | 13 | TICK = Svg([ 14 | Path(fillRule='evenodd', d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z', clipRule='evenodd') 15 | ], className='icon icon-xxs ms-auto', fill='currentColor', viewBox='0 0 20 20', xmlns='http://www.w3.org/2000/svg') 16 | 17 | SEARCH = Svg([ 18 | Path(fillRule='evenodd', d='M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z', clipRule='evenodd') 19 | ], className='icon icon-xs', xmlns='http://www.w3.org/2000/svg', viewBox='0 0 20 20', fill='currentColor') 20 | -------------------------------------------------------------------------------- /pages/transactions/table_header.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from dash import html 3 | from dash_spa import prefix, trigger_index 4 | from dash_spa.components.dropdown_aio import DropdownAIO 5 | from dash_spa.components.button_container_aoi import ButtonContainerAIO 6 | from .icons import ICON 7 | 8 | from dash_spa.components.table import SearchAIO, TableContext 9 | 10 | 11 | class PageSizeSelect(ButtonContainerAIO): 12 | 13 | className ='dropdown-menu dropdown-menu-xs dropdown-menu-end pb-0' 14 | 15 | def __init__(self, page_sizes: List, current:int, id): 16 | super().__init__(page_sizes, current, id=id, className=PageSizeSelect.className) 17 | 18 | state = TableContext.getState() 19 | 20 | @TableContext.On(self.button_match.input.n_clicks) 21 | def page_select(clicks): 22 | index = trigger_index() 23 | if index is not None and clicks[index]: 24 | state.page_size = int(page_sizes[index]) 25 | state.last_page = int(state.table_rows / state.page_size) 26 | state.current_page = 1 27 | 28 | def render_buttons(self, elements): 29 | state = TableContext.getState() 30 | 31 | def render_button(text): 32 | if int(text) == state.page_size: 33 | element = html.Div([text, ICON.TICK], className='dropdown-item d-flex align-items-center fw-bold') 34 | else: 35 | element = html.Div(text, className='dropdown-item fw-bold') 36 | 37 | if text == elements[-1]: 38 | element.className += ' rounded-bottom' 39 | return element 40 | 41 | return [render_button(text) for text in elements] 42 | 43 | 44 | def _settingsDropdown(id) -> html.Div: 45 | pid = prefix(id) 46 | 47 | button = DropdownAIO.Button([ 48 | ICON.GEAR,html.Span("Toggle Dropdown", className='visually-hidden') 49 | ], className='btn btn-link text-dark dropdown-toggle dropdown-toggle-split m-0 p-1') 50 | 51 | container = PageSizeSelect(["10", "20", "30"], 0, id=pid('settings_container')) 52 | dropdown = DropdownAIO(button, container, id=pid('settings_dropdown')) 53 | 54 | return html.Div(dropdown, className='col-4 col-md-2 col-xl-1 ps-md-0 text-end') 55 | 56 | 57 | def create_header(id) -> html.Div: 58 | """Create the search input and page size drop-down""" 59 | 60 | search = SearchAIO(id=id, placeholder='Search customers') 61 | 62 | return html.Div([ 63 | html.Div([ 64 | search, 65 | _settingsDropdown(id=id), 66 | ], className='row align-items-center justify-content-between') 67 | ], className='table-settings mb-4') 68 | -------------------------------------------------------------------------------- /pages/transactions_page.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | 3 | from dash_spa import register_page, prefix, url_for, NOUPDATE 4 | from dash_spa.components import SPA_LOCATION, TableAIOPaginator, TableAIOPaginatorView, TableContext 5 | from dash_spa.logging import log 6 | 7 | from pages import TRANSACTIONS_SLUG 8 | from .transactions import create_table, create_header 9 | from .transactions.icons import ICON 10 | 11 | 12 | page = register_page(__name__, path=TRANSACTIONS_SLUG, title="Dash/Flightdeck - Transactions", short_name='Transactions') 13 | 14 | def button(text): 15 | return html.Button(text, type='button', className='btn btn-sm btn-outline-gray-600') 16 | 17 | def newPlanButton(): 18 | return html.A([ICON.PLUS, "New Plan"], href='#', className='btn btn-sm btn-gray-800 d-inline-flex align-items-center') 19 | 20 | 21 | @TableContext.Provider() 22 | def layout_transactions_table(query_string: dict = None): 23 | 24 | pid = prefix('transactions_table') 25 | 26 | state = TableContext.getState(update=query_string) 27 | 28 | log.info('layout_transactions_table: %s', state) 29 | 30 | # Create the table components 31 | 32 | table = create_table(id=pid()) 33 | header = create_header(id=pid('header')) 34 | paginator = TableAIOPaginator(className='pagination mb-0', id=pid('paginator')) 35 | viewer = TableAIOPaginatorView() 36 | 37 | # Update the browser address bar whenever the table state changes 38 | 39 | @SPA_LOCATION.update(TableContext.store.input.data, prevent_initial_call=True) 40 | def update_location(state, location): 41 | if state: 42 | try: 43 | href = url_for(page.module, state, attr=['current_page', 'search_term']) 44 | return { 'href': href } 45 | except Exception: 46 | pass 47 | 48 | return NOUPDATE 49 | 50 | return html.Div([ 51 | header, 52 | html.Div(table, className='card card-body border-0 shadow table-wrapper table-responsive'), 53 | html.Div([ 54 | paginator, 55 | viewer 56 | ], className='card-footer px-3 border-0 d-flex flex-column flex-lg-row align-items-center justify-content-between') 57 | ]) 58 | 59 | 60 | def layout(**kwargs): 61 | 62 | log.info('********** layout transactions.page %s ****************', kwargs) 63 | 64 | transactions_table = layout_transactions_table(query_string=kwargs) 65 | 66 | return transactions_table 67 | -------------------------------------------------------------------------------- /pages/user/profile.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from flask_login import current_user 3 | 4 | from dash_spa import register_page, current_user, login_required 5 | from pages import USER_SLUG 6 | 7 | register_page(__name__, path=USER_SLUG, title="Dash - Profile", short_name='User') 8 | 9 | # NOTE: This page is used by pytest. The id='user-name' is 10 | # used as part of the admin_login_test to confirm the test user 11 | # has logged in successfully. The id would normally not be needed. 12 | 13 | # This page is hidden from guest visitors by @login_required 14 | 15 | @login_required 16 | def layout(): 17 | 18 | def big_center(text, id=None): 19 | if id: 20 | return html.H2(text, id=id, className='display-3 text-center') 21 | else: 22 | return html.H2(text, className='display-3 text-center') 23 | 24 | def page_content(): 25 | name = current_user.name 26 | 27 | return html.Header([ 28 | big_center("DashSPA Welcomes"), 29 | big_center(name, id='user-name') 30 | ], className='jumbotron my-4') 31 | 32 | content = html.Div(page_content(), id='content') 33 | return html.Div(content) 34 | -------------------------------------------------------------------------------- /pages/welcome.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa import register_page 3 | 4 | register_page(__name__, path='/', title='Welcome') 5 | 6 | header_text = """ 7 | DashSPA is a minimal framework and component suite that allows you to build complex 8 | Dash based single-page applications with ease. The demo application includes 9 | several well known Dash examples that have been pasted into the SPA framework 10 | to show how easy it is to transition to SPA. 11 | 12 | The framework, component suite and demo are 100% Python 13 | """ 14 | 15 | def jumbotron_header(title, text): 16 | return html.Header([ 17 | html.H1(title, className='display-4 text-center'), 18 | html.P(text), 19 | ], className='jumbotron my-4') 20 | 21 | 22 | def card(title, text): 23 | return html.Div([ 24 | html.Div([ 25 | html.Img(alt=''), 26 | html.Div([ 27 | html.H4(title, className='card-title'), 28 | html.P(text, className='card-text') 29 | ], className='card-body'), 30 | html.Div([ 31 | # spa.ButtonLink('Find Out More!', href=spa.url_for(link)).layout 32 | ], className='card-footer') 33 | ], className='card h-100') 34 | ], className='col-lg-3 col-md-6 mb-4') 35 | 36 | 37 | def layout(): 38 | return html.Div([ 39 | jumbotron_header('Welcome to DashSPA', header_text), 40 | html.Div([ 41 | card('Pages', 'Support for Dash Pages, '), 42 | card('Navbar', 'Includes an optional NAVBAR, configured by a simple dictionary'), 43 | card('Forms', 'Easy creation of interactive forms'), 44 | card('Admin', 'Admin blueprint that supports user registration, email authentication and login authorization') 45 | ], className='row text-center'), 46 | ], className='container') 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dash-spa" 3 | version = "1.1.5" 4 | description = "Dash Pages SPA Framework" 5 | authors = ["Steve Jones "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [ 9 | { include = "dash_spa" }, 10 | { include = "dash_spa_admin" }, 11 | ] 12 | include = ["CHANGELOG.md", "LICENSE", "landing-page.md"] 13 | 14 | [tool.poetry.dependencies] 15 | 16 | python = "^3.8" 17 | 18 | dash-bootstrap-components = ">=1.1.0" 19 | dash-holoniq-components = ">=0.0.19" 20 | dash-prefix = ">=0.0.4" 21 | dash-redux = ">=0.0.4" 22 | dash-svg = ">=0.0.8" 23 | dash = "2.6.1" 24 | iniconfig = ">=1.1.1" 25 | appdirs = ">=1.4.4" 26 | itsdangerous = ">=2.0.1" 27 | 28 | [tool.poetry.extras] 29 | 30 | admin = ["werkzeug", "SQLAlchemy", "cachetools", "dash-datatables", "Flask_Login", "Flask_SQLAlchemy", "pystache", "SQLAlchemy_Utils"] 31 | diskcache = ["diskcache"] 32 | redis = ["redis"] 33 | 34 | [tool.poetry.group.dev.dependencies] 35 | 36 | werkzeug = "2.1.2" 37 | sqlalchemy = "1.4.40" 38 | cachetools = "5.2.0" 39 | dash = {version = "2.6.1", extras = ["testing"]} 40 | dash-datatables = ">=0.0.9" 41 | flask-login = "0.6.2" 42 | flask-sqlalchemy = "2.5.1" 43 | pystache = "0.6.0" 44 | sqlalchemy-utils = "0.38.3" 45 | diskcache = ">=5.4.0" 46 | keyrings-alt = ">=4.1.0" 47 | 48 | # Testing 49 | 50 | pytest-env = "0.6.2" 51 | pytest-mock = "3.7.0" 52 | pytest-cov = "3.0.0" 53 | pytest = "7.1.1" 54 | selenium = ">=4.1.0" 55 | setuptools = ">=63.2.0" 56 | twine = "3.7.1" 57 | 58 | pandas = "1.4.2" 59 | numpy = "1.24.4" 60 | colorlover = "^0.3.0" 61 | wheel = "^0.42.0" 62 | 63 | [build-system] 64 | requires = ["poetry-core"] 65 | build-backend = "poetry.core.masonry.api" 66 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | ; https://docs.pytest.org/en/stable/warnings.html#disabling-warnings-summary 2 | [pytest] 3 | 4 | testpaths = 5 | tests 6 | 7 | addopts = -rsxX -vv --headless 8 | 9 | filterwarnings = 10 | ignore:.*werkzeug.server.shutdown.* 11 | ignore::UserWarning 12 | ignore::DeprecationWarning 13 | 14 | ; log_format = %(levelname)s %(asctime)s.%(msecs)03d %(module)10s/%(lineno)-5d %(message)s 15 | log_format = %(levelname)s %(module)10s/%(lineno)-5d %(message)s 16 | log_level = INFO 17 | 18 | ; Uncomment this to enable loggging 19 | ;log_cli = 1 20 | 21 | 22 | # This uses pytest-env (pip install pytest-env) 23 | env = 24 | DASH_SPA_ENV=test 25 | PYTHONHASHSEED=1234 26 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dash import Dash 3 | from dash_spa import logging 4 | 5 | def serve_app(app: Dash, path="/", debug=False): 6 | """Serve Dash application 7 | 8 | Args: 9 | app (Dash): Dash instance to be served 10 | path (str, optional): Initial URL. Defaults to "". 11 | debug (bool, optional): Enable Dash debug. Defaults to False. 12 | """ 13 | 14 | # Turn off werkzeug logging as it's very noisy 15 | 16 | _log = logging.getLogger('werkzeug') 17 | _log.setLevel(logging.ERROR) 18 | 19 | _log = logging.getLogger('redux_store') 20 | _log.setLevel(logging.WARN) 21 | 22 | # _log = logging.getLogger('dash_spa') 23 | # _log.setLevel(logging.INFO) 24 | 25 | # When running in a Docker container the internal port 26 | # is mapped onto a host port. Use the env variables passed 27 | # in to the container to determine the host URL. 28 | 29 | port = int(os.environ.get("PORT", 5000)) 30 | hostname = os.environ.get("HOSTNAME", "localhost") 31 | hostport = os.environ.get("HOSTPORT", "5000") 32 | 33 | print(f' * Visit http://{hostname}:{hostport}{path}') 34 | 35 | app.run_server(debug=debug, host='0.0.0.0', port=port, threaded=False, dev_tools_serve_dev_bundles=debug) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin/__init__.py: -------------------------------------------------------------------------------- 1 | USER_NAME = 'Big Joe' 2 | USER_EMAIL = 'bigjoe@bigjoe.com' 3 | USER_PASSWORD = 'bigjoe99' 4 | 5 | def delete_user(login_manager, email): 6 | """Delete given user""" 7 | try: 8 | login_manager.delete_user(email=email) 9 | except Exception: 10 | pass 11 | 12 | def css_id(prefix): 13 | class _convert: 14 | def __getattr__(self, name): 15 | return f"#spa_admin_{prefix}_view_{name}" 16 | return _convert() 17 | 18 | -------------------------------------------------------------------------------- /tests/admin/admin_login_test.py: -------------------------------------------------------------------------------- 1 | from itsdangerous import base64_decode 2 | import zlib 3 | from dash_spa.logging import log 4 | from tests.admin import USER_NAME, USER_EMAIL, USER_PASSWORD, delete_user, css_id 5 | 6 | 7 | # https://github.com/noraj/flask-session-cookie-manager/blob/master/flask_session_cookie_manager3.py 8 | # https://www.kirsle.net/wizards/flask-session.cgi#source 9 | 10 | def decode(cookie): 11 | """Decode a Flask cookie.""" 12 | try: 13 | compressed = False 14 | payload = cookie 15 | 16 | if payload.startswith('.'): 17 | compressed = True 18 | payload = payload[1:] 19 | 20 | data = payload.split(".")[0] 21 | 22 | data = base64_decode(data) 23 | if compressed: 24 | data = zlib.decompress(data) 25 | 26 | return data.decode("utf-8") 27 | except Exception as e: 28 | return "[Decoding error: are you sure this was a Flask session cookie? {}]".format(e) 29 | 30 | 31 | def test_login(duo, test_app): 32 | 33 | login_manager = test_app.server.login_manager 34 | 35 | # Delete the test user as preparation for test 36 | 37 | delete_user(login_manager, USER_EMAIL) 38 | 39 | result = login_manager.add_user(name=USER_NAME, password=USER_PASSWORD, email=USER_EMAIL) 40 | assert result 41 | 42 | duo.driver.delete_all_cookies() 43 | 44 | # Login known user - confirm success 45 | 46 | duo.server_url = duo.server_url + "/admin/login" 47 | 48 | form = css_id('login') 49 | 50 | result = duo.wait_for_text_to_equal(form.btn, "Sign In", timeout=20) 51 | assert result 52 | 53 | log.info('fill form') 54 | 55 | email=duo.find_element(form.email) 56 | email.send_keys(USER_EMAIL) 57 | 58 | password=duo.find_element(form.password) 59 | password.send_keys(USER_PASSWORD) 60 | 61 | btn = duo.find_element(form.btn) 62 | 63 | log.info('submit form') 64 | btn.click() 65 | 66 | log.info('Wait for user profile') 67 | 68 | result = duo.wait_for_text_to_equal("#user-name", "Big Joe", timeout=20) 69 | assert result 70 | 71 | # Confirm flask_login.login_user() has been called and the session cookies 72 | # have been created. 73 | 74 | cookies = duo.driver.get_cookie('session') 75 | assert cookies 76 | 77 | session = decode(cookies['value']) 78 | assert '_id' in session 79 | 80 | 81 | def test_admin_login_fail(duo, test_app): 82 | 83 | login_manager = test_app.server.login_manager 84 | 85 | # Login known user with a bad password - confirm rejection 86 | # 87 | # Rejection results in a red flash up field being displayed inviting the user 88 | # to re-enter the user details. 89 | 90 | duo.driver.delete_all_cookies() 91 | 92 | duo.server_url = duo.server_url + "/admin/login" 93 | form = css_id('login') 94 | 95 | result = duo.wait_for_text_to_equal(form.btn, "Sign In", timeout=20) 96 | assert result 97 | 98 | btn = duo.find_element(form.btn) 99 | assert btn.text == "Sign In" 100 | 101 | email=duo.find_element(form.email) 102 | email.send_keys(USER_EMAIL) 103 | 104 | password=duo.find_element(form.password) 105 | password.send_keys('bad') 106 | 107 | btn.click() 108 | 109 | result = duo.wait_for_text_to_equal(form.flash, "Please check your login details and try again.", timeout=20) 110 | assert result 111 | -------------------------------------------------------------------------------- /tests/admin/admin_register_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.admin import USER_NAME, USER_EMAIL, USER_PASSWORD, delete_user, css_id 3 | 4 | from dash_spa import page_container 5 | 6 | mail_args = {} 7 | 8 | def test_register(mocker, test_app, duo): 9 | 10 | # Test the registration UI and confirm that a verification 11 | # email is sent to the user 12 | 13 | login_manager = test_app.server.login_manager 14 | 15 | if not login_manager.is_test(): 16 | pytest.exit("You must be in test mode to run this test") 17 | 18 | # We must have an admin before regular users can register 19 | 20 | if login_manager.user_count() == 0: 21 | result = login_manager.add_user(name='Admin', password='1234', email='admin@test.com', role=['admin']) 22 | assert result 23 | 24 | # Delete the test user as preparation for test 25 | 26 | delete_user(login_manager, USER_EMAIL) 27 | 28 | # Mock the template emailer 29 | 30 | def mock_send(self, receiver, subject, test_mode=True): 31 | mail_args.update(self.args) 32 | mail_args['sender'] = 'test@pytest.com' 33 | mail_args['receiver'] = receiver 34 | mail_args['subject'] = subject 35 | 36 | mocker.patch('dash_spa_admin.template_mailer.TemplateMailer.send',mock_send) 37 | 38 | register_form = css_id('register') 39 | 40 | # Register new user confirm, via mock, that a registration email 41 | 42 | duo.server_url = duo.server_url + "/admin/register" 43 | result = duo.wait_for_text_to_equal(register_form.btn, "Register", timeout=20) 44 | assert result 45 | 46 | # Fill in the registration form 47 | 48 | name=duo.find_element(register_form.name) 49 | name.send_keys(USER_NAME) 50 | 51 | email=duo.find_element(register_form.email) 52 | email.send_keys(USER_EMAIL) 53 | 54 | password=duo.find_element(register_form.password) 55 | password.send_keys(USER_PASSWORD) 56 | 57 | confirm_password=duo.find_element(register_form.confirm_password) 58 | confirm_password.send_keys(USER_PASSWORD) 59 | 60 | terms=duo.find_element(register_form.terms) 61 | terms.click() 62 | 63 | # Submit the registration form and wait for the verify form to appear 64 | 65 | registration_submit = duo.find_element(register_form.btn) 66 | registration_submit.click() 67 | 68 | # Browser switching to verify code page ... 69 | 70 | verify_form = css_id('register_verify') 71 | 72 | result = duo.wait_for_text_to_equal(verify_form.btn, "Submit", timeout=20) 73 | assert result 74 | 75 | # Confirm the mailer mock has captured the email arguments 76 | 77 | assert mail_args['receiver'] == USER_EMAIL 78 | 79 | # Enter the verification code 80 | 81 | code=duo.find_element(verify_form.code) 82 | code.send_keys(mail_args['code']) 83 | 84 | verify_submit = duo.find_element(verify_form.btn) 85 | verify_submit.click() 86 | 87 | # Confirm redirect to login page 88 | 89 | login_form = css_id('login') 90 | 91 | result = duo.wait_for_text_to_equal(login_form.btn, "Sign In", timeout=20) 92 | assert result 93 | -------------------------------------------------------------------------------- /tests/admin/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from usage import create_dash, create_app 4 | 5 | 6 | @pytest.fixture 7 | def test_app(): 8 | """An SPA Application for the admin tests.""" 9 | spa = create_app(create_dash) 10 | yield spa 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/admin/forgot_password_test.py: -------------------------------------------------------------------------------- 1 | from tests.admin import USER_EMAIL 2 | 3 | from tests.admin import css_id 4 | 5 | NEW_PASSWORD = 'bigjoe66' 6 | 7 | mail_args = {} 8 | 9 | def test_admin_forgot(mocker, duo): 10 | 11 | # Mock the template emailer 12 | 13 | def mock_send(self, receiver, subject, test_mode=True): 14 | mail_args.update(self.args) 15 | mail_args['sender'] = 'test@pytest.com' 16 | mail_args['receiver'] = receiver 17 | mail_args['subject'] = subject 18 | mail_args['code'] = self.args['code'] 19 | 20 | mocker.patch('dash_spa_admin.template_mailer.TemplateMailer.send', mock_send) 21 | 22 | # Mock password changer 23 | 24 | def mock_change_password(self, email, password): 25 | mail_args['new_password'] = password 26 | return True 27 | 28 | mocker.patch('dash_spa_admin.login_manager.AdminLoginManager.change_password', mock_change_password) 29 | 30 | # Render the forgot password page, enter the test user email. 31 | 32 | duo.server_url = duo.server_url + "/admin/forgot" 33 | 34 | forgot_form = css_id('forgot') 35 | 36 | result = duo.wait_for_text_to_equal(forgot_form.btn, "Reset Request", timeout=20) 37 | assert result 38 | 39 | email=duo.find_element(forgot_form.email) 40 | email.send_keys(USER_EMAIL) 41 | 42 | forgot_btn = duo.find_element(forgot_form.btn) 43 | forgot_btn.click() 44 | 45 | # A verification code is sent by email, this is intercepted 46 | # by TemplateMailer.mock_send(). The user is automatically redirected to 47 | # the verification code page. 48 | 49 | # Enter verification code 50 | 51 | forgot_code_form = css_id('forgot_code') 52 | 53 | result = duo.wait_for_text_to_equal(forgot_code_form.btn, "Enter Verification Code", timeout=20) 54 | assert result 55 | 56 | code_input=duo.find_element(forgot_code_form.code) 57 | code_input.send_keys(mail_args['code']) 58 | 59 | reset_btn=duo.find_element(forgot_code_form.btn) 60 | reset_btn.click() 61 | 62 | # If the verification code checks out the user is redirected to the password 63 | # reset page 64 | 65 | forgot_password_form = css_id('forgot_password') 66 | 67 | result = duo.wait_for_text_to_equal(forgot_password_form.btn, "Update Password", timeout=20) 68 | assert result 69 | 70 | # Enter the new password. 71 | 72 | password=duo.find_element(forgot_password_form.password) 73 | password.send_keys(NEW_PASSWORD) 74 | 75 | confirm_password=duo.find_element(forgot_password_form.confirm_password) 76 | confirm_password.send_keys(NEW_PASSWORD) 77 | 78 | reset_btn=duo.find_element(forgot_password_form.btn) 79 | reset_btn.click() 80 | 81 | # If new password is accepted the user is redirected to the login page. 82 | 83 | login_form = css_id('login') 84 | 85 | result = duo.wait_for_text_to_equal(login_form.btn, "Sign In", timeout=20) 86 | assert result 87 | -------------------------------------------------------------------------------- /tests/admin/mailer_test.py: -------------------------------------------------------------------------------- 1 | from dash_spa_admin.template_mailer import TemplateMailer 2 | from dash_spa_admin.login_manager import VERIFICATION_TEMPLATE 3 | 4 | def test_send(): 5 | name = 'Big Joe' 6 | code = 'ABCD' 7 | email = 'bigjoe@bigjoe.com' 8 | sender = 'admin@mailhoast.com' 9 | mailer = TemplateMailer(VERIFICATION_TEMPLATE, {'name' : name, 'code': code}) 10 | email = mailer.send(sender, email, 'Password verification') 11 | assert code in email 12 | -------------------------------------------------------------------------------- /tests/admin/test_login_manager.py: -------------------------------------------------------------------------------- 1 | from dash_spa_admin.synchronised_cache import SynchronisedTTLCache 2 | 3 | 4 | def test_cache_send(): 5 | cache = SynchronisedTTLCache(maxsize=1000, ttl=30*60) 6 | cache['test'] = {'test': 1234} 7 | assert 'test' in cache 8 | 9 | def test_register_verification_cache(duo, test_app): 10 | login_manager = test_app.server.login_manager 11 | 12 | vrec1 = login_manager.create_verification_record("Steve", "steve@gmail.com", "1234") 13 | vrec2 = login_manager.get_verification_record("steve@gmail.com") 14 | 15 | assert vrec1.code == vrec2.code 16 | -------------------------------------------------------------------------------- /tests/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/components/__init__.py -------------------------------------------------------------------------------- /tests/components/alert_test.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa.logging import log 3 | from dash_spa import NOUPDATE 4 | 5 | from dash_spa.components import Alert, SPA_ALERT 6 | from .app_factory import single_page_app 7 | 8 | class Page(): 9 | 10 | def __init__(self): 11 | self.btn = html.Button("Alert Button", id='alert_btn') 12 | 13 | def layout(self): 14 | 15 | @SPA_ALERT.update(self.btn.input.n_clicks) 16 | def btn_cb(clicks, store): 17 | if clicks: 18 | log.info('issue alert') 19 | alert = Alert("Basic alert", 'You clicked the button!') 20 | return alert.report() 21 | else: 22 | return NOUPDATE 23 | 24 | return html.Div(self.btn) 25 | 26 | def test_alert(dash_duo): 27 | 28 | page = Page() 29 | test_app = single_page_app(page.layout) 30 | dash_duo.start_server(test_app) 31 | 32 | # Click a button to trigger the notify toast 33 | 34 | browser_btn = dash_duo.find_element(page.btn.css_id) 35 | browser_btn.click() 36 | 37 | result = dash_duo.wait_for_text_to_equal("#swal2-title", "Basic alert", timeout=3) 38 | assert result 39 | 40 | result = dash_duo.wait_for_text_to_equal("#swal2-html-container", "You clicked the button!", timeout=3) 41 | assert result 42 | -------------------------------------------------------------------------------- /tests/components/app_factory.py: -------------------------------------------------------------------------------- 1 | from dash_spa.logging import log 2 | from dash_spa import DashSPA, page_container, register_page 3 | 4 | def single_page_app(page_layout): 5 | log.info('********************* create alert app *************************') 6 | app = DashSPA(__name__, pages_folder='') 7 | register_page('test', path='/', title="test", layout=page_layout()) 8 | app.layout = page_container 9 | return app -------------------------------------------------------------------------------- /tests/components/notify_test.py: -------------------------------------------------------------------------------- 1 | from dash import html 2 | from dash_spa.logging import log 3 | from dash_spa import NOUPDATE 4 | from dash_spa.components import Notyf, SPA_NOTIFY 5 | 6 | from .app_factory import single_page_app 7 | 8 | 9 | class Page(): 10 | 11 | def __init__(self): 12 | self.btn = html.Button("Button", id='btn') 13 | 14 | def layout(self): 15 | 16 | @SPA_NOTIFY.update(self.btn.input.n_clicks) 17 | def btn_cb(clicks, store): 18 | if clicks: 19 | log.info('Notify click') 20 | notyf = Notyf(message='TESTING NOTIFY TESTING') 21 | return notyf.report() 22 | else: 23 | return NOUPDATE 24 | 25 | return html.Div(self.btn) 26 | 27 | def test_notify(dash_duo): 28 | 29 | page = Page() 30 | test_app = single_page_app(page.layout) 31 | dash_duo.start_server(test_app) 32 | 33 | # Click a button to trigger the notify toast 34 | 35 | browser_btn = dash_duo.find_element(page.btn.css_id) 36 | browser_btn.click() 37 | 38 | result = dash_duo.wait_for_text_to_equal(".notyf__message", "TESTING NOTIFY TESTING", timeout=3) 39 | assert result 40 | -------------------------------------------------------------------------------- /tests/components/table/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/components/table/__init__.py -------------------------------------------------------------------------------- /tests/components/table/test_table_filter.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from dash_spa.components.table import filter_dash, filter_str 3 | 4 | df = pd.read_csv('pages/data/customers.csv') 5 | 6 | # 7 | # https://json-generator.com/# 8 | # https://www.convertcsv.com/json-to-csv.htm 9 | # 10 | # [ 11 | # '{{repeat(300, 300)}}', 12 | # { 13 | # index: '{{index()}}', 14 | # isActive: '{{bool()}}', 15 | # balance: '{{floating(1000, 4000, 2, "$0,0.00")}}', 16 | # age: '{{integer(20, 40)}}', 17 | # eyeColor: '{{random("blue", "brown", "green")}}', 18 | # name: '{{firstName()}} {{surname()}}', 19 | # gender: '{{gender()}}', 20 | # company: '{{company().toUpperCase()}}', 21 | # email: '{{email()}}', 22 | # phone: '+1 {{phone()}}', 23 | # address: '{{integer(100, 999)}} {{street()}}, {{city()}}, {{state()}}, {{integer(100, 10000)}}', 24 | # registered: '{{date(new Date(2014, 0, 1), new Date(), "YYYY-MM-ddThh:mm:ss Z")}}', 25 | # } 26 | # ] 27 | 28 | def test_dash_filter(): 29 | 30 | assert len(df) == 300 31 | 32 | result = filter_dash(df, "{isActive} eq true") 33 | assert len(result) == 160 34 | 35 | result = filter_dash(df, "{isActive} eq true && {eyeColor} eq blue") 36 | assert len(result) == 45 37 | 38 | 39 | def test_str_filter(): 40 | 41 | assert len(df) == 300 42 | 43 | result = filter_str(df, "brown", case=True) 44 | assert len(result) == 104 45 | 46 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pytest 3 | from selenium.webdriver.chrome.options import Options 4 | 5 | # Turn off werkzeug logging as it's very noisy 6 | 7 | aps_log = logging.getLogger('werkzeug') 8 | aps_log.setLevel(logging.ERROR) 9 | 10 | # https://dash.plotly.com/testing 11 | 12 | def pytest_setup_options(): 13 | options = Options() 14 | options.add_argument('--disable-gpu') 15 | # options.add_argument("user-data-dir=tmp/pytest/custom/profile") 16 | 17 | # This is needed to force Chrome to run without sandbox enabled. Docker 18 | # does not support namespaces so running Chrome in a sandbox is not possible. 19 | # 20 | # See https://github.com/plotly/dash/issues/1420 21 | 22 | options.add_argument('--no-sandbox') 23 | 24 | # https://stackoverflow.com/a/67222761/489239 25 | 26 | options.add_experimental_option('excludeSwitches', ['enable-logging']) 27 | 28 | return options 29 | 30 | @pytest.fixture 31 | def duo(dash_duo, test_app): 32 | """A client for the dash_duo/Flask tests.""" 33 | dash_duo.driver.set_window_size(1500, 1200) 34 | # dash_duo.driver.maximize_window() 35 | dash_duo.start_server(test_app) 36 | return dash_duo 37 | -------------------------------------------------------------------------------- /tests/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/examples/__init__.py -------------------------------------------------------------------------------- /tests/examples/dropdown_test.py: -------------------------------------------------------------------------------- 1 | import time 2 | from selenium.common.exceptions import TimeoutException 3 | import pytest 4 | from dash import html, ALL 5 | from dash_spa import match, prefix 6 | 7 | from examples.button_dropdown.app import create_dash, create_app 8 | 9 | 10 | @pytest.fixture 11 | def test_app(): 12 | spa = create_app(create_dash) 13 | yield spa 14 | 15 | 16 | def test_dropdown_select(duo, test_app): 17 | 18 | duo.server_url = duo.server_url + "/" 19 | 20 | # Confirm page is showing 21 | 22 | result = duo.wait_for_text_to_equal("#test_h4", "Page size is 10", timeout=20) 23 | assert result 24 | 25 | # Open the dropdown 26 | 27 | dropdown_button = duo.find_element("#test_settings_dropdown_btn") 28 | dropdown_button.click() 29 | 30 | # Confirm dropdown is open 31 | 32 | result = duo.wait_for_text_to_equal('.dropdown-item', '10', timeout=20) 33 | assert result is True 34 | 35 | # Click the last element in the dropdown 36 | 37 | btn = duo.find_elements('.dropdown-item')[2] 38 | btn.click() 39 | 40 | result = duo.wait_for_text_to_equal("#test_h4", "Page size is 30", timeout=20) 41 | assert result 42 | 43 | 44 | def test_dropdown_cancel(duo, test_app): 45 | 46 | duo.server_url = duo.server_url + "/" 47 | 48 | # Confirm page is showing 49 | 50 | result = duo.wait_for_text_to_equal("#test_h4", "Page size is 10", timeout=20) 51 | assert result 52 | 53 | # Open the dropdown 54 | 55 | dropdown_button = duo.find_element("#test_settings_dropdown_btn") 56 | dropdown_button.click() 57 | 58 | # Confirm dropdown is open 59 | 60 | result = duo.wait_for_text_to_equal('.dropdown-item', '10', timeout=20) 61 | assert result is True 62 | 63 | # Click the banner (could use any element outside the drop down) 64 | 65 | h4 = duo.find_element('#test_h4') 66 | h4.click() 67 | 68 | # Wait for drop down to close 69 | 70 | time.sleep(1) 71 | 72 | # Confirm that drop down items are no longer visible 73 | 74 | with pytest.raises(TimeoutException) as error: 75 | duo.wait_for_text_to_equal('.dropdown-item', '10', timeout=2) 76 | -------------------------------------------------------------------------------- /tests/examples/multipage_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from examples.multipage.app import create_dash, create_app 3 | 4 | @pytest.fixture 5 | def test_app(): 6 | spa = create_app(create_dash) 7 | yield spa 8 | 9 | 10 | def test_page_nav1(duo, test_app): 11 | 12 | duo.server_url = duo.server_url + "/page1" 13 | 14 | # Confirm we're on page 1 15 | 16 | result = duo.wait_for_text_to_equal("#page", "Page 1", timeout=20) 17 | assert result 18 | 19 | # Switch to page 2 and confirm we're on it 20 | 21 | nav_link2 = duo.find_element("#nav-page2") 22 | nav_link2.click() 23 | result = duo.wait_for_text_to_equal("#page", "Page 2", timeout=20) 24 | assert result 25 | 26 | # Switch back to page 1 and confirm we're on it 27 | 28 | nav_link1 = duo.find_element("#nav-page1") 29 | nav_link1.click() 30 | result = duo.wait_for_text_to_equal("#page", "Page 1", timeout=20) 31 | assert result 32 | 33 | # Use history to return to page 2 and confirm we're on it 34 | 35 | duo.driver.back() 36 | result = duo.wait_for_text_to_equal("#page", "Page 2", timeout=20) 37 | assert result 38 | 39 | # Use history to return to page 1 and confirm we're on it 40 | 41 | duo.driver.forward() 42 | result = duo.wait_for_text_to_equal("#page", "Page 1", timeout=20) 43 | assert result 44 | 45 | -------------------------------------------------------------------------------- /tests/spa/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/spa/__init__.py -------------------------------------------------------------------------------- /tests/spa/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/spa/config/__init__.py -------------------------------------------------------------------------------- /tests/spa/config/config_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from dash_spa.spa_config import read_config, ConfigurationError, UNDEFINED_SECTION 4 | 5 | def get_config(file): 6 | """Load module relative config file""" 7 | module = __name__.split('.')[-1] 8 | file = __file__.replace(f"{module}.py", file) 9 | return read_config(file) 10 | 11 | def test_config_simple(): 12 | """ 13 | Test that environmental variable reference in the 14 | configuration file './test.ini' is handled correctly. 15 | """ 16 | 17 | # This should fail as environmental variable SPA_ADMIN_PASSWORD is not defined 18 | 19 | with pytest.raises(ConfigurationError) as error: 20 | get_config('test.ini') 21 | 22 | assert 'ENV variable "SPA_ADMIN_PASSWORD" is not assigned' in str(error) 23 | 24 | # Define SPA_ADMIN_PASSWORD and try again ... 25 | 26 | os.environ["SPA_ADMIN_PASSWORD"] = "secret" 27 | 28 | config = get_config('test.ini') 29 | assert config 30 | 31 | # read 'admin' config 32 | 33 | admin = config.get('admin') 34 | 35 | assert admin 36 | assert admin.email == 'bigjoe@gmail.com' 37 | assert admin.password == 'secret' 38 | 39 | # Try to access a nonexistent section 40 | 41 | admin = config.get('users') 42 | assert admin == UNDEFINED_SECTION 43 | 44 | # Try to access a nonexistent attribute 45 | 46 | assert admin.undefined is None 47 | -------------------------------------------------------------------------------- /tests/spa/config/test.ini: -------------------------------------------------------------------------------- 1 | [admin] 2 | email=bigjoe@gmail.com 3 | password = ${SPA_ADMIN_PASSWORD} -------------------------------------------------------------------------------- /tests/spa/context/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/spa/context/__init__.py -------------------------------------------------------------------------------- /tests/spa/context/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from dash_spa import DashSPA 3 | 4 | @pytest.fixture 5 | def app(): 6 | _app = DashSPA(__name__, use_pages=False) 7 | _app.server.config['SECRET_KEY'] = "A secret key" 8 | yield _app 9 | -------------------------------------------------------------------------------- /tests/spa/context/context_use_initial_state.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | import pytest 3 | from dash import html 4 | from dash_spa.spa_context import createContext, ContextState, dataclass, field 5 | 6 | @dataclass 7 | class TButton(ContextState): 8 | name: str = '' 9 | clicks: int = 0 10 | 11 | @dataclass 12 | class TBState(ContextState): 13 | title: str = "" 14 | buttons: List[TButton] = None 15 | 16 | def __post_init__(self): 17 | self.buttons = [TButton(name, 0) for name in self.buttons] 18 | 19 | ToolbarContext = createContext() 20 | 21 | def test_context_initial_state(): 22 | 23 | state = None 24 | 25 | @ToolbarContext.Provider(id='tb_test') 26 | def toolbar_layout(id, initial_state:TBState): 27 | nonlocal state 28 | state, _ = ToolbarContext.useState(id, initial_state=initial_state) 29 | return html.Div() 30 | 31 | toolbar_layout('main', TBState("main", ['close', "exit", 'refresh'])) 32 | 33 | state = ToolbarContext.get_context_state('tb_test') 34 | assert state.main.buttons[0].name == 'close' 35 | -------------------------------------------------------------------------------- /tests/spa/context/context_use_list_test.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | import pytest 3 | from dash import html 4 | from dash_spa.spa_context import createContext, ContextState, dataclass, field 5 | 6 | def test_context_list1(): 7 | 8 | @dataclass 9 | class SizesState(ContextState): 10 | size: int = 10 11 | sizes: list = field(default_factory=lambda: [10, 20, 30]) 12 | 13 | SizesContext = createContext(SizesState) 14 | 15 | @SizesContext.Provider() 16 | def layout_test1(expected): 17 | state = SizesContext.getState() 18 | assert state.sizes[0] == expected 19 | state.sizes[0] = expected + 10 20 | return html.Div() 21 | 22 | for value in [10, 20, 30, 40]: 23 | layout_test1(value) 24 | 25 | # Confirm ButtonState dataclass is unchanged 26 | 27 | assert SizesState.size == 10 28 | 29 | 30 | def test_context_list2(): 31 | 32 | @dataclass 33 | class ColourState(ContextState): 34 | selected: str = 'red' 35 | colours: list = field(default_factory=lambda: ['red', 'green', 'blue']) 36 | 37 | ColourContext = createContext(ColourState) 38 | 39 | @ColourContext.Provider() 40 | def layout_test1(index): 41 | state = ColourContext.getState() 42 | assert state.selected == state.colours[index] 43 | state.selected = state.colours[index+1] 44 | return html.Div() 45 | 46 | for value in [0, 1]: 47 | layout_test1(value) 48 | -------------------------------------------------------------------------------- /tests/spa/context/context_use_simple_test.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | import pytest 3 | from dash import html 4 | from dash_spa.spa_context import createContext, ContextState, dataclass, field 5 | 6 | @dataclass 7 | class ButtonState(ContextState): 8 | clicks: int = 10 9 | 10 | ButtonContext = createContext(ButtonState) 11 | 12 | def test_context_simple(): 13 | 14 | # Try and use context outside of provider - exception expected 15 | 16 | with pytest.raises(Exception): 17 | state = ButtonContext.getState() 18 | 19 | # Use two context providers with same 20 | # ButtonContext. Confirm they don't interfere with each other 21 | 22 | @ButtonContext.Provider() 23 | def layout_test1(expected): 24 | state = ButtonContext.getState() 25 | assert state.clicks == expected 26 | state.clicks = expected + 10 27 | return html.Div() 28 | 29 | @ButtonContext.Provider() 30 | def layout_test2(expected): 31 | state = ButtonContext.getState() 32 | assert state.clicks == expected 33 | state.clicks = expected + 10 34 | return html.Div() 35 | 36 | for value in [10, 20, 30, 40]: 37 | layout_test1(value) 38 | layout_test2(value) 39 | 40 | # Confirm ButtonState dataclass is unchanged 41 | 42 | assert ButtonState.clicks == 10 43 | -------------------------------------------------------------------------------- /tests/spa/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevej2608/dash-spa/aa801d215eb721dd52b35d71db314ace68905ee3/tests/spa/session/__init__.py -------------------------------------------------------------------------------- /tests/spa/session/diskcache_basic_test.py: -------------------------------------------------------------------------------- 1 | 2 | from dash_spa.session.backends.diskcache import SessionDiskCache 3 | 4 | SESSION_ID = '791b95982fbd2cb5a4b47a6509e38c0bc812ecda94683ff908f1c290176724b0' 5 | TEST_KEY = 'test1' 6 | 7 | def test_diskcache_backend(): 8 | session = SessionDiskCache(SESSION_ID) 9 | 10 | session.remove(TEST_KEY) 11 | 12 | test1 = session.get(TEST_KEY) 13 | 14 | assert test1 == {} 15 | 16 | session.set(TEST_KEY, {'hello': 1234}) 17 | 18 | test1 = session.get(TEST_KEY) 19 | 20 | assert test1 == {'hello': 1234} 21 | -------------------------------------------------------------------------------- /tests/spa/session/redis_basic_test.py: -------------------------------------------------------------------------------- 1 | 2 | from dash_spa.session.backends.redis import RedisSessionBackend 3 | 4 | 5 | SESSION_ID ='791b95982fbd2cb5a4b47a6509e38c0bc812ecda94683ff908f1c290176724b0' 6 | TEST_KEY = 'test1' 7 | 8 | def xtest_redis_backend(): 9 | session = RedisSessionBackend(SESSION_ID) 10 | 11 | session.remove(TEST_KEY) 12 | 13 | test1 = session.get(TEST_KEY) 14 | 15 | assert test1 == {} 16 | 17 | session.set(TEST_KEY, {'hello': 1234}) 18 | 19 | test1 = session.get(TEST_KEY) 20 | 21 | assert test1 == {'hello': 1234} 22 | -------------------------------------------------------------------------------- /tests/spa/session/session_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from dash import html 3 | from dash_spa import DashSPA, prefix, callback, NOUPDATE 4 | from dash_spa.session import session_data, SessionContext, session_context 5 | from dash_spa.logging import log 6 | 7 | # Simple Dash App, single button when clicked increments session 8 | # data clicks count. The test confirms that the session data is 9 | # persistent and the latest state is presented for update in 10 | # the button callback 11 | 12 | @pytest.fixture() 13 | def app(): 14 | pfx = prefix("session_test") 15 | 16 | # dash_duo.driver.delete_all_cookies() 17 | 18 | app = DashSPA(__name__, use_pages=False) 19 | app.server.config['SECRET_KEY'] = "A secret key" 20 | 21 | @session_data() 22 | class ButtonState(SessionContext): 23 | clicks: int = 0 24 | 25 | # Layout the test app 26 | 27 | app.BUTTON_TEST ='Button Test' 28 | app.btn = html.Button("Button", id=pfx('session_btn')) 29 | app.container = html.Div(app.BUTTON_TEST, id=pfx('container')) 30 | 31 | @callback(app.container.output.children, app.btn.input.n_clicks, prevent_initial_call=True) 32 | def btn_update(clicks): 33 | ctx = session_context(ButtonState) 34 | log.info('btn1_update clicks=%s', ctx.clicks) 35 | if clicks: 36 | ctx.clicks += 1 37 | return f"Button pressed {ctx.clicks} times!" 38 | return NOUPDATE 39 | 40 | app.layout = html.Div([app.btn, app.container]) 41 | return app 42 | 43 | 44 | def test_session_button(dash_duo, app): 45 | 46 | def wait_text(text): 47 | return dash_duo.wait_for_text_to_equal(app.container.css_id, text, timeout=4) 48 | 49 | dash_duo.start_server(app) 50 | 51 | # Get button reference 52 | 53 | btn = dash_duo.find_element(app.btn.css_id) 54 | 55 | # Testing 56 | 57 | assert wait_text(app.BUTTON_TEST) 58 | 59 | btn.click() 60 | assert wait_text("Button pressed 1 times!") 61 | 62 | btn.click() 63 | assert wait_text("Button pressed 2 times!") 64 | 65 | btn.click() 66 | assert wait_text("Button pressed 3 times!") 67 | 68 | btn.click() 69 | assert wait_text("Button pressed 4 times!") 70 | 71 | 72 | def test_session_no_data_leak(dash_duo, app): 73 | 74 | dash_duo.start_server(app) 75 | 76 | @session_data() 77 | class ButtonState(SessionContext): 78 | clicks: int = 0 79 | 80 | ctx = session_context(ButtonState) 81 | 82 | assert ctx.clicks == 0 83 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38 3 | 4 | [testenv] 5 | commands = py.test dash-pages-spa 6 | deps = pytest 7 | -------------------------------------------------------------------------------- /usage.py: -------------------------------------------------------------------------------- 1 | from app import create_dash 2 | from dash_spa import logging, config, DashSPA, page_container 3 | from server import serve_app 4 | 5 | from dash_spa_admin import AdminLoginManager 6 | 7 | options = config.get('logging') 8 | 9 | def create_app(dash_factory) -> DashSPA: 10 | """Create Dash application 11 | 12 | Args: 13 | dash_factory (Dash): Callable that returns a Dash Instance 14 | 15 | Returns: 16 | Dash: Dash instance 17 | """ 18 | app = dash_factory() 19 | 20 | def layout(): 21 | return page_container 22 | 23 | app.layout = layout 24 | 25 | if AdminLoginManager.enabled: 26 | login_manager = AdminLoginManager(app.server) 27 | login_manager.init_app(app.server) 28 | 29 | # Optionally add admin user here or via the admin web interface 30 | # if login_manager.user_count() == 0: 31 | # login_manager.add_user("admin", "bigjoe@gmail.com", "1234", role=['admin']) 32 | 33 | 34 | # Other users can also be added here. Alternatively login as 'admin' 35 | # and manage users from the users view. 36 | 37 | # if not login_manager.get_user("littlejoe@gmail.com"): 38 | # login_manager.add_user("littlejoe", "littlejoe@gmail.com", "5678") 39 | 40 | return app 41 | 42 | 43 | if __name__ == "__main__": 44 | 45 | logging.setLevel(options.level) 46 | 47 | app = create_app(create_dash) 48 | serve_app(app, debug=False) 49 | -------------------------------------------------------------------------------- /waitress_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sys import argv 3 | from waitress import serve 4 | from paste.translogger import TransLogger 5 | from app import create_dash 6 | from usage import create_app 7 | from dash_spa import __version__, logging, config 8 | 9 | options = config.get('logging') 10 | 11 | logging.setLevel(options.level) 12 | 13 | logger = logging.getLogger('waitress') 14 | logger.setLevel(logging.WARN) 15 | 16 | _log = logging.getLogger('redux_store') 17 | _log.setLevel(logging.INFO) 18 | 19 | app = create_app(create_dash) 20 | 21 | app_with_logger = TransLogger(app.server, setup_console_handler=False) 22 | 23 | try: 24 | index = argv.index('--port') 25 | port = argv[index+1] 26 | except Exception: 27 | port = int(os.environ.get("PORT", 5000)) 28 | 29 | print(f'DashSPA V{__version__}') 30 | 31 | # serve(app_with_logger, host='0.0.0.0', port=port) 32 | 33 | serve(app.server, host='0.0.0.0', port=port) 34 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from usage import create_spa 4 | 5 | 6 | if __name__ == "__main__": 7 | 8 | app = create_spa() 9 | 10 | aps_log = logging.getLogger('werkzeug') 11 | aps_log.setLevel(logging.ERROR) 12 | 13 | app.run(debug=False, threaded=False) 14 | --------------------------------------------------------------------------------