diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 74083d89..69467439 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,10 +11,15 @@ jobs: - uses: actions/setup-python@v4 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 # minimum required for dependencies - run: npm install -g pnpm@7 @mermaid-js/mermaid-cli + - name: Corepack + run: | + npm install --global corepack@latest + corepack enable + - name: Install Project run: make install diff --git a/.github/workflows/test_js.yml b/.github/workflows/test_js.yml new file mode 100644 index 00000000..3b0fccff --- /dev/null +++ b/.github/workflows/test_js.yml @@ -0,0 +1,25 @@ +name: JS Tests + +on: [push] + +jobs: + test: + name: Test + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Corepack + run: | + npm install --global corepack@latest + corepack enable + + - name: Install + run: | + corepack enable + make install + + - name: Run tests + run: make wtr diff --git a/.github/workflows/test.yml b/.github/workflows/test_py.yml similarity index 100% rename from .github/workflows/test.yml rename to .github/workflows/test_py.yml diff --git a/.gitignore b/.gitignore index e877b9ef..5d250ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,17 +12,18 @@ /constraints-mxdev.txt /coverage/ /dist/ -/docs/Makefile /docs/cone.app.zip /docs/doctrees/ /docs/html/ /docs/latex/ /docs/make.bat +/docs/Makefile /htmlcov/ /js/karma/ /node_modules/ /package-lock.json /pip-selfcheck.json +/pnpm-lock.yaml /pyvenv.cfg /requirements-mxdev.txt /sources/ diff --git a/CHANGES.rst b/CHANGES.rst index 68a405f5..2d34407f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,38 @@ Changes ======= -1.1.1 (unreleased) +2.0a1 (unreleased) ------------------ -- Nothing changed yet. +- Add ``center_content``, ``limit_page_width``, ``sidebar_left_static`` and + ``sidebar_right_static`` properties to ``ILayoutConfig``. + [lenadax] + +- Upgrade jquery to version 4.0.0. + [lenadax] + +- Add ``sidebar_left_min_width`` and ``sidebar_right_min_width`` properties + to ``ILayoutConfig`` (Integer / px value). + [lenadax] + +- Remove no longer used ``mainmenu_fluid``, ``columns_fluid``, ``content_grid_width`` + and ``sidebar_left_grid_width`` properties from ``ILayoutConfig``. + Replace with ``limit_content_width`` property (Boolean). + [lenadax] + +- Add ``sidebar_left_mode`` and ``sidebar_right_mode`` properties + to ``ILayoutConfig`` ('toggle'/'stacked'). + [lenadax] + +- Cleanup js widgets to prevent DOM memory leaks. + [lenadax] + +- Remove no longer used ``content_grid_width`` and ``sidebar_left_grid_width`` + properties from ``ILayoutConfig``. + [rnix] + +- Run tests on bootstrap5 factory theme. + [lenadax] 1.1.0 (2026-02-03) diff --git a/Makefile b/Makefile index 5591c3bd..d4789d5e 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ #: js.scss #: js.wtr #: qa.coverage +#: qa.ruff #: qa.test # # SETTINGS (ALL CHANGES MADE BELOW SETTINGS WILL BE LOST) @@ -32,7 +33,7 @@ RUN_TARGET?= # Additional files and folders to remove when running clean target # No default value. -CLEAN_FS?= +CLEAN_FS?=pnpm-lock.yaml # Optional makefile to include before default targets. This can # be used to provide custom targets or hook up to existing targets. @@ -45,6 +46,12 @@ INCLUDE_MAKEFILE?=include.mk # No default value. EXTRA_PATH?= +## js.nodejs + +# The package manager to use. Defaults to `npm`. Possible values +# are `npm` and `pnpm` +# Default: npm +NODEJS_PACKAGE_MANAGER?=pnpm # Path to Python project relative to Makefile (repository root). # Leave empty if Python project is in the same directory as Makefile. # For monorepo setups, set to subdirectory name (e.g., `backend`). @@ -174,6 +181,12 @@ MXDEV?=mxdev # Default: mxmake MXMAKE?=mxmake +## qa.ruff + +# Source folder to scan for Python files to run ruff on. +# Default: src +RUFF_SRC?=src + ## docs.sphinx # Documentation source folder. @@ -383,7 +396,7 @@ rollup: $(NODEJS_TARGET) # mxenv ############################################################################## -OS?= +export OS:=$(OS) # Determine the executable path ifeq ("$(VENV_ENABLED)", "true") @@ -498,6 +511,41 @@ INSTALL_TARGETS+=mxenv DIRTY_TARGETS+=mxenv-dirty CLEAN_TARGETS+=mxenv-clean +############################################################################## +# ruff +############################################################################## + +RUFF_TARGET:=$(SENTINEL_FOLDER)/ruff.sentinel +$(RUFF_TARGET): $(MXENV_TARGET) + @echo "Install Ruff" + @$(PYTHON_PACKAGE_COMMAND) install ruff + @touch $(RUFF_TARGET) + +.PHONY: ruff-check +ruff-check: $(RUFF_TARGET) + @echo "Run ruff check" + @ruff check $(RUFF_SRC) + +.PHONY: ruff-format +ruff-format: $(RUFF_TARGET) + @echo "Run ruff format" + @ruff format $(RUFF_SRC) + +.PHONY: ruff-dirty +ruff-dirty: + @rm -f $(RUFF_TARGET) + +.PHONY: ruff-clean +ruff-clean: ruff-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y ruff || : + @rm -rf .ruff_cache + +INSTALL_TARGETS+=$(RUFF_TARGET) +CHECK_TARGETS+=ruff-check +FORMAT_TARGETS+=ruff-format +DIRTY_TARGETS+=ruff-dirty +CLEAN_TARGETS+=ruff-clean + ############################################################################## # sphinx ############################################################################## diff --git a/README.rst b/README.rst index 3c4d79b2..2203d962 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,14 @@ The sources are in a GIT DVCS with its main branches at `github `_. +Third Party Resources +===================== + +- https://github.com/jquery/jquery +- https://github.com/twbs/bootstrap +- https://github.com/HatScripts/circle-flags + + Copyright ========= diff --git a/TODO.rst b/TODO.rst index ed176826..e1804c75 100644 --- a/TODO.rst +++ b/TODO.rst @@ -1,65 +1,15 @@ -==== TODO ==== -Docs -==== - -- Document expected permissions for tiles and actions -- Create writing tests documenatation -- Create twisted integration documentation -- Create websocket integration documentation -- Create tutorial extending ``cone.example`` -- Proper cross linking all over the place -- Add proper API docs to code and include in docs. -- Update javascript development docs (treibstoff/rollup) - -Upcoming -======== - -- Treibstoff migration docs. -- Treibtroff AjaxOverlay docs. -- Context bound toolbar and action (named utilities?). -- Layout property for excluding entire navbar. -- Reference blueprint -> use lookup function also for single value references - if present. -- Consolidate "Unauthorized" and "Insufficient Privileges" tiles. -- Check ``title`` of transition names before using ``name`` in workflow - dropdown. -- Bind sharing view to ``cone.app.interfaces.IPrincipalACL``. -- Fix ACL registry lookup. First check by cls and node info name, then by - class only and finally return default. -- CopySupport is used both for marking containers supporting cut/copy/paste - and objects being copyable. Make dedicated interface/mechanism for marking - objects copyable. -- Add template for creating ``cone.app`` plugins. -- Overhaul plugin entry hooks staying closer to pyramid if possible. -- Pyramid request wrapper for autobahn websocket requests to enable proper - security integration. -- ``cone.tile.Tile`` should point to template at ``template`` instead of - ``path``. -- Use ``BatchedItems`` as base for ``Table``. -- Rename ``cone.app.browser.batch.Batch`` to - ``cone.app.browser.batch.Pagination`` providing B/C. -- Provide a ``form_action`` property on ``cone.app.browser.form.Form`` - considering ``action_resource`` attribute. Consolidate with - ``cone.app.browser.Form.YAMLForm.form_action``. -- Test ``cone.app.browser.actions.DropdownAction`` with BS3. -- ``cone.app.browser.copysupport#124``: trigger ``contextchanged`` on - ``#layout`` instead of ``.contextsensitiv``. -- Get rid of remaining ``contextsensitiv`` CSS class related bdajax - bindings and remove ``contextsensitiv`` CSS class entirly from markup and - tests. -- Consolidate ``cone.app.model.AppSettings.__acl__```and - ``cone.app.security.DEFAULT_SETTINGS_ACL`` which is not used yet in - ``cone.app``. -- Fix lookup in ACL registry. First node by class or base class and node - info name if given, Then by class or base class only if not found, then - by node info name only if no class given at lookup. Or so... -- Create and use constants for all default roles and permissions. -- Adopt livesearch JS intergration to provide hooks for passing typeahead - options and datasets instead of just datasets. -- Sharing tile table sorting by principal title instead of principal id -- Bind navtree to ``list`` permission? -- Update jQuery. -- Update bootstrap. +- Upgrade pyramid to 2.0 for python 3.12 and higher. This is necessary because pyramid 1.9.4 + depends on the imp module, which has been removed since python 3.12. +- Main menu display children. +- Custom root node in example. +- Referencebrowser widget. +- Treibstoff styles. +- Yafowil bootstrap 5 styles. +- Yafowil addon widgets. +- SCSS splitting. +- Reduce CSS class noise and add some custom rules to SCSS files. +- Check if we can retrieve Bootstrap sources other than including in this repo + for development. diff --git a/artwork/layout.svg b/artwork/layout.svg index ece75ff7..72560cd2 100644 --- a/artwork/layout.svg +++ b/artwork/layout.svg @@ -2,114 +2,17 @@ + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> - - - - - - - - - - - - - - - - - - - @@ -118,13 +21,10 @@ image/svg+xml - + width="100.049" + height="351.28143" + x="25.636421" + y="147.9986" /> + width="435.80447" + height="286.49084" + x="140.94795" + y="148.04533" /> + + width="434.98193" + height="46.632023" + x="141.20399" + y="452.97476" /> - - - - logo -   + + + logo + + + + mainmenu - mainmenu + + + + personaltools + + + + livesearch + y="66.412689" + id="text3043">livesearch + pathbar + x="45.729378" + y="120.10072" + style="font-size:16px;line-height:1.25">pathbar sidebar left + x="45.992683" + y="175.63354" + style="font-size:16px;line-height:1.25">leftsidebar + rightsidebar content + x="160.47169" + y="186.74268" + style="font-size:16px;line-height:1.25">content footer + x="174.66505" + y="483.12091" + style="font-size:16px;line-height:1.25">footer diff --git a/docs/source/_static/icon.svg b/docs/source/_static/icon.svg new file mode 100644 index 00000000..dbbb6e0a --- /dev/null +++ b/docs/source/_static/icon.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/docs/source/_static/styles.css b/docs/source/_static/styles.css index b08a994e..93b8b7ae 100644 --- a/docs/source/_static/styles.css +++ b/docs/source/_static/styles.css @@ -40,3 +40,15 @@ table td ul { padding-left:1em; margin-bottom:0; } + +/* conestack variables */ +[data-bs-theme="light"] { + --cs-color: var(--bs-gray-900); + --cs-bg-color: var(--bs-gray-200); + --sidebar-left-width: 250px; +} +[data-bs-theme="dark"] { + --cs-color: var(--bs-gray-100); + --cs-bg-color: var(--bs-gray-800); + --sidebar-left-width: 250px; +} \ No newline at end of file diff --git a/docs/source/_templates/layout.html b/docs/source/_templates/layout.html deleted file mode 100644 index 5b9a43b7..00000000 --- a/docs/source/_templates/layout.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "!layout.html" %} - -{% set bootswatch_css_custom = ['_static/styles.css'] %} - -{%- block content %} - - Fork me on GitHub - - -{{ navBar() }} -
-
-
- -
-
- {% block body %}{% endblock %} -
-
-
-{%- endblock %} diff --git a/docs/source/ajax.rst b/docs/source/ajax.rst index 24e07ec0..013f1d42 100644 --- a/docs/source/ajax.rst +++ b/docs/source/ajax.rst @@ -198,6 +198,7 @@ a single or a list of continuation operation instances. from cone.app.browser.ajax import ajax_continue from cone.tile import Tile from cone.tile import tile + from cone.app.browser.utils import make_url @tile(name='exampleaction', permission='view') class ExampleAction(Tile): @@ -215,7 +216,7 @@ a single or a list of continuation operation instances. ) # queue continuation operations - ajax_continue(request, [overlay, event]) + ajax_continue(self.request, [overlay, event]) return u'' A shortcut for continuation message operations is located at diff --git a/docs/source/conf.py b/docs/source/conf.py index 935ea6b2..bf56e329 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,7 +2,6 @@ # import sys # import os -import sphinx_bootstrap_theme # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -75,8 +74,6 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -85,21 +82,22 @@ # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'bootstrap' +html_theme = 'conestack' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'bootstrap_version': '3', - 'navbar_fixed_top': 'false', - 'navbar_class': 'navbar navbar-inverse', - 'navbar_pagenav': False, - 'source_link_position': 'false', + 'cs_color': '#FFFFFF', + 'cs_bg_color': 'var(--bs-gray-900)', + 'logo_url': '_static/icon.svg', + 'logo_title': 'Cone.app', + 'logo_height': '50px', + 'logo_width': '50px', + 'github_url': 'https://github.com/conestack/cone.app', + 'pypi_url': 'https://pypi.org/project/cone.app/' } -html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() - # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = "cone - pyramid based web apps" diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index c1d38eba..d9703897 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -33,7 +33,7 @@ deployments. - **cone.admin_password**: Password of superuser. - **cone.authenticator**: Utility registration name of a - ``cone.app.interfaces.IAuthenticator`` impementation. + ``cone.app.interfaces.IAuthenticator`` implementation. Authentication Policy Configuration @@ -89,7 +89,7 @@ If desired, the concrete UGM implementation is created on application startup. - **ugm.backend**: Registration name of UGM implementation. A default file based UGM factory is registered under name ``file``, which -creates a ``cone.ugm.file.Ugm`` instance. +creates a ``node.ext.ugm.file.Ugm`` instance. Configuration is done through the following parameters. @@ -180,4 +180,4 @@ application config file: - **cone.root.node_available**: Callable returning whether the node is allowed to be used in this application. Gets passed the application model and a - node info instane as arguments. + node info instance as arguments. diff --git a/docs/source/development.rst b/docs/source/development.rst index 855e6220..3e482b2e 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -35,10 +35,11 @@ Generate sphinx docs:: Browser Resources -================= +----------------- Included resources: -- https://github.com/twbs/bootstrap/releases/tag/v5.0.2 -- https://github.com/twbs/icons/archive/v1.5.0.zip +- https://github.com/jquery/jquery/releases/tag/4.0.0-beta +- https://github.com/twbs/bootstrap/releases/tag/v5.3.3 +- https://github.com/twbs/icons/releases/tag/v1.11.3 - https://github.com/corejavascript/typeahead.js/releases/tag/v1.3.1 diff --git a/docs/source/forms.rst b/docs/source/forms.rst index 89a11131..0e0a3466 100644 --- a/docs/source/forms.rst +++ b/docs/source/forms.rst @@ -709,6 +709,7 @@ form's ``save`` function. from plumber import Behavior from plumber import plumb + from yafowil.base import factory class FormExtension(Behavior): """Plumbing behavior used as form extension. @@ -730,12 +731,12 @@ form's ``save`` function. }) # add new widget before save widget save_widget = self.form['save'] - self.form.insertbefore(roles_widget, save_widget) + self.form.insertbefore(widget, save_widget) @plumb def save(_next, self, widget, data): # fetch extension field value from form data - value = data.fetch('%s.generic' % self.form_name).extracted + value = data.fetch('%s.generic' % self.form.name).extracted # set extracted value to model attributes self.model.attrs['generic'] = value # call downstream ``save`` function diff --git a/docs/source/layout.rst b/docs/source/layout.rst index db8ea43f..f60816ae 100644 --- a/docs/source/layout.rst +++ b/docs/source/layout.rst @@ -2,6 +2,14 @@ Layout ====== +.. version-added:: 2.0 + + Version 2.0 introduces a new, more flexible layout system and the ``sidebar_right`` + and ``personaltools`` elements. + + There are new layout configuration settings available. See + :ref:`layout_configuration` for details. + .. _layout_main_template: Main Template @@ -25,6 +33,30 @@ file:: cone.main_template = cone.example.browser:templates/main.pt +Rendering the Main Template +--------------------------- + +To render a view using the main template, use ``render_main_template`` from +``cone.app.browser``. + +.. code-block:: python + + from cone.app.browser import render_main_template + from pyramid.view import view_config + + @view_config(name='myview', permission='view') + def myview(model, request): + # Renders main template with 'mycontent' tile in content area + return render_main_template(model, request, 'mycontent') + +Parameters: + +- **model**: The application model node. +- **request**: The current request object. +- **contenttile**: Name of the tile to render in the content area. Defaults + to ``'content'``. + + Application Layout ------------------ @@ -36,6 +68,12 @@ is structured as follows. .. figure:: ../../artwork/layout.svg :width: 100% + +.. _layout_configuration: + +Layout Configuration +-------------------- + The layout can be configured for each application node. Layout configuration is described in ``cone.app.interfaces.ILayoutConfig`` and is registered for one or more model classes with ``cone.app.layout_config`` decorator. @@ -53,45 +91,79 @@ one or more model classes with ``cone.app.layout_config`` decorator. pass @layout_config(CustomNodeOne, CustomNodeTwo) - class CustomLayoutConfig(LayoutConfig) + class CustomLayoutConfig(LayoutConfig): def __init__(self, model, request): - super(ExampleNodeLayoutConfig, self).__init__(model, request) + super(CustomLayoutConfig, self).__init__(model, request) self.mainmenu = True - self.mainmenu_fluid = False self.livesearch = True self.personaltools = True - self.columns_fluid = False + self.limit_content_width = False self.pathbar = True self.sidebar_left = ['navtree'] - self.sidebar_left_grid_width = 3 - self.content_grid_width = 9 - -Provided layout settings: - -- **mainmenu**: Flag whether to display mainmenu. - -- **mainmenu_fluid**: Flag whether mainmenu is fluid. - -- **livesearch**: Flag whether to display livesearch. - -- **personaltools**: Flag whether to display personaltools. - -- **columns_fluid**: Flag whether columns are fluid. - -- **pathbar**: Flag whether to display pathbar. - -- **sidebar_left**: List of tiles by name which should be rendered in sidebar. - -- **sidebar_left_grid_width**: Sidebar grid width as integer, total grid width - is 12. - -- **content_grid_width**: Content grid width as integer, total grid width - is 12. - -.. note:: - - As of version 1.1, ``mainmenu_fluid`` defaults to ``True``. + self.sidebar_right = ['my_tile'] + self.sidebar_left_min_width = 250 + self.sidebar_right_min_width = 250 + + +.. list-table:: Layout Configuration Settings + :widths: 20 60 20 + :header-rows: 1 + + * - Setting + - Description + - Default + * - ``mainmenu`` + - Flag whether to display mainmenu. + - True + * - ``livesearch`` + - Flag whether to display livesearch. + - True + * - ``personaltools`` + - Flag whether to display personaltools. + - True + * - ``limit_content_width`` + - Flag whether content width should be limited on large screens. + - True + * - ``pathbar`` + - Flag whether to display pathbar. + - True + * - ``sidebar_left`` + - List of tiles by name which should be rendered in left sidebar. + - ['navtree'] + * - ``sidebar_left_mode`` + - The mode of the left sidebar (``'stacked'`` or ``'toggle'``). + - 'stacked' + * - ``sidebar_left_min_width`` + - Minimum left sidebar width as integer (in px). + - 150 + * - ``sidebar_right`` + - List of tiles by name which should be rendered in right sidebar. + - [] + * - ``sidebar_right_mode`` + - The mode of the right sidebar (``'stacked'`` or ``'toggle'``). + - 'stacked' + * - ``sidebar_right_min_width`` + - Minimum right sidebar width as integer (in px). + - 150 + +.. version-added:: 2.0 + + - The ``sidebar_left_min_width`` and ``sidebar_right_min_width`` settings have been + added to specify minimum sidebar widths in px. + - The ``sidebar_left_mode`` and ``sidebar_right_mode`` settings have been + added to specify sidebar modes (either ``'stacked'`` or ``'toggle'``). + - The ``limit_content_width`` setting has been added to replace the former + ``columns_fluid`` setting. + - As of version 2.0, ``limit_content_width`` defaults to ``True``. + +.. version-removed:: 2.0 + + ``mainmenu_fluid``, ``columns_fluid``, ``sidebar_left_grid_width`` and + ``content_grid_width`` have been removed in ``cone.app 2.0`` in favor of a + more flexible layout. + Use the ``limit_content_width`` setting instead to limit content width on + large screens. .. deprecated:: 1.1 diff --git a/docs/source/migration.rst b/docs/source/migration.rst index 7719bd01..f88c20fd 100644 --- a/docs/source/migration.rst +++ b/docs/source/migration.rst @@ -1,15 +1,23 @@ .. _migration_deprecated_patterns: -=========================== -Migration from 1.0.x to 1.1 -=========================== +============================= +Migration from older versions +============================= + +1.1 to 2.0 +---------- + +TODO + +1.0.x to 1.1 +------------ This section covers deprecated patterns and their replacements when upgrading from ``cone.app`` 1.0.x to 1.1. Package Layout --------------- +~~~~~~~~~~~~~~ **Old (setup.py with explicit namespaces):** @@ -50,7 +58,7 @@ Package Layout Resource Registration ---------------------- +~~~~~~~~~~~~~~~~~~~~~ **Old (entries on global cfg object):** @@ -88,7 +96,7 @@ https://github.com/conestack/cone.ugm/blob/1.0.x/src/cone/ugm/__init__.py#L92 JavaScript/Ajax ---------------- +~~~~~~~~~~~~~~~ **Old (bdajax):** No longer supported. @@ -114,7 +122,7 @@ The JavaScript patterns are largely compatible. Main changes: Layout Configuration --------------------- +~~~~~~~~~~~~~~~~~~~~ **Old (layout property or ILayout adapter):** @@ -152,7 +160,7 @@ Layout Configuration Form Rendering --------------- +~~~~~~~~~~~~~~ **Old (EditTile, OverlayEditTile):** @@ -187,7 +195,7 @@ Form Rendering YAML Forms ----------- +~~~~~~~~~~ **Old (form_flavor and form_action):** @@ -218,7 +226,7 @@ YAML Forms Settings UI ------------ +~~~~~~~~~~~ **Old (custom settings implementation):** @@ -253,7 +261,7 @@ controls visibility per settings node. Bootstrap/jQuery Updates ------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~ As of version 1.1: diff --git a/docs/source/model.rst b/docs/source/model.rst index 7efb638f..3e870165 100644 --- a/docs/source/model.rst +++ b/docs/source/model.rst @@ -105,6 +105,24 @@ used to serve the entry nodes of the application. return BaseNode() +LeafNode +-------- + +The ``cone.app.model.LeafNode`` behavior is used for application model nodes +that cannot have children. It disables child-related operations. + +.. code-block:: python + + from cone.app.model import BaseNode + from cone.app.model import LeafNode + from plumber import plumbing + + @plumbing(LeafNode) + class DocumentNode(BaseNode): + """A node that cannot contain children.""" + pass + + AdapterNode ----------- @@ -296,7 +314,7 @@ Available properties are provided by ``keys`` function. >>> from cone.app.model import Properties - >>> props = Properties + >>> props = Properties() >>> props.a = '1' >>> props.b = '2' >>> props.keys() @@ -362,7 +380,7 @@ property, ``ProtectedProperties`` behaves as if this property is inexistent. Metadata -------- -``cone.app.model.Metadada`` class inherits from ``cone.app.model.Properties`` +``cone.app.model.Metadata`` class inherits from ``cone.app.model.Properties`` and adds the marker interface ``cone.app.interfaces.IMetadata``. This object is for ``cone.app.interfaces.IApplicationNode.metadata``. @@ -510,18 +528,23 @@ Node Availability ~~~~~~~~~~~~~~~~~ The ``node_available`` callback can be used to control whether a registered -node type is available in the application context: +node type is available in the application context. -.. code-block:: python +The callback is configured via the application ini file: - from cone.app.model import node_available +.. code-block:: ini + + cone.root.node_available = my.package.check_node_available + +The callback function must have the following signature: + +.. code-block:: python - @node_available - def check_node_available(node_info, container): - """Return True if node type should be available for the container. + def check_node_available(model, node_info_name): + """Return True if node type should be available. - :param node_info: The NodeInfo instance - :param container: The parent container where node would be added + :param model: The application node to gain access to the application model. + :param node_info_name: The node info name of the node to check availability. :return: Boolean indicating availability """ # Custom logic to determine availability diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index b894f67b..51d4a94d 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -150,7 +150,7 @@ Plugin root node factories are registered to the application via from cone.app import main_hook from cone.app import register_entry - import cone.example.model import ExamplePlugin + from cone.example.model import ExamplePlugin @main_hook def example_main_hook(config, global_config, settings): @@ -180,7 +180,7 @@ and registered via ``cone.app.register_config``. name='example_settings', title='Example Settings', description='Settings for the example plugin', - icon='glyphicon glyphicon-cog') + icon='bi bi-gear') class ExampleSettings(SettingsNode): """Plugin settings node.""" # Category for grouping in settings UI @@ -223,7 +223,7 @@ settings should be editable by users without ``manage`` permission. .. note:: - As of version 1.1, settings are accessible to authenticated users, not just + Since version 1.1, settings are accessible to authenticated users, not just managers. The ``display`` property on ``SettingsNode`` controls visibility per settings node. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 5edee822..b4fb312e 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -19,8 +19,8 @@ contains both the integration and plugin code. `_. -Create Python Package ---------------------- +1. Create Python Package +------------------------ First thing to do is to create a `Python Package `_. @@ -72,8 +72,8 @@ dependency. Add the following to ``pyproject.toml``. where = ["src"] -Virtual Environment -------------------- +2. Virtual Environment +---------------------- Create a virtual environment and install the package: @@ -99,8 +99,8 @@ Alternatively, using ``uv`` (faster): uv pip install -e . -Application Configuration -------------------------- +3. Application Configuration +---------------------------- ``cone.app`` uses `PasteDeploy `_ for application configuration. PasteDeploy defines a way to declare WSGI @@ -173,8 +173,8 @@ Details about the available ``cone.app`` dedicated configuration options can be found in the :doc:`Application Configuration ` documentation. -ZCML Configuration ------------------- +4. ZCML Configuration +--------------------- Plugins may contain a :ref:`ZCML ` configuration which contains ZCML configuration directives. If desired, add @@ -188,8 +188,8 @@ contains ZCML configuration directives. If desired, add -Static Resources ----------------- +5. Static Resources +------------------- Delivering :ref:`static resources ` is done by creating a directory for serving the assets and telling the application @@ -213,7 +213,7 @@ Register the resources in ``src/cone/example/browser/__init__.py``: ) cone_example_resources.add(wr.ScriptResource( name='cone-example-js', - depends='cone-app-protected-js', + depends='cone-app-js', resource='example.js' )) cone_example_resources.add(wr.StyleResource( @@ -245,8 +245,8 @@ function to tell the application to deliver the CSS and JS file to the browser. configure_resources(config, settings) -Application Model ------------------ +6. Application Model +-------------------- ``cone.app`` uses the traversal mechanism of Pyramid and utilize `node `_ package for publishing. @@ -287,8 +287,8 @@ the model. register_entry('example', ExamplePlugin) -UI Widgets ----------- +7. UI Widgets +------------- ``cone.app`` follows the concept of tiles in it's UI. Each part of the application is represented by a tile, i.e. main menu, navigation tree, site @@ -340,30 +340,48 @@ gets executed. config.scan('cone.example.browser') -Working with JavaScript ------------------------ +8. Working with JavaScript +--------------------------- ``cone.app`` utilizes `treibstoff `_ for it's user interface. The documentation how to properly integrate custom JavaScript into Ajax SSR can be found :ref:`here `. -Installation ------------- +9. Run Application +------------------ -To install the application, create and activate the virtual environment: +After creating the virtual environment as described in section 2, run the +application: .. code-block:: sh - python3 -m venv venv - ./venv/bin/pip install -e . + ./venv/bin/pserve example.ini +The application is now available at ``localhost:8081``. + + +Advanced: Using mxmake +---------------------- + +For larger projects, consider using `mxmake `_ +to manage your build process. mxmake generates a Makefile with common +development tasks. -Run Application ---------------- +With mxmake, you get convenient make targets: + +- ``make install`` - Create virtual environment and install dependencies +- ``make test`` - Run tests +- ``make coverage`` - Run tests with coverage +- ``make docs`` - Build Sphinx documentation +- ``make lingua`` - Extract and compile translations +- ``make clean`` - Clean build artifacts + +To set up mxmake for your plugin: .. code-block:: sh - ./venv/bin/pserve example.ini + pip install mxmake + mxmake init -The application is now available at ``localhost:8081``. +See the ``cone.app`` repository for an example mxmake configuration. diff --git a/docs/source/security.rst b/docs/source/security.rst index 3897f410..d892ca41 100644 --- a/docs/source/security.rst +++ b/docs/source/security.rst @@ -54,6 +54,9 @@ The permissions used by default in ``cone.app`` are: - **change_state**: Grants access to change workflow state of an application model node. +- **change_order**: Grants access to change order of an application + model node. + - **manage**: Grants access to manage application settings. - **login**: Grants access to login to the application. @@ -76,13 +79,14 @@ The roles which come out of the box with ``cone.app`` are: - **editor**: This role is supposed to grant users permissions needed to add and edit application model nodes. By default, permissions assigned to - this role are ``viewer`` role permissions and ``add`` and ``edit``. + this role are ``viewer`` role permissions and ``add``, ``edit`` and + ``change_order``. - **admin**: This role is supposed to grant users permissions to duplicate model nodes, change the workflow state or grant access to parts of the - application model to other uses. By default, permissions assigned to + application model to other users. By default, permissions assigned to this role are ``editor`` role permissions and ``delete``, ``cut``, ``copy``, - ``paste``, ``manage_permissions`` and ``change_state``. + ``paste``, ``manage_permissions``, ``change_state`` and ``change_order``. - **manager**: This role is supposed to grant users permissions to access and modify the application settings. By default, permissions assigned to this @@ -135,12 +139,12 @@ application node. .. _security_acl_registry: -ALC Registry +ACL Registry ------------ A less immersive way for providing ACLs for model nodes is to use the ACL registry. The plumbing behavior ``cone.app.model.AppNode`` only returns -the ``cone.app.security.DEFAULT_ACL`` if no dedicated ALC for this node has +the ``cone.app.security.DEFAULT_ACL`` if no dedicated ACL for this node has been registered in the registry. Registering a custom ACL for application root which grants view access to the @@ -158,9 +162,9 @@ application root model node for unauthenticated uses looks like so: # permission sets authenticated_permissions = ['view'] viewer_permissions = authenticated_permissions + ['list'] - editor_permissions = viewer_permissions + ['add', 'edit'] + editor_permissions = viewer_permissions + ['add', 'edit', 'change_order'] admin_permissions = editor_permissions + [ - 'delete', 'cut', 'copy', 'paste', 'change_state', + 'delete', 'cut', 'copy', 'paste', 'manage_permissions', 'change_state', ] manager_permissions = admin_permissions + ['manage'] everyone_permissions = ['login', 'view'] @@ -178,7 +182,7 @@ application root model node for unauthenticated uses looks like so: acl_registry.register(custom_acl, AppRoot) -``cone.app.model.AppNode.__acl__`` tries to find a registered ALC by +``cone.app.model.AppNode.__acl__`` tries to find a registered ACL by ``self.__class__`` and ``self.node_info_name``, thus application nodes must be registered by both. @@ -276,7 +280,7 @@ Adapter ACL The ``cone.app.security.AdapterACL`` looks up the ACL via ``cone.app.interfaces.IACLAdapter`` interface. This can be useful to support -ALC customization on generic application model nodes. +ACL customization on generic application model nodes. Therefor the model node needs to plumb ``AdapterACL`` behavior. @@ -447,3 +451,72 @@ The utility name must be defined in application ini file. If a UGM implementation is configured, it gets used as fallback for authentication. + + +Security Utility Functions +-------------------------- + +``cone.app.security`` provides several utility functions for working with +authentication and authorization programmatically. + + +authenticate +~~~~~~~~~~~~ + +Authenticates a user with login and password. Tries authentication in order: +admin user credentials, custom authenticator utility, UGM backend. + +.. code-block:: python + + from cone.app.security import authenticate + + # Returns user ID if authentication successful, None otherwise + user_id = authenticate(request, login='username', password='secret') + if user_id: + # Authentication successful + pass + + +authenticated_user +~~~~~~~~~~~~~~~~~~ + +Returns the user principal object for the currently authenticated request. + +.. code-block:: python + + from cone.app.security import authenticated_user + + user = authenticated_user(request) + if user: + # user is a node.ext.ugm User object + print(user.attrs.get('fullname')) + + +principal_by_id +~~~~~~~~~~~~~~~ + +Looks up a user or group by principal ID. + +.. code-block:: python + + from cone.app.security import principal_by_id + + # Returns user or group object, or None if not found + principal = principal_by_id('user123') + if principal: + print(principal.attrs) + + +search_for_principals +~~~~~~~~~~~~~~~~~~~~~ + +Searches for users and groups matching a search term. + +.. code-block:: python + + from cone.app.security import search_for_principals + + # Returns list of matching principal objects + results = search_for_principals('john') + for principal in results: + print(principal.name) diff --git a/docs/source/widgets.rst b/docs/source/widgets.rst index 3378c5f7..39a2321e 100644 --- a/docs/source/widgets.rst +++ b/docs/source/widgets.rst @@ -90,7 +90,7 @@ node in order to get a reasonable result. return [{ 'value': 'Example', 'target': 'https://example.com/example', - 'icon': 'ion-ios7-gear' + 'icon': 'bi bi-gear' }] Another option to implement the serverside search logic is to overwrite the @@ -110,7 +110,7 @@ Another option to implement the serverside search logic is to overwrite the return [{ 'value': 'Example', 'target': 'https://example.com/example', - 'icon': 'ion-ios7-gear' + 'icon': 'bi bi-gear' }] ``cone.app`` uses `typeahead.js `_ @@ -173,7 +173,7 @@ To add more items to the dropdown, register an action with the @personal_tools_action(name='example') class ExampleAction(LinkAction): text = 'Example' - icon = 'ion-ios7-gear' + icon = 'bi bi-gear' event = 'contextchanged:#layout' @property @@ -230,7 +230,7 @@ Considered ``properties``: props.mainmenu_empty_title = False props.mainmenu_display_children = False props.default_content_tile = 'examplecontent' - props.icon = 'ion-ios7-gear' + props.icon = 'bi bi-gear' return props @instance_property @@ -325,7 +325,7 @@ Considered ``properties``: props.default_child = 'child' props.hide_if_default = False props.default_content_tile = 'examplecontent' - props.icon = 'ion-ios7-gear' + props.icon = 'bi bi-gear' return props @instance_property @@ -422,7 +422,7 @@ Furthermore it's possible to register a view action for the contextmenu's interface=ExamplePlugin, permission='view', text='Details', - icon='glyphicons glyphicons-magic') + icon='bi bi-magic') class DetailsContentTile(Tile): """For this class a pyramid view is registered which is reachable under 'http://domain.com/path/to/node/details'. This view renders @@ -563,7 +563,7 @@ Navigation related actions are registered in the ``navigation`` group: @context_menu_item(group='navigation', name='link_to_somewhere') class LinkToSomewhereAction(LinkAction): id = 'toolbaraction-link-to-somewhere' - icon = 'glyphicon glyphicon-arrow-down' + icon = 'bi bi-arrow-down' event = 'contextchanged:#layout' text = 'Link to somewhere' @@ -936,19 +936,20 @@ for all children of model node. @property def vocab(self): count = len(self.model) - pages = count / self.slicesize + pages = count // self.slicesize if count % self.slicesize != 0: pages += 1 current = self.request.params.get('b_page', '0') + ret = [] for i in range(pages): query = make_query(b_page=str(i)) href = make_url( self.request, - path=path, + node=self.model, resource='viewname', query=query ) - target = make_url(self.request, path=path, query=query) + target = make_url(self.request, node=self.model, query=query) ret.append({ 'page': '{}'.format(i + 1), 'current': current == str(i), @@ -1089,8 +1090,8 @@ More customization options on ``BatchedItems`` class: - **show_title**: Flag whether to show title in the listing header. Defaults to ``True``. -- **title_css**: CSS classes to set on title container DOM element. Defaults - to ``col-xs-4``. Can be used to change the size of the title area. +- **title_css**: CSS classes to set on title container DOM element. + Can be used to change the size of the title area. - **default_slice_size**: Default number of items displayed in slice. Defaults to ``15``. @@ -1101,14 +1102,14 @@ More customization options on ``BatchedItems`` class: listing header. Defaults to ``True``. - **slice_size_css**: CSS classes to set on slice size selection container DOM - element. Defaults to ``col-xs-4 col-sm3``. Can be used to change the size + element. Can be used to change the size of the slice size selection. - **show_filter**: Flag whether to display the search filter input in listing header. Defaults to ``True``. - **filter_css**: CSS classes to set on search filter input container DOM - element. Defaults to ``col-xs-3``. Can be used to change the size + Can be used to change the size of the search filter input. - **head_additional**: Additional arbitrary markup rendered in listing header. @@ -1184,7 +1185,7 @@ Futher the implementation must provide ``col_defs``, ``item_count`` and # ``sort`` and ``order`` must be considered when creating the # sorted results. rows = list() - for child in self.model.values()[start:end]: + for child in list(self.model.values())[start:end]: row_data = RowData() row_data['column_a'] = child.attrs['attr_a'] row_data['column_b'] = child.attrs['attr_b'] @@ -1396,7 +1397,7 @@ are used as dropdown menu items. @property def items(self): item = model.Properties() - item.icon = 'ion-ios7-gear' + item.icon = 'bi bi-gear' item.url = item.target = make_url(self.request, node=self.model) item.action = 'example_action:NONE:NONE' item.title = 'Example Action' @@ -1630,3 +1631,125 @@ ActionState Renders workflow state dropdown menu. Action related node must implement ``cone.app.interfaces.IWorkflowState``. + + +Browser Utilities +================= + +``cone.app.browser.utils`` provides several utility functions used throughout +the application. + + +make_url +-------- + +Builds URLs for application nodes. + +.. code-block:: python + + from cone.app.browser.utils import make_url + + # URL to a node + url = make_url(request, node=model) + + # URL to a node with a specific resource/view + url = make_url(request, node=model, resource='edit') + + # URL with query parameters + url = make_url(request, node=model, query='param=value') + + # URL using a path list instead of node + url = make_url(request, path=['path', 'to', 'node']) + +Parameters: + +- **request**: The current request object. +- **path**: Optional path as list of path segments. +- **node**: Optional application node to build URL for. +- **resource**: Optional resource/view name to append. +- **query**: Optional query string to append. + + +make_query +---------- + +Builds query strings from keyword arguments. + +.. code-block:: python + + from cone.app.browser.utils import make_query + + # Build a simple query string + query = make_query(page='1', sort='name') + # Returns: 'page=1&sort=name' + + # Values are URL-encoded automatically + query = make_query(search='hello world') + # Returns: 'search=hello%20world' + +Parameters: + +- **quote_params**: Optional list of parameter names that should be URL-quoted. +- **\*\*kw**: Keyword arguments to include in query string. None values are skipped. + + +choose_name +----------- + +Generates a unique name for a node within a container. + +.. code-block:: python + + from cone.app.browser.utils import choose_name + + # Get unique name based on desired name + name = choose_name(container, 'document') + # Returns 'document' if not taken, or 'document-1', 'document-2', etc. + + +format_date +----------- + +Formats a datetime object for display. + +.. code-block:: python + + from cone.app.browser.utils import format_date + from datetime import datetime + + dt = datetime.now() + + # Long format (default) + formatted = format_date(dt, long=True) + # Returns: '22.01.2026 14:30' + + # Short format + formatted = format_date(dt, long=False) + # Returns: '22.01.2026' + + +node_icon +--------- + +Returns the icon CSS class for a node. + +.. code-block:: python + + from cone.app.browser.utils import node_icon + + icon_class = node_icon(model) + # Returns icon from node's properties or default icon + + +authenticated +------------- + +Checks if the current request is from an authenticated user. + +.. code-block:: python + + from cone.app.browser.utils import authenticated + + if authenticated(request): + # User is logged in + pass diff --git a/docs/source/workflows.rst b/docs/source/workflows.rst index fbef4333..456ca6e0 100644 --- a/docs/source/workflows.rst +++ b/docs/source/workflows.rst @@ -235,3 +235,73 @@ An implementation integrating the publication workflow as described in # Workflow state specific ACL's state_acls = publication_state_acls + + +Workflow Utility Functions +-------------------------- + +``cone.app.workflow`` provides several utility functions for working with +workflows programmatically. + + +lookup_workflow +~~~~~~~~~~~~~~~ + +Looks up the workflow for a given node. + +.. code-block:: python + + from cone.app.workflow import lookup_workflow + + workflow = lookup_workflow(node) + if workflow is not None: + # workflow is a repoze.workflow.Workflow instance + print(workflow.name) + + +lookup_state_data +~~~~~~~~~~~~~~~~~ + +Returns the state data dictionary for the current workflow state of a node. + +.. code-block:: python + + from cone.app.workflow import lookup_state_data + + state_data = lookup_state_data(node) + if state_data is not None: + # state_data contains keys like 'title', 'description' + print(state_data.get('title')) + print(state_data.get('description')) + + +initialize_workflow +~~~~~~~~~~~~~~~~~~~ + +Initializes the workflow state for a node. This is typically called +automatically when using the ``WorkflowState`` behavior, but can be +called manually if needed. + +.. code-block:: python + + from cone.app.workflow import initialize_workflow + + # Initialize workflow, only sets state if not already set + initialize_workflow(node) + + # Force re-initialization even if state already exists + initialize_workflow(node, force=True) + + +WfDropdown Tile +~~~~~~~~~~~~~~~ + +The ``wf_dropdown`` tile renders a dropdown menu for changing workflow states. +It displays available transitions for the current user based on permissions. + +.. code-block:: python + + from cone.tile import render_tile + + # Render the workflow dropdown + html = render_tile(model, request, 'wf_dropdown') diff --git a/examples/cone.example/.gitignore b/examples/cone.example/.gitignore deleted file mode 100644 index 9c175fe0..00000000 --- a/examples/cone.example/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -/pyvenv.cfg -/bin/ -/develop-eggs/ -/eggs/ -/include/ -/lib/ -/local/ -/share/ -/lib64 -/pip-selfcheck.json -/src/cone.example.egg-info/ - diff --git a/examples/cone.example/bootstrap.sh b/examples/cone.example/bootstrap.sh deleted file mode 100755 index 6deab9fc..00000000 --- a/examples/cone.example/bootstrap.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -rm -r ./lib ./include ./local ./bin ./share -python3 -m venv . -./bin/pip install pyramid==1.9.4 -./bin/pip install repoze.zcml==1.0b1 -./bin/pip install repoze.workflow==1.0b1 -./bin/pip install -e . diff --git a/examples/cone.example/example.ini b/examples/cone.example/example.ini index 3d5018e5..f92ef530 100644 --- a/examples/cone.example/example.ini +++ b/examples/cone.example/example.ini @@ -20,9 +20,12 @@ pyramid.debug_templates = true # default language pyramid.default_locale_name = en +# available languages +cone.available_languages = de, en + # cone.app admin user and password -cone.admin_user = admin -cone.admin_password = admin +#cone.admin_user = +#cone.admin_password = # cone.app auth tkt settings cone.auth_secret = 12345 @@ -41,10 +44,16 @@ cone.plugins = cone.example # application root node settings cone.root.title = cone.example -cone.root.default_child = example +#cone.root.default_child = example #cone.root.default_content_tile = #cone.root.mainmenu_empty_title = false +ugm.backend = file +ugm.users_file = var/ugm/users +ugm.groups_file = var/ugm/groups +ugm.roles_file = var/ugm/roles +ugm.datadir = var/ugm/data + [pipeline:main] pipeline = example diff --git a/examples/cone.example/i18n.sh b/examples/cone.example/i18n.sh deleted file mode 100755 index acb520d7..00000000 --- a/examples/cone.example/i18n.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Usage: -# Initial catalog creation (lang is the language identifier): -# ./i18n.sh lang -# Updating translation and compile catalog: -# ./i18n.sh - -# configuration -DOMAIN="cone.example" -SEARCH_PATH="src/cone/example" -LOCALES_PATH="src/cone/example/locale" -# end configuration - -# create locales folder if not exists -if [ ! -d "$LOCALES_PATH" ]; then - echo "Locales directory not exists, create" - mkdir -p "$LOCALES_PATH" -fi - -# create pot if not exists -if [ ! -f "$LOCALES_PATH"/$DOMAIN.pot ]; then - echo "Create pot file" - touch "$LOCALES_PATH"/$DOMAIN.pot -fi - -# no arguments, extract and update -if [ $# -eq 0 ]; then - echo "Extract messages" - pot-create "$SEARCH_PATH" -o "$LOCALES_PATH"/$DOMAIN.pot - - echo "Update translations" - for po in "$LOCALES_PATH"/*/LC_MESSAGES/$DOMAIN.po; do - msgmerge -o "$po" "$po" "$LOCALES_PATH"/$DOMAIN.pot - done - - echo "Compile message catalogs" - for po in "$LOCALES_PATH"/*/LC_MESSAGES/*.po; do - msgfmt --statistics -o "${po%.*}.mo" "$po" - done - -# first argument represents language identifier, create catalog -else - cd "$LOCALES_PATH" - mkdir -p $1/LC_MESSAGES - msginit -i $DOMAIN.pot -o $1/LC_MESSAGES/$DOMAIN.po -l $1 -fi diff --git a/examples/cone.example/lingua.cfg b/examples/cone.example/lingua.cfg new file mode 100644 index 00000000..697625bf --- /dev/null +++ b/examples/cone.example/lingua.cfg @@ -0,0 +1,2 @@ +[extensions] +.zcml = xml \ No newline at end of file diff --git a/examples/cone.example/setup.py b/examples/cone.example/setup.py index 53cfe0c7..4f76c5a2 100644 --- a/examples/cone.example/setup.py +++ b/examples/cone.example/setup.py @@ -16,7 +16,7 @@ include_package_data=True, zip_safe=False, install_requires=[ - 'waitress', - 'cone.app' + 'cone.app', + 'waitress' ] ) diff --git a/examples/cone.example/src/cone/example/__init__.py b/examples/cone.example/src/cone/example/__init__.py index 8fcee891..f0764a72 100644 --- a/examples/cone.example/src/cone/example/__init__.py +++ b/examples/cone.example/src/cone/example/__init__.py @@ -1,24 +1,27 @@ from cone.app import main_hook from cone.app import register_entry -from cone.example.browser import static_resources -from cone.example.model import ExamplePlugin -import cone.app +from cone.example.browser import configure_resources +from cone.example.model import EntryFolder +from cone.example.model import LiveSearch @main_hook -def example_main_hook(config, global_config, local_config): +def example_main_hook(config, global_config, settings): """Function which gets called at application startup to initialize this plugin. """ - # register static resources view - config.add_view(static_resources, name='example-static') + # register live search adapter + config.registry.registerAdapter(LiveSearch) - # register static resources to be delivered - cone.app.cfg.css.public.append('example-static/example.css') - cone.app.cfg.js.public.append('example-static/example.js') + # add translation + config.add_translation_dirs('cone.example:locale/') - # register plugin entry node - register_entry('example', ExamplePlugin) + # register plugin entry nodes + for i in range(1, 6): + register_entry(f'folder_{i}', EntryFolder) + + # static resources + configure_resources(config, settings) # scan browser package config.scan('cone.example.browser') diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index e51fab32..db562d34 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -1,15 +1,144 @@ +from cone.app.browser.authoring import ContentAddForm +from cone.app.browser.authoring import ContentEditForm +from cone.app.browser.form import AddFormTarget +from cone.app.browser.form import EditFormTarget +from cone.app.browser.form import Form from cone.app.browser.layout import ProtectedContentTile -from cone.example.model import ExamplePlugin +from cone.app.browser.utils import choose_name +from cone.app.utils import add_creation_metadata +from cone.app.utils import update_creation_metadata +from cone.example.model import EntryFolder +from cone.example.model import Folder +from cone.example.model import Item +from cone.example.model import Translation from cone.tile import tile -from pyramid.static import static_view +from node.utils import UNSET +from plumber import plumbing +from pyramid.i18n import TranslationStringFactory +from yafowil.base import factory +from yafowil.persistence import write_mapping_writer +import os +import webresource as wr -static_resources = static_view('static', use_subpath=True) +_ = TranslationStringFactory('cone.example') +resources_dir = os.path.join(os.path.dirname(__file__), 'static') +cone_example_resources = wr.ResourceGroup( + name='cone.example', + directory=resources_dir, + path='example' +) +cone_example_resources.add(wr.StyleResource( + name='cone-example-css', + resource='cone.example.css' +)) + + +def configure_resources(config, settings): + config.register_resource(cone_example_resources) + config.set_resource_include('cone-example-css', 'authenticated') + + +@tile(name='view', + path='templates/view.pt', + interface=EntryFolder, + permission='login') +@tile(name='view', + path='templates/view.pt', + interface=Folder, + permission='login') @tile(name='content', - path='templates/example.pt', - interface=ExamplePlugin, + path='templates/view.pt', + interface=Item, permission='login') -class ExamplePluginContent(ProtectedContentTile): +class ViewContent(ProtectedContentTile): pass + + +class ExampleForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='contentform', + props={ + 'action': self.form_action, + 'persist_writer': write_mapping_writer + }) + form['title'] = factory( + 'field:label:help:error:translation:text', + value=self.model.attrs.get('title', UNSET), + props={ + 'factory': Translation, + 'label': _('title', default='Title'), + 'help': _('title_description', default='Enter a title'), + 'required': _('title_required', default='Title is mandatory') + }) + form['description'] = factory( + 'field:label:help:error:translation:textarea', + value=self.model.attrs.get('description', UNSET), + props={ + 'factory': Translation, + 'label': _('description', default='Description'), + 'help': _( + 'description_description', + default='Enter a description' + ), + 'rows': 4 + }) + form['save'] = factory( + 'submit', + props={ + 'action': 'save', + 'expression': True, + 'handler': self.save, + 'next': self.next, + 'label': _('save', default='Save') + }) + form['cancel'] = factory( + 'submit', + props={ + 'action': 'cancel', + 'expression': True, + 'skip': True, + 'next': self.next, + 'label': _('cancel', default='Cancel') + }) + + def save(self, widget, data): + data.write(self.model.attrs) + + +@plumbing(AddFormTarget) +class ExampleAddForm(ExampleForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super(ExampleAddForm, self).save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class ExampleEditForm(ExampleForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super(ExampleEditForm, self).save(widget, data) + + +@tile(name='addform', interface=Folder, permission='add') +@tile(name='addform', interface=Item, permission='add') +@plumbing(ContentAddForm) +class ExampleContentAddForm(ExampleAddForm): + ... + + +@tile(name='editform', interface=EntryFolder, permission='edit') +@tile(name='editform', interface=Folder, permission='edit') +@tile(name='editform', interface=Item, permission='edit') +@plumbing(ContentEditForm) +class ExampleContentEditForm(ExampleEditForm): + ... diff --git a/examples/cone.example/src/cone/example/browser/static/cone.example.css b/examples/cone.example/src/cone/example/browser/static/cone.example.css new file mode 100644 index 00000000..a53f4db8 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -0,0 +1,8 @@ +#contextmenu a.state-private, +tr.state-private td.title a { + color: red; +} +#contextmenu a.state-public, +tr.state-public td.title a { + color: green; +} diff --git a/examples/cone.example/src/cone/example/browser/static/example.js b/examples/cone.example/src/cone/example/browser/static/example.js deleted file mode 100644 index ed31f0f9..00000000 --- a/examples/cone.example/src/cone/example/browser/static/example.js +++ /dev/null @@ -1,24 +0,0 @@ -(function($) { - - $(document).ready(function() { - // register binder function to bdajax. - $.extend(bdajax.binders, { - example_binder: example.binder - }); - - // call binder function on initial page load. - example.binder(); - }); - - // plugin namespace - example = { - - // plugin binder function. gets called on initial page load and - // every time bdajax modifies the DOM tree. - binder: function(context) { - // event binding code goes here. context is the modified - // part of the DOM tree if called by bdajax. - } - }; - -})(jQuery); diff --git a/examples/cone.example/src/cone/example/browser/templates/example.pt b/examples/cone.example/src/cone/example/browser/templates/example.pt deleted file mode 100644 index 02d53390..00000000 --- a/examples/cone.example/src/cone/example/browser/templates/example.pt +++ /dev/null @@ -1,3 +0,0 @@ -
- Example app content. -
diff --git a/examples/cone.example/src/cone/example/browser/templates/view.pt b/examples/cone.example/src/cone/example/browser/templates/view.pt new file mode 100644 index 00000000..83cb1e2c --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/view.pt @@ -0,0 +1,27 @@ + + + + +
+ +
+ +

+ + Title + +

+ + + +

+ Description +

+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/configure.zcml b/examples/cone.example/src/cone/example/configure.zcml index 5ab97640..bad07151 100644 --- a/examples/cone.example/src/cone/example/configure.zcml +++ b/examples/cone.example/src/cone/example/configure.zcml @@ -1,4 +1,6 @@ - + + + diff --git a/examples/cone.example/src/cone/example/locale/cone.example.pot b/examples/cone.example/src/cone/example/locale/cone.example.pot new file mode 100644 index 00000000..d76cd702 --- /dev/null +++ b/examples/cone.example/src/cone/example/locale/cone.example.pot @@ -0,0 +1,87 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2024. +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2024-06-02 08:52+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 4.15.0\n" + +#. Default: Folder +#: ./examples/cone.example/src/cone/example/model.py:118 +#: ./examples/cone.example/src/cone/example/model.py:136 +msgid "folder" +msgstr "" + +#. Default: Item +#: ./examples/cone.example/src/cone/example/model.py:150 +msgid "item" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:16 +msgid "private" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:21 +msgid "Object is private" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:25 +msgid "public" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:30 +msgid "Object is public" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:35 +msgid "private_to_public" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:45 +msgid "public_to_private" +msgstr "" + +#. Default: Title +#: ./examples/cone.example/src/cone/example/browser/__init__.py:76 +msgid "title" +msgstr "" + +#. Default: Enter a title +#: ./examples/cone.example/src/cone/example/browser/__init__.py:77 +msgid "title_description" +msgstr "" + +#. Default: Title is mandatory +#: ./examples/cone.example/src/cone/example/browser/__init__.py:78 +msgid "title_required" +msgstr "" + +#. Default: Description +#: ./examples/cone.example/src/cone/example/browser/__init__.py:85 +msgid "description" +msgstr "" + +#. Default: Enter a description +#: ./examples/cone.example/src/cone/example/browser/__init__.py:86 +msgid "description_description" +msgstr "" + +#. Default: Save +#: ./examples/cone.example/src/cone/example/browser/__init__.py:99 +msgid "save" +msgstr "" + +#. Default: Cancel +#: ./examples/cone.example/src/cone/example/browser/__init__.py:108 +msgid "cancel" +msgstr "" diff --git a/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.mo b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.mo new file mode 100644 index 00000000..0ca9688e Binary files /dev/null and b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.mo differ diff --git a/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.po b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.po new file mode 100644 index 00000000..860beb77 --- /dev/null +++ b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.po @@ -0,0 +1,86 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2024. +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2024-06-02 08:52+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 4.15.0\n" + +#. Default: Folder +#: examples/cone.example/src/cone/example/model.py:118 +#: examples/cone.example/src/cone/example/model.py:136 +msgid "folder" +msgstr "Ordner" + +#. Default: Item +#: examples/cone.example/src/cone/example/model.py:150 +msgid "item" +msgstr "Object" + +#: examples/cone.example/src/cone/example/publication.zcml:16 +msgid "private" +msgstr "Privat" + +#: examples/cone.example/src/cone/example/publication.zcml:21 +msgid "Object is private" +msgstr "Object ist privat" + +#: examples/cone.example/src/cone/example/publication.zcml:25 +msgid "public" +msgstr "Öffentlich" + +#: examples/cone.example/src/cone/example/publication.zcml:30 +msgid "Object is public" +msgstr "Objekt ist öffentlich" + +#: examples/cone.example/src/cone/example/publication.zcml:35 +msgid "private_to_public" +msgstr "veröffentlichen" + +#: examples/cone.example/src/cone/example/publication.zcml:45 +msgid "public_to_private" +msgstr "zurückziehen" + +#. Default: Title +#: examples/cone.example/src/cone/example/browser/__init__.py:76 +msgid "title" +msgstr "Titel" + +#. Default: Enter a title +#: examples/cone.example/src/cone/example/browser/__init__.py:77 +msgid "title_description" +msgstr "Geben Sie einen Titel ein" + +#. Default: Title is mandatory +#: examples/cone.example/src/cone/example/browser/__init__.py:78 +msgid "title_required" +msgstr "Titel ist erforderlich" + +#. Default: Description +#: examples/cone.example/src/cone/example/browser/__init__.py:85 +msgid "description" +msgstr "Beschreibung" + +#. Default: Enter a description +#: examples/cone.example/src/cone/example/browser/__init__.py:86 +msgid "description_description" +msgstr "Geben Sie eine Beschreibung ein" + +#. Default: Save +#: examples/cone.example/src/cone/example/browser/__init__.py:99 +msgid "save" +msgstr "Speichern" + +#. Default: Cancel +#: examples/cone.example/src/cone/example/browser/__init__.py:108 +msgid "cancel" +msgstr "Abbrechen" diff --git a/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.mo b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.mo new file mode 100644 index 00000000..29cdd3bb Binary files /dev/null and b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.mo differ diff --git a/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.po b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.po new file mode 100644 index 00000000..0dbc1c10 --- /dev/null +++ b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.po @@ -0,0 +1,86 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2024. +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2024-06-02 08:52+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 4.15.0\n" + +#. Default: Folder +#: examples/cone.example/src/cone/example/model.py:118 +#: examples/cone.example/src/cone/example/model.py:136 +msgid "folder" +msgstr "Folder" + +#. Default: Item +#: examples/cone.example/src/cone/example/model.py:150 +msgid "item" +msgstr "Item" + +#: examples/cone.example/src/cone/example/publication.zcml:16 +msgid "private" +msgstr "Private" + +#: examples/cone.example/src/cone/example/publication.zcml:21 +msgid "Object is private" +msgstr "Object is private" + +#: examples/cone.example/src/cone/example/publication.zcml:25 +msgid "public" +msgstr "Public" + +#: examples/cone.example/src/cone/example/publication.zcml:30 +msgid "Object is public" +msgstr "Object is public" + +#: examples/cone.example/src/cone/example/publication.zcml:35 +msgid "private_to_public" +msgstr "publish" + +#: examples/cone.example/src/cone/example/publication.zcml:45 +msgid "public_to_private" +msgstr "retract" + +#. Default: Title +#: examples/cone.example/src/cone/example/browser/__init__.py:76 +msgid "title" +msgstr "Title" + +#. Default: Enter a title +#: examples/cone.example/src/cone/example/browser/__init__.py:77 +msgid "title_description" +msgstr "Enter a title" + +#. Default: Title is mandatory +#: examples/cone.example/src/cone/example/browser/__init__.py:78 +msgid "title_required" +msgstr "Title is mandatory" + +#. Default: Description +#: examples/cone.example/src/cone/example/browser/__init__.py:85 +msgid "description" +msgstr "Description" + +#. Default: Enter a description +#: examples/cone.example/src/cone/example/browser/__init__.py:86 +msgid "description_description" +msgstr "Enter a description" + +#. Default: Save +#: examples/cone.example/src/cone/example/browser/__init__.py:99 +msgid "save" +msgstr "Save" + +#. Default: Cancel +#: examples/cone.example/src/cone/example/browser/__init__.py:108 +msgid "cancel" +msgstr "Cancel" diff --git a/examples/cone.example/src/cone/example/model.py b/examples/cone.example/src/cone/example/model.py index 7330137a..2d989507 100644 --- a/examples/cone.example/src/cone/example/model.py +++ b/examples/cone.example/src/cone/example/model.py @@ -1,5 +1,244 @@ -from cone.app.model import BaseNode +from cone.app.browser.utils import make_url +from cone.app.interfaces import IApplicationNode +from cone.app.interfaces import ILiveSearch +from cone.app.interfaces import INavigationLeaf +from cone.app.model import AppNode +from cone.app.model import CopySupport +from cone.app.model import Metadata +from cone.app.model import node_info +from cone.app.model import Properties +from cone.app.model import Translation as TranslationBehavior +from cone.app.security import PrincipalACL +from cone.app.utils import add_creation_metadata +from cone.app.workflow import WorkflowACL +from cone.app.workflow import WorkflowState +from node.behaviors import Attributes +from node.behaviors import DictStorage +from node.behaviors import MappingAdopt +from node.behaviors import MappingNode +from node.behaviors import MappingOrder +from node.behaviors import NodeInit +from node.behaviors import OdictStorage +from node.utils import instance_property +from plumber import plumbing +from pyramid.i18n import TranslationStringFactory +from pyramid.security import ALL_PERMISSIONS +from pyramid.security import Allow +from pyramid.security import Deny +from pyramid.security import Everyone +from pyramid.threadlocal import get_current_request +from zope.component import adapter +from zope.interface import implementer -class ExamplePlugin(BaseNode): - pass +_ = TranslationStringFactory('cone.example') + + +@plumbing( + NodeInit, + MappingNode, + DictStorage, + TranslationBehavior) +class Translation: + ... + + +@plumbing( + AppNode, + WorkflowState, + WorkflowACL, + MappingAdopt, + Attributes, + NodeInit, + MappingNode, + MappingOrder, + OdictStorage) +class PublicationWorkflowNode: + workflow_name = 'publication' + workflow_tsf = staticmethod(_) + default_acl = [ + (Allow, 'system.Authenticated', ['view']), + (Allow, 'role:viewer', ['view', 'list']), + (Allow, 'role:editor', [ + 'view', 'list', 'add', 'edit', 'cut', 'copy', 'paste', + 'change_order' + ]), + (Allow, 'role:admin', [ + 'view', 'list', 'add', 'edit', 'delete', 'cut', 'copy', 'paste', + 'change_order', 'change_state', 'manage_permissions' + ]), + (Allow, 'role:manager', [ + 'view', 'list', 'add', 'edit', 'delete', 'cut', 'copy', 'paste', + 'change_order', 'change_state', 'manage_permissions', 'manage' + ]), + (Allow, Everyone, ['login']), + (Deny, Everyone, ALL_PERMISSIONS), + ] + + def __call__(self): + ... + + +@plumbing(PrincipalACL, CopySupport) +class BaseContainer(PublicationWorkflowNode): + role_inheritance = True + + @instance_property + def principal_roles(self): + return {} + + @property + def properties(self): + props = Properties() + props.in_navtree = True + props.default_content_tile = 'listing' + props.action_up = True + props.action_view = True + props.action_edit = True + props.action_list = True + props.action_sharing = True + props.action_move = True + props.action_add = True + return props + + @property + def metadata(self): + md = Metadata() + md.icon = self.nodeinfo.icon + md.title = self.attrs['title'].value + md.description = self.attrs['description'].value + md.creator = self.attrs['creator'] + md.created = self.attrs['created'] + md.modified = self.attrs['modified'] + return md + + +@node_info( + name='entry_folder', + title=_('folder', default='Folder'), + icon='bi-folder', + addables=['folder', 'item']) +class EntryFolder(BaseContainer): + + def __init__(self, name=None, parent=None): + super().__init__(name=name, parent=parent) + create_content(self) + + @property + def properties(self): + props = super().properties + props.mainmenu_display_children = True + return props + + +@node_info( + name='folder', + title=_('folder', default='Folder'), + icon='bi-folder', + addables=['folder', 'item']) +class Folder(BaseContainer): + + @property + def properties(self): + props = super().properties + props.action_delete = True + return props + + +@node_info( + name='item', + title=_('item', default='Item'), + icon='bi-file') +@plumbing(PrincipalACL) +@implementer(INavigationLeaf) +class Item(PublicationWorkflowNode): + role_inheritance = True + + @instance_property + def principal_roles(self): + return {} + + @property + def properties(self): + props = Properties() + props.in_navtree = True + props.action_up = True + props.action_view = True + props.action_edit = True + props.action_delete = True + props.action_sharing = True + return props + + @property + def metadata(self): + md = Metadata() + md.icon = self.nodeinfo.icon + md.title = self.attrs['title'].value + md.description = self.attrs['description'].value + md.creator = self.attrs['creator'] + md.created = self.attrs['created'] + md.modified = self.attrs['modified'] + return md + + +def create_content(node): + request = get_current_request() + name = node.name + + add_creation_metadata(request, node.attrs) + + title = node.attrs['title'] = Translation() + title['en'] = f'Folder {name[name.rfind("_") + 1:]}' + title['de'] = f'Ordner {name[name.rfind("_") + 1:]}' + + description = node.attrs['description'] = Translation() + description['en'] = f'Folder Description' + description['de'] = f'Ordner Beschreibung' + + for i in range(1, 21): + folder = node[f'folder_{i}'] = Folder() + add_creation_metadata(request, folder.attrs) + + title = folder.attrs['title'] = Translation() + title['en'] = f'Folder {i}' + title['de'] = f'Ordner {i}' + + description = folder.attrs['description'] = Translation() + description['en'] = f'Folder Description' + description['de'] = f'Ordner Beschreibung' + + for j in range(1, 21): + item = folder[f'item_{j}'] = Item() + add_creation_metadata(request, item.attrs) + + title = item.attrs['title'] = Translation() + title['en'] = f'Item {j}' + title['de'] = f'Object {j}' + + description = item.attrs['description'] = Translation() + description['en'] = f'Item Description' + description['de'] = f'Object Beschreibung' + + +@implementer(ILiveSearch) +@adapter(IApplicationNode) +class LiveSearch(object): + + def __init__(self, model): + self.model = model + + def search(self, request, query): + result = [] + for child in self.model.values(): + md = child.metadata + if ( + md.title.lower().find(query.lower()) > -1 or + md.description.lower().find(query.lower()) > -1 + ): + result.append({ + 'value': md.title, + 'target': make_url(request, node=child), + 'icon': md.icon, + 'description': md.description, + }) + return result diff --git a/examples/cone.example/src/cone/example/publication.zcml b/examples/cone.example/src/cone/example/publication.zcml new file mode 100644 index 00000000..788eda8c --- /dev/null +++ b/examples/cone.example/src/cone/example/publication.zcml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/cone.example/var/ugm/data/users/admin b/examples/cone.example/var/ugm/data/users/admin new file mode 100644 index 00000000..c6dc48f3 --- /dev/null +++ b/examples/cone.example/var/ugm/data/users/admin @@ -0,0 +1 @@ +fullname:Administrator \ No newline at end of file diff --git a/examples/cone.example/var/ugm/data/users/max b/examples/cone.example/var/ugm/data/users/max new file mode 100644 index 00000000..42493449 --- /dev/null +++ b/examples/cone.example/var/ugm/data/users/max @@ -0,0 +1 @@ +fullname:Max Mustermann \ No newline at end of file diff --git a/examples/cone.example/var/ugm/data/users/sepp b/examples/cone.example/var/ugm/data/users/sepp new file mode 100644 index 00000000..8f3a7054 --- /dev/null +++ b/examples/cone.example/var/ugm/data/users/sepp @@ -0,0 +1 @@ +fullname:Sepp Unterwurzacher \ No newline at end of file diff --git a/examples/cone.example/src/cone/example/browser/static/example.css b/examples/cone.example/var/ugm/groups similarity index 100% rename from examples/cone.example/src/cone/example/browser/static/example.css rename to examples/cone.example/var/ugm/groups diff --git a/examples/cone.example/var/ugm/roles b/examples/cone.example/var/ugm/roles new file mode 100644 index 00000000..45ed3fd8 --- /dev/null +++ b/examples/cone.example/var/ugm/roles @@ -0,0 +1,3 @@ +admin::manager +max::editor +sepp::viewer diff --git a/examples/cone.example/var/ugm/users b/examples/cone.example/var/ugm/users new file mode 100644 index 00000000..77f5d4bd --- /dev/null +++ b/examples/cone.example/var/ugm/users @@ -0,0 +1,3 @@ +admin:DTH11mwkW4ty9Jcer2CeGhqlX63whH6Nhi4MSi6V4im0xTdRs58Hwg== +max:hBVsuSCk67/zcfxGcljarlwM66s0WcAoRtqCOfd0R16n5weEvvcHPg== +sepp:6P+q3xCly0ikwT3cmDDIvhHAp0fjkRfy2knx6BJEExOodLumMtcNpA== diff --git a/include.mk b/include.mk new file mode 100644 index 00000000..64349d12 --- /dev/null +++ b/include.mk @@ -0,0 +1,48 @@ +############################################################################## +# custom bootstrap +############################################################################## + +# The bootstrap SCSS root source file. +# Default: scss/styles.scss +SCSS_BOOTSTRAP_SOURCE?=scss/bootstrap/bootstrap.scss + +# The target file for the compiled bootstrap Stylesheet. +# Default: scss/styles.css +SCSS_BOOTSTRAP_TARGET?=src/cone/app/browser/static/bootstrap/css/bootstrap.css + +# The target file for the compressed bootstrap Stylesheet. +# Default: scss/styles.min.css +SCSS_BOOTSTRAP_MIN_TARGET?=src/cone/app/browser/static/bootstrap/css/bootstrap.min.css + +.PHONY: bootstrap +bootstrap: $(NPM_TARGET) + @sass $(SCSS_OPTIONS) $(SCSS_BOOTSTRAP_SOURCE) $(SCSS_BOOTSTRAP_TARGET) + @sass $(SCSS_OPTIONS) --style compressed $(SCSS_BOOTSTRAP_SOURCE) $(SCSS_BOOTSTRAP_MIN_TARGET) + +############################################################################## +# example +############################################################################## + +.PHONY: example-install +example-install: install + @$(PYTHON_PACKAGE_COMMAND) install -e examples/cone.example + +.PHONY: example-run +example-run: + @cd examples/cone.example + @../../venv/bin/pserve example.ini + +EXAMPLE_GETTEXT_LOCALES_PATH=examples/cone.example/src/cone/example/locale +EXAMPLE_GETTEXT_DOMAIN=cone.example +EXAMPLE_GETTEXT_LANGUAGES=en de +EXAMPLE_LINGUA_SEARCH_PATH=examples/cone.example/src/cone/example +EXAMPLE_LINGUA_OPTIONS="-c examples/cone.example/lingua.cfg" + +PHONY: example-lingua +example-lingua: $(LINGUA_TARGET) + @make GETTEXT_LOCALES_PATH=$(EXAMPLE_GETTEXT_LOCALES_PATH) \ + GETTEXT_DOMAIN=$(EXAMPLE_GETTEXT_DOMAIN) \ + GETTEXT_LANGUAGES="$(EXAMPLE_GETTEXT_LANGUAGES)" \ + LINGUA_SEARCH_PATH=$(EXAMPLE_LINGUA_SEARCH_PATH) \ + LINGUA_OPTIONS=$(EXAMPLE_LINGUA_OPTIONS) \ + lingua diff --git a/js/rollup.conf.js b/js/rollup.conf.js index a0f69f51..d7d8b833 100644 --- a/js/rollup.conf.js +++ b/js/rollup.conf.js @@ -3,34 +3,24 @@ import terser from '@rollup/plugin-terser'; const out_dir = 'src/cone/app/browser/static/cone'; -const default_outro = ` -window.cone = window.cone || {}; -Object.assign(window.cone, exports); -` - -const protected_outro = default_outro + ` +const outro = ` window.createCookie = createCookie; window.readCookie = readCookie; `; -const protected_globals = { - jquery: 'jQuery', - treibstoff: 'treibstoff' -}; - -const public_globals = { +const globals = { jquery: 'jQuery', treibstoff: 'treibstoff', - Bloodhound: 'Bloodhound' + bootstrap: 'bootstrap' }; -const create_bundle = function(name, globals, outro, debug) { +export default args => { let conf = { - input: `js/src/bundles/${name}.js`, + input: `js/src/bundle.js`, plugins: [cleanup()], output: [{ - file: `${out_dir}/cone.app.${name}.js`, - name: `cone_app_${name}`, + file: `${out_dir}/cone.app.js`, + name: 'cone', format: 'iife', outro: outro, globals: globals, @@ -38,13 +28,14 @@ const create_bundle = function(name, globals, outro, debug) { }], external: [ 'jquery', - 'treibstoff' + 'treibstoff', + 'bootstrap' ] }; - if (debug !== true) { + if (args.configDebug !== true) { conf.output.push({ - file: `${out_dir}/cone.app.${name}.min.js`, - name: `cone_app_${name}`, + file: `${out_dir}/cone.app.min.js`, + name: 'cone', format: 'iife', plugins: [terser()], outro: outro, @@ -53,12 +44,4 @@ const create_bundle = function(name, globals, outro, debug) { }); } return conf; -} - -export default args => { - let debug = args.configDebug; - return [ - create_bundle('public', public_globals, default_outro, debug), - create_bundle('protected', protected_globals, protected_outro, debug) - ]; }; diff --git a/js/src/bundle.js b/js/src/bundle.js new file mode 100644 index 00000000..8965c2ab --- /dev/null +++ b/js/src/bundle.js @@ -0,0 +1,68 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; + +import { + BatchedItemsSize, + BatchedItemsSearch +} from './batcheditems.js'; +import {ColorMode} from './colormode.js'; +import {ColorToggler} from './colormode.js'; +import {CopySupport} from './copysupport.js'; +import {KeyBinder} from './keybinder.js'; +import {LiveSearch} from './livesearch.js'; +import { + ReferenceBrowserLoader, + ReferenceHandle +} from './referencebrowser.js'; +import {Scrollbar} from './scrollbar.js'; +import {Sharing} from './sharing.js'; +import {SidebarLeft} from './sidebar.js'; +import {SidebarRight} from './sidebar.js'; +import {TableToolbar} from './tabletoolbar.js'; +import {Translation} from './translation.js'; +import {MainMenu} from './mainmenu.js'; +import {Header} from './header.js'; +import {MainArea} from './layout.js'; +import {NavTree} from './navtree.js'; + +export * from './batcheditems.js'; +export * from './colormode.js'; +export * from './copysupport.js'; +export * from './globals.js'; +export * from './header.js'; +export * from './keybinder.js'; +export * from './layout.js'; +export * from './livesearch.js'; +export * from './mainmenu.js'; +export * from './navtree.js'; +export * from './referencebrowser.js'; +export * from './scrollbar.js'; +export * from './selectable.js'; +export * from './sharing.js'; +export * from './sidebar.js'; +export * from './tabletoolbar.js'; +export * from './translation.js'; +export * from './utils.js'; + +$(function() { + new KeyBinder(); + new ColorMode(); + + ts.ajax.register(BatchedItemsSize.initialize, true); + ts.ajax.register(BatchedItemsSearch.initialize, true); + ts.ajax.register(CopySupport.initialize, true); + ts.ajax.register(ReferenceBrowserLoader.initialize, true); + ts.ajax.register(ReferenceHandle.initialize, true); + ts.ajax.register(Sharing.initialize, true); + ts.ajax.register(TableToolbar.initialize, true); + ts.ajax.register(Translation.initialize, true); + ts.ajax.register(ColorToggler.initialize, true); + ts.ajax.register(Scrollbar.initialize, true); + ts.ajax.register(LiveSearch.initialize, true); + ts.ajax.register(MainMenu.initialize, true); + ts.ajax.register(Header.initialize, true); + ts.ajax.register(MainArea.initialize, true); + ts.ajax.register(SidebarLeft.initialize, true); + ts.ajax.register(SidebarRight.initialize, true); + ts.ajax.register(NavTree.initialize, true); +}); diff --git a/js/src/bundles/protected.js b/js/src/bundles/protected.js deleted file mode 100644 index 297c10c4..00000000 --- a/js/src/bundles/protected.js +++ /dev/null @@ -1,40 +0,0 @@ -import $ from 'jquery'; -import ts from 'treibstoff'; - -import { - BatchedItemsSize, - BatchedItemsSearch -} from '../batcheditems.js'; -import {CopySupport} from '../copysupport.js'; -import {KeyBinder} from '../keybinder.js'; -import { - ReferenceBrowserLoader, - ReferenceHandle -} from '../referencebrowser.js'; -import {Sharing} from '../sharing.js'; -import {TableToolbar} from '../tabletoolbar.js'; -import {Translation} from '../translation.js' - -export * from '../batcheditems.js'; -export * from '../copysupport.js'; -export * from '../keybinder.js'; -export * from '../keybinder.js'; -export * from '../referencebrowser.js'; -export * from '../selectable.js'; -export * from '../sharing.js'; -export * from '../tabletoolbar.js'; -export * from '../translation.js'; -export * from '../utils.js'; - -$(function() { - new KeyBinder(); - - ts.ajax.register(BatchedItemsSize.initialize, true); - ts.ajax.register(BatchedItemsSearch.initialize, true); - ts.ajax.register(CopySupport.initialize, true); - ts.ajax.register(ReferenceBrowserLoader.initialize, true); - ts.ajax.register(ReferenceHandle.initialize, true); - ts.ajax.register(Sharing.initialize, true); - ts.ajax.register(TableToolbar.initialize, true); - ts.ajax.register(Translation.initialize, true); -}); diff --git a/js/src/bundles/public.js b/js/src/bundles/public.js deleted file mode 100644 index 6b091d52..00000000 --- a/js/src/bundles/public.js +++ /dev/null @@ -1,10 +0,0 @@ -import $ from 'jquery'; -import ts from 'treibstoff'; - -import {LiveSearch} from '../livesearch.js'; - -export * from '../livesearch.js'; - -$(function() { - ts.ajax.register(LiveSearch.initialize, true); -}); diff --git a/js/src/colormode.js b/js/src/colormode.js new file mode 100644 index 00000000..bd95766d --- /dev/null +++ b/js/src/colormode.js @@ -0,0 +1,152 @@ +import ts from 'treibstoff'; + +/** + * Class to manage color modes (light and dark themes). + */ +export class ColorMode { + + /** + * The media query for the user's preferred color scheme. + * @returns {MediaQueryList} + */ + static get media_query() { + return window.matchMedia('(prefers-color-scheme: dark)'); + } + + /** + * The stored theme from local storage. + * @returns {string | null} + */ + static get stored_theme() { + return localStorage.getItem('cone-app-color-theme'); + } + + /** + * @param {string} theme The theme to store in local storage. + */ + static set stored_theme(theme) { + localStorage.setItem('cone-app-color-theme', theme); + } + + /** + * The user's preferred theme ('dark' or 'light'). + * @returns {string} + */ + static get preferred_theme() { + if (this.stored_theme) { + return this.stored_theme; + } + return this.media_query.matches ? 'dark' : 'light'; + } + + /** + * Adds an event listener to watch for changes in the media query. + * @param {function} handle The callback function to handle changes. + */ + static watch(handle) { + this.media_query.addEventListener('change', handle); + } + + /** + * Sets the current theme on the document. + * @param {string} theme The theme to set ('dark', 'light', or 'auto'). + */ + static set_theme(theme) { + const elem = document.documentElement; + if (theme === 'auto' && this.media_query.matches) { + // Set to dark if 'auto' and dark mode is preferred + elem.setAttribute('data-bs-theme', 'dark'); + } else { + elem.setAttribute('data-bs-theme', theme); + } + } + + /** + * Initializes the ColorMode instance and sets the preferred theme. + */ + constructor() { + ColorMode.bind(); + ColorMode.set_theme(ColorMode.preferred_theme); + } + + /** + * Binds the change event listener to update the theme. + */ + static bind() { + this.boundCallback = this.callback.bind(this); + this.watch(this.boundCallback); + } + + /** + * Static callback function to handle the color scheme change. + */ + static callback() { + const stored_theme = this.stored_theme; + if (stored_theme !== 'light' && stored_theme !== 'dark') { + ColorMode.set_theme(ColorMode.preferred_theme); + } + } + + /** + * Static method to remove the event listener and clean up. + */ + static unbind() { + if (this.boundCallback) { + this.media_query.removeEventListener('change', this.boundCallback); + document.documentElement.removeAttribute('data-bs-theme'); + } + } +} + + +/** + * Class to toggle the color theme based on user input (visible as a Switch). + */ +export class ColorToggler extends ts.ChangeListener { + + /** + * Initializes the ColorToggler and binds the toggle switch. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#colortoggle-switch', context); + if (!elem) { + return; + } + new ColorToggler(elem); + } + + /** + * @param {Element} elem The toggle switch element. + */ + constructor(elem) { + super({ elem: elem }); + this.update = this.update.bind(this); + this.update(); + ColorMode.watch(this.update()); + } + + /** + * Updates the toggle switch state based on the preferred theme. + */ + update() { + const preferred_theme = ColorMode.preferred_theme; + const elem = this.elem; + const checked = elem.is(':checked'); + + if (preferred_theme === 'dark' && !checked) { + elem.prop('checked', true); + } else if (preferred_theme === 'light' && checked) { + elem.prop('checked', false); + } + } + + /** + * Handles changes when the toggle is switched. + */ + on_change() { + const theme = this.elem.is(':checked') ? 'dark' : 'light'; + ColorMode.set_theme(theme); + ColorMode.stored_theme = theme; + } +} diff --git a/js/src/cone.js b/js/src/cone.js index 421a4e05..97ba6b21 100644 --- a/js/src/cone.js +++ b/js/src/cone.js @@ -1,25 +1,41 @@ import * as batcheditems from './batcheditems.js'; +import * as colormode from './colormode.js'; import * as copysupport from './copysupport.js'; +import * as globals from './globals.js'; +import * as header from './header.js'; import * as keybinder from './keybinder.js'; +import * as layout from './layout.js'; import * as livesearch from './livesearch.js'; +import * as mainmenu from './mainmenu.js'; +import * as navtree from './navtree.js'; import * as referencebrowser from './referencebrowser.js'; +import * as scrollbar from './scrollbar.js'; import * as selectable from './selectable.js'; -import * as settingstabs from './settingstabs.js'; import * as sharing from './sharing.js'; +import * as sidebar from './sidebar.js'; import * as tabletoolbar from './tabletoolbar.js'; +import * as translation from './translation.js'; import * as utils from './utils.js'; let api = {}; Object.assign(api, batcheditems); +Object.assign(api, colormode); Object.assign(api, copysupport); +Object.assign(api, globals); +Object.assign(api, header); Object.assign(api, keybinder); +Object.assign(api, layout); Object.assign(api, livesearch); +Object.assign(api, mainmenu); +Object.assign(api, navtree); Object.assign(api, referencebrowser); +Object.assign(api, scrollbar); Object.assign(api, selectable); -Object.assign(api, settingstabs); Object.assign(api, sharing); +Object.assign(api, sidebar); Object.assign(api, tabletoolbar); +Object.assign(api, translation); Object.assign(api, utils); let cone = api; diff --git a/js/src/globals.js b/js/src/globals.js new file mode 100644 index 00000000..f266cc60 --- /dev/null +++ b/js/src/globals.js @@ -0,0 +1,33 @@ +import ts from 'treibstoff'; + +/** + * Class to manage global events. + */ +export class GlobalEvents extends ts.Events { + + /** + * Gets triggered when left sidebar is resized. + * + * @param {Sidebar} inst + */ + on_sidebar_left_resize(inst) { + } + + /** + * Gets triggered when right sidebar is resized. + * + * @param {Sidebar} inst + */ + on_sidebar_right_resize(inst) { + } + + /** + * Gets triggered when main area toggles between compact and full mode. + * + * @param {MainArea} inst + */ + on_main_area_mode(inst) { + } +} + +export const global_events = new GlobalEvents(); diff --git a/js/src/header.js b/js/src/header.js new file mode 100644 index 00000000..b33b3830 --- /dev/null +++ b/js/src/header.js @@ -0,0 +1,156 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { ScrollbarY } from './scrollbar.js'; +import { LayoutAware } from './layout.js'; + +/** + * Class to manage the header layout and interactions. + * @extends LayoutAware + */ +export class Header extends LayoutAware { + + /** + * Initializes the Header instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#header-main', context); + if (!elem) { + return; + } + new Header(elem); + } + + /** + * @param {Element} elem The main header element. + */ + constructor(elem) { + super(elem); + this.elem = elem; + this.header_content = ts.query_elem('#header-content', elem); + this.navbar_content_wrapper = ts.query_elem('#navbar-content-wrapper', elem); + this.navbar_content = ts.query_elem('#navbar-content', elem); + this.navbar_toggler = ts.query_elem('#navbar-toggler', this.elem); + this.personal_tools = ts.query_elem('#personaltools', elem); + this.mainmenu = ts.query_elem('#mainmenu', elem); + this.mainmenu_elems = $('.nav-link.dropdown-toggle', this.mainmenu); + + this.render_mobile_scrollbar = this.render_mobile_scrollbar.bind(this); + this.mainmenu_elems.each((i, el) => { + $(el).on('shown.bs.dropdown', this.render_mobile_scrollbar); + $(el).on('hidden.bs.dropdown', this.render_mobile_scrollbar); + }); + + this.set_mobile_menu_open = this.set_mobile_menu_open.bind(this); + this.set_mobile_menu_closed = this.set_mobile_menu_closed.bind(this); + this.bind(); + } + + /** + * Destroys the Header instance and cleans up event listeners. + */ + destroy() { + super.destroy(); + if (this.mobile_scrollbar) { + this.mobile_scrollbar.destroy(); + this.mobile_scrollbar = null; + } + this.mainmenu_elems.each((i, el) => { + $(el).off('shown.bs.dropdown', this.render_mobile_scrollbar); + $(el).off('hidden.bs.dropdown', this.render_mobile_scrollbar); + }); + const wrapper = this.navbar_content_wrapper; + wrapper.off('show.bs.collapse shown.bs.collapse', this.set_mobile_menu_open); + wrapper.off('hide.bs.collapse hidden.bs.collapse', this.set_mobile_menu_closed); + } + + /** + * Renders the mobile scrollbar if in compact mode. + */ + render_mobile_scrollbar() { + if (this.is_compact && this.mobile_scrollbar) { + this.mobile_scrollbar.render(); + } + } + + /** + * Binds event listeners for bootstrap navbar collapse events. + */ + bind() { + const wrapper = this.navbar_content_wrapper; + wrapper.on('show.bs.collapse shown.bs.collapse', this.set_mobile_menu_open); + wrapper.on('hidden.bs.collapse', this.set_mobile_menu_closed); + } + + /** + * Sets a header class to indicate the mobile menu is open. + */ + set_mobile_menu_open() { + this.elem.addClass('mobile-menu-open'); + } + + /** + * Removes a header class to indicate the mobile menu is closed. + */ + set_mobile_menu_closed() { + this.elem.removeClass('mobile-menu-open'); + } + + /** + * Handles changes (scrollbar and bootstrap dropdown) + * in the compact state of the header. + * @param {boolean} val + */ + on_is_compact(val) { + if (this.mobile_scrollbar) { + // remove mobile scrollbar + this.navbar_content.removeClass('scrollable-content'); + this.mobile_scrollbar.destroy(); + this.mobile_scrollbar = null; + } + if (val) { + this.elem.removeClass('full').removeClass('navbar-expand'); + this.elem.addClass('compact'); + + // create mobile scrollbar + this.navbar_content.addClass('scrollable-content'); + this.mobile_scrollbar = new ScrollbarY(this.navbar_content_wrapper); + + this.navbar_content_wrapper.on('shown.bs.collapse', () => { + // disable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'none'); + this.mobile_scrollbar.render(); + }); + this.navbar_content_wrapper.on('hide.bs.collapse', () => { + // enable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'auto'); + this.mobile_scrollbar.scrollbar.hide(); + }); + } else { + this.elem.removeClass('compact'); + this.elem.addClass('full').addClass('navbar-expand'); + } + } + + /** + * Handles changes in the super compact state of the header. + * @param {boolean} val + */ + on_is_super_compact(val) { + const in_navbar_content = ts.query_elem( + '#personaltools', + this.navbar_content + ) !== null; + if (val) { + if (!in_navbar_content) { + this.personal_tools.detach().appendTo(this.navbar_content); + } + } else { + if (in_navbar_content) { + this.personal_tools.detach().prependTo(this.header_content); + } + // close any header dropdowns + $(".dropdown-menu.show").removeClass('show'); + } + } +} diff --git a/js/src/keybinder.js b/js/src/keybinder.js index fbe67cd1..f7fe4788 100644 --- a/js/src/keybinder.js +++ b/js/src/keybinder.js @@ -7,7 +7,6 @@ export let keys = { /** * XXX: Use ``ts.KeyState`` instead. - * Need a mechanism to attach and unload instances with ``ts.ajax`` first. */ export class KeyBinder { @@ -31,7 +30,7 @@ export class KeyBinder { switch (e.keyCode || e.which) { case 16: keys.shift_down = false; - break; + break; case 17: keys.ctrl_down = false; break; diff --git a/js/src/layout.js b/js/src/layout.js new file mode 100644 index 00000000..6fcbeb70 --- /dev/null +++ b/js/src/layout.js @@ -0,0 +1,249 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { global_events } from './globals.js'; + +/** + * Class to manage the main area of the application. + * @extends ts.Events + */ +export class MainArea extends ts.Events { + + /** + * Initializes the MainArea instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#main-area', context); + if (!elem) { + return; + } + new MainArea(elem); + } + + /** + * @param {Element} elem The main area element. + */ + constructor(elem) { + super(); + this.elem = elem; + + new ts.Property(this, 'is_compact', null); + new ts.Property(this, 'is_super_compact', null); + + this.set_mode = this.set_mode.bind(this); + global_events.on('on_sidebar_left_resize', this.set_mode); + global_events.on('on_sidebar_right_resize', this.set_mode); + $(window).on('resize', this.set_mode); + this.set_mode(); + + ts.ajax.attach(this, elem); + } + + /** + * Destroys the MainArea instance and cleans up event listeners. + */ + destroy() { + $(window).off('resize', this.set_mode); + global_events.off('on_sidebar_left_resize', this.set_mode); + global_events.off('on_sidebar_right_resize', this.set_mode); + } + + /** + * Sets the compact state based on the current width of the main area element. + */ + set_mode() { + this.is_compact = this.elem.outerWidth() < 992; // tablet + this.is_super_compact = this.elem.outerWidth() < 576; // mobile + } + + /** + * Handles changes in the compact state of the main area. + * @param {boolean} val + */ + on_is_compact(val) { + if (val) { + this.elem.removeClass('full'); + this.elem.addClass('compact'); + } else { + this.elem.removeClass('compact'); + this.elem.addClass('full'); + } + + global_events.trigger('on_main_area_mode', this); + } + + /** + * Handles changes in the super compact state of the main area. + * @param {boolean} val + */ + on_is_super_compact(val) { + if (val) { + this.elem.addClass('super-compact'); + } else { + this.elem.removeClass('super-compact'); + } + + global_events.trigger('on_main_area_mode', this); + } +} + +/** + * Base class for subclasses that inherit layout-aware behavior. + * @extends ts.Events + */ +export class LayoutAware extends ts.Events { + + /** + * @param {Element} elem + */ + constructor(elem) { + super(); + this.elem = elem; + + new ts.Property(this, 'is_compact', null); + new ts.Property(this, 'is_super_compact', null); + new ts.Property(this, 'is_sidebar_collapsed', null); + + this.set_mode = this.set_mode.bind(this); + global_events.on('on_main_area_mode', this.set_mode); + + this.on_sidebar_left_resize = this.on_sidebar_left_resize.bind(this); + global_events.on('on_sidebar_left_resize', this.on_sidebar_left_resize); + global_events.on('on_sidebar_right_resize', this.on_sidebar_left_resize); + + ts.ajax.attach(this, elem); + } + + /** + * Destroys the LayoutAware instance and cleans up event listeners. + */ + destroy() { + global_events.off('on_main_area_mode', this.set_mode); + global_events.off('on_sidebar_left_resize', this.on_sidebar_left_resize); + global_events.off('on_sidebar_right_resize', this.on_sidebar_left_resize); + } + + /** + * Sets the layout mode based on the main area's state. + * @param {} inst + * @param {MainArea} mainarea + */ + set_mode(inst, mainarea) { + this.is_compact = mainarea.is_compact; + this.is_super_compact = mainarea.is_super_compact; + } + + /** + * Handles changes in the compact state of the main area layout. + * @param {boolean} val Indicates if the main area layout is compact. + */ + on_is_compact(val) { + if (val) { + this.elem.removeClass('full'); + this.elem.addClass('compact'); + } else { + this.elem.removeClass('compact'); + this.elem.addClass('full'); + } + } + + /** + * Handles changes in the super compact state of the main area layout. + * @param {boolean} val Indicates if the main area layout is super compact. + */ + on_is_super_compact(val) { + if (val) { + this.elem.addClass('super-compact'); + } else { + this.elem.removeClass('super-compact'); + } + } + + /** + * Handles changes on the sidebar left resize event. + * @param {} inst + * @param {Object} sidebar + */ + on_sidebar_left_resize(inst, sidebar) { + this.is_sidebar_left_collapsed = sidebar.collapsed; + } + + /** + * Handles changes on the sidebar resize event. + * @param {} inst + * @param {Object} sidebar + */ + on_sidebar_right_resize(inst, sidebar) { + this.is_sidebar_right_collapsed = sidebar.collapsed; + } + + /** + * Handles changes in the sidebar left collapsed state. + * @param {boolean} val + */ + on_is_sidebar_left_collapsed(val) { + // ... + } + + /** + * Handles changes in the sidebar right collapsed state. + * @param {boolean} val + */ + on_is_sidebar_right_collapsed(val) { + // ... + } +} + +/** + * A mixin to add window resize awareness to a class. + * + * This mixin expects that the class it extends has an `elem` property. + * It attaches a resize event listener to the window that triggers the + * `on_window_resize` method defined in the subclass. + * + * @param {class} Base - The base class to extend. + * @returns {class} + */ +export const ResizeAware = (Base) => class extends Base { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + + if (this.elem) { + ts.ajax.attach(this, this.elem); + } + + this.on_window_resize = this.on_window_resize.bind(this); + + $(window).on('resize', this.on_window_resize); + } + + /** + * Handler for the window resize event. + * + * This method should be overridden in the subclass to define custom resize + * behavior. + */ + on_window_resize(evt) { + // Call `on_window_resize` of the base class, if it exists + if (super.on_window_resize) { + super.on_window_resize(evt); + } + } + + /** + * Removes the resize event handler from the window. + */ + destroy() { + try { + super.destroy(); + } catch (error) { + console.warn(error); + } finally { + $(window).off('resize', this.on_window_resize); + } + } +}; diff --git a/js/src/livesearch.js b/js/src/livesearch.js index 570f3d3e..8ed226e1 100644 --- a/js/src/livesearch.js +++ b/js/src/livesearch.js @@ -4,8 +4,8 @@ import ts from 'treibstoff'; export class LiveSearch { static initialize(context, factory=null) { - let elem = $('input#search-text', context); - if (!elem.length) { + const elem = ts.query_elem('input#search-text', context); + if (!elem) { return; } if (factory === null) { @@ -16,40 +16,113 @@ export class LiveSearch { constructor(elem) { this.elem = elem; - let source = new Bloodhound({ - datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), - queryTokenizer: Bloodhound.tokenizers.whitespace, - remote: 'livesearch?term=%QUERY' + this.target = `${elem.data('search-target')}/livesearch`; + this.content = $('#content'); + this.result = null; + + this._term = ''; + this._minlen = 3; + this._delay = 250; + this._timeout_event = null; + this._in_progress = false; + + this.on_keydown = this.on_keydown.bind(this); + this.on_change = this.on_change.bind(this); + this.on_result = this.on_result.bind(this); + + elem.on('keydown', this.on_keydown); + elem.on('change', this.on_change); + } + + search() { + this._in_progress = true; + ts.http_request({ + url: this.target, + params: {term: this._term}, + type: 'json', + success: this.on_result }); - source.initialize(); - this.render_suggestion = this.render_suggestion.bind(this); - elem.typeahead(null, { - name: 'livesearch', - displayKey: 'value', - source: source.ttAdapter(), - templates: { - suggestion: this.render_suggestion, - empty: '
No search results
' + this._in_progress = false; + } + + render_no_results() { + ts.compile_template(this, ` +
No search results
+ `, this.result); + } + + render_suggestion(item) { + ts.compile_template(this, ` +
+
+ + + ${item.value} + +
+

+ ${item.description === undefined ? '' : item.description} +

+
+ `, this.result); + } + + on_result(data, status, request) { + this.content.empty(); + ts.compile_template(this, ` +
+
+

Search results for "${this._term}"

+
+
+ `, this.content); + if (!data.length) { + this.render_no_results(); + } else { + ts.compile_template(this, ` +

+ ${data.length} Results +

+ `, this.result); + for (const item of data) { + this.render_suggestion(item); } - }); - this.on_select = this.on_select.bind(this); - let event = 'typeahead:selected'; - elem.off(event).on(event, this.on_select); + } + this.result.tsajax(); } - on_select(evt, suggestion, dataset) { - if (!suggestion.target) { - console.log('No suggestion target defined.'); + on_keydown(evt) { + if (evt.keyCode === 13) { return; } - ts.ajax.trigger( - 'contextchanged', - '#layout', - suggestion.target - ); + ts.clock.schedule_frame(() => { + if (this._term !== this.elem.val()) { + this.elem.trigger('change'); + } + }); } - render_suggestion(suggestion) { - return `${suggestion.value}`; + on_change(evt) { + if (this._in_progress) { + return; + } + const term = this.elem.val(); + if (this._term === term) { + return; + } + this._term = term; + if (this._term.length < this._minlen) { + return; + } + if (this._timeout_event !== null) { + this._timeout_event.cancel(); + } + this._timeout_event = ts.clock.schedule_timeout(() => { + this._timeout_event = null; + this.search(); + }, this._delay); } } diff --git a/js/src/mainmenu.js b/js/src/mainmenu.js new file mode 100644 index 00000000..10709191 --- /dev/null +++ b/js/src/mainmenu.js @@ -0,0 +1,142 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import 'bootstrap'; +import { LayoutAware } from './layout.js'; + +/** + * Class to manage the main menu of the application. + * @extends LayoutAware + */ +export class MainMenu extends LayoutAware { + + /** + * Initializes the MainMenu instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#mainmenu', context); + if (!elem) { + return; + } + new MainMenu(elem); + } + + /** + * @param {Element} elem + */ + constructor(elem) { + super(elem); + this.elem = elem; + this.scrollbar = elem.data('scrollbar'); + this.elems = $('.nav-link.dropdown-toggle', elem); + this.open_dropdown = null; + + this.on_show_dropdown_desktop = this.on_show_dropdown_desktop.bind(this); + this.on_hide_dropdown_desktop = this.on_hide_dropdown_desktop.bind(this); + this.hide_dropdowns = this.hide_dropdowns.bind(this); + this.scrollbar.on('on_position', this.hide_dropdowns); + } + + /** + * Returns the main menu element's outer height. + */ + get height() { + return this.elem.outerHeight(true); + } + + /** + * Handles changes in the sidebar resize event. + * @param {} inst + * @param {Object} sidebar + */ + on_sidebar_left_resize(inst, sidebar) { + super.on_sidebar_left_resize(inst, sidebar); + // defer to next frame to ensure elements have correct dimensions + requestAnimationFrame(() => { + this.scrollbar.render(); + }); + } + + /** + * Handles changes in the compact state of the main menu. + * Binds modified dropdown behavior to bootstrap dropdowns. + * @param {boolean} val + */ + on_is_compact(val) { + this.hide_dropdowns(); + + if (val) { + this.scrollbar.off('on_position', this.hide_dropdowns); + this.bind_dropdowns_mobile(); + } else { + this.bind_dropdowns_desktop(); + this.scrollbar.on('on_position', this.hide_dropdowns); + } + } + + /** + * Handles the event when a dropdown is shown on desktop. + * Sets the dropdown position manually due to position being set as + * 'static' in css (to avoid dropdowns being cut by overflow: hidden) + * @param {Event} evt + */ + on_show_dropdown_desktop(evt) { + const el = evt.target; + this.open_dropdown = el; + + // prevent element being cut by scrollbar while open + const dropdown = $(el).siblings('ul.dropdown-menu'); + dropdown.css({ + top: `${this.height - 1}px`, // remove border from position + left: `${$(el).offset().left}px` + }); + } + + /** + * Handles the event when a dropdown is hidden on desktop. + * @param {Event} evt + */ + on_hide_dropdown_desktop(evt) { + const el = evt.target; + // return if the click that closes the dropdown opens another dropdown + if (this.open_dropdown !== el) { + return; + } + this.open_dropdown = null; + } + + /** + * Binds the dropdown events for desktop view. + */ + bind_dropdowns_desktop() { + // this.elems.on(...) deprecated in jquery 4.0.0-beta.2 (?) + this.elem.on('shown.bs.dropdown', '.nav-link.dropdown-toggle', this.on_show_dropdown_desktop); + this.elem.on('hidden.bs.dropdown', '.nav-link.dropdown-toggle', this.on_hide_dropdown_desktop); + } + + /** + * Unbinds the dropdown events for mobile view. + */ + bind_dropdowns_mobile() { + this.elem.off('shown.bs.dropdown', '.nav-link.dropdown-toggle', this.on_show_dropdown_desktop); + this.elem.off('hidden.bs.dropdown', '.nav-link.dropdown-toggle', this.on_hide_dropdown_desktop); + } + + /** + * Hides all dropdowns in the main menu. + */ + hide_dropdowns() { + const Dropdown = bootstrap.Dropdown; + this.elems.each((i, el) => { + Dropdown.getOrCreateInstance(el).hide(); + }); + } + + destroy() { + super.destroy(); + this.elem.off('shown.bs.dropdown', '.nav-link.dropdown-toggle', this.on_show_dropdown_desktop); + this.elem.off('hidden.bs.dropdown', '.nav-link.dropdown-toggle', this.on_hide_dropdown_desktop); + this.scrollbar.off('on_position', this.hide_dropdowns); + this.scrollbar.destroy(); + } +} diff --git a/js/src/navtree.js b/js/src/navtree.js new file mode 100644 index 00000000..432be664 --- /dev/null +++ b/js/src/navtree.js @@ -0,0 +1,69 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; + +/** + * Class to manage a collapsible navigation tree component. + */ +export class NavTree { + + /** + * Initializes the NavTree instance for a given context. + * @param {Element} context - DOM context. + */ + static initialize(context) { + const elem = ts.query_elem('#navtree', context); + if (!elem) { + return; + } + new NavTree(elem); + } + + /** + * Constructs a NavTree instance and sets up its behavior. + * @param {Element} elem - The root ul element of the navigation tree. + */ + constructor(elem) { + this.elem = elem; + this.dropdown_elem = $('#navigation-collapse', elem); + if (this.dropdown_elem.hasClass('no-collapse')) { + // navroot is hidden, no need for collapsing logic + return; + } + + // Expand menu if previously opened. + if (localStorage.getItem('cone.app.navtree.open')) { + this.dropdown_elem.addClass('show'); + } + + this.set_menu_open = this.set_menu_open.bind(this); + this.set_menu_closed = this.set_menu_closed.bind(this); + + this.dropdown_elem.on('shown.bs.collapse', this.set_menu_open); + this.dropdown_elem.on('hidden.bs.collapse', this.set_menu_closed); + + ts.ajax.attach(this, elem); + } + + /** + * Handles the event when the navigation menu is opened. + * Stores the menu state in localStorage. + * @param {Event} e - Event object. + */ + set_menu_open(e) { + localStorage.setItem('cone.app.navtree.open', 'true'); + } + + /** + * Handles the event when the navigation menu is closed. + * Removes the menu state from localStorage. + * @param {Event} e - Event object. + */ + set_menu_closed(e) { + localStorage.removeItem('cone.app.navtree.open'); + } + + destroy() { + this.dropdown_elem.off(); + this.elem.off(); + } +} diff --git a/js/src/referencebrowser.js b/js/src/referencebrowser.js index 8820a7f2..c18c0dbe 100644 --- a/js/src/referencebrowser.js +++ b/js/src/referencebrowser.js @@ -13,6 +13,11 @@ export class ReferenceHandle { } let ol = ol_elem.data('overlay'), target = ol.ref_target; + // Skip binding if no ref_target is set. This allows other components + // to reuse the referencebrowser overlay with custom selection handling. + if (!target) { + return; + } $('a.addreference', context).each(function() { new AddReferenceHandle($(this), target, ol); }); @@ -158,6 +163,8 @@ export class ReferenceBrowserLoader { evt.preventDefault(); let ol = ts.ajax.overlay({ action: 'referencebrowser', + title: 'Referencebrowser', + css: 'modal-lg', target: this.wrapper.attr('ajax:target'), on_complete: this.on_complete.bind(this) }); diff --git a/js/src/scrollbar.js b/js/src/scrollbar.js new file mode 100644 index 00000000..d8cf072b --- /dev/null +++ b/js/src/scrollbar.js @@ -0,0 +1,527 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {ResizeAware} from './layout.js'; + +/** + * Class representing a generic scrollbar. + * @extends ts.Motion + */ +export class Scrollbar extends ts.Motion { + + /** + * Initializes all scrollable elements in the given context. + * @param {Element} context + */ + static initialize(context) { + $('.scrollable-x', context).each(function() { + new ScrollbarX($(this)); + }); + $('.scrollable-y', context).each(function() { + new ScrollbarY($(this)); + }); + } + + /** + * Creates an instance of a Scrollbar. + * @param {jQuery} elem + */ + constructor(elem) { + super(elem); + + this.elem = elem; + if (this.elem.data('scrollbar')) { + console.warn('cone.app: Only one Scrollbar can be bound to each element.'); + return; + } + this.elem.data('scrollbar', this); + this.content = ts.query_elem('> .scrollable-content', elem); + + this.on_scroll = this.on_scroll.bind(this); + this.on_click = this.on_click.bind(this); + this.on_hover = this.on_hover.bind(this); + this.on_window_resize = this.on_window_resize.bind(this); + + this.compile(); + this.position = 0; + this.scroll_step = 50; // Scroll step in pixels + new ts.Property(this, 'disabled', false); + + const is_mobile = $(window).width() <= 768; // bs5 small/medium breakpoint + new ts.Property(this, 'is_mobile', is_mobile); + + // Persist scroll position + this.persist_scroll = elem.data('persist-scroll') === true; + this._persist_key = elem.data('persist-scroll-key') || elem.attr('id') || null; + if (this.persist_scroll && this.storage_key) { + this._save_position = this._save_position.bind(this); + this.on('on_position', this._save_position); + } + + ts.clock.schedule_frame(() => { + // Read saved position BEFORE render() triggers save handler + const saved = this.persist_scroll && this.storage_key + ? sessionStorage.getItem(this.storage_key) + : null; + this.render(); + if (saved !== null) { + this.position = parseFloat(saved); + } + }); + } + + /** + * Returns the sessionStorage key for persisting scroll position. + * @returns {string|null} + */ + get storage_key() { + if (!this._persist_key) { + return null; + } + return `cone.app.scroll.${this._persist_key}`; + } + + /** + * Saves the current scroll position to sessionStorage. + * @param {Scrollbar} inst - The scrollbar instance (from event) + * @param {number} pos - The scroll position + */ + _save_position(inst, pos) { + if (this.storage_key) { + sessionStorage.setItem(this.storage_key, pos); + } + } + + /** + * Handles window resize event to adjust the scrollbar. + * Invoked by ResizeAware mixin. + * @param {Event} evt + */ + on_window_resize(evt) { + this.is_mobile = $(window).innerWidth() <= 768; // bs5 small/medium breakpoint + this.position = this.safe_position(this.position); + this.render(); + } + + /** + * Gets the current scroll position. + * @returns {number} + */ + get position() { + return this._position || 0; + } + + /** + * Sets the scroll position and triggers position update. + * @param {number} position + */ + set position(position) { + this._position = this.safe_position(position); + this.update(); + this.trigger('on_position', this._position); + } + + /** + * Gets the pointer events status. + * @returns {boolean} Whether pointer events are enabled. + */ + get pointer_events() { + return this.elem.css('pointer-events') === 'all'; + } + + /** + * Sets the pointer events status. + * @param {boolean} value Whether to enable pointer events. + */ + set pointer_events(value) { + this.elem.css('pointer-events', value ? 'all' : 'none'); + } + + /** + * Handles fading in and out of the scrollbar based on activity. + */ + fade_timer() { + if (!this.scrollbar.is(':visible')) { + this.scrollbar.fadeIn('fast'); + } + if (this.fade_out_timeout) { + clearTimeout(this.fade_out_timeout); + } + this.fade_out_timeout = setTimeout(() => { + this.scrollbar.fadeOut('slow'); + }, 700); + } + + /** + * Handles changes in the mobile state. + * @param {boolean} val + */ + on_is_mobile(val) { + if (val && this.contentsize > this.scrollsize) { + this.scrollbar.stop(true, true).show(); + this.elem.off('mouseenter mouseleave', this.on_hover); + } else { + this.scrollbar.stop(true, true).hide(); + this.elem.on('mouseenter mouseleave', this.on_hover); + } + } + + /** + * Binds events to the scrollbar. + */ + bind() { + this.pointer_events = true; + this.elem.on('mousewheel wheel', this.on_scroll); + this.scrollbar.on('click', this.on_click); + this.set_scope(this.thumb, $(document), this.elem); + } + + /** + * Unbinds events from the scrollbar. + */ + unbind() { + this.elem.off('mousewheel wheel', this.on_scroll); + this.elem.off('mouseenter mouseleave', this.on_hover); + this.scrollbar.off('click', this.on_click); + $(this.thumb).off('mousedown', this._down_handle); + } + + /** + * Destroys the scrollbar instance and cleans up. + */ + destroy() { + if (this.fade_out_timeout) { + clearTimeout(this.fade_out_timeout); + } + if (this.persist_scroll && this._save_position) { + this.off('on_position', this._save_position); + } + this.unbind(); + this.elem.removeData('scrollbar'); + } + + /** + * Compiles the scrollbar template. + */ + compile() { + ts.compile_template(this, ` +
+
+
+
+ `, this.elem); + } + + /** + * Renders the scrollbar and updates its dimensions. + * @param {string} [attr] Attribute to update ('width' or 'height'). + */ + render(attr) { + this.scrollbar.css(attr, this.scrollsize); + if (this.contentsize <= this.scrollsize) { + this.thumbsize = this.scrollsize; + } else { + this.thumbsize = Math.pow(this.scrollsize, 2) / this.contentsize; + } + this.thumb.css(attr, this.thumbsize); + this.update(); + // ensure correct scroll position when outside of safe bounds + this.position = this.safe_position(this.position); + } + + /** + * Validates and returns a safe scroll position. + * @param {number} position The desired scroll position. + * @returns {number} A safe scroll position within bounds. + * @throws Will throw an error if position is not a number. + */ + safe_position(position) { + if (typeof position !== 'number') { + throw new Error(`Scrollbar position must be a Number, position is: "${position}".`); + } + if (this.contentsize <= this.scrollsize) { + // reset position + return 0; + } + const max_pos = this.contentsize - this.scrollsize; + if (position >= max_pos) { + position = max_pos; + } else if (position <= 0) { + position = 0; + } + return position; + } + + /** + * Handles the state of the scrollbar when disabled. + * @param {boolean} value + */ + on_disabled(value) { + if (value) { + this.unbind(); + } else { + this.bind(); + } + } + + /** + * Handles hover events to show/hide the scrollbar. + * @param {Event} evt + */ + on_hover(evt) { + evt.preventDefault(); + evt.stopPropagation(); + const elem = this.elem; + if ( + (elem.has(evt.target).length > 0 || elem.is(evt.target)) && + this.contentsize > this.scrollsize + ) { + if (evt.type === 'mouseenter') { + this.scrollbar.stop(true, true).fadeIn(); + } else if ( + evt.type === 'mouseleave' && + evt.relatedTarget !== elem.get(0) + ) { + this.scrollbar.stop(true, true).fadeOut(); + } + } + } + + /** + * Handles scroll events to adjust the scrollbar position. + * @param {Event} evt + */ + on_scroll(evt) { + if (this.contentsize <= this.scrollsize) { + return; + } + let evt_ = evt.originalEvent; + if (typeof evt_.deltaY === 'number') { + if (evt_.deltaY > 0) { // down + this.position += this.scroll_step; + } else if (evt_.deltaY < 0) { // up + this.position -= this.scroll_step; + } + } + } + + /** + * Handles click events on the scrollbar. + * @param {Event} evt + */ + on_click(evt) { + evt.preventDefault(); // prevent text selection + this.thumb.addClass('active'); + let position = this.pos_from_evt(evt), + thumb_pos = position - this.offset - this.thumbsize / 2; + this.position = this.contentsize * thumb_pos / this.scrollsize; + this.thumb.removeClass('active'); + } + + /** + * Handles touch start events. + * @param {Event} evt + */ + touchstart(evt) { + const touch = evt.originalEvent.touches[0]; + this._touch_pos = this.pos_from_evt(touch); + this._start_position = this.position; + } + + /** + * Handles touch move events. + * @param {Event} evt + */ + touchmove(evt) { + if (this.contentsize <= this.scrollsize) { + return; + } + const touch = evt.originalEvent.touches[0]; + const delta = this.pos_from_evt(touch) - this._touch_pos; + this.position = this._start_position - delta; + this.fade_timer(); + } + + /** + * Handles touch end events. + * @param {Event} + */ + touchend(evt) { + delete this._touch_pos; + delete this._start_position; + } + + /** + * Handles mouse down events on the scrollbar thumb. + * @param {Event} evt + */ + down(evt) { + this._mouse_pos = this.pos_from_evt(evt) - this.offset; + this._thumb_pos = this.position / (this.contentsize / this.scrollsize); + this.elem.off('mouseenter mouseleave', this.on_hover); + this.thumb.addClass('active'); + } + + /** + * Handles mouse move events while dragging the scrollbar thumb. + * @param {Event} evt + */ + move(evt) { + let mouse_pos = this.pos_from_evt(evt) - this.offset, + thumb_pos = this._thumb_pos + mouse_pos - this._mouse_pos; + this.position = this.contentsize * thumb_pos / this.scrollsize; + } + + /** + * Handles mouse up events to finalize scrollbar movement. + * @param {Event} evt + */ + up(evt) { + delete this._mouse_pos; + delete this._thumb_pos; + this.elem.on('mouseenter mouseleave', this.on_hover); + this.thumb.removeClass('active'); + } +} + +/** + * Class representing a horizontal scrollbar. + * @extends Scrollbar + */ +export class ScrollbarX extends ResizeAware(Scrollbar) { + + /** + * Gets the horizontal offset of the scrollbar element. + * @returns {number} + */ + get offset() { + return this.elem.offset().left; + } + + /** + * Gets the total width of the content. + * @returns {number} + */ + get contentsize() { + return this.content.outerWidth(); + } + + /** + * Gets the scrollable container width. + * @returns {number} + */ + get scrollsize() { + const padding_r = parseFloat(this.elem.css('padding-right')); + const padding_l = parseFloat(this.elem.css('padding-left')); + return this.elem.outerWidth() - padding_l - padding_r; + } + + /** + * Compiles the scrollbar template and styles for horizontal scrollbar. + */ + compile() { + super.compile(); + this.thumb.css('height', '6px'); + this.scrollbar + .css('height', '6px') + .css('width', this.scrollsize); + this.thumbsize = this.scrollsize / (this.contentsize / this.scrollsize); + this.thumb.css('width', this.thumbsize); + } + + /** + * Renders the scrollbar and updates its width. + */ + render() { + super.render('width'); + } + + /** + * Updates the content position based on the current scrollbar position. + */ + update() { + let thumb_pos = this.position / (this.contentsize / this.scrollsize); + this.content.css('right', this.position + 'px'); + this.thumb.css('left', thumb_pos + 'px'); + } + + /** + * Gets the x-coordinate from the mouse event. + * @param {Event} e + * @returns {number} + */ + pos_from_evt(e) { + return e.pageX; + } +} + +/** + * Class representing a vertical scrollbar. + * @extends Scrollbar + */ +export class ScrollbarY extends ResizeAware(Scrollbar) { + + /** + * Gets the vertical offset of the scrollbar element. + * @returns {number} + */ + get offset() { + return this.elem.offset().top; + } + + /** + * Gets the total height of the content. + * @returns {number} + */ + get contentsize() { + return this.content.outerHeight(); + } + + /** + * Gets the scrollable container height. + * @returns {number} + */ + get scrollsize() { + const padding_t = parseFloat(this.elem.css('padding-top')); + const padding_b = parseFloat(this.elem.css('padding-bottom')); + return this.elem.outerHeight() - padding_t - padding_b; + } + + /** + * Compiles the scrollbar template and styles for vertical scrollbar. + */ + compile() { + super.compile(); + this.thumb.css('width', '6px'); + this.scrollbar + .css('width', '6px') + .css('top', '0px') + .css('height', this.scrollsize); + this.thumbsize = this.scrollsize / (this.contentsize / this.scrollsize); + this.thumb.css('height', this.thumbsize); + } + + /** + * Renders the scrollbar and updates its height. + */ + render() { + super.render('height'); + } + + /** + * Updates the content position based on the current scrollbar position. + */ + update() { + let thumb_pos = this.position / (this.contentsize / this.scrollsize); + this.content.css('bottom', this.position + 'px'); + this.thumb.css('top', thumb_pos + 'px'); + } + + /** + * Gets the y-coordinate from the mouse event. + * @param {Event} e + * @returns {number} + */ + pos_from_evt(e) { + return e.pageY; + } +} diff --git a/js/src/sidebar.js b/js/src/sidebar.js new file mode 100644 index 00000000..ff72fce1 --- /dev/null +++ b/js/src/sidebar.js @@ -0,0 +1,540 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { global_events } from './globals.js'; +import { ResizeAware } from './layout.js'; + + +export class SidebarControl extends ts.Events { + constructor(sidebar_content, elem) { + super(elem); + this.elem = elem; + const target = this.target = elem.data('target'); + this.parent = sidebar_content; + this.related_tile = $(`[data-tile="${target}"]`, this.parent.tiles_container); + this.on_click = this.on_click.bind(this); + this.compile(); + + ts.ajax.attach(this, elem); + } + + compile() { + this.elem.on('click', this.on_click); + } + + on_click(e) { + e.preventDefault(); + this.parent.activate_tile(this); + } + + activate_tile() { + this.elem.addClass('active'); + this.related_tile.removeClass('d-none'); + this.parent.sidebar.elem.attr('tile', this.target); + } + + deactivate_tile() { + this.elem.removeClass('active'); + this.related_tile.addClass('d-none'); + } + + destroy() { + this.elem.off('click', this.on_click); + } +} + +export class SidebarContent extends ts.Events { + constructor(sidebar, elem) { + super(elem); + this.sidebar = sidebar; + this.elem = elem; + this.mode = sidebar.elem.data('mode') || 'stacked'; + this.tiles = sidebar.elem.data('tiles') ?? []; + this.navigation = $('.sidebar-controls', this.sidebar.elem); + this.tiles_container = $('.sidebar-tiles', this.elem); + this.controls = []; + this.compile(); + + if (this.mode === 'toggle' && this.controls.length > 0) { + this.activate_tile(this.controls[0]); + } + } + + compile() { + if (this.mode === 'toggle' && this.tiles.length > 1) { + $('.sidebar-control', this.navigation).each((i, el) => { + this.controls.push(new SidebarControl(this, $(el))); + }); + } else { + this.navigation.addClass('d-none'); + } + } + + activate_tile(tile) { + if (this.mode === 'stacked') return; + this.deactivate_all(); + tile.activate_tile(); + } + + deactivate_all() { + for (const control of this.controls) { + if (this.mode === 'stacked') return; + control.deactivate_tile(); + } + } +} + + +/** + * Class to manage the sidebar of the application. + * @extends ts.Motion + */ +export class Sidebar extends ResizeAware(ts.Motion) { + + /** + * Creates an instance of the Sidebar. + * @param {Element} elem + */ + constructor(elem) { + super(elem); + this.elem = elem; + this.min_width = elem.data('min-width') || 115; + const width = Math.max(this.min_width, this.sidebar_width); + elem.css('width', width + 'px'); + + const static_data = this.elem.data('static'); + this.static = static_data === true || static_data === 'True'; + this.moving = false; + this.trigger_event = this.trigger_event.bind(this); + this.scrollbar = ts.query_elem('.scrollable-y', elem).data('scrollbar'); + this.on_click = this.on_click.bind(this); + this.collapse_elem = ts.query_elem('#sidebar_collapse', elem); + this.collapse_elem.on('click', this.on_click); + this.on_lock = this.on_lock.bind(this); + this.lock_input = ts.query_elem('.lock-state-input', elem); + this.lock_elem = ts.query_elem('.lock-state-btn', elem); + this.lock_elem.on('click', this.on_lock); + + this.resizer_elem = ts.query_elem('#sidebar_resizer', elem); + this.set_scope(this.resizer_elem, $(document)); + + this.responsive_toggle = this.responsive_toggle.bind(this); + this.responsive_toggle(); + + if (this.locked !== undefined && this.locked !== null) { + this.lock_input.prop('checked', true).trigger('change'); + if (this.disable_lock) return; + if (this.locked.collapsed) { + this.collapse(); + } else { + this.expand(); + } + } + this.disable_or_enable_interaction = this.disable_or_enable_interaction.bind(this); + this.disable_or_enable_interaction(); + + // Enable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'auto'); + + const content_elem = $('.sidebar-content', elem); + this.sidebar_content = new SidebarContent(this, content_elem); + + ts.ajax.attach(this, elem); + } + + /** + * Checks if the sidebar is collapsed. + * @returns {boolean} + */ + get collapsed() { + return this.elem.outerWidth() <= 0; + } + + /** + * Handles sidebar responsive collapsed state. + * Invoked by ResizeAware mixin. + * @param {*} evt + */ + on_window_resize(evt) { + this.responsive_toggle(); + } + + /** + * Handles click on the lock switch and sets localStorage collapsed flag. + */ + on_lock(evt) { + // checked property sets after on_lock is done + const checked = !(this.lock_input.get(0).checked); + if (checked) { + this.set_state(); + } else { + this.unset_state(); + this.elem.removeClass('collapsed'); + this.elem.removeClass('expanded'); + } + if (this.collapsed) { + this.elem.addClass('collapsed'); + } else { + this.elem.addClass('expanded'); + } + this.disable_or_enable_interaction(); + } + + /** + * Handles disabling and enabling of collapse and resize elements. + */ + disable_or_enable_interaction() { + const locked = this.locked; + if (locked && !this.disable_lock) { + $('.collapse_btn', this.collapse_elem).addClass('disabled'); + this.resizer_elem.addClass('d-none'); + } else { + $('.collapse_btn', this.collapse_elem).removeClass('disabled'); + this.resizer_elem.removeClass('d-none'); + } + } + + /** + * Toggles the sidebar's responsive state and css class based on its width. + */ + responsive_toggle() { + if (!this.locked) { + this.elem.removeClass('collapsed'); + this.elem.removeClass('expanded'); + } + if (this.static) { + if (!this.collapsed) { + this.collapse(); + } + this.elem.removeClass('responsive-expanded'); + this.elem.removeClass('responsive-collapsed'); + } else { + if (this.collapsed) { + this.elem.removeClass('responsive-expanded'); + this.elem.addClass('responsive-collapsed'); + } else { + this.elem.addClass('responsive-expanded'); + this.elem.removeClass('responsive-collapsed'); + } + } + + if (this.collapsed !== this.responsive_collapsed) { + this.responsive_collapsed = this.collapsed; + this.trigger_event(); + } + + if ($(window).width() < 768) { + // disable locking functionality on mobile + this.disable_lock = true; + if (this.locked && !this.locked.collapsed) { + // collapse sidebar even if locked in expanded state + this.collapse(); + } + } else { + // enable locking functionality on tablet/desktop + this.disable_lock = false; + if (this.locked && !this.locked.collapsed && this.collapsed) { + // expand sidebar if previously collapsed due to viewport width + this.expand(); + } else if (this.locked && this.locked.collapsed && !this.collapsed) { + // collapse sidebar if previously expanded in mobile view + this.collapse(); + } + } + if (this.locked) { + this.disable_or_enable_interaction(); + } + } + + /** + * Collapses the sidebar. + */ + collapse() { + // Enable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'auto'); + this.elem + .removeClass('expanded') + .addClass('collapsed'); + this.trigger_event(); + } + + /** + * Expands the sidebar. + */ + expand() { + // Disable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'none'); + this.elem + .removeClass('collapsed') + .addClass('expanded'); + this.trigger_event(); + } + + /** + * Handles click events to toggle the sidebar's collapsed state. + * @param {Event} evt + */ + on_click(evt) { + if (this.collapsed) { + this.expand(); + } else { + this.collapse(); + } + if (this.locked !== undefined && this.locked !== null && !this.disable_lock) { + this.set_state(); + } + } + + /** + * Handles the mouse move event to adjust the sidebar width. + * @param {Event} evt + */ + move(evt) { + if (this.locked) return; + this.moving = true; + this.scrollbar.pointer_events = false; + this.sidebar_width = this.get_width_from_event(evt); + this.elem.css('width', this.sidebar_width); + this.trigger_event(); + } + + /** + * Finalizes the sidebar resizing on mouse up. + */ + up() { + this.scrollbar.pointer_events = true; + this.trigger_event(); + this.moving = false; + } + + /** + * Handle collapsing if sibling sidebar resized. + */ + on_sibling_sidebar_resize(inst, sb) { + const max_w = $(window).width() - this.elem.outerWidth() - 300; + const is_mobile = $(window).width() < 768; + const is_locked_expanded = this.locked && !this.locked.collapsed; + const sb_exceeds_width = sb.elem.outerWidth() >= max_w; + + if (!sb.collapsed && is_mobile) { + // collapse sidebar if sibling sidebar expanded in mobile view + this.collapse(); + this.elem.addClass('d-none'); + } else if (!sb.collapsed && sb_exceeds_width && (!sb.moving || !this.locked)) { + // collapse sidebar on sibling sidebar expand if not locked and + // sibling sidebar width exceeds available width + this.collapse(); + } else if (sb.collapsed && is_locked_expanded && this.collapsed && !is_mobile) { + // expand sidebar if locked in expanded state but previously + // collapsed due to viewport width + this.expand(); + this.elem.removeClass('d-none'); + } else if (sb.collapsed) { + this.elem.removeClass('d-none'); + } + } + + /* Destroy the sidebar and remove event listeners. */ + destroy() { + this.reset_state(); + $(window).off('resize', this.on_window_resize); + this.collapse_elem.off(); + this.scrollbar = null; + this.elem.off(); + this.lock_elem.off('click', this.on_lock); + } +} + +export class SidebarLeft extends Sidebar { + /** + * Initializes the Sidebar instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#sidebar_left', context); + if (!elem) { + return; + } + new SidebarLeft(elem); + } + + constructor(elem) { + super(elem); + this.on_sidebar_right_resize = this.on_sidebar_right_resize.bind(this); + global_events.on('on_sidebar_right_resize', this.on_sidebar_right_resize); + } + + /** + * Returns the locked state set in localStorage. + */ + get locked() { + return JSON.parse(localStorage.getItem('cone.app.sidebar_left.locked')); + } + + /** + * Gets the current width of the sidebar from local storage. + * @returns {number} + */ + get sidebar_width() { + return localStorage.getItem('cone-app-sidebar-left-width') || 300; + } + + /** + * Sets the width of the sidebar in local storage. + * @param {number} width + */ + set sidebar_width(width) { + localStorage.setItem('cone-app-sidebar-left-width', width); + } + + /** + * Triggers the resize event. + */ + trigger_event() { + global_events.trigger('on_sidebar_left_resize', this); + } + + /** + * Calculate sidebar width from move Event. + */ + get_width_from_event(evt) { + let width = evt.pageX; + + // Prevent sidebar from collapsing too much and expanding too far + let sidebar_w = 0; + if ($('#sidebar_right').length > 0) { + sidebar_w = $('#sidebar_right').outerWidth(); + } + // Allow a minimal content area width of 300px. + const max_w = $(window).width() - sidebar_w - 300; + width = Math.max(this.min_width, Math.min(width, max_w)); + + return parseInt(width); + } + + /** + * Handle collapsing if sidebar_right resized. + */ + on_sidebar_right_resize(inst, sb) { + this.on_sibling_sidebar_resize(inst, sb); + } + + /** + * Remembers the sidebar state for repaint. + */ + set_state() { + // remember sidebar state + localStorage.setItem('cone.app.sidebar_left.locked', JSON.stringify({ + collapsed: this.collapsed + })); + } + + /** + * Unsets the sidebar state in localStorage. + */ + unset_state() { + // remember sidebar state + localStorage.removeItem('cone.app.sidebar_left.locked'); + } + + destroy() { + super.destroy(); + global_events.off('on_sidebar_right_resize', this.on_sidebar_right_resize); + } +} + +export class SidebarRight extends Sidebar { + /** + * Initializes the Sidebar instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#sidebar_right', context); + if (!elem) { + return; + } + new SidebarRight(elem); + } + + constructor(elem) { + super(elem); + this.on_sidebar_left_resize = this.on_sidebar_left_resize.bind(this); + global_events.on('on_sidebar_left_resize', this.on_sidebar_left_resize); + } + + /** + * Returns the locked state set in localStorage. + */ + get locked() { + return JSON.parse(localStorage.getItem('cone.app.sidebar_right.locked')); + } + + /** + * Gets the current width of the sidebar from local storage. + * @returns {number} + */ + get sidebar_width() { + return localStorage.getItem('cone-app-sidebar-right-width') || 300; + } + + /** + * Sets the width of the sidebar in local storage. + * @param {number} width + */ + set sidebar_width(width) { + localStorage.setItem('cone-app-sidebar-right-width', width); + } + + /** + * Triggers the resize event. + */ + trigger_event() { + global_events.trigger('on_sidebar_right_resize', this); + } + + /** + * Calculate sidebar width from move Event. + */ + get_width_from_event(evt) { + let width = $(window).outerWidth() - evt.pageX; + // Prevent sidebar from collapsing too much and expanding too far + let sidebar_w = 0; + if ($('#sidebar_left').length > 0) { + sidebar_w = $('#sidebar_left').outerWidth(); + } + // Allow a minimal content area width of 300px. + const max_w = $(window).width() - sidebar_w - 300; + width = Math.max(this.min_width, Math.min(width, max_w)); + + return parseInt(width); + } + + /** + * Handle collapsing if sidebar_left resized. + */ + on_sidebar_left_resize(inst, sb) { + this.on_sibling_sidebar_resize(inst, sb); + } + + /** + * Remembers the sidebar state for repaint. + */ + set_state() { + // remember sidebar state + localStorage.setItem('cone.app.sidebar_right.locked', JSON.stringify({ + collapsed: this.collapsed + })); + } + + /** + * Unsets the sidebar state in localStorage. + */ + unset_state() { + // remember sidebar state + localStorage.removeItem('cone.app.sidebar_right.locked'); + } + + destroy() { + super.destroy(); + global_events.off('on_sidebar_left_resize', this.on_sidebar_left_resize); + } +} diff --git a/js/src/tabletoolbar.js b/js/src/tabletoolbar.js index 067e93b2..3bc4b5c7 100644 --- a/js/src/tabletoolbar.js +++ b/js/src/tabletoolbar.js @@ -1,7 +1,7 @@ import { BatchedItemsSize, BatchedItemsSearch -} from "./batcheditems.js"; +} from './batcheditems.js'; export class TableToolbar { diff --git a/js/src/translation.js b/js/src/translation.js index 55b06d7d..9ff2bdb4 100644 --- a/js/src/translation.js +++ b/js/src/translation.js @@ -1,6 +1,5 @@ import $ from 'jquery'; - export class Translation { static initialize(context) { @@ -10,24 +9,25 @@ export class Translation { } constructor(nav_elem) { + $('div.invalid-feedback', nav_elem.parent()).show(); this.nav_elem = nav_elem; this.fields_elem = nav_elem.next(); this.show_lang_handle = this.show_lang_handle.bind(this); $('li > a', nav_elem).on('click', this.show_lang_handle); if ($('li.error', nav_elem).length) { - $('li.error:first > a', nav_elem).click(); + $('li.error:first > a', nav_elem).trigger('click'); } else { - $('li.active > a', nav_elem).click(); + $('li > a.active', nav_elem).trigger('click'); } this.fields_elem.show(); } show_lang_handle(evt) { evt.preventDefault(); - this.nav_elem.children().removeClass('active'); + $('li > a', this.nav_elem).removeClass('active'); this.fields_elem.children().hide(); let elem = $(evt.currentTarget); - elem.parent().addClass('active'); + elem.addClass('active'); $(elem.attr('href'), this.fields_elem).show(); } } diff --git a/js/src/utils.js b/js/src/utils.js index 92042123..cd13c9c2 100644 --- a/js/src/utils.js +++ b/js/src/utils.js @@ -1,10 +1,25 @@ import ts from 'treibstoff'; +/** + * Creates a cookie with the specified name, value, and expiration days. + * This function is deprecated; use `ts.create_cookie` instead. + * + * @param {string} name + * @param {string} value + * @param {number} days + */ export function createCookie(name, value, days) { ts.deprecate('createCookie', 'ts.create_cookie', '1.1'); ts.create_cookie(name, value, days); } +/** + * Reads the value of the cookie with the specified name. + * This function is deprecated; use `ts.read_cookie` instead. + * + * @param {string} name + * @returns {string|null} + */ export function readCookie(name) { ts.deprecate('readCookie', 'ts.read_cookie', '1.1'); return ts.read_cookie(name); diff --git a/js/tests/test_batcheditems.js b/js/tests/test_batcheditems.js index 69dc58f7..aa13baa6 100644 --- a/js/tests/test_batcheditems.js +++ b/js/tests/test_batcheditems.js @@ -1,7 +1,495 @@ -QUnit.module('cone.app.batcheditems', hooks => { +import $ from 'jquery'; +import ts from 'treibstoff'; +import { + BatchedItemsFilter, + BatchedItemsSize, + BatchedItemsSearch, + batcheditems_handle_filter, + batcheditems_size_binder, + batcheditems_filter_binder +} from '../src/batcheditems.js'; - QUnit.test('Test stub', assert => { - assert.ok(true); - }) +QUnit.module('cone.app.batcheditems.BatchedItemsFilter', hooks => { + let container, + ajax_trigger_origin, + ajax_path_origin, + ajax_parse_target_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_trigger_origin = ts.ajax.trigger; + ajax_path_origin = ts.ajax.path; + ajax_parse_target_origin = ts.ajax.parse_target; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.trigger = ajax_trigger_origin; + ts.ajax.path = ajax_path_origin; + ts.ajax.parse_target = ajax_parse_target_origin; + }); + + QUnit.test('BatchedItemsFilter constructor stores elem and name', assert => { + let elem = $('
'); + let filter = new BatchedItemsFilter(elem, 'test_name'); + + assert.strictEqual(filter.elem, elem, 'elem stored'); + assert.strictEqual(filter.name, 'test_name', 'name stored'); + }); + + QUnit.test('set_filter triggers ajax with parsed target', assert => { + let elem = $(` +
+
+ `).appendTo(container); + + ts.ajax.parse_target = function(target) { + return { + path: '/api/items', + query: '?page=1', + params: {} + }; + }; + + let triggered = null; + ts.ajax.trigger = function(opts) { + triggered = opts; + }; + + let filter = new BatchedItemsFilter(elem, 'size'); + filter.set_filter('10'); + + assert.strictEqual(triggered.name, 'reload', 'correct event name'); + assert.strictEqual(triggered.selector, '#table', 'correct selector'); + assert.strictEqual(triggered.target.params.size, '10', + 'filter param added to target'); + }); + + QUnit.test('set_filter updates path when ajax:path present', assert => { + let elem = $(` +
+
+ `).appendTo(container); + + ts.ajax.parse_target = function() { + return { + path: '/api/items', + query: '?page=1', + params: {} + }; + }; + + let path_opts = null; + ts.ajax.path = function(opts) { + path_opts = opts; + }; + ts.ajax.trigger = function() {}; + + let filter = new BatchedItemsFilter(elem, 'term'); + filter.set_filter('search'); + + assert.ok(path_opts, 'ajax.path called'); + assert.strictEqual(path_opts.path, '/api/items?page=1&term=search', + 'path includes filter param'); + assert.strictEqual(path_opts.event, 'reload:#table', + 'event from ajax:event (full event string)'); + }); + + QUnit.test('set_filter uses ajax:path-event when specified', assert => { + let elem = $(` +
+
+ `).appendTo(container); + + ts.ajax.parse_target = function() { + return {path: '/api/items', query: '', params: {}}; + }; + + let path_opts = null; + ts.ajax.path = function(opts) { + path_opts = opts; + }; + ts.ajax.trigger = function() {}; + + let filter = new BatchedItemsFilter(elem, 'page'); + filter.set_filter('2'); + + assert.strictEqual(path_opts.event, 'navigate', + 'uses ajax:path-event instead of ajax:event'); + }); +}); + +QUnit.module('cone.app.batcheditems.BatchedItemsSize', hooks => { + + let container, + ajax_trigger_origin, + ajax_parse_target_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_trigger_origin = ts.ajax.trigger; + ajax_parse_target_origin = ts.ajax.parse_target; + + ts.ajax.parse_target = function() { + return {path: '/api', query: '', params: {}}; + }; + ts.ajax.trigger = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.trigger = ajax_trigger_origin; + ts.ajax.parse_target = ajax_parse_target_origin; + }); + + QUnit.test('BatchedItemsSize.initialize creates instances', assert => { + let html = $(` +
+ +
+ `).appendTo(container); + + BatchedItemsSize.initialize(container); + + let select = container.find('select'); + let events = $._data(select.get(0), 'events'); + assert.ok(events && events.change, 'change event bound'); + }); + + QUnit.test('BatchedItemsSize.initialize with custom selector', assert => { + let html = $(` + + `).appendTo(container); + + BatchedItemsSize.initialize(container, '.custom-size'); + + let select = container.find('select'); + let events = $._data(select.get(0), 'events'); + assert.ok(events && events.change, 'change event bound with custom selector'); + }); + + QUnit.test('BatchedItemsSize constructor sets name to size', assert => { + let select = $(` + + `).appendTo(container); + + let instance = new BatchedItemsSize(select); + + assert.strictEqual(instance.name, 'size', 'name is size'); + }); + + QUnit.test('BatchedItemsSize change_handle triggers filter', assert => { + let select = $(` + + `).appendTo(container); + + let triggered_target = null; + ts.ajax.trigger = function(opts) { + triggered_target = opts.target; + }; + + let instance = new BatchedItemsSize(select); + select.trigger('change'); + + assert.strictEqual(triggered_target.params.size, '25', + 'selected value passed to filter'); + }); + + QUnit.test('BatchedItemsSize rebinds on subsequent initialization', assert => { + let select = $(` + + `).appendTo(container); + + let trigger_count = 0; + ts.ajax.trigger = function() { + trigger_count++; + }; + + new BatchedItemsSize(select); + new BatchedItemsSize(select); + select.trigger('change'); + + assert.strictEqual(trigger_count, 1, 'handler fires only once'); + }); +}); + +QUnit.module('cone.app.batcheditems.BatchedItemsSearch', hooks => { + + let container, + ajax_trigger_origin, + ajax_parse_target_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_trigger_origin = ts.ajax.trigger; + ajax_parse_target_origin = ts.ajax.parse_target; + + ts.ajax.parse_target = function() { + return {path: '/api', query: '', params: {}}; + }; + ts.ajax.trigger = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.trigger = ajax_trigger_origin; + ts.ajax.parse_target = ajax_parse_target_origin; + }); + + QUnit.test('BatchedItemsSearch.initialize creates instances', assert => { + let html = $(` +
+ +
+ `).appendTo(container); + + BatchedItemsSearch.initialize(container); + + let input = container.find('input'); + let events = $._data(input.get(0), 'events'); + assert.ok(events && events.keyup, 'keyup event bound'); + assert.ok(events && events.keypress, 'keypress event bound'); + assert.ok(events && events.focus, 'focus event bound'); + assert.ok(events && events.change, 'change event bound'); + }); + + QUnit.test('BatchedItemsSearch.initialize with custom selector and name', assert => { + let html = $(` + + `).appendTo(container); + + BatchedItemsSearch.initialize(container, '.custom-search', 'query'); + + let input = container.find('input'); + let events = $._data(input.get(0), 'events'); + assert.ok(events && events.keyup, 'events bound with custom selector'); + }); + + QUnit.test('BatchedItemsSearch focus_handle clears empty_filter', assert => { + let input = $(` + + `).appendTo(container); + + let instance = new BatchedItemsSearch(input, 'term'); + input.trigger('focus'); + + assert.false(input.hasClass('empty_filter'), 'empty_filter class removed'); + assert.strictEqual(input.val(), '', 'value cleared'); + }); + + QUnit.test('BatchedItemsSearch focus_handle ignores non-empty filter', assert => { + let input = $(` + + `).appendTo(container); + + let instance = new BatchedItemsSearch(input, 'term'); + input.trigger('focus'); + + assert.strictEqual(input.val(), 'actual search', 'value unchanged'); + }); + + QUnit.test('BatchedItemsSearch keypress_handle prevents Enter default', assert => { + let input = $(` + + `).appendTo(container); + + let instance = new BatchedItemsSearch(input, 'term'); + + let evt = new $.Event('keypress', {keyCode: 13}); + input.trigger(evt); + + assert.true(evt.isDefaultPrevented(), 'default prevented for Enter'); + }); + + QUnit.test('BatchedItemsSearch keypress_handle allows other keys', assert => { + let input = $(` + + `).appendTo(container); + + let instance = new BatchedItemsSearch(input, 'term'); + + let evt = new $.Event('keypress', {keyCode: 65}); + input.trigger(evt); + + assert.false(evt.isDefaultPrevented(), 'default not prevented for other keys'); + }); + + QUnit.test('BatchedItemsSearch keyup_handle triggers on Enter', assert => { + let input = $(` + + `).appendTo(container); + + let triggered_target = null; + ts.ajax.trigger = function(opts) { + triggered_target = opts.target; + }; + + let instance = new BatchedItemsSearch(input, 'term'); + let evt = new $.Event('keyup', {keyCode: 13}); + input.trigger(evt); + + assert.strictEqual(triggered_target.params.term, 'test search', + 'search triggered on Enter'); + }); + + QUnit.test('BatchedItemsSearch change_handle triggers filter', assert => { + let input = $(` + + `).appendTo(container); + + let triggered_target = null; + ts.ajax.trigger = function(opts) { + triggered_target = opts.target; + }; + + let instance = new BatchedItemsSearch(input, 'term'); + input.trigger('change'); + + assert.strictEqual(triggered_target.params.term, 'changed value', + 'filter triggered on change'); + }); +}); + +QUnit.module('cone.app.batcheditems.deprecated', hooks => { + + let container, + deprecate_origin, + ajax_trigger_origin, + ajax_parse_target_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + deprecate_origin = ts.deprecate; + ajax_trigger_origin = ts.ajax.trigger; + ajax_parse_target_origin = ts.ajax.parse_target; + + ts.ajax.parse_target = function() { + return {path: '/api', query: '', params: {}}; + }; + ts.ajax.trigger = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.deprecate = deprecate_origin; + ts.ajax.trigger = ajax_trigger_origin; + ts.ajax.parse_target = ajax_parse_target_origin; + }); + + QUnit.test('batcheditems_handle_filter calls deprecate', assert => { + let deprecate_called = false; + ts.deprecate = function(old_name) { + if (old_name === 'batcheditems_handle_filter') { + deprecate_called = true; + } + }; + + let elem = $(` +
+ `).appendTo(container); + + batcheditems_handle_filter(elem, 'param', 'value'); + + assert.true(deprecate_called, 'deprecate warning issued'); + }); + + QUnit.test('batcheditems_size_binder calls deprecate', assert => { + let deprecate_called = false; + ts.deprecate = function(old_name) { + if (old_name === 'batcheditems_size_binder') { + deprecate_called = true; + } + }; + + batcheditems_size_binder(container); + + assert.true(deprecate_called, 'deprecate warning issued'); + }); + + QUnit.test('batcheditems_filter_binder calls deprecate', assert => { + let deprecate_called = false; + ts.deprecate = function(old_name) { + if (old_name === 'batcheditems_filter_binder') { + deprecate_called = true; + } + }; + + batcheditems_filter_binder(container); + + assert.true(deprecate_called, 'deprecate warning issued'); + }); + + QUnit.test('batcheditems_size_binder uses default selector', assert => { + let html = $(` +
+ +
+ `).appendTo(container); + + ts.deprecate = function() {}; + batcheditems_size_binder(container); + + let select = container.find('select'); + let events = $._data(select.get(0), 'events'); + assert.ok(events && events.change, 'default selector works'); + }); + + QUnit.test('batcheditems_filter_binder uses default selector and name', assert => { + let html = $(` +
+ +
+ `).appendTo(container); + + ts.deprecate = function() {}; + batcheditems_filter_binder(container); + + let input = container.find('input'); + let events = $._data(input.get(0), 'events'); + assert.ok(events && events.keyup, 'default selector works'); + }); }); diff --git a/js/tests/test_colormode.js b/js/tests/test_colormode.js new file mode 100644 index 00000000..80056e42 --- /dev/null +++ b/js/tests/test_colormode.js @@ -0,0 +1,288 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {ColorMode, ColorToggler} from '../src/colormode.js'; + +QUnit.module('cone.app.colormode.ColorMode', hooks => { + + let stored_theme_origin, + theme_attr_origin; + + hooks.beforeEach(() => { + stored_theme_origin = localStorage.getItem('cone-app-color-theme'); + theme_attr_origin = document.documentElement.getAttribute('data-bs-theme'); + localStorage.removeItem('cone-app-color-theme'); + document.documentElement.removeAttribute('data-bs-theme'); + }); + + hooks.afterEach(() => { + if (stored_theme_origin) { + localStorage.setItem('cone-app-color-theme', stored_theme_origin); + } else { + localStorage.removeItem('cone-app-color-theme'); + } + if (theme_attr_origin) { + document.documentElement.setAttribute('data-bs-theme', theme_attr_origin); + } else { + document.documentElement.removeAttribute('data-bs-theme'); + } + ColorMode.unbind(); + }); + + QUnit.test('media_query returns MediaQueryList', assert => { + let mq = ColorMode.media_query; + assert.ok(mq, 'media_query returns object'); + assert.strictEqual(typeof mq.matches, 'boolean', 'has matches property'); + }); + + QUnit.test('stored_theme getter returns null when not set', assert => { + assert.strictEqual(ColorMode.stored_theme, null, 'returns null'); + }); + + QUnit.test('stored_theme setter/getter works correctly', assert => { + ColorMode.stored_theme = 'dark'; + assert.strictEqual(ColorMode.stored_theme, 'dark', 'stored value retrieved'); + assert.strictEqual(localStorage.getItem('cone-app-color-theme'), 'dark', + 'value in localStorage'); + }); + + QUnit.test('preferred_theme returns stored theme if set', assert => { + ColorMode.stored_theme = 'light'; + assert.strictEqual(ColorMode.preferred_theme, 'light', + 'returns stored theme'); + }); + + QUnit.test('preferred_theme falls back to media query', assert => { + localStorage.removeItem('cone-app-color-theme'); + let preferred = ColorMode.preferred_theme; + assert.ok(preferred === 'dark' || preferred === 'light', + 'returns valid theme based on media query'); + }); + + QUnit.test('watch adds event listener', assert => { + let called = false; + let handle = function() { called = true; }; + + ColorMode.watch(handle); + assert.ok(true, 'watch does not throw'); + }); + + QUnit.test('set_theme sets data-bs-theme attribute', assert => { + ColorMode.set_theme('dark'); + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + 'dark', + 'dark theme set' + ); + + ColorMode.set_theme('light'); + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + 'light', + 'light theme set' + ); + }); + + QUnit.test('set_theme handles auto mode', assert => { + ColorMode.set_theme('auto'); + let theme = document.documentElement.getAttribute('data-bs-theme'); + assert.ok(theme === 'dark' || theme === 'auto', + 'auto mode sets theme based on media query'); + }); + + QUnit.test('bind sets up boundCallback', assert => { + ColorMode.bind(); + assert.ok(ColorMode.boundCallback, 'boundCallback set'); + }); + + QUnit.test('callback updates theme when not explicitly set', assert => { + localStorage.removeItem('cone-app-color-theme'); + ColorMode.callback(); + let theme = document.documentElement.getAttribute('data-bs-theme'); + assert.ok(theme === 'dark' || theme === 'light' || theme === null, + 'theme updated or left alone'); + }); + + QUnit.test('callback does not change theme when light stored', assert => { + ColorMode.stored_theme = 'light'; + ColorMode.set_theme('light'); + ColorMode.callback(); + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + 'light', + 'theme unchanged when light stored' + ); + }); + + QUnit.test('callback does not change theme when dark stored', assert => { + ColorMode.stored_theme = 'dark'; + ColorMode.set_theme('dark'); + ColorMode.callback(); + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + 'dark', + 'theme unchanged when dark stored' + ); + }); + + QUnit.test('unbind removes event listener and attribute', assert => { + ColorMode.bind(); + ColorMode.set_theme('dark'); + ColorMode.unbind(); + + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + null, + 'data-bs-theme attribute removed' + ); + }); + + QUnit.test('unbind handles missing boundCallback', assert => { + ColorMode.boundCallback = null; + ColorMode.unbind(); + assert.ok(true, 'unbind does not throw without boundCallback'); + }); + + QUnit.test('constructor calls bind and set_theme', assert => { + localStorage.removeItem('cone-app-color-theme'); + new ColorMode(); + + assert.ok(ColorMode.boundCallback, 'bind called'); + let theme = document.documentElement.getAttribute('data-bs-theme'); + assert.ok(theme === 'dark' || theme === 'light', + 'theme set from preferred'); + }); +}); + +QUnit.module('cone.app.colormode.ColorToggler', hooks => { + + let container, + stored_theme_origin, + theme_attr_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + stored_theme_origin = localStorage.getItem('cone-app-color-theme'); + theme_attr_origin = document.documentElement.getAttribute('data-bs-theme'); + localStorage.removeItem('cone-app-color-theme'); + }); + + hooks.afterEach(() => { + container.remove(); + if (stored_theme_origin) { + localStorage.setItem('cone-app-color-theme', stored_theme_origin); + } else { + localStorage.removeItem('cone-app-color-theme'); + } + if (theme_attr_origin) { + document.documentElement.setAttribute('data-bs-theme', theme_attr_origin); + } else { + document.documentElement.removeAttribute('data-bs-theme'); + } + ColorMode.unbind(); + }); + + QUnit.test('initialize returns early without toggle switch', assert => { + ColorToggler.initialize(container); + assert.ok(true, 'no error without toggle switch'); + }); + + QUnit.test('initialize creates instance when toggle exists', assert => { + let toggle = $('') + .appendTo(container); + + ColorToggler.initialize(container); + + let events = $._data(toggle.get(0), 'events'); + assert.ok(events && events.change, 'change event bound'); + }); + + QUnit.test('constructor stores elem and binds events', assert => { + let toggle = $('') + .appendTo(container); + + let toggler = new ColorToggler(toggle); + + assert.ok(toggler.elem, 'elem stored'); + let events = $._data(toggle.get(0), 'events'); + assert.ok(events && events.change, 'change event bound'); + }); + + QUnit.test('update checks toggle when dark theme preferred', assert => { + ColorMode.stored_theme = 'dark'; + + let toggle = $('') + .appendTo(container); + + let toggler = new ColorToggler(toggle); + toggler.update(); + + assert.true(toggle.is(':checked'), 'toggle checked for dark theme'); + }); + + QUnit.test('update unchecks toggle when light theme preferred', assert => { + ColorMode.stored_theme = 'light'; + + let toggle = $('') + .appendTo(container); + + let toggler = new ColorToggler(toggle); + toggler.update(); + + assert.false(toggle.is(':checked'), 'toggle unchecked for light theme'); + }); + + QUnit.test('update leaves toggle unchanged when already correct', assert => { + ColorMode.stored_theme = 'dark'; + + let toggle = $('') + .appendTo(container); + + let toggler = new ColorToggler(toggle); + toggler.update(); + + assert.true(toggle.is(':checked'), 'toggle remains checked'); + }); + + QUnit.test('on_change sets dark theme when checked', assert => { + let toggle = $('') + .appendTo(container); + + let toggler = new ColorToggler(toggle); + toggle.prop('checked', true); + toggler.on_change(); + + assert.strictEqual(ColorMode.stored_theme, 'dark', 'dark theme stored'); + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + 'dark', + 'dark theme set on document' + ); + }); + + QUnit.test('on_change sets light theme when unchecked', assert => { + let toggle = $('') + .appendTo(container); + + let toggler = new ColorToggler(toggle); + toggler.on_change(); + + assert.strictEqual(ColorMode.stored_theme, 'light', 'light theme stored'); + assert.strictEqual( + document.documentElement.getAttribute('data-bs-theme'), + 'light', + 'light theme set on document' + ); + }); + + QUnit.test('toggler responds to change event', assert => { + let toggle = $('') + .appendTo(container); + + new ColorToggler(toggle); + + toggle.prop('checked', true).trigger('change'); + + assert.strictEqual(ColorMode.stored_theme, 'dark', + 'change event triggers on_change'); + }); +}); diff --git a/js/tests/test_cone.js b/js/tests/test_cone.js index 4456d18c..31d4c1ed 100644 --- a/js/tests/test_cone.js +++ b/js/tests/test_cone.js @@ -1,7 +1,101 @@ -QUnit.module('cone.app.cone', hooks => { +// NOTE: The cone.js module imports sidebar.js which has an incompatible import: +// `import $, { event } from 'jquery'` - jQuery 4.0 doesn't export 'event'. +// This test file tests individual module exports instead of the aggregated cone object. - QUnit.test('Test stub', assert => { - assert.ok(true); - }) +import {Selectable} from '../src/selectable.js'; +import {keys, KeyBinder} from '../src/keybinder.js'; +import {createCookie, readCookie} from '../src/utils.js'; +import {Sharing} from '../src/sharing.js'; +import {CopySupport} from '../src/copysupport.js'; +import {LiveSearch} from '../src/livesearch.js'; +import {TableToolbar} from '../src/tabletoolbar.js'; +import {ColorMode, ColorToggler} from '../src/colormode.js'; +import {global_events, GlobalEvents} from '../src/globals.js'; +import { + BatchedItemsFilter, + BatchedItemsSize, + BatchedItemsSearch +} from '../src/batcheditems.js'; +import { + ReferenceHandle, + AddReferenceHandle, + RemoveReferenceHandle, + ReferenceBrowserLoader +} from '../src/referencebrowser.js'; +import {Translation} from '../src/translation.js'; +QUnit.module('cone.app.modules', hooks => { + + QUnit.test('keybinder module exports correctly', assert => { + assert.ok(keys, 'keys object exported'); + assert.strictEqual(typeof keys.shift_down, 'boolean', 'keys.shift_down exists'); + assert.strictEqual(typeof keys.ctrl_down, 'boolean', 'keys.ctrl_down exists'); + assert.ok(KeyBinder, 'KeyBinder class exported'); + }); + + QUnit.test('selectable module exports correctly', assert => { + assert.ok(Selectable, 'Selectable class exported'); + assert.strictEqual(typeof Selectable.prototype.bind, 'function', + 'Selectable has bind method'); + }); + + QUnit.test('utils module exports correctly', assert => { + assert.strictEqual(typeof createCookie, 'function', 'createCookie exported'); + assert.strictEqual(typeof readCookie, 'function', 'readCookie exported'); + }); + + QUnit.test('sharing module exports correctly', assert => { + assert.ok(Sharing, 'Sharing class exported'); + assert.strictEqual(typeof Sharing.initialize, 'function', + 'Sharing has static initialize'); + }); + + QUnit.test('copysupport module exports correctly', assert => { + assert.ok(CopySupport, 'CopySupport class exported'); + assert.strictEqual(typeof CopySupport.initialize, 'function', + 'CopySupport has static initialize'); + }); + + QUnit.test('livesearch module exports correctly', assert => { + assert.ok(LiveSearch, 'LiveSearch class exported'); + assert.strictEqual(typeof LiveSearch.initialize, 'function', + 'LiveSearch has static initialize'); + }); + + QUnit.test('tabletoolbar module exports correctly', assert => { + assert.ok(TableToolbar, 'TableToolbar class exported'); + assert.strictEqual(typeof TableToolbar.initialize, 'function', + 'TableToolbar has static initialize'); + }); + + QUnit.test('colormode module exports correctly', assert => { + assert.ok(ColorMode, 'ColorMode class exported'); + assert.ok(ColorToggler, 'ColorToggler class exported'); + }); + + QUnit.test('globals module exports correctly', assert => { + assert.ok(global_events, 'global_events instance exported'); + assert.ok(GlobalEvents, 'GlobalEvents class exported'); + assert.ok(global_events instanceof GlobalEvents, + 'global_events is GlobalEvents instance'); + }); + + QUnit.test('batcheditems module exports correctly', assert => { + assert.ok(BatchedItemsFilter, 'BatchedItemsFilter class exported'); + assert.ok(BatchedItemsSize, 'BatchedItemsSize class exported'); + assert.ok(BatchedItemsSearch, 'BatchedItemsSearch class exported'); + }); + + QUnit.test('referencebrowser module exports correctly', assert => { + assert.ok(ReferenceHandle, 'ReferenceHandle class exported'); + assert.ok(AddReferenceHandle, 'AddReferenceHandle class exported'); + assert.ok(RemoveReferenceHandle, 'RemoveReferenceHandle class exported'); + assert.ok(ReferenceBrowserLoader, 'ReferenceBrowserLoader class exported'); + }); + + QUnit.test('translation module exports correctly', assert => { + assert.ok(Translation, 'Translation class exported'); + assert.strictEqual(typeof Translation.initialize, 'function', + 'Translation has static initialize'); + }); }); diff --git a/js/tests/test_copysupport.js b/js/tests/test_copysupport.js index 8ca40c26..b3cf674f 100644 --- a/js/tests/test_copysupport.js +++ b/js/tests/test_copysupport.js @@ -1,7 +1,329 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {CopySupport} from '../src/copysupport.js'; +import {keys} from '../src/keybinder.js'; +// Import selectable to register $.fn.selectable jQuery plugin +import '../src/selectable.js'; + QUnit.module('cone.app.copysupport', hooks => { - QUnit.test('Test stub', assert => { - assert.ok(true); - }) + let container, + create_cookie_origin, + read_cookie_origin, + ajax_action_origin, + ajax_parse_target_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + create_cookie_origin = ts.create_cookie; + read_cookie_origin = ts.read_cookie; + ajax_action_origin = ts.ajax.action; + ajax_parse_target_origin = ts.ajax.parse_target; + + // Reset key state + keys.shift_down = false; + keys.ctrl_down = false; + }); + + hooks.afterEach(() => { + container.remove(); + ts.create_cookie = create_cookie_origin; + ts.read_cookie = read_cookie_origin; + ts.ajax.action = ajax_action_origin; + ts.ajax.parse_target = ajax_parse_target_origin; + keys.shift_down = false; + keys.ctrl_down = false; + }); + + function create_copysupport_fixture() { + return $(` +
+ Cut + Copy + Paste + + + + + + + + + + +
Item 1
Item 2
Item 3
+
+ `).appendTo(container); + } + + QUnit.test('CopySupport.initialize creates instance', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + CopySupport.initialize(container); + + let paste_btn = container.find('#toolbaraction-paste'); + let events = $._data(paste_btn.get(0), 'events'); + assert.ok(events && events.click, 'paste click handler bound'); + }); + + QUnit.test('CopySupport constructor sets up cookie names', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let copysupport = new CopySupport(container); + + assert.strictEqual(copysupport.cut_cookie, 'cone.app.copysupport.cut', + 'cut cookie name set'); + assert.strictEqual(copysupport.copy_cookie, 'cone.app.copysupport.copy', + 'copy cookie name set'); + }); + + QUnit.test('CopySupport binds selectable to copyable rows', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let copysupport = new CopySupport(container); + + assert.ok(copysupport.selectable, 'selectable instance created'); + assert.strictEqual(copysupport.copyable.length, 3, 'three copyable items'); + }); + + QUnit.test('CopySupport reads cut selection from cookie', assert => { + create_copysupport_fixture(); + ts.read_cookie = function(name) { + if (name === 'cone.app.copysupport.cut') { + return '/item1::/item2'; + } + return null; + }; + + let copysupport = new CopySupport(container); + + let selected = container.find('.selected'); + assert.strictEqual(selected.length, 2, 'two items selected from cookie'); + assert.true(selected.eq(0).hasClass('copysupport_cut'), + 'cut class applied'); + }); + + QUnit.test('CopySupport reads copy selection from cookie', assert => { + create_copysupport_fixture(); + ts.read_cookie = function(name) { + if (name === 'cone.app.copysupport.copy') { + return '/item3'; + } + return null; + }; + + let copysupport = new CopySupport(container); + + let selected = container.find('.selected'); + assert.strictEqual(selected.length, 1, 'one item selected from cookie'); + assert.false(selected.hasClass('copysupport_cut'), + 'cut class not applied for copy'); + }); + + QUnit.test('CopySupport write_selected_to_cookie creates cookie', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let cookie_value = null; + ts.create_cookie = function(name, value) { + if (name === 'test_cookie') { + cookie_value = value; + } + }; + + let copysupport = new CopySupport(container); + copysupport.selectable.add(container.find('tr').get(0)); + copysupport.selectable.add(container.find('tr').get(1)); + copysupport.write_selected_to_cookie('test_cookie'); + + assert.strictEqual(cookie_value, '/item1::/item2', + 'cookie contains selected targets'); + }); + + QUnit.test('CopySupport write_selected_to_cookie enables paste', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + ts.create_cookie = function() {}; + + let copysupport = new CopySupport(container); + copysupport.selectable.add(container.find('tr').get(0)); + copysupport.write_selected_to_cookie('test'); + + assert.false(copysupport.paste_action.hasClass('disabled'), + 'paste enabled when items selected'); + }); + + QUnit.test('CopySupport write_selected_to_cookie disables paste when empty', + assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + ts.create_cookie = function() {}; + + let copysupport = new CopySupport(container); + copysupport.paste_action.removeClass('disabled'); + copysupport.write_selected_to_cookie('test'); + + assert.true(copysupport.paste_action.hasClass('disabled'), + 'paste disabled when no items selected'); + }); + + QUnit.test('CopySupport handle_cut writes to cut cookie', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let cookies = {}; + ts.create_cookie = function(name, value) { + cookies[name] = value; + }; + + let copysupport = new CopySupport(container); + container.find('tr').eq(0).addClass('selected'); + copysupport.selectable.add(container.find('tr').get(0)); + + let evt = new $.Event('click'); + copysupport.handle_cut(evt); + + assert.ok(cookies['cone.app.copysupport.cut'], 'cut cookie written'); + assert.strictEqual(cookies['cone.app.copysupport.copy'], '', + 'copy cookie cleared'); + assert.true(container.find('tr').eq(0).hasClass('copysupport_cut'), + 'cut class added to selected'); + }); + + QUnit.test('CopySupport handle_copy writes to copy cookie', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let cookies = {}; + ts.create_cookie = function(name, value) { + cookies[name] = value; + }; + + let copysupport = new CopySupport(container); + container.find('tr').eq(1).addClass('selected'); + copysupport.selectable.add(container.find('tr').get(1)); + + let evt = new $.Event('click'); + copysupport.handle_copy(evt); + + assert.ok(cookies['cone.app.copysupport.copy'], 'copy cookie written'); + assert.strictEqual(cookies['cone.app.copysupport.cut'], '', + 'cut cookie cleared'); + }); + + QUnit.test('CopySupport handle_copy removes cut class', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + ts.create_cookie = function() {}; + + let copysupport = new CopySupport(container); + container.find('tr').addClass('copysupport_cut'); + + let evt = new $.Event('click'); + copysupport.handle_copy(evt); + + assert.false(container.find('tr').hasClass('copysupport_cut'), + 'cut class removed'); + }); + + QUnit.test('CopySupport handle_paste triggers ajax action', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + ts.ajax.parse_target = function(target) { + return {url: '/api/paste', params: {}}; + }; + + let action_opts = null; + ts.ajax.action = function(opts) { + action_opts = opts; + }; + + let copysupport = new CopySupport(container); + copysupport.paste_action.removeClass('disabled'); + + let evt = new $.Event('click'); + evt.currentTarget = copysupport.paste_action.get(0); + copysupport.handle_paste(evt); + + assert.strictEqual(action_opts.name, 'paste', 'paste action triggered'); + assert.strictEqual(action_opts.mode, 'NONE', 'mode is NONE'); + assert.strictEqual(action_opts.url, '/api/paste', 'correct url'); + }); + + QUnit.test('CopySupport handle_paste ignores when disabled', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let action_called = false; + ts.ajax.action = function() { + action_called = true; + }; + + let copysupport = new CopySupport(container); + + let evt = new $.Event('click'); + evt.currentTarget = copysupport.paste_action.get(0); + copysupport.handle_paste(evt); + + assert.false(action_called, 'action not called when disabled'); + }); + + QUnit.test('CopySupport handle_paste prevents default', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + ts.ajax.action = function() {}; + ts.ajax.parse_target = function() { return {url: '', params: {}}; }; + + let copysupport = new CopySupport(container); + + let evt = new $.Event('click'); + evt.currentTarget = copysupport.paste_action.get(0); + copysupport.handle_paste(evt); + + assert.true(evt.isDefaultPrevented(), 'default prevented'); + }); + + QUnit.test('CopySupport without copyable items still handles paste', assert => { + $(` + + `).appendTo(container); + + ts.read_cookie = function() { return null; }; + + let copysupport = new CopySupport(container); + + assert.ok(copysupport.paste_action.length, 'paste action found'); + assert.notOk(copysupport.selectable, 'no selectable without copyable'); + }); + + QUnit.test('CopySupport click event bindings use off/on pattern', assert => { + create_copysupport_fixture(); + ts.read_cookie = function() { return null; }; + + let action_count = 0; + ts.ajax.action = function() { action_count++; }; + ts.ajax.parse_target = function() { return {url: '', params: {}}; }; + + // Initialize twice + new CopySupport(container); + new CopySupport(container); + + let paste_btn = container.find('#toolbaraction-paste'); + paste_btn.removeClass('disabled'); + + let evt = new $.Event('click'); + evt.currentTarget = paste_btn.get(0); + paste_btn.trigger(evt); + assert.strictEqual(action_count, 1, 'handler fires only once'); + }); }); diff --git a/js/tests/test_globals.js b/js/tests/test_globals.js new file mode 100644 index 00000000..1c97dcff --- /dev/null +++ b/js/tests/test_globals.js @@ -0,0 +1,62 @@ +import ts from 'treibstoff'; +import {GlobalEvents, global_events} from '../src/globals.js'; + +QUnit.module('cone.app.globals', hooks => { + + QUnit.test('GlobalEvents class exported', assert => { + assert.ok(GlobalEvents, 'GlobalEvents class exported'); + }); + + QUnit.test('GlobalEvents extends ts.Events', assert => { + let instance = new GlobalEvents(); + assert.true(instance instanceof ts.Events, + 'GlobalEvents extends ts.Events'); + }); + + QUnit.test('global_events is GlobalEvents instance', assert => { + assert.ok(global_events, 'global_events exported'); + assert.true(global_events instanceof GlobalEvents, + 'global_events is GlobalEvents instance'); + }); + + QUnit.test('on_sidebar_left_resize is callable', assert => { + let instance = new GlobalEvents(); + let mock_sidebar = {width: 250}; + + instance.on_sidebar_left_resize(mock_sidebar); + assert.ok(true, 'on_sidebar_left_resize callable without error'); + }); + + QUnit.test('on_sidebar_right_resize is callable', assert => { + let instance = new GlobalEvents(); + let mock_sidebar = {width: 300}; + + instance.on_sidebar_right_resize(mock_sidebar); + assert.ok(true, 'on_sidebar_right_resize callable without error'); + }); + + QUnit.test('on_main_area_mode is callable', assert => { + let instance = new GlobalEvents(); + let mock_main_area = {mode: 'compact'}; + + instance.on_main_area_mode(mock_main_area); + assert.ok(true, 'on_main_area_mode callable without error'); + }); + + QUnit.test('GlobalEvents can register event handlers', assert => { + let instance = new GlobalEvents(); + let handler_called = false; + + instance.on_sidebar_left_resize = function(inst) { + handler_called = true; + }; + + instance.on_sidebar_left_resize({}); + assert.true(handler_called, 'custom handler can be set'); + }); + + QUnit.test('global_events singleton is reused', assert => { + assert.strictEqual(global_events, global_events, + 'global_events is same instance'); + }); +}); diff --git a/js/tests/test_header.js b/js/tests/test_header.js new file mode 100644 index 00000000..dcb693ae --- /dev/null +++ b/js/tests/test_header.js @@ -0,0 +1,203 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {Header} from '../src/header.js'; +import {global_events} from '../src/globals.js'; + +QUnit.module('cone.app.header', hooks => { + + let container, + ajax_attach_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_attach_origin = ts.ajax.attach; + ts.ajax.attach = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.attach = ajax_attach_origin; + }); + + QUnit.test('Header.initialize returns early without #header-main', assert => { + Header.initialize(container); + assert.ok(true, 'no error without header-main'); + }); + + QUnit.test('Header.initialize creates instance', assert => { + let elem = $(` +
+
+
+
+ + + +
+ `).appendTo(container); + + Header.initialize(container); + assert.ok(true, 'instance created'); + }); + + QUnit.test('Header constructor stores elements', assert => { + let elem = $(` +
+
+
+
+ + + +
+ `).appendTo(container); + + let instance = new Header(elem); + + assert.ok(instance.elem, 'elem stored'); + assert.ok(instance.header_content, 'header_content stored'); + assert.ok(instance.navbar_content_wrapper, 'navbar_content_wrapper stored'); + assert.ok(instance.navbar_content, 'navbar_content stored'); + assert.ok(instance.navbar_toggler, 'navbar_toggler stored'); + assert.ok(instance.personal_tools, 'personal_tools stored'); + assert.ok(instance.mainmenu, 'mainmenu stored'); + + instance.destroy(); + }); + + QUnit.test('Header set_mobile_menu_open adds class', assert => { + let elem = $(` +
+
+ + + +
+ `).appendTo(container); + + let instance = new Header(elem); + instance.set_mobile_menu_open(); + + assert.true(elem.hasClass('mobile-menu-open'), 'class added'); + + instance.destroy(); + }); + + QUnit.test('Header set_mobile_menu_closed removes class', assert => { + let elem = $(` +
+
+ + + +
+ `).appendTo(container); + + let instance = new Header(elem); + instance.set_mobile_menu_closed(); + + assert.false(elem.hasClass('mobile-menu-open'), 'class removed'); + + instance.destroy(); + }); + + QUnit.test('Header on_is_compact handles compact mode', assert => { + let elem = $(` + + `).appendTo(container); + + let instance = new Header(elem); + + instance.on_is_compact(true); + assert.true(elem.hasClass('compact'), 'compact class added'); + assert.false(elem.hasClass('full'), 'full class removed'); + + instance.on_is_compact(false); + assert.false(elem.hasClass('compact'), 'compact class removed'); + assert.true(elem.hasClass('full'), 'full class added'); + + instance.destroy(); + }); + + QUnit.test('Header on_is_super_compact moves personal tools', assert => { + let elem = $(` +
+
+
+
+ + + +
+ `).appendTo(container); + + let instance = new Header(elem); + + instance.on_is_super_compact(true); + assert.ok($('#personaltools', instance.navbar_content).length, + 'personal_tools moved to navbar_content'); + + instance.on_is_super_compact(false); + assert.ok($('#personaltools', instance.header_content).length, + 'personal_tools moved back to header_content'); + + instance.destroy(); + }); + + QUnit.test('Header destroy cleans up', assert => { + let elem = $(` +
+
+ + + +
+ `).appendTo(container); + + let instance = new Header(elem); + instance.destroy(); + + assert.ok(true, 'destroy completed'); + }); + + QUnit.test('Header render_mobile_scrollbar called when compact', assert => { + let elem = $(` +
+
+ + + +
+ `).appendTo(container); + + let instance = new Header(elem); + instance.is_compact = true; + instance.mobile_scrollbar = {render: function() {}, destroy: function() {}}; + + instance.render_mobile_scrollbar(); + assert.ok(true, 'render_mobile_scrollbar handled'); + + instance.destroy(); + }); +}); diff --git a/js/tests/test_keybinder.js b/js/tests/test_keybinder.js index 8ac0b670..df0717e7 100644 --- a/js/tests/test_keybinder.js +++ b/js/tests/test_keybinder.js @@ -1,7 +1,87 @@ +import $ from 'jquery'; +import {keys, KeyBinder} from '../src/keybinder.js'; + QUnit.module('cone.app.keybinder', hooks => { - QUnit.test('Test stub', assert => { - assert.ok(true); - }) + let keybinder; + + hooks.beforeEach(() => { + // Reset keys state before each test + keys.shift_down = false; + keys.ctrl_down = false; + }); + + hooks.afterEach(() => { + // Clean up event handlers + $(window).off('keydown keyup'); + keys.shift_down = false; + keys.ctrl_down = false; + }); + + QUnit.test('keys object initial state', assert => { + assert.false(keys.shift_down, 'shift_down is initially false'); + assert.false(keys.ctrl_down, 'ctrl_down is initially false'); + }); + + QUnit.test('KeyBinder constructor binds window events', assert => { + keybinder = new KeyBinder(); + + // Trigger shift key down (keyCode 16) + $(window).trigger(new $.Event('keydown', {keyCode: 16})); + assert.true(keys.shift_down, 'shift_down set to true on keydown'); + + $(window).trigger(new $.Event('keyup', {keyCode: 16})); + assert.false(keys.shift_down, 'shift_down set to false on keyup'); + }); + + QUnit.test('KeyBinder tracks Ctrl key state', assert => { + keybinder = new KeyBinder(); + + // Trigger ctrl key down (keyCode 17) + $(window).trigger(new $.Event('keydown', {keyCode: 17})); + assert.true(keys.ctrl_down, 'ctrl_down set to true on keydown'); + + $(window).trigger(new $.Event('keyup', {keyCode: 17})); + assert.false(keys.ctrl_down, 'ctrl_down set to false on keyup'); + }); + + QUnit.test('KeyBinder handles which property', assert => { + keybinder = new KeyBinder(); + + // Test using 'which' property instead of 'keyCode' + $(window).trigger(new $.Event('keydown', {which: 16})); + assert.true(keys.shift_down, 'shift_down set via which property'); + + $(window).trigger(new $.Event('keydown', {which: 17})); + assert.true(keys.ctrl_down, 'ctrl_down set via which property'); + }); + + QUnit.test('KeyBinder ignores other keys', assert => { + keybinder = new KeyBinder(); + + // Trigger a different key (e.g., Enter = 13) + $(window).trigger(new $.Event('keydown', {keyCode: 13})); + assert.false(keys.shift_down, 'shift_down unchanged for other keys'); + assert.false(keys.ctrl_down, 'ctrl_down unchanged for other keys'); + }); + + QUnit.test('KeyBinder handles simultaneous key presses', assert => { + keybinder = new KeyBinder(); + + // Press both Shift and Ctrl + $(window).trigger(new $.Event('keydown', {keyCode: 16})); + $(window).trigger(new $.Event('keydown', {keyCode: 17})); + + assert.true(keys.shift_down, 'shift_down is true'); + assert.true(keys.ctrl_down, 'ctrl_down is true'); + + // Release Shift, Ctrl still down + $(window).trigger(new $.Event('keyup', {keyCode: 16})); + assert.false(keys.shift_down, 'shift_down released'); + assert.true(keys.ctrl_down, 'ctrl_down still pressed'); + // Release Ctrl + $(window).trigger(new $.Event('keyup', {keyCode: 17})); + assert.false(keys.ctrl_down, 'ctrl_down released'); + }); }); diff --git a/js/tests/test_layout.js b/js/tests/test_layout.js new file mode 100644 index 00000000..6ef5c8c9 --- /dev/null +++ b/js/tests/test_layout.js @@ -0,0 +1,259 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {MainArea, LayoutAware, ResizeAware} from '../src/layout.js'; +import {global_events} from '../src/globals.js'; + +QUnit.module('cone.app.layout.MainArea', hooks => { + + let container, + ajax_attach_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_attach_origin = ts.ajax.attach; + ts.ajax.attach = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.attach = ajax_attach_origin; + }); + + QUnit.test('MainArea.initialize returns early without #main-area', assert => { + MainArea.initialize(container); + assert.ok(true, 'no error without main-area'); + }); + + QUnit.test('MainArea.initialize creates instance', assert => { + let elem = $('
').appendTo(container); + MainArea.initialize(container); + assert.ok(true, 'instance created'); + elem.remove(); + }); + + QUnit.test('MainArea constructor sets up properties', assert => { + let elem = $('
').appendTo(container); + let instance = new MainArea(elem); + + assert.ok(instance.elem, 'elem stored'); + assert.strictEqual(typeof instance.is_compact, 'boolean', + 'is_compact property exists'); + assert.strictEqual(typeof instance.is_super_compact, 'boolean', + 'is_super_compact property exists'); + + instance.destroy(); + elem.remove(); + }); + + QUnit.test('MainArea set_mode sets compact based on width', assert => { + let elem = $('
').css('width', '500px').appendTo(container); + let instance = new MainArea(elem); + + instance.set_mode(); + assert.true(instance.is_compact, 'is_compact when width < 992'); + assert.true(instance.is_super_compact, 'is_super_compact when width < 576'); + + instance.destroy(); + elem.remove(); + }); + + QUnit.test('MainArea on_is_compact toggles classes', assert => { + let elem = $('
').appendTo(container); + let instance = new MainArea(elem); + + instance.on_is_compact(true); + assert.true(elem.hasClass('compact'), 'compact class added'); + assert.false(elem.hasClass('full'), 'full class removed'); + + instance.on_is_compact(false); + assert.false(elem.hasClass('compact'), 'compact class removed'); + assert.true(elem.hasClass('full'), 'full class added'); + + instance.destroy(); + elem.remove(); + }); + + QUnit.test('MainArea on_is_super_compact toggles classes', assert => { + let elem = $('
').appendTo(container); + let instance = new MainArea(elem); + + instance.on_is_super_compact(true); + assert.true(elem.hasClass('super-compact'), 'super-compact class added'); + + instance.on_is_super_compact(false); + assert.false(elem.hasClass('super-compact'), 'super-compact class removed'); + + instance.destroy(); + elem.remove(); + }); + + QUnit.test('MainArea destroy removes event listeners', assert => { + let elem = $('
').appendTo(container); + let instance = new MainArea(elem); + + instance.destroy(); + assert.ok(true, 'destroy completed without error'); + elem.remove(); + }); +}); + +QUnit.module('cone.app.layout.LayoutAware', hooks => { + + let container, + ajax_attach_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_attach_origin = ts.ajax.attach; + ts.ajax.attach = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.attach = ajax_attach_origin; + }); + + QUnit.test('LayoutAware constructor sets up properties', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + assert.ok(instance.elem, 'elem stored'); + assert.ok(instance.set_mode, 'set_mode method exists'); + + instance.destroy(); + }); + + QUnit.test('LayoutAware set_mode copies mainarea state', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + let mock_mainarea = {is_compact: true, is_super_compact: false}; + instance.set_mode(null, mock_mainarea); + + assert.true(instance.is_compact, 'is_compact copied'); + assert.false(instance.is_super_compact, 'is_super_compact copied'); + + instance.destroy(); + }); + + QUnit.test('LayoutAware on_is_compact toggles classes', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + instance.on_is_compact(true); + assert.true(elem.hasClass('compact'), 'compact class added'); + + instance.on_is_compact(false); + assert.true(elem.hasClass('full'), 'full class added'); + + instance.destroy(); + }); + + QUnit.test('LayoutAware on_is_super_compact toggles classes', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + instance.on_is_super_compact(true); + assert.true(elem.hasClass('super-compact'), 'super-compact class added'); + + instance.on_is_super_compact(false); + assert.false(elem.hasClass('super-compact'), 'super-compact class removed'); + + instance.destroy(); + }); + + QUnit.test('LayoutAware on_sidebar_left_resize stores collapsed state', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + instance.on_sidebar_left_resize(null, {collapsed: true}); + assert.true(instance.is_sidebar_left_collapsed, 'collapsed state stored'); + + instance.destroy(); + }); + + QUnit.test('LayoutAware on_sidebar_right_resize stores collapsed state', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + instance.on_sidebar_right_resize(null, {collapsed: false}); + assert.false(instance.is_sidebar_right_collapsed, 'collapsed state stored'); + + instance.destroy(); + }); + + QUnit.test('LayoutAware destroy removes listeners', assert => { + let elem = $('
').appendTo(container); + let instance = new LayoutAware(elem); + + instance.destroy(); + assert.ok(true, 'destroy completed'); + }); +}); + +QUnit.module('cone.app.layout.ResizeAware', hooks => { + + let container, + ajax_attach_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_attach_origin = ts.ajax.attach; + ts.ajax.attach = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.attach = ajax_attach_origin; + }); + + QUnit.test('ResizeAware mixin adds window resize handler', assert => { + class TestClass extends ts.Events { + constructor(elem) { + super(); + this.elem = elem; + } + destroy() {} + } + + const ResizeAwareTest = ResizeAware(TestClass); + let elem = $('
').appendTo(container); + let instance = new ResizeAwareTest(elem); + + assert.ok(instance.on_window_resize, 'on_window_resize method exists'); + + instance.destroy(); + }); + + QUnit.test('ResizeAware destroy removes resize handler', assert => { + class TestClass extends ts.Events { + constructor(elem) { + super(); + this.elem = elem; + } + destroy() {} + } + + const ResizeAwareTest = ResizeAware(TestClass); + let elem = $('
').appendTo(container); + let instance = new ResizeAwareTest(elem); + + instance.destroy(); + assert.ok(true, 'destroy completed'); + }); + + QUnit.test('ResizeAware handles base class without destroy', assert => { + class TestClass { + constructor(elem) { + this.elem = elem; + } + } + + const ResizeAwareTest = ResizeAware(TestClass); + let elem = $('
').appendTo(container); + let instance = new ResizeAwareTest(elem); + + instance.destroy(); + assert.ok(true, 'destroy handles missing base destroy'); + }); +}); diff --git a/js/tests/test_livesearch.js b/js/tests/test_livesearch.js index 63b37a6f..7972cdae 100644 --- a/js/tests/test_livesearch.js +++ b/js/tests/test_livesearch.js @@ -1,7 +1,390 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {LiveSearch} from '../src/livesearch.js'; + QUnit.module('cone.app.livesearch', hooks => { - QUnit.test('Test stub', assert => { - assert.ok(true); - }) + let container, + http_request_origin, + query_elem_origin, + compile_template_origin, + clock_schedule_frame_origin, + clock_schedule_timeout_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + http_request_origin = ts.http_request; + query_elem_origin = ts.query_elem; + compile_template_origin = ts.compile_template; + clock_schedule_frame_origin = ts.clock.schedule_frame; + clock_schedule_timeout_origin = ts.clock.schedule_timeout; + + // Default mock for compile_template + ts.compile_template = function(inst, template, target) { + let html = $(template); + html.appendTo(target); + // Set result elem if t-elem="result" exists + let result_elem = html.filter('[t-elem="result"]'); + if (result_elem.length) { + inst.result = result_elem; + } + result_elem = html.find('[t-elem="result"]'); + if (result_elem.length) { + inst.result = result_elem; + } + }; + }); + + hooks.afterEach(() => { + container.remove(); + ts.http_request = http_request_origin; + ts.query_elem = query_elem_origin; + ts.compile_template = compile_template_origin; + ts.clock.schedule_frame = clock_schedule_frame_origin; + ts.clock.schedule_timeout = clock_schedule_timeout_origin; + }); + + QUnit.test('LiveSearch.initialize creates instance when element exists', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + ts.query_elem = function(selector, context) { + return $(selector, context); + }; + + // Mock clock methods + ts.clock.schedule_frame = function(cb) { cb(); }; + ts.clock.schedule_timeout = function(cb, delay) { + return {cancel: function() {}}; + }; + + // Pass LiveSearch as factory to avoid referencing global cone + LiveSearch.initialize(container, LiveSearch); + + let events = $._data(input.get(0), 'events'); + assert.ok(events && events.keydown, 'keydown event bound'); + assert.ok(events && events.change, 'change event bound'); + }); + + QUnit.test('LiveSearch.initialize does nothing without element', assert => { + ts.query_elem = function() { return null; }; + + // Should not throw + LiveSearch.initialize(container); + assert.ok(true, 'no error when element not found'); + }); + + QUnit.test('LiveSearch.initialize accepts custom factory', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + ts.query_elem = function(selector, context) { + return $(selector, context); + }; + + let custom_created = false; + class CustomLiveSearch extends LiveSearch { + constructor(elem) { + super(elem); + custom_created = true; + } + } + + LiveSearch.initialize(container, CustomLiveSearch); + + assert.true(custom_created, 'custom factory used'); + }); + + QUnit.test('LiveSearch constructor initializes properties', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let livesearch = new LiveSearch(input); + + assert.strictEqual(livesearch.elem, input, 'elem stored'); + assert.strictEqual(livesearch.target, '/api/livesearch', 'target constructed'); + assert.strictEqual(livesearch._term, '', 'initial term empty'); + assert.strictEqual(livesearch._minlen, 3, 'default minlen is 3'); + assert.strictEqual(livesearch._delay, 250, 'default delay is 250ms'); + assert.strictEqual(livesearch._timeout_event, null, 'no pending timeout'); + assert.false(livesearch._in_progress, 'not in progress'); + }); + + QUnit.test('LiveSearch search sends http request', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let request_opts = null; + ts.http_request = function(opts) { + request_opts = opts; + }; + + let livesearch = new LiveSearch(input); + livesearch._term = 'test query'; + livesearch.search(); + + assert.strictEqual(request_opts.url, '/api/livesearch', 'correct url'); + assert.strictEqual(request_opts.params.term, 'test query', 'term in params'); + assert.strictEqual(request_opts.type, 'json', 'json type'); + assert.ok(request_opts.success, 'success callback provided'); + }); + + QUnit.test('LiveSearch on_result renders results', assert => { + let input = $(` + + `).appendTo(container); + let content = $('
').appendTo(container); + + let livesearch = new LiveSearch(input); + livesearch._term = 'test'; + + let data = [ + {target: '/item1', icon: 'fa-file', value: 'Item 1', description: 'Desc 1'}, + {target: '/item2', icon: 'fa-folder', value: 'Item 2'} + ]; + + livesearch.on_result(data, 'success', {}); + + assert.ok(content.find('.card').length > 0, 'card rendered'); + }); + + QUnit.test('LiveSearch on_result handles empty results', assert => { + let input = $(` + + `).appendTo(container); + let content = $('
').appendTo(container); + + let livesearch = new LiveSearch(input); + livesearch._term = 'nomatch'; + + livesearch.on_result([], 'success', {}); + + assert.ok(content.find('.card').length > 0, 'card still rendered'); + }); + + QUnit.test('LiveSearch on_keydown ignores Enter key', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let frame_scheduled = false; + ts.clock.schedule_frame = function() { + frame_scheduled = true; + }; + + let livesearch = new LiveSearch(input); + + let evt = new $.Event('keydown', {keyCode: 13}); + input.trigger(evt); + + assert.false(frame_scheduled, 'no frame scheduled for Enter'); + }); + + QUnit.test('LiveSearch on_keydown schedules frame for other keys', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let frame_scheduled = false; + ts.clock.schedule_frame = function(cb) { + frame_scheduled = true; + }; + + let livesearch = new LiveSearch(input); + + let evt = new $.Event('keydown', {keyCode: 65}); + input.trigger(evt); + + assert.true(frame_scheduled, 'frame scheduled for other keys'); + }); + + QUnit.test('LiveSearch on_change ignores when in progress', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let timeout_scheduled = false; + ts.clock.schedule_timeout = function() { + timeout_scheduled = true; + return {cancel: function() {}}; + }; + + let livesearch = new LiveSearch(input); + livesearch._in_progress = true; + input.val('test'); + livesearch.on_change({}); + + assert.false(timeout_scheduled, 'no timeout when in progress'); + }); + + QUnit.test('LiveSearch on_change ignores unchanged term', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let timeout_scheduled = false; + ts.clock.schedule_timeout = function() { + timeout_scheduled = true; + return {cancel: function() {}}; + }; + + let livesearch = new LiveSearch(input); + livesearch._term = 'same'; + input.val('same'); + livesearch.on_change({}); + + assert.false(timeout_scheduled, 'no timeout when term unchanged'); + }); + + QUnit.test('LiveSearch on_change ignores short terms', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let timeout_scheduled = false; + ts.clock.schedule_timeout = function() { + timeout_scheduled = true; + return {cancel: function() {}}; + }; + + let livesearch = new LiveSearch(input); + input.val('ab'); + livesearch.on_change({}); + + assert.strictEqual(livesearch._term, 'ab', 'term updated'); + assert.false(timeout_scheduled, 'no timeout for short term'); + }); + + QUnit.test('LiveSearch on_change schedules search for valid term', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let timeout_scheduled = false; + let timeout_delay = null; + ts.clock.schedule_timeout = function(cb, delay) { + timeout_scheduled = true; + timeout_delay = delay; + return {cancel: function() {}}; + }; + + let livesearch = new LiveSearch(input); + input.val('test'); + livesearch.on_change({}); + + assert.true(timeout_scheduled, 'timeout scheduled'); + assert.strictEqual(timeout_delay, 250, 'correct delay'); + assert.strictEqual(livesearch._term, 'test', 'term updated'); + }); + + QUnit.test('LiveSearch on_change cancels previous timeout', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let cancelled = false; + ts.clock.schedule_timeout = function(cb, delay) { + return { + cancel: function() { cancelled = true; } + }; + }; + + let livesearch = new LiveSearch(input); + input.val('first'); + livesearch.on_change({}); + + input.val('second'); + livesearch.on_change({}); + + assert.true(cancelled, 'previous timeout cancelled'); + }); + + QUnit.test('LiveSearch render_no_results displays message', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let rendered_template = null; + ts.compile_template = function(inst, template, target) { + rendered_template = template; + }; + + let livesearch = new LiveSearch(input); + livesearch.result = $('
'); + livesearch.render_no_results(); + + assert.ok(rendered_template.includes('No search results'), + 'no results message rendered'); + }); + + QUnit.test('LiveSearch render_suggestion displays item', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let rendered_template = null; + ts.compile_template = function(inst, template, target) { + rendered_template = template; + }; + + let livesearch = new LiveSearch(input); + livesearch.result = $('
'); + + let item = { + target: '/items/1', + icon: 'fa-file', + value: 'Test Item', + description: 'Test description' + }; + livesearch.render_suggestion(item); + + assert.ok(rendered_template.includes('/items/1'), 'target in template'); + assert.ok(rendered_template.includes('fa-file'), 'icon in template'); + assert.ok(rendered_template.includes('Test Item'), 'value in template'); + assert.ok(rendered_template.includes('Test description'), + 'description in template'); + }); + + QUnit.test('LiveSearch render_suggestion handles missing description', assert => { + let input = $(` + + `).appendTo(container); + $('
').appendTo(container); + + let rendered_template = null; + ts.compile_template = function(inst, template, target) { + rendered_template = template; + }; + + let livesearch = new LiveSearch(input); + livesearch.result = $('
'); + + let item = { + target: '/items/1', + icon: 'fa-file', + value: 'Test Item' + }; + livesearch.render_suggestion(item); + assert.ok(rendered_template.includes('Test Item'), 'value in template'); + assert.false(rendered_template.includes('undefined'), + 'undefined not in template'); + }); }); diff --git a/js/tests/test_mainmenu.js b/js/tests/test_mainmenu.js new file mode 100644 index 00000000..bf2b6629 --- /dev/null +++ b/js/tests/test_mainmenu.js @@ -0,0 +1,270 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {MainMenu} from '../src/mainmenu.js'; +import {global_events} from '../src/globals.js'; + +QUnit.module('cone.app.mainmenu', hooks => { + + let container, + ajax_attach_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_attach_origin = ts.ajax.attach; + ts.ajax.attach = function() {}; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.attach = ajax_attach_origin; + }); + + QUnit.test('MainMenu.initialize returns early without #mainmenu', assert => { + MainMenu.initialize(container); + assert.ok(true, 'no error without mainmenu'); + }); + + QUnit.test('MainMenu.initialize creates instance', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + MainMenu.initialize(container); + assert.ok(true, 'instance created'); + }); + + QUnit.test('MainMenu constructor stores elements', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + + assert.ok(instance.elem, 'elem stored'); + assert.ok(instance.scrollbar, 'scrollbar stored'); + assert.ok(instance.elems.length >= 0, 'dropdown elems found'); + + instance.destroy(); + }); + + QUnit.test('MainMenu height getter returns outer height', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + assert.ok(instance.height > 0, 'height is positive'); + + instance.destroy(); + }); + + QUnit.test('MainMenu on_is_compact binds/unbinds dropdowns', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + + instance.on_is_compact(true); + assert.ok(true, 'compact mode handled'); + + instance.on_is_compact(false); + assert.ok(true, 'full mode handled'); + + instance.destroy(); + }); + + QUnit.test('MainMenu hide_dropdowns hides all', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + + // Mock Bootstrap dropdown + $.fn.dropdown = function(action) { + return this; + }; + + instance.hide_dropdowns(); + assert.ok(true, 'hide_dropdowns completed'); + + instance.destroy(); + }); + + QUnit.test('MainMenu on_show_dropdown_desktop sets position', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + let link = elem.find('.nav-link.dropdown-toggle'); + + let evt = {target: link.get(0)}; + instance.on_show_dropdown_desktop(evt); + + assert.strictEqual(instance.open_dropdown, link.get(0), + 'open_dropdown set'); + + instance.destroy(); + }); + + QUnit.test('MainMenu on_hide_dropdown_desktop clears open_dropdown', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + let link = elem.find('.nav-link.dropdown-toggle').get(0); + + instance.open_dropdown = link; + instance.on_hide_dropdown_desktop({target: link}); + + assert.strictEqual(instance.open_dropdown, null, 'open_dropdown cleared'); + + instance.destroy(); + }); + + QUnit.test('MainMenu on_hide_dropdown_desktop ignores different dropdown', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + let link1 = elem.find('#dd1').get(0); + let link2 = elem.find('#dd2').get(0); + + instance.open_dropdown = link1; + instance.on_hide_dropdown_desktop({target: link2}); + + assert.strictEqual(instance.open_dropdown, link1, + 'open_dropdown unchanged for different dropdown'); + + instance.destroy(); + }); + + QUnit.test('MainMenu on_sidebar_left_resize renders scrollbar', assert => { + let render_called = false; + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() { render_called = true; }, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + + // Use requestAnimationFrame callback + instance.on_sidebar_left_resize(null, {collapsed: false}); + + // Wait for requestAnimationFrame + setTimeout(() => { + assert.true(render_called, 'scrollbar.render called'); + instance.destroy(); + }, 50); + + assert.ok(true, 'on_sidebar_left_resize called'); + }); + + QUnit.test('MainMenu destroy cleans up', assert => { + let mock_scrollbar = { + on: function() {}, + off: function() {}, + render: function() {}, + destroy: function() {} + }; + + let elem = $(` + + `).data('scrollbar', mock_scrollbar).appendTo(container); + + let instance = new MainMenu(elem); + instance.destroy(); + + assert.ok(true, 'destroy completed'); + }); +}); diff --git a/js/tests/test_navtree.js b/js/tests/test_navtree.js new file mode 100644 index 00000000..c3684b69 --- /dev/null +++ b/js/tests/test_navtree.js @@ -0,0 +1,147 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {NavTree} from '../src/navtree.js'; + +QUnit.module('cone.app.navtree', hooks => { + + let container, + ajax_attach_origin, + localstorage_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_attach_origin = ts.ajax.attach; + ts.ajax.attach = function() {}; + localstorage_origin = localStorage.getItem('cone.app.navtree.open'); + localStorage.removeItem('cone.app.navtree.open'); + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.attach = ajax_attach_origin; + if (localstorage_origin) { + localStorage.setItem('cone.app.navtree.open', localstorage_origin); + } else { + localStorage.removeItem('cone.app.navtree.open'); + } + }); + + QUnit.test('NavTree.initialize returns early without #navtree', assert => { + NavTree.initialize(container); + assert.ok(true, 'no error without navtree'); + }); + + QUnit.test('NavTree.initialize creates instance', assert => { + let elem = $(` + + `).appendTo(container); + + NavTree.initialize(container); + assert.ok(true, 'instance created'); + }); + + QUnit.test('NavTree constructor stores elem', assert => { + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + assert.strictEqual(instance.elem.get(0), elem.get(0), 'elem stored'); + + instance.destroy(); + }); + + QUnit.test('NavTree with no-collapse class skips binding', assert => { + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + assert.ok(true, 'no-collapse handled'); + }); + + QUnit.test('NavTree expands if previously opened', assert => { + localStorage.setItem('cone.app.navtree.open', 'true'); + + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + assert.true(instance.dropdown_elem.hasClass('show'), + 'show class added from localStorage'); + + instance.destroy(); + }); + + QUnit.test('NavTree set_menu_open stores state', assert => { + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + instance.set_menu_open({}); + + assert.strictEqual(localStorage.getItem('cone.app.navtree.open'), 'true', + 'state stored in localStorage'); + + instance.destroy(); + }); + + QUnit.test('NavTree set_menu_closed removes state', assert => { + localStorage.setItem('cone.app.navtree.open', 'true'); + + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + instance.set_menu_closed({}); + + assert.strictEqual(localStorage.getItem('cone.app.navtree.open'), null, + 'state removed from localStorage'); + + instance.destroy(); + }); + + QUnit.test('NavTree binds collapse events', assert => { + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + + let events = $._data(instance.dropdown_elem.get(0), 'events'); + assert.ok(events, 'events bound to dropdown_elem'); + + instance.destroy(); + }); + + QUnit.test('NavTree destroy removes event listeners', assert => { + let elem = $(` + + `).appendTo(container); + + let instance = new NavTree(elem); + instance.destroy(); + + let events = $._data(instance.dropdown_elem.get(0), 'events'); + assert.notOk(events, 'events removed'); + }); +}); diff --git a/js/tests/test_referencebrowser.js b/js/tests/test_referencebrowser.js index d84b6338..81183779 100644 --- a/js/tests/test_referencebrowser.js +++ b/js/tests/test_referencebrowser.js @@ -1,7 +1,501 @@ -QUnit.module('cone.app.referencebrowser', hooks => { +import $ from 'jquery'; +import ts from 'treibstoff'; +import { + ReferenceHandle, + AddReferenceHandle, + RemoveReferenceHandle, + ReferenceBrowserLoader +} from '../src/referencebrowser.js'; - QUnit.test('Test stub', assert => { - assert.ok(true); - }) +QUnit.module('cone.app.referencebrowser.ReferenceHandle', hooks => { + let container, + ajax_parse_target_origin; + + hooks.beforeEach(() => { + container = $('
').appendTo('body'); + ajax_parse_target_origin = ts.ajax.parse_target; + }); + + hooks.afterEach(() => { + container.remove(); + ts.ajax.parse_target = ajax_parse_target_origin; + }); + + QUnit.test('ReferenceHandle.initialize returns early without context', assert => { + ReferenceHandle.initialize(null); + assert.ok(true, 'no error with null context'); + }); + + QUnit.test('ReferenceHandle.initialize returns early without modal parent', + assert => { + let context = $('
').appendTo(container); + + ReferenceHandle.initialize(context); + assert.ok(true, 'no error without modal parent'); + }); + + QUnit.test('ReferenceHandle.initialize returns early without ref_target', + assert => { + // This allows other components (e.g. catalog item picker) to reuse + // the referencebrowser overlay with custom selection handling. + let modal = $('