From 60c8df8f6d6d6d0ce717e108c1194a3098dcfae1 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Mon, 26 Jan 2026 12:32:03 +0100 Subject: [PATCH 01/25] basic setup --- docs/TODO.md | 6 + docs/source/quickstart.rst | 72 ++++- examples/cone.example/example.ini | 23 +- examples/cone.example/pyproject.toml | 15 + examples/cone.example/setup.py | 22 -- examples/cone.example/src/cone/__init__.py | 1 - .../cone.example/src/cone/example/__init__.py | 49 +++- .../src/cone/example/ajax/__init__.py | 0 .../src/cone/example/ajax/browser.py | 159 +++++++++++ .../example/ajax/templates/ajax_playground.pt | 137 +++++++++ .../src/cone/example/browser/__init__.py | 269 ++++++++++-------- .../example/browser/static/cone.example.css | 38 +++ .../src/cone/example/configure.zcml | 3 +- .../src/cone/example/document/__init__.py | 0 .../src/cone/example/document/browser.py | 243 ++++++++++++++++ .../src/cone/example/document/model.py | 93 ++++++ .../document/templates/document_source.pt | 55 ++++ .../document/templates/document_view.pt | 36 +++ .../src/cone/example/document_workflow.zcml | 98 +++++++ .../cone.example/src/cone/example/model.py | 204 ++++--------- .../src/cone/example/project/__init__.py | 0 .../src/cone/example/project/browser.py | 268 +++++++++++++++++ .../src/cone/example/project/model.py | 183 ++++++++++++ .../project/templates/project_items.pt | 29 ++ .../example/project/templates/task_view.pt | 36 +++ .../src/cone/example/publication.zcml | 56 ---- .../src/cone/example/settings/__init__.py | 0 .../src/cone/example/settings/browser.py | 83 ++++++ .../src/cone/example/settings/model.py | 44 +++ .../src/cone/example/task_workflow.zcml | 98 +++++++ .../cone.example/src/cone/example/testing.py | 24 ++ .../src/cone/example/tests/__init__.py | 0 .../src/cone/example/wiki/__init__.py | 0 .../src/cone/example/wiki/browser.py | 248 ++++++++++++++++ .../src/cone/example/wiki/model.py | 95 +++++++ .../cone/example/wiki/templates/wiki_view.pt | 55 ++++ 36 files changed, 2375 insertions(+), 367 deletions(-) create mode 100644 docs/TODO.md create mode 100644 examples/cone.example/pyproject.toml delete mode 100644 examples/cone.example/setup.py delete mode 100644 examples/cone.example/src/cone/__init__.py create mode 100644 examples/cone.example/src/cone/example/ajax/__init__.py create mode 100644 examples/cone.example/src/cone/example/ajax/browser.py create mode 100644 examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt create mode 100644 examples/cone.example/src/cone/example/document/__init__.py create mode 100644 examples/cone.example/src/cone/example/document/browser.py create mode 100644 examples/cone.example/src/cone/example/document/model.py create mode 100644 examples/cone.example/src/cone/example/document/templates/document_source.pt create mode 100644 examples/cone.example/src/cone/example/document/templates/document_view.pt create mode 100644 examples/cone.example/src/cone/example/document_workflow.zcml create mode 100644 examples/cone.example/src/cone/example/project/__init__.py create mode 100644 examples/cone.example/src/cone/example/project/browser.py create mode 100644 examples/cone.example/src/cone/example/project/model.py create mode 100644 examples/cone.example/src/cone/example/project/templates/project_items.pt create mode 100644 examples/cone.example/src/cone/example/project/templates/task_view.pt delete mode 100644 examples/cone.example/src/cone/example/publication.zcml create mode 100644 examples/cone.example/src/cone/example/settings/__init__.py create mode 100644 examples/cone.example/src/cone/example/settings/browser.py create mode 100644 examples/cone.example/src/cone/example/settings/model.py create mode 100644 examples/cone.example/src/cone/example/task_workflow.zcml create mode 100644 examples/cone.example/src/cone/example/testing.py create mode 100644 examples/cone.example/src/cone/example/tests/__init__.py create mode 100644 examples/cone.example/src/cone/example/wiki/__init__.py create mode 100644 examples/cone.example/src/cone/example/wiki/browser.py create mode 100644 examples/cone.example/src/cone/example/wiki/model.py create mode 100644 examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 00000000..1a8410ac --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,6 @@ +Docs for 2.0 TODO +================= + +[ ] fix quickstart documentation - unreleased packages, test all approaches +[ ] seperate docs for integrated and seperate plugin +[ ] clear up confusion around quickstart (maybe rename "creating a plugin", "create an app") diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index b4fb312e..288972e8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -56,7 +56,7 @@ dependency. Add the following to ``pyproject.toml``. .. code-block:: toml [build-system] - requires = ["setuptools>=61.0"] + requires = ["setuptools>=61.0,<81"] build-backend = "setuptools.build_meta" [project] @@ -75,7 +75,8 @@ dependency. Add the following to ``pyproject.toml``. 2. Virtual Environment ---------------------- -Create a virtual environment and install the package: +Create a virtual environment and install the package. +From the cone.app root directory, run: .. code-block:: sh @@ -99,6 +100,13 @@ Alternatively, using ``uv`` (faster): uv pip install -e . +.. Error:: + + If your package depends on unreleased versions of other ``cone.*`` packages, + ensure to install those packages first in editable mode before installing + your package. See :ref:`Developing on unreleased versions `. + + 3. Application Configuration ---------------------------- @@ -352,11 +360,16 @@ into Ajax SSR can be found :ref:`here `. ------------------ After creating the virtual environment as described in section 2, run the -application: +application from the ``cone.app`` root directory: .. code-block:: sh - ./venv/bin/pserve example.ini + ./venv/bin/pserve examples/cone.example/example.ini + +.. .. XXX: document running from cone.example directory +.. .. code-block:: sh + +.. ./venv/bin/pserve example.ini The application is now available at ``localhost:8081``. @@ -385,3 +398,54 @@ To set up mxmake for your plugin: mxmake init See the ``cone.app`` repository for an example mxmake configuration. + + +.. _developing_unreleased: + +Advanced: Developing with unreleased package versions +----------------------------------------------------- + +When developing your plugin with unreleased versions of ``cone.*`` packages, +you need to ensure that those packages are installed in your virtual environment. + +To do so, first checkout and install the unreleased packages in sources/: + +.. code-block:: sh + + # cone.app root directory + make install + +Follow up by creating your virtual environment as described in section 2: + +.. code-block:: sh + + # cone.app root directory + set -e + rm -rf ./venv/ + python3 -m venv venv + source ./venv/bin/activate + +Then, install the unreleased application packages in editable mode: + +.. code-block:: sh + + # cone.app root directory + # venv + pip install -e sources/cone.tile + # add other unreleased cone.* packages as needed + + # Then install cone.app + pip install -e . + +Finally, install your application package in editable mode: + +.. code-block:: sh + + # cone.app root directory + # venv + pip install -e examples/cone.example + + # exit the venv + deactivate + +You are now ready to run your application as described in section 9. \ No newline at end of file diff --git a/examples/cone.example/example.ini b/examples/cone.example/example.ini index f92ef530..c5bd6930 100644 --- a/examples/cone.example/example.ini +++ b/examples/cone.example/example.ini @@ -21,11 +21,12 @@ pyramid.debug_templates = true pyramid.default_locale_name = en # available languages -cone.available_languages = de, en +cone.available_languages = en, de # cone.app admin user and password -#cone.admin_user = -#cone.admin_password = +cone.admin_user = admin +cone.admin_password = admin +#cone.authenticator = # cone.app auth tkt settings cone.auth_secret = 12345 @@ -39,21 +40,19 @@ cone.auth_secret = 12345 #cone.auth_path = #cone.auth_wild_domain = +# application main template +#cone.main_template = package.browser:templates/main.pt + # plugins to be loaded cone.plugins = cone.example # application root node settings +#cone.root.node_factory = package.root_node_factory cone.root.title = cone.example -#cone.root.default_child = example -#cone.root.default_content_tile = +#cone.root.default_child = example # XXX throws error +#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 + example \ No newline at end of file diff --git a/examples/cone.example/pyproject.toml b/examples/cone.example/pyproject.toml new file mode 100644 index 00000000..d377926f --- /dev/null +++ b/examples/cone.example/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=61.0,<81"] # XXX: depends on PIL +build-backend = "setuptools.build_meta" + +[project] +name = "cone.example" +version = "0.1" +description = "Example cone plugin" +dependencies = [ + "waitress", + "cone.app" +] + +[tool.setuptools.packages.find] +where = ["src"] \ No newline at end of file diff --git a/examples/cone.example/setup.py b/examples/cone.example/setup.py deleted file mode 100644 index 4f76c5a2..00000000 --- a/examples/cone.example/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -from setuptools import find_packages -from setuptools import setup - - -version = '0.1' -shortdesc = 'Example cone plugin' - - -setup( - name='cone.example', - version=version, - description=shortdesc, - packages=find_packages('src'), - package_dir={'': 'src'}, - namespace_packages=['cone'], - include_package_data=True, - zip_safe=False, - install_requires=[ - 'cone.app', - 'waitress' - ] -) diff --git a/examples/cone.example/src/cone/__init__.py b/examples/cone.example/src/cone/__init__.py deleted file mode 100644 index de40ea7c..00000000 --- a/examples/cone.example/src/cone/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__import__('pkg_resources').declare_namespace(__name__) diff --git a/examples/cone.example/src/cone/example/__init__.py b/examples/cone.example/src/cone/example/__init__.py index f0764a72..8a81281a 100644 --- a/examples/cone.example/src/cone/example/__init__.py +++ b/examples/cone.example/src/cone/example/__init__.py @@ -1,27 +1,56 @@ from cone.app import main_hook +from cone.app import register_config from cone.app import register_entry +from cone.example.browser import _configure_layout_configs 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, settings): - """Function which gets called at application startup to initialize - this plugin. + """Application startup hook. + + Registers all entry nodes, settings, search adapters, static resources, + and scans browser packages for tiles and views. """ - # register live search adapter + # Register live search adapter config.registry.registerAdapter(LiveSearch) - # add translation + # Add translation directories config.add_translation_dirs('cone.example:locale/') - # register plugin entry nodes - for i in range(1, 6): - register_entry(f'folder_{i}', EntryFolder) + # Register entry nodes in the main menu + from cone.example.document.model import DocumentLibrary + from cone.example.project.model import ProjectBoard + from cone.example.wiki.model import Wiki + from cone.example.ajax.browser import AjaxPlayground + + register_entry('documents', DocumentLibrary) + register_entry('projects', ProjectBoard) + register_entry('wiki', Wiki) + register_entry('ajax_playground', AjaxPlayground) + + # Register settings node + from cone.example.settings.model import ExampleSettings + register_config('example_settings', ExampleSettings) - # static resources + # Register custom add model factory for Task + from cone.app.model import get_node_info + from cone.example.project.browser import task_addmodel_factory + task_info = get_node_info('task') + if task_info: + task_info.factory = task_addmodel_factory + + # Static resources configure_resources(config, settings) - # scan browser package + # Register layout configs (deferred to avoid circular imports) + _configure_layout_configs() + + # Scan browser packages for tile and view registrations config.scan('cone.example.browser') + config.scan('cone.example.document.browser') + config.scan('cone.example.project.browser') + config.scan('cone.example.wiki.browser') + config.scan('cone.example.settings.browser') + config.scan('cone.example.ajax.browser') diff --git a/examples/cone.example/src/cone/example/ajax/__init__.py b/examples/cone.example/src/cone/example/ajax/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/ajax/browser.py b/examples/cone.example/src/cone/example/ajax/browser.py new file mode 100644 index 00000000..9092cd2d --- /dev/null +++ b/examples/cone.example/src/cone/example/ajax/browser.py @@ -0,0 +1,159 @@ +from cone.app.browser.ajax import ajax_continue +from cone.app.browser.ajax import ajax_message +from cone.app.browser.ajax import ajax_status_message +from cone.app.browser.ajax import AjaxAction +from cone.app.browser.ajax import AjaxEvent +from cone.app.browser.ajax import AjaxMessage +from cone.app.browser.ajax import AjaxOverlay +from cone.app.browser.ajax import AjaxPath +from cone.app.browser.layout import ProtectedContentTile +from cone.app.browser.utils import make_url +from cone.app.model import AppNode +from cone.app.model import BaseNode +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import node_info +from cone.example.model import _ +from cone.tile import tile +from node.utils import instance_property + + +@node_info( + name='ajax_playground', + title=_('ajax_playground', default='AJAX Playground'), + icon='bi-lightning') +class AjaxPlayground(BaseNode): + """Entry node for demonstrating all AJAX operation types. + + This node provides tiles that trigger each of the six AJAX + continuation operations available in cone.app: + - AjaxPath: Set browser URL path + - AjaxAction: Render tile into DOM element + - AjaxEvent: Trigger JS event + - AjaxMessage: Display a message + - AjaxOverlay: Show/close overlay + - ajax_continue: Send multiple operations + """ + + @instance_property + def properties(self): + props = Properties() + props.in_navtree = True + props.action_up = True + props.action_view = True + return props + + @instance_property + def metadata(self): + md = Metadata() + md.title = _('ajax_playground', default='AJAX Playground') + md.description = _( + 'ajax_playground_desc', + default='Demonstrates all AJAX operation types') + md.icon = 'bi-lightning' + return md + + +@tile(name='content', + path='cone.example.ajax:templates/ajax_playground.pt', + interface=AjaxPlayground, + permission='login') +class AjaxPlaygroundView(ProtectedContentTile): + + @property + def ajax_path_url(self): + return make_url(self.request, node=self.model) + + @property + def ajax_action_url(self): + return make_url(self.request, node=self.model) + + +# Tile triggered by AjaxAction demo +@tile(name='ajax_demo_content', + interface=AjaxPlayground, + permission='view') +class AjaxDemoContent(ProtectedContentTile): + + def render(self): + return '
' \ + 'AjaxAction worked! ' \ + 'This content was loaded via AjaxAction and inserted ' \ + 'into the DOM.
' + + +# Tile for AjaxPath demo +@tile(name='ajax_path_demo', + interface=AjaxPlayground, + permission='view') +class AjaxPathDemo(ProtectedContentTile): + + def render(self): + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxPath( + path='/'.join(self.model.path), + target=url, + event='contextchanged:#layout' + ) + ]) + return '' + + +# Tile for AjaxEvent demo +@tile(name='ajax_event_demo', + interface=AjaxPlayground, + permission='view') +class AjaxEventDemo(ProtectedContentTile): + + def render(self): + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxEvent(url, 'contextchanged', '#layout') + ]) + return '' + + +# Tile for AjaxMessage demo +@tile(name='ajax_message_demo', + interface=AjaxPlayground, + permission='view') +class AjaxMessageDemo(ProtectedContentTile): + + def render(self): + ajax_message( + self.request, + 'This is a demo message from AjaxMessage!', + 'info' + ) + return '' + + +# Tile for AjaxAction demo +@tile(name='ajax_action_demo', + interface=AjaxPlayground, + permission='view') +class AjaxActionDemo(ProtectedContentTile): + + def render(self): + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxAction(url, 'ajax_demo_content', 'inner', '#ajax-demo-target') + ]) + return '' + + +# Tile for combined operations demo +@tile(name='ajax_combined_demo', + interface=AjaxPlayground, + permission='view') +class AjaxCombinedDemo(ProtectedContentTile): + + def render(self): + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxAction(url, 'ajax_demo_content', 'inner', '#ajax-demo-target'), + AjaxMessage('Combined: action + message + event!', 'success'), + AjaxEvent(url, 'contextchanged', '#layout'), + ]) + return '' diff --git a/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt b/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt new file mode 100644 index 00000000..59436ac2 --- /dev/null +++ b/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt @@ -0,0 +1,137 @@ + + + + +
+
+

+ + AJAX Playground +

+
+
+

+ This page demonstrates all six AJAX continuation operation types + available in cone.app. Click each button to see the operation in action. +

+ +
+ + +
+
+
+
+ AjaxPath +
+

+ Sets the browser URL path without page reload. + Updates browser history. +

+ + Trigger AjaxPath + +
+
+
+ + +
+
+
+
+ AjaxAction +
+

+ Renders a tile and inserts the result into a DOM element. +

+ + Trigger AjaxAction + +
+
+
+ + +
+
+
+
+ AjaxEvent +
+

+ Triggers a JavaScript event on a DOM element. + Used for inter-component communication. +

+ + Trigger AjaxEvent + +
+
+
+ + +
+
+
+
+ AjaxMessage +
+

+ Displays a notification message to the user. + Supports info, warning, and error flavors. +

+ + Trigger AjaxMessage + +
+
+
+ + +
+
+
+
+ Combined Operations +
+

+ Demonstrates ajax_continue with multiple operations + chained together (AjaxAction + AjaxMessage + AjaxEvent). +

+ + Trigger Combined + +
+
+
+ +
+ + +
+
+ AjaxAction target area - content will be inserted here. +
+
+ +
+
+ +
diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index db562d34..d3c82998 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -1,28 +1,24 @@ -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 import DefaultLayoutConfig +from cone.app import layout_config +from cone.app.browser.actions import LinkAction +from cone.app.browser.actions import TemplateAction +from cone.app.browser.contextmenu import context_menu_group +from cone.app.browser.contextmenu import context_menu_item +from cone.app.browser.contextmenu import ContextMenuToolbar +from cone.app.browser.layout import personal_tools_action from cone.app.browser.layout import ProtectedContentTile -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.app.browser.utils import make_url +from cone.app.browser.utils import request_property +from cone.app.model import Properties +from cone.example.model import _ from cone.tile import tile -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 -_ = TranslationStringFactory('cone.example') - +############################################################################### +# Static Resources +############################################################################### resources_dir = os.path.join(os.path.dirname(__file__), 'static') cone_example_resources = wr.ResourceGroup( @@ -41,104 +37,137 @@ def configure_resources(config, settings): 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/view.pt', - interface=Item, - permission='login') -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): - ... +############################################################################### +# Layout Configs +# +# Layout configs control the appearance of the page for different node types. +# They determine which sidebar tiles are rendered, whether the main menu, +# search bar, and path bar are shown, etc. +############################################################################### + +# Import model classes here to avoid circular imports at module level. +# layout_config decorators register factories looked up by model class. + +def _configure_layout_configs(): + """Register layout configs after model classes are available.""" + from cone.example.document.model import Document + from cone.example.document.model import DocumentFolder + from cone.example.document.model import DocumentLibrary + from cone.example.project.model import ProjectBoard + from cone.example.project.model import Task + from cone.example.wiki.model import Wiki + from cone.example.wiki.model import WikiPage + from cone.example.ajax.browser import AjaxPlayground + + @layout_config(DocumentLibrary, DocumentFolder) + class DocumentContainerLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super().__init__(model=model, request=request) + self.sidebar_left = ['navtree'] + + @layout_config(Document) + class DocumentLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super().__init__(model=model, request=request) + self.sidebar_left = ['navtree'] + + @layout_config(ProjectBoard) + class ProjectBoardLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super().__init__(model=model, request=request) + self.sidebar_left = ['navtree'] + + @layout_config(Task) + class TaskLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super().__init__(model=model, request=request) + self.sidebar_left = ['navtree'] + + @layout_config(Wiki, WikiPage) + class WikiLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super().__init__(model=model, request=request) + self.sidebar_left = ['navtree'] + + @layout_config(AjaxPlayground) + class AjaxPlaygroundLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super().__init__(model=model, request=request) + self.sidebar_left = [] + + +############################################################################### +# Custom Personal Tools Action +# +# Adds a custom item to the personal tools dropdown menu (top right). +############################################################################### + +@personal_tools_action(name='example_info') +class ExampleInfoAction(LinkAction): + """Custom personal tools action - shows a link in the user dropdown.""" + text = _('example_info', default='Example Info') + icon = 'bi-info-circle' + event = 'contextchanged:#layout' + path = 'href' + + @property + def target(self): + return make_url(self.request, node=self.model.root) + + href = target + + @property + def display(self): + return bool(self.request.authenticated_userid) + + +############################################################################### +# Custom Context Menu Group +# +# Adds a custom group to the context menu with custom actions. +############################################################################### + +@context_menu_group(name='example_tools') +class ExampleToolsToolbar(ContextMenuToolbar): + """Custom context menu toolbar group for example-specific actions.""" + + +@context_menu_item(group='example_tools', name='example_action') +class ExampleContextAction(LinkAction): + """Custom context menu action demonstrating LinkAction in context menu.""" + css = 'nav-link' + text = _('refresh', default='Refresh') + icon = 'bi-arrow-clockwise' + event = 'contextchanged:#layout' + + @property + def target(self): + return make_url(self.request, node=self.model) + + @property + def href(self): + return make_url(self.request, node=self.model) + + @property + def display(self): + return self.permitted('view') + + +############################################################################### +# request_property Demo +# +# request_property caches a computed value for the duration of a single +# request, avoiding redundant computation. +############################################################################### + +class RequestPropertyDemo: + """Demonstrates request_property decorator. + + The decorated method is called once per request and the result is cached. + Subsequent access returns the cached value. + """ + + @request_property + def expensive_computation(self): + """This would only be computed once per request.""" + return {'computed': True, 'data': [1, 2, 3]} 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 index a53f4db8..aa82d393 100644 --- a/examples/cone.example/src/cone/example/browser/static/cone.example.css +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -1,3 +1,41 @@ +/* Document workflow states */ +#contextmenu a.state-draft, +tr.state-draft td.title a { + color: #6c757d; +} +#contextmenu a.state-review, +tr.state-review td.title a { + color: #fd7e14; +} +#contextmenu a.state-published, +tr.state-published td.title a { + color: #198754; +} +#contextmenu a.state-archived, +tr.state-archived td.title a { + color: #6c757d; + text-decoration: line-through; +} + +/* Task workflow states */ +#contextmenu a.state-todo, +tr.state-todo td.title a { + color: #0d6efd; +} +#contextmenu a.state-in_progress, +tr.state-in_progress td.title a { + color: #fd7e14; +} +#contextmenu a.state-done, +tr.state-done td.title a { + color: #198754; +} +#contextmenu a.state-closed, +tr.state-closed td.title a { + color: #6c757d; +} + +/* Publication workflow states (backward compat) */ #contextmenu a.state-private, tr.state-private td.title a { color: red; diff --git a/examples/cone.example/src/cone/example/configure.zcml b/examples/cone.example/src/cone/example/configure.zcml index bad07151..1416468b 100644 --- a/examples/cone.example/src/cone/example/configure.zcml +++ b/examples/cone.example/src/cone/example/configure.zcml @@ -1,6 +1,7 @@ - + + diff --git a/examples/cone.example/src/cone/example/document/__init__.py b/examples/cone.example/src/cone/example/document/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/document/browser.py b/examples/cone.example/src/cone/example/document/browser.py new file mode 100644 index 00000000..5922a3af --- /dev/null +++ b/examples/cone.example/src/cone/example/document/browser.py @@ -0,0 +1,243 @@ +from cone.app.browser.authoring import ContentAddForm +from cone.app.browser.authoring import ContentEditForm +from cone.app.browser.content import content_view_action +from cone.app.browser.content import content_view_tile +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.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.document.model import Document +from cone.example.document.model import DocumentFolder +from cone.example.document.model import DocumentLibrary +from cone.example.model import Translation +from cone.example.model import _ +from cone.tile import tile +from node.utils import UNSET +from plumber import plumbing +from yafowil.base import factory +from yafowil.persistence import write_mapping_writer + + +# View tiles for DocumentLibrary and DocumentFolder (container view) +@tile(name='view', + path='cone.example.browser:templates/view.pt', + interface=DocumentLibrary, + permission='login') +@tile(name='view', + path='cone.example.browser:templates/view.pt', + interface=DocumentFolder, + permission='login') +class DocumentContainerView(ProtectedContentTile): + pass + + +# Content view tile for Document - main view +@content_view_tile( + name='content', + path='cone.example.document:templates/document_view.pt', + interface=Document, + permission='login') +@content_view_action( + name='view', + tilename='content', + interface=Document, + permission='view', + text=_('view', default='View'), + icon='bi-eye') +class DocumentView(ProtectedContentTile): + pass + + +# Content view tile for Document - source/raw view +@content_view_tile( + name='source', + path='cone.example.document:templates/document_source.pt', + interface=Document, + permission='login') +@content_view_action( + name='source', + tilename='source', + interface=Document, + permission='edit', + text=_('source', default='Source'), + icon='bi-code') +class DocumentSourceView(ProtectedContentTile): + pass + + +# Base form for Document types +class DocumentForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='documentform', + 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_help', default='Enter a title'), + 'required': _('title_required', default='Title is required') + }) + 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_help', default='Enter a description'), + 'rows': 3 + }) + form['body'] = factory( + 'field:label:help:error:textarea', + value=self.model.attrs.get('body', UNSET), + props={ + 'label': _('body', default='Body'), + 'help': _('body_help', default='Enter the document body'), + 'rows': 10 + }) + 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) + + +# Folder form (title + description only) +class FolderForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='folderform', + 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_help', default='Enter a title'), + 'required': _('title_required', default='Title is required') + }) + 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_help', default='Enter a description'), + 'rows': 3 + }) + 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 DocumentAddForm(DocumentForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class DocumentEditForm(DocumentForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + + +@plumbing(AddFormTarget) +class FolderAddForm(FolderForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class FolderEditForm(FolderForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + + +# Add forms +@tile(name='addform', interface=Document, permission='add') +@plumbing(ContentAddForm) +class DocumentContentAddForm(DocumentAddForm): + ... + + +@tile(name='addform', interface=DocumentFolder, permission='add') +@plumbing(ContentAddForm) +class FolderContentAddForm(FolderAddForm): + ... + + +# Edit forms +@tile(name='editform', interface=Document, permission='edit') +@plumbing(ContentEditForm) +class DocumentContentEditForm(DocumentEditForm): + ... + + +@tile(name='editform', interface=DocumentLibrary, permission='edit') +@tile(name='editform', interface=DocumentFolder, permission='edit') +@plumbing(ContentEditForm) +class FolderContentEditForm(FolderEditForm): + ... diff --git a/examples/cone.example/src/cone/example/document/model.py b/examples/cone.example/src/cone/example/document/model.py new file mode 100644 index 00000000..19269131 --- /dev/null +++ b/examples/cone.example/src/cone/example/document/model.py @@ -0,0 +1,93 @@ +from cone.app.interfaces import INavigationLeaf +from cone.app.model import Metadata +from cone.app.model import node_info +from cone.app.model import Properties +from cone.app.model import ProtectedProperties +from cone.app.model import UUIDAttributeAware +from cone.app.security import OwnerSupport +from cone.app.security import PrincipalACL +from cone.example.model import _ +from cone.example.model import BaseContainer +from cone.example.model import Translation +from cone.example.model import WorkflowNode +from node.utils import instance_property +from plumber import plumbing +from zope.interface import implementer + + +@node_info( + name='document_library', + title=_('document_library', default='Document Library'), + icon='bi-collection', + addables=['document_folder', 'document']) +class DocumentLibrary(BaseContainer): + + @property + def properties(self): + props = super().properties + props.mainmenu_display_children = False + return props + + +@node_info( + name='document_folder', + title=_('document_folder', default='Document Folder'), + icon='bi-folder', + addables=['document_folder', 'document']) +class DocumentFolder(BaseContainer): + + @property + def properties(self): + props = super().properties + props.action_delete = True + return props + + +@node_info( + name='document', + title=_('document', default='Document'), + icon='bi-file-earmark-text') +@plumbing(OwnerSupport, PrincipalACL, UUIDAttributeAware) +@implementer(INavigationLeaf) +class Document(WorkflowNode): + workflow_name = 'document_workflow' + 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 + title = self.attrs.get('title') + md.title = title.value if title else self.name + description = self.attrs.get('description') + md.description = description.value if description else '' + md.creator = self.attrs.get('creator', '') + md.created = self.attrs.get('created') + md.modified = self.attrs.get('modified') + return md + + @property + def protected_properties(self): + """Demonstrates ProtectedProperties - body requires edit permission.""" + props = ProtectedProperties( + self, + permissions={ + 'body': ['edit'], + }) + props.body = self.attrs.get('body', '') + return props diff --git a/examples/cone.example/src/cone/example/document/templates/document_source.pt b/examples/cone.example/src/cone/example/document/templates/document_source.pt new file mode 100644 index 00000000..ab0e25e6 --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/document_source.pt @@ -0,0 +1,55 @@ + + + + +
+
+

+ + Source: + Title +

+
+
+
Attributes
+ + + + + + + + + + + + + +
KeyValue
keyvalue
+ +
Properties
+ + + + + + + + + + + + + + + + + + + +
UUIDuuid
Ownerowner
Workflow Statestate
Pathpath
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/document/templates/document_view.pt b/examples/cone.example/src/cone/example/document/templates/document_view.pt new file mode 100644 index 00000000..56f6f6a5 --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/document_view.pt @@ -0,0 +1,36 @@ + + + + +
+
+

+ + Title +

+
+
+ + +

+ Description +

+ +
+ +
+
Body content
+
+ +
+ No content yet. +
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/document_workflow.zcml b/examples/cone.example/src/cone/example/document_workflow.zcml new file mode 100644 index 00000000..322103e8 --- /dev/null +++ b/examples/cone.example/src/cone/example/document_workflow.zcml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/cone.example/src/cone/example/model.py b/examples/cone.example/src/cone/example/model.py index 2d989507..237efa5b 100644 --- a/examples/cone.example/src/cone/example/model.py +++ b/examples/cone.example/src/cone/example/model.py @@ -1,15 +1,12 @@ 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 @@ -26,7 +23,6 @@ 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 @@ -34,6 +30,9 @@ _ = TranslationStringFactory('cone.example') +# Translation-aware string storage. +# Stores values per language key (e.g., 'en', 'de'). The ``value`` property +# returns the translation for the current request locale. @plumbing( NodeInit, MappingNode, @@ -43,6 +42,28 @@ class Translation: ... +# Default ACL shared across example node types. +DEFAULT_EXAMPLE_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), +] + + +# Base node with workflow support, ordered storage, and attributes. @plumbing( AppNode, WorkflowState, @@ -53,34 +74,34 @@ class Translation: MappingNode, MappingOrder, OdictStorage) -class PublicationWorkflowNode: - workflow_name = 'publication' +class WorkflowNode: + workflow_name = None 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), - ] + default_acl = DEFAULT_EXAMPLE_ACL def __call__(self): ... +# Base node without workflow, ordered storage, and attributes. +@plumbing( + AppNode, + MappingAdopt, + Attributes, + NodeInit, + MappingNode, + MappingOrder, + OdictStorage) +class ContainerNode: + default_acl = DEFAULT_EXAMPLE_ACL + + def __call__(self): + ... + + +# Container base with PrincipalACL and CopySupport. @plumbing(PrincipalACL, CopySupport) -class BaseContainer(PublicationWorkflowNode): +class BaseContainer(ContainerNode): role_inheritance = True @instance_property @@ -105,124 +126,23 @@ def properties(self): 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'] + title = self.attrs.get('title') + md.title = title.value if title else self.name + description = self.attrs.get('description') + md.description = description.value if description else '' + md.creator = self.attrs.get('creator', '') + md.created = self.attrs.get('created') + md.modified = self.attrs.get('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): + """Live search adapter. + + Searches child node metadata for title and description matches. + """ def __init__(self, model): self.model = model @@ -231,14 +151,16 @@ def search(self, request, query): result = [] for child in self.model.values(): md = child.metadata + title = md.title or '' + description = md.description or '' if ( - md.title.lower().find(query.lower()) > -1 or - md.description.lower().find(query.lower()) > -1 + title.lower().find(query.lower()) > -1 + or description.lower().find(query.lower()) > -1 ): result.append({ - 'value': md.title, + 'value': title, 'target': make_url(request, node=child), 'icon': md.icon, - 'description': md.description, + 'description': description, }) return result diff --git a/examples/cone.example/src/cone/example/project/__init__.py b/examples/cone.example/src/cone/example/project/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/project/browser.py b/examples/cone.example/src/cone/example/project/browser.py new file mode 100644 index 00000000..eb0329f3 --- /dev/null +++ b/examples/cone.example/src/cone/example/project/browser.py @@ -0,0 +1,268 @@ +from cone.app.browser.authoring import ContentAddForm +from cone.app.browser.authoring import ContentEditForm +from cone.app.browser.batch import BatchedItems +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.app.browser.utils import make_url +from cone.app.utils import add_creation_metadata +from cone.app.utils import update_creation_metadata +from cone.example.model import Translation +from cone.example.model import _ +from cone.example.project.model import Project +from cone.example.project.model import ProjectBoard +from cone.example.project.model import Task +from cone.example.project.model import TaskData +from cone.tile import tile +from node.utils import UNSET +from plumber import plumbing +from yafowil.base import factory +from yafowil.persistence import write_mapping_writer + + +# View tiles +@tile(name='view', + path='cone.example.browser:templates/view.pt', + interface=ProjectBoard, + permission='login') +class ProjectBoardView(ProtectedContentTile): + pass + + +@tile(name='view', + path='cone.example.browser:templates/view.pt', + interface=Project, + permission='login') +class ProjectView(ProtectedContentTile): + pass + + +@tile(name='content', + path='cone.example.project:templates/task_view.pt', + interface=Task, + permission='login') +class TaskView(ProtectedContentTile): + pass + + +# BatchedItems tile for ProjectBoard - demonstrates batched/searchable items +@tile(name='batched_projects', + path='cone.app.browser:templates/batched_items.pt', + interface=ProjectBoard, + permission='view') +class ProjectBatchedItems(BatchedItems): + items_id = 'projects' + slice_template = 'cone.example.project:templates/project_items.pt' + default_slice_size = 10 + show_title = True + show_filter = True + + @property + def title(self): + return self.model.metadata.title + + @property + def item_count(self): + term = self.filter_term + if term: + return len([ + c for c in self.model.values() + if term.lower() in (c.metadata.title or '').lower() + ]) + return len(self.model) + + @property + def slice_items(self): + start, end = self.current_slice + children = list(self.model.values()) + term = self.filter_term + if term: + children = [ + c for c in children + if term.lower() in (c.metadata.title or '').lower() + ] + items = [] + for child in children[start:end]: + items.append({ + 'title': child.metadata.title, + 'description': child.metadata.description or '', + 'icon': child.metadata.icon or 'bi-clipboard', + 'target': make_url(self.request, node=child), + }) + return items + + +# Project form +class ProjectForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='projectform', + 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': _('project_title_help', default='Enter a project title'), + 'required': _('title_required', default='Title is required') + }) + form['description'] = factory( + 'field:label:help:error:translation:textarea', + value=self.model.attrs.get('description', UNSET), + props={ + 'factory': Translation, + 'label': _('description', default='Description'), + 'help': _('project_desc_help', default='Enter a project 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) + + +# Task form +class TaskForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='taskform', + 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': _('task_title_help', default='Enter a task title'), + 'required': _('title_required', default='Title is required') + }) + form['description'] = factory( + 'field:label:help:error:translation:textarea', + value=self.model.attrs.get('description', UNSET), + props={ + 'factory': Translation, + 'label': _('description', default='Description'), + 'help': _('task_desc_help', default='Enter a task 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 ProjectAddForm(ProjectForm): + + def save(self, widget, data): + from cone.app.browser.utils import choose_name + add_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class ProjectEditForm(ProjectForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + + +# For Task add form, we need to create a TaskData and wrap in Task +def task_addmodel_factory(parent, nodeinfo): + """Custom add model factory for Task. + Creates a TaskData wrapped by Task (AdapterNode).""" + task = Task(TaskData(), None, None) + task.__parent__ = parent + return task + + +@plumbing(AddFormTarget) +class TaskAddForm(TaskForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + parent = self.model.parent + # UUIDAsName: name is determined by UUID + parent[self.model.__name__] = self.model + + +@plumbing(EditFormTarget) +class TaskEditForm(TaskForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + + +@tile(name='addform', interface=Project, permission='add') +@plumbing(ContentAddForm) +class ProjectContentAddForm(ProjectAddForm): + ... + + +@tile(name='editform', interface=Project, permission='edit') +@plumbing(ContentEditForm) +class ProjectContentEditForm(ProjectEditForm): + ... + + +@tile(name='addform', interface=Task, permission='add') +@plumbing(ContentAddForm) +class TaskContentAddForm(TaskAddForm): + ... + + +@tile(name='editform', interface=Task, permission='edit') +@plumbing(ContentEditForm) +class TaskContentEditForm(TaskEditForm): + ... diff --git a/examples/cone.example/src/cone/example/project/model.py b/examples/cone.example/src/cone/example/project/model.py new file mode 100644 index 00000000..8f7c9d9a --- /dev/null +++ b/examples/cone.example/src/cone/example/project/model.py @@ -0,0 +1,183 @@ +from cone.app.interfaces import INavigationLeaf +from cone.app.model import AdapterNode +from cone.app.model import AppNode +from cone.app.model import BaseNode +from cone.app.model import Categories +from cone.app.model import FactoryNode +from cone.app.model import Metadata +from cone.app.model import NamespaceUUID +from cone.app.model import Properties +from cone.app.model import UUIDAsName +from cone.app.model import node_info +from cone.app.security import PrincipalACL +from cone.app.workflow import WorkflowACL +from cone.app.workflow import WorkflowState +from cone.example.model import _ +from cone.example.model import DEFAULT_EXAMPLE_ACL +from cone.example.model import Translation +from node.behaviors import Attributes +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 odict import odict +from plumber import plumbing +from pyramid.security import ALL_PERMISSIONS +from pyramid.security import Allow +from pyramid.security import Deny +from pyramid.security import Everyone +from zope.interface import implementer + + +@node_info( + name='project_board', + title=_('project_board', default='Project Board'), + icon='bi-kanban', + addables=['project']) +class ProjectBoard(FactoryNode): + """Entry container using FactoryNode pattern. + + FactoryNode lazily instantiates children from the ``factories`` dict. + Children are volatile and recreated on invalidation. + """ + factories = odict() + + @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 = False + props.action_list = True + props.action_add = True + return props + + @property + def metadata(self): + md = Metadata() + md.title = _('project_board', default='Project Board') + md.description = _('project_board_desc', + default='Manage projects and tasks') + md.icon = 'bi-kanban' + return md + + +@node_info( + name='project', + title=_('project', default='Project'), + icon='bi-clipboard', + addables=['task']) +@plumbing(NamespaceUUID, PrincipalACL, Categories, AppNode, + MappingAdopt, Attributes, NodeInit, MappingNode, + MappingOrder, OdictStorage) +class Project: + """Container demonstrating NamespaceUUID and Categories. + + NamespaceUUID: UUID is calculated from node path + namespace. + Categories: provides a list of categorization translation strings. + """ + categories = [ + _('cat_development', default='Development'), + _('cat_design', default='Design') + ] + role_inheritance = True + default_acl = DEFAULT_EXAMPLE_ACL + + @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_delete = True + props.action_list = True + props.action_sharing = True + props.action_add = True + return props + + @property + def metadata(self): + md = Metadata() + md.icon = 'bi-clipboard' + title = self.attrs.get('title') + md.title = title.value if title else self.name + description = self.attrs.get('description') + md.description = description.value if description else '' + md.creator = self.attrs.get('creator', '') + md.created = self.attrs.get('created') + md.modified = self.attrs.get('modified') + return md + + def __call__(self): + ... + + +# Internal data model for Task - demonstrates that AdapterNode wraps +# a simpler data model. +@plumbing(NodeInit, MappingNode, Attributes, OdictStorage) +class TaskData: + """Simple data node wrapped by TaskAdapter.""" + ... + + +@node_info( + name='task', + title=_('task', default='Task'), + icon='bi-check2-square') +@plumbing(UUIDAsName, WorkflowState, WorkflowACL, PrincipalACL, Categories) +@implementer(INavigationLeaf) +class Task(AdapterNode): + """Leaf node demonstrating UUIDAsName, AdapterNode wrapping, + and a custom task workflow. + + UUIDAsName: The node's __name__ is its UUID string. + AdapterNode: Wraps a TaskData instance, proxying attrs. + WorkflowState: Workflow state stored in attrs['state']. + Categories: Categorization support. + """ + workflow_name = 'task_workflow' + workflow_tsf = staticmethod(_) + role_inheritance = True + categories = [ + _('cat_development', default='Development'), + _('cat_design', default='Design') + ] + default_acl = DEFAULT_EXAMPLE_ACL + + @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 = 'bi-check2-square' + title = self.attrs.get('title') + md.title = title.value if title else str(self.uuid) if self.uuid else self.name + description = self.attrs.get('description') + md.description = description.value if description else '' + md.creator = self.attrs.get('creator', '') + md.created = self.attrs.get('created') + md.modified = self.attrs.get('modified') + return md diff --git a/examples/cone.example/src/cone/example/project/templates/project_items.pt b/examples/cone.example/src/cone/example/project/templates/project_items.pt new file mode 100644 index 00000000..bb21855f --- /dev/null +++ b/examples/cone.example/src/cone/example/project/templates/project_items.pt @@ -0,0 +1,29 @@ + + + + +
+ No projects found. +
+ +
diff --git a/examples/cone.example/src/cone/example/project/templates/task_view.pt b/examples/cone.example/src/cone/example/project/templates/task_view.pt new file mode 100644 index 00000000..7482c5f4 --- /dev/null +++ b/examples/cone.example/src/cone/example/project/templates/task_view.pt @@ -0,0 +1,36 @@ + + + + +
+
+

+ + Title +

+ + state + +
+
+ + +

+ Description +

+ +
+
UUID
+
uuid
+
Path
+
path
+
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/publication.zcml b/examples/cone.example/src/cone/example/publication.zcml deleted file mode 100644 index 788eda8c..00000000 --- a/examples/cone.example/src/cone/example/publication.zcml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/examples/cone.example/src/cone/example/settings/__init__.py b/examples/cone.example/src/cone/example/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/settings/browser.py b/examples/cone.example/src/cone/example/settings/browser.py new file mode 100644 index 00000000..54ab85e8 --- /dev/null +++ b/examples/cone.example/src/cone/example/settings/browser.py @@ -0,0 +1,83 @@ +from cone.app.browser.form import Form +from cone.app.browser.settings import SettingsForm +from cone.app.browser.settings import settings_form +from cone.example.model import _ +from cone.example.settings.model import ExampleSettings +from plumber import plumbing +from yafowil.base import factory + + +@settings_form(interface=ExampleSettings) +@plumbing(SettingsForm) +class ExampleSettingsForm(Form): + """Settings form demonstrating the settings_form decorator. + + settings_form registers the form tile as 'editform' for the + given interface. SettingsForm behavior provides heading and + contextmenu handling. + """ + + def prepare(self): + model = self.model + props = model.config_properties + self.form = form = factory( + 'form', + name='example_settings_form', + props={ + 'action': self.form_action, + }) + form['items_per_page'] = factory( + 'field:label:help:error:number', + value=props.get('items_per_page', '15'), + props={ + 'label': _('items_per_page', default='Items per Page'), + 'help': _('items_per_page_help', + default='Number of items shown per page'), + 'datatype': int, + 'min': 5, + 'max': 100, + }) + form['enable_notifications'] = factory( + 'field:label:help:error:select', + value=props.get('enable_notifications', 'true'), + props={ + 'label': _('enable_notifications', + default='Enable Notifications'), + 'help': _('notifications_help', + default='Toggle notification emails'), + 'vocabulary': [ + ('true', _('yes', default='Yes')), + ('false', _('no', default='No')), + ], + }) + form['default_language'] = factory( + 'field:label:help:error:select', + value=props.get('default_language', 'en'), + props={ + 'label': _('default_language', default='Default Language'), + 'help': _('language_help', + default='Default language for new content'), + 'vocabulary': [ + ('en', _('english', default='English')), + ('de', _('german', default='German')), + ], + }) + form['save'] = factory( + 'submit', + props={ + 'action': 'save', + 'expression': True, + 'handler': self.save, + 'next': self.next, + 'label': _('save', default='Save') + }) + + def save(self, widget, data): + props = self.model.config_properties + props.items_per_page = data.fetch( + 'example_settings_form.items_per_page').extracted + props.enable_notifications = data.fetch( + 'example_settings_form.enable_notifications').extracted + props.default_language = data.fetch( + 'example_settings_form.default_language').extracted + props() diff --git a/examples/cone.example/src/cone/example/settings/model.py b/examples/cone.example/src/cone/example/settings/model.py new file mode 100644 index 00000000..a76d7a5e --- /dev/null +++ b/examples/cone.example/src/cone/example/settings/model.py @@ -0,0 +1,44 @@ +from cone.app.model import ConfigProperties +from cone.app.model import Metadata +from cone.app.model import SettingsNode +from cone.app.model import node_info +from cone.example.model import _ +from node.utils import instance_property +import os +import tempfile + + +@node_info( + name='example_settings', + title=_('example_settings', default='Example Settings'), + description=_('example_settings_desc', + default='Configure example application settings'), + icon='bi-sliders') +class ExampleSettings(SettingsNode): + """Settings node demonstrating ConfigProperties. + + ConfigProperties persists settings to a config file using ConfigParser. + The settings_form decorator (in browser.py) creates the edit form tile. + """ + category = _('example_category', default='Example') + + @instance_property + def config_properties(self): + config_dir = os.environ.get( + 'CONE_EXAMPLE_CONFIG_DIR', + os.path.join(tempfile.gettempdir(), 'cone_example') + ) + os.makedirs(config_dir, exist_ok=True) + config_path = os.path.join(config_dir, 'example_settings.cfg') + return ConfigProperties(config_path, data={ + 'items_per_page': '15', + 'enable_notifications': 'true', + 'default_language': 'en', + }) + + @instance_property + def metadata(self): + md = Metadata() + md.title = self.nodeinfo.title + md.description = self.nodeinfo.description + return md diff --git a/examples/cone.example/src/cone/example/task_workflow.zcml b/examples/cone.example/src/cone/example/task_workflow.zcml new file mode 100644 index 00000000..b3dd17c7 --- /dev/null +++ b/examples/cone.example/src/cone/example/task_workflow.zcml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/cone.example/src/cone/example/testing.py b/examples/cone.example/src/cone/example/testing.py new file mode 100644 index 00000000..d3e75516 --- /dev/null +++ b/examples/cone.example/src/cone/example/testing.py @@ -0,0 +1,24 @@ +from cone.app.testing import Security as BaseSecurityLayer +from yafowil.base import factory +from yafowil.bootstrap import configure_factory + + +class ExampleSecurity(BaseSecurityLayer): + """Test layer for cone.example. + + Extends the base cone.app security layer to include cone.example + as a plugin. This ensures all entry nodes, settings, browser + registrations, and ZCML workflows are loaded. + """ + + def make_app(self, **kw): + kw.setdefault('cone.plugins', 'node.ext.ugm\ncone.example') + super().make_app(**kw) + + def setUp(self, args=None): + self.make_app() + factory.push_state() + configure_factory('bootstrap5') + + +security = ExampleSecurity() diff --git a/examples/cone.example/src/cone/example/tests/__init__.py b/examples/cone.example/src/cone/example/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/wiki/__init__.py b/examples/cone.example/src/cone/example/wiki/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/wiki/browser.py b/examples/cone.example/src/cone/example/wiki/browser.py new file mode 100644 index 00000000..84a8fde4 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/browser.py @@ -0,0 +1,248 @@ +from cone.app.browser import RelatedViewProvider +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.form import YAMLAddFormTarget +from cone.app.browser.form import YAMLEditFormTarget +from cone.app.browser.form import YAMLForm +from cone.app.browser.layout import ProtectedContentTile +from cone.app.browser.utils import choose_name +from cone.app.browser.utils import make_url +from cone.app.utils import add_creation_metadata +from cone.app.utils import update_creation_metadata +from cone.example.model import Translation +from cone.example.model import _ +from cone.example.wiki.model import Wiki +from cone.example.wiki.model import WikiPage +from cone.tile import tile +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 + + +# View tile for Wiki container +@tile(name='view', + path='cone.example.browser:templates/view.pt', + interface=Wiki, + permission='login') +class WikiContainerView(ProtectedContentTile): + pass + + +# View tile for WikiPage +@tile(name='content', + path='cone.example.wiki:templates/wiki_view.pt', + interface=WikiPage, + permission='login') +class WikiPageView(ProtectedContentTile): + + @property + def references(self): + """Resolve reference UUIDs to actual nodes for display.""" + refs = self.model.attrs.get('references', []) + if not refs: + return [] + result = [] + # Walk up to find wiki container to search for referenced pages + wiki = self.model.parent + for page in wiki.values(): + if hasattr(page, 'uuid') and str(page.uuid) in refs: + result.append({ + 'title': page.metadata.title, + 'target': make_url(self.request, node=page), + 'icon': page.metadata.icon or 'bi-journal-text', + }) + return result + + +# WikiPage form using standard yafowil factory +class WikiPageForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='wikipageform', + 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': _('wiki_title_help', default='Enter a page title'), + 'required': _('title_required', default='Title is required') + }) + form['description'] = factory( + 'field:label:help:error:translation:textarea', + value=self.model.attrs.get('description', UNSET), + props={ + 'factory': Translation, + 'label': _('description', default='Description'), + 'help': _('wiki_desc_help', default='Short description'), + 'rows': 2 + }) + form['body'] = factory( + 'field:label:help:error:textarea', + value=self.model.attrs.get('body', UNSET), + props={ + 'label': _('body', default='Content'), + 'help': _('wiki_body_help', default='Enter wiki page content'), + 'rows': 10 + }) + # Reference browser widget for linking to other wiki pages + form['references'] = factory( + 'field:label:help:error:reference', + value=self.reference_value, + props={ + 'label': _('references', default='Related Pages'), + 'help': _('references_help', + default='Select related wiki pages'), + 'multivalued': True, + 'target': self.reference_target, + 'root': self.reference_root, + 'referencable': 'wiki_page', + 'lookup': self.reference_lookup, + }) + 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') + }) + + @property + def reference_value(self): + refs = self.model.attrs.get('references') + if refs: + return refs + return [] + + @property + def reference_target(self): + return make_url(self.request, node=self.model.root) + + @property + def reference_root(self): + # Find the wiki container + wiki = self.model.parent + return '/' + '/'.join(wiki.path[1:]) if wiki else '/' + + def reference_lookup(self, uuid): + """Lookup label for a reference UUID.""" + wiki = self.model.parent + for page in wiki.values(): + if hasattr(page, 'uuid') and str(page.uuid) == uuid: + return page.metadata.title + return uuid + + def save(self, widget, data): + # Extract references separately since reference widget has special + # extraction + refs = data.fetch('wikipageform.references') + if refs.extracted: + self.model.attrs['references'] = refs.extracted + # Write other fields + data.write(self.model.attrs) + + +@plumbing(AddFormTarget) +class WikiPageAddForm(WikiPageForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class WikiPageEditForm(WikiPageForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super().save(widget, data) + + +@tile(name='addform', interface=WikiPage, permission='add') +@plumbing(ContentAddForm) +class WikiPageContentAddForm(WikiPageAddForm): + ... + + +@tile(name='editform', interface=WikiPage, permission='edit') +@plumbing(ContentEditForm) +class WikiPageContentEditForm(WikiPageEditForm): + ... + + +# Edit form for Wiki container (just title/description) +@tile(name='editform', interface=Wiki, permission='edit') +@plumbing(ContentEditForm, EditFormTarget) +class WikiEditForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='wikiform', + 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'), + 'required': _('title_required', default='Title is required') + }) + form['description'] = factory( + 'field:label:help:error:translation:textarea', + value=self.model.attrs.get('description', UNSET), + props={ + 'factory': Translation, + 'label': _('description', default='Description'), + 'rows': 3 + }) + 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): + update_creation_metadata(self.request, self.model.attrs) + data.write(self.model.attrs) diff --git a/examples/cone.example/src/cone/example/wiki/model.py b/examples/cone.example/src/cone/example/wiki/model.py new file mode 100644 index 00000000..9a623787 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/model.py @@ -0,0 +1,95 @@ +from cone.app.interfaces import INavigationLeaf +from cone.app.model import AppNode +from cone.app.model import Categories +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import UUIDAttributeAware +from cone.app.model import node_info +from cone.app.security import PrincipalACL +from cone.example.model import _ +from cone.example.model import BaseContainer +from cone.example.model import DEFAULT_EXAMPLE_ACL +from cone.example.model import Translation +from node.behaviors import Attributes +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 zope.interface import implementer + + +@node_info( + name='wiki', + title=_('wiki', default='Wiki'), + icon='bi-book', + addables=['wiki_page']) +class Wiki(BaseContainer): + """Entry container for wiki pages. + + Acts as the reference browser root for WikiPage references. + """ + + @property + def properties(self): + props = super().properties + props.mainmenu_display_children = False + return props + + +@node_info( + name='wiki_page', + title=_('wiki_page', default='Wiki Page'), + icon='bi-journal-text') +@plumbing(UUIDAttributeAware, PrincipalACL, Categories, AppNode, + MappingAdopt, Attributes, NodeInit, MappingNode, + MappingOrder, OdictStorage) +@implementer(INavigationLeaf) +class WikiPage: + """Leaf node demonstrating Categories, UUIDAttributeAware, + and reference browser widget for linking to other pages. + + Categories: Categorization support with translation strings. + UUIDAttributeAware: UUID stored in node attributes. + Reference browser: Used in the form to select related pages. + """ + categories = [ + _('cat_general', default='General'), + _('cat_technical', default='Technical'), + _('cat_howto', default='How-To') + ] + role_inheritance = True + default_acl = DEFAULT_EXAMPLE_ACL + + @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 = 'bi-journal-text' + title = self.attrs.get('title') + md.title = title.value if title else self.name + description = self.attrs.get('description') + md.description = description.value if description else '' + md.creator = self.attrs.get('creator', '') + md.created = self.attrs.get('created') + md.modified = self.attrs.get('modified') + return md + + def __call__(self): + ... diff --git a/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt b/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt new file mode 100644 index 00000000..32665d36 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt @@ -0,0 +1,55 @@ + + + + +
+
+

+ + Title +

+
+
+ + +

+ Description +

+ +
+ +
+
Body content
+
+ +
+ No content yet. +
+
+
+ +
+
+
Related Pages
+
+ +
+ +
From 54cbaa8075576bf0c80cab737db7f60c9178a6d4 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Mon, 26 Jan 2026 12:57:18 +0100 Subject: [PATCH 02/25] add modal-xl class to rendered Ajax Tile Errors --- src/cone/app/browser/ajax.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cone/app/browser/ajax.py b/src/cone/app/browser/ajax.py index 81c274bc..35d2babc 100644 --- a/src/cone/app/browser/ajax.py +++ b/src/cone/app/browser/ajax.py @@ -51,7 +51,7 @@ def ajax_tile(model, request): except Exception: logging.exception('Error within ajax tile') tb = format_traceback() - continuation = AjaxContinue([AjaxMessage(tb, 'error', None)]) + continuation = AjaxContinue([AjaxMessage(tb, 'error', None, 'modal-xl')]) return dict( mode='NONE', selector='NONE', From b3cf76ecf568eadcded8413cc38f2e4991e4c3b7 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Mon, 26 Jan 2026 13:37:59 +0100 Subject: [PATCH 03/25] fix ajax_playground --- .../src/cone/example/ajax/browser.py | 14 ++++++---- .../example/ajax/templates/ajax_playground.pt | 28 +++++++++++-------- .../src/cone/example/project/browser.py | 4 +-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/examples/cone.example/src/cone/example/ajax/browser.py b/examples/cone.example/src/cone/example/ajax/browser.py index 9092cd2d..7534333d 100644 --- a/examples/cone.example/src/cone/example/ajax/browser.py +++ b/examples/cone.example/src/cone/example/ajax/browser.py @@ -1,18 +1,16 @@ from cone.app.browser.ajax import ajax_continue from cone.app.browser.ajax import ajax_message -from cone.app.browser.ajax import ajax_status_message from cone.app.browser.ajax import AjaxAction from cone.app.browser.ajax import AjaxEvent from cone.app.browser.ajax import AjaxMessage -from cone.app.browser.ajax import AjaxOverlay from cone.app.browser.ajax import AjaxPath from cone.app.browser.layout import ProtectedContentTile from cone.app.browser.utils import make_url -from cone.app.model import AppNode from cone.app.model import BaseNode from cone.app.model import Metadata -from cone.app.model import Properties from cone.app.model import node_info +from cone.app.model import Properties +from cone.app.utils import node_path from cone.example.model import _ from cone.tile import tile from node.utils import instance_property @@ -87,12 +85,13 @@ def render(self): interface=AjaxPlayground, permission='view') class AjaxPathDemo(ProtectedContentTile): + # XXX: make this demo more obvious, e.g. by changing some content def render(self): url = make_url(self.request, node=self.model) ajax_continue(self.request, [ AjaxPath( - path='/'.join(self.model.path), + path='/'.join(node_path(self.model)), target=url, event='contextchanged:#layout' ) @@ -105,6 +104,7 @@ def render(self): interface=AjaxPlayground, permission='view') class AjaxEventDemo(ProtectedContentTile): + # XXX: make this demo more obvious, e.g. by changing some content def render(self): url = make_url(self.request, node=self.model) @@ -148,12 +148,14 @@ def render(self): interface=AjaxPlayground, permission='view') class AjaxCombinedDemo(ProtectedContentTile): + # XXX: this makes little sense, as the AjaxAction will be overridden + # by the AjaxEvent. Find a better combination demo. def render(self): url = make_url(self.request, node=self.model) ajax_continue(self.request, [ AjaxAction(url, 'ajax_demo_content', 'inner', '#ajax-demo-target'), - AjaxMessage('Combined: action + message + event!', 'success'), + AjaxMessage('Combined: action + message + event!', 'success', None), AjaxEvent(url, 'contextchanged', '#layout'), ]) return '' diff --git a/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt b/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt index 59436ac2..380175a6 100644 --- a/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt +++ b/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt @@ -1,5 +1,6 @@ + xmlns:ajax="http://namespaces.conestack.org/ajax" + omit-tag="True"> @@ -31,8 +32,9 @@

+ ajax:bind="click" + ajax:target="${context.ajax_path_url}" + ajax:action="ajax_path_demo:NONE:NONE"> Trigger AjaxPath @@ -51,8 +53,9 @@

+ ajax:bind="click" + ajax:target="${context.ajax_action_url}" + ajax:action="ajax_action_demo:NONE:NONE"> Trigger AjaxAction @@ -72,8 +75,9 @@

+ ajax:bind="click" + ajax:target="${context.ajax_action_url}" + ajax:action="ajax_event_demo:NONE:NONE"> Trigger AjaxEvent @@ -93,8 +97,9 @@

+ ajax:bind="click" + ajax:target="${context.ajax_action_url}" + ajax:action="ajax_message_demo:NONE:NONE"> Trigger AjaxMessage @@ -114,8 +119,9 @@

+ ajax:bind="click" + ajax:target="${context.ajax_action_url}" + ajax:action="ajax_combined_demo:NONE:NONE"> Trigger Combined diff --git a/examples/cone.example/src/cone/example/project/browser.py b/examples/cone.example/src/cone/example/project/browser.py index eb0329f3..bde0f199 100644 --- a/examples/cone.example/src/cone/example/project/browser.py +++ b/examples/cone.example/src/cone/example/project/browser.py @@ -203,7 +203,7 @@ class ProjectAddForm(ProjectForm): def save(self, widget, data): from cone.app.browser.utils import choose_name add_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(ProjectAddForm, self).save(widget, data) parent = self.model.parent parent[choose_name(parent, self.model.metadata.title)] = self.model @@ -213,7 +213,7 @@ class ProjectEditForm(ProjectForm): def save(self, widget, data): update_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(ProjectEditForm, self).save(widget, data) # For Task add form, we need to create a TaskData and wrap in Task From d781fd3aa7e4104d0a38faedcb9b4e78e9165d75 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Mon, 26 Jan 2026 13:49:35 +0100 Subject: [PATCH 04/25] add example to change navbar height variable via css --- .../src/cone/example/browser/static/cone.example.css | 5 +++++ 1 file changed, 5 insertions(+) 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 index aa82d393..e9f06360 100644 --- a/examples/cone.example/src/cone/example/browser/static/cone.example.css +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -44,3 +44,8 @@ tr.state-private td.title a { tr.state-public td.title a { color: green; } + + +:root { + /* --navbar-height: 100px; */ /* Example of custom navbar height */ +} \ No newline at end of file From 3be549ad9fe6af4bd96699da8d766e1f849fe3a3 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Tue, 27 Jan 2026 09:24:57 +0100 Subject: [PATCH 05/25] extend ajax message example --- examples/cone.example/src/cone/example/ajax/browser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/cone.example/src/cone/example/ajax/browser.py b/examples/cone.example/src/cone/example/ajax/browser.py index 7534333d..04fe56b7 100644 --- a/examples/cone.example/src/cone/example/ajax/browser.py +++ b/examples/cone.example/src/cone/example/ajax/browser.py @@ -124,7 +124,9 @@ def render(self): ajax_message( self.request, 'This is a demo message from AjaxMessage!', - 'info' + 'info', + 'modal-lg', + 'Demo Message' ) return '' From 76d8427c2874bbd71632b08fad574231387ab81a Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Tue, 27 Jan 2026 09:25:28 +0100 Subject: [PATCH 06/25] allow title for ajax_message and ajax_status_message --- src/cone/app/browser/ajax.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cone/app/browser/ajax.py b/src/cone/app/browser/ajax.py index 35d2babc..96bf81e5 100644 --- a/src/cone/app/browser/ajax.py +++ b/src/cone/app/browser/ajax.py @@ -76,18 +76,18 @@ def ajax_continue(request, operations): request.environ['cone.app.continuation'].append(operations) -def ajax_message(request, payload, flavor='message', css=None): +def ajax_message(request, payload, flavor='message', css=None, title=None): """Convenience to add ajax message operation to ajax continuation operations. """ - ajax_continue(request, AjaxMessage(payload, flavor, None, css)) + ajax_continue(request, AjaxMessage(payload, flavor, None, css, title)) -def ajax_status_message(request, payload, css=None): +def ajax_status_message(request, payload, css=None, title=None): """Convenience to add ajax status message operation to ajax continuation operations. """ - ajax_continue(request, AjaxMessage(payload, None, '#status_message', css)) + ajax_continue(request, AjaxMessage(payload, None, '#status_message', css, title)) class AjaxPath(object): @@ -189,7 +189,7 @@ class AjaxMessage(object): """Ajax message continuation operation. """ - def __init__(self, payload, flavor, selector, css=''): + def __init__(self, payload, flavor, selector, css='', title=None): """Create ajax message continuation operation. :param payload: Message payload as text or markup. @@ -202,6 +202,7 @@ def __init__(self, payload, flavor, selector, css=''): self.flavor = flavor self.selector = selector self.css = css + self.title = title def as_json(self): return { @@ -209,7 +210,8 @@ def as_json(self): 'payload': self.payload, 'flavor': self.flavor, 'selector': self.selector, - 'css': self.css + 'css': self.css, + 'title': self.title } From 7e50d7be72732760e2ce1353e64e3c1317176e7a Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Tue, 27 Jan 2026 12:21:21 +0100 Subject: [PATCH 07/25] populate with examples. create landing page. improve views and fix issues --- .../cone.example/src/cone/example/__init__.py | 26 ++- .../src/cone/example/browser/__init__.py | 49 +++- .../example/browser/static/cone.example.css | 66 ++++++ .../example/browser/static/images/icon.svg | 98 ++++++++ .../cone/example/browser/templates/landing.pt | 133 +++++++++++ .../src/cone/example/document/browser.py | 25 +- .../src/cone/example/document/model.py | 4 +- .../document/templates/document_source.pt | 2 +- .../document/templates/library_view.pt | 36 +++ .../cone.example/src/cone/example/populate.py | 218 ++++++++++++++++++ .../src/cone/example/project/browser.py | 6 +- .../src/cone/example/project/model.py | 3 + .../example/project/templates/board_view.pt | 37 +++ .../example/project/templates/task_view.pt | 2 +- .../src/cone/example/wiki/browser.py | 6 +- .../src/cone/example/wiki/model.py | 2 +- .../wiki/templates/wiki_container_view.pt | 35 +++ .../cone/example/wiki/templates/wiki_view.pt | 2 +- 18 files changed, 719 insertions(+), 31 deletions(-) create mode 100644 examples/cone.example/src/cone/example/browser/static/images/icon.svg create mode 100644 examples/cone.example/src/cone/example/browser/templates/landing.pt create mode 100644 examples/cone.example/src/cone/example/document/templates/library_view.pt create mode 100644 examples/cone.example/src/cone/example/populate.py create mode 100644 examples/cone.example/src/cone/example/project/templates/board_view.pt create mode 100644 examples/cone.example/src/cone/example/wiki/templates/wiki_container_view.pt diff --git a/examples/cone.example/src/cone/example/__init__.py b/examples/cone.example/src/cone/example/__init__.py index 8a81281a..0b26e379 100644 --- a/examples/cone.example/src/cone/example/__init__.py +++ b/examples/cone.example/src/cone/example/__init__.py @@ -24,10 +24,26 @@ def example_main_hook(config, global_config, settings): from cone.example.project.model import ProjectBoard from cone.example.wiki.model import Wiki from cone.example.ajax.browser import AjaxPlayground + from cone.example.populate import populate_documents + from cone.example.populate import populate_projects + from cone.example.populate import populate_wiki - register_entry('documents', DocumentLibrary) + def make_document_library(): + lib = DocumentLibrary() + populate_documents(lib) + return lib + + def make_wiki(): + wiki = Wiki() + populate_wiki(wiki) + return wiki + + # ProjectBoard uses FactoryNode — populate registers class-level factories + populate_projects() + + register_entry('documents', make_document_library) register_entry('projects', ProjectBoard) - register_entry('wiki', Wiki) + register_entry('wiki', make_wiki) register_entry('ajax_playground', AjaxPlayground) # Register settings node @@ -47,6 +63,12 @@ def example_main_hook(config, global_config, settings): # Register layout configs (deferred to avoid circular imports) _configure_layout_configs() + # add static view for example images + config.add_static_view( + name='example-images', + path='cone.example.browser:static/images' + ) + # Scan browser packages for tile and view registrations config.scan('cone.example.browser') config.scan('cone.example.document.browser') diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index d3c82998..e7b3b0b7 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -1,7 +1,6 @@ from cone.app import DefaultLayoutConfig from cone.app import layout_config from cone.app.browser.actions import LinkAction -from cone.app.browser.actions import TemplateAction from cone.app.browser.contextmenu import context_menu_group from cone.app.browser.contextmenu import context_menu_item from cone.app.browser.contextmenu import ContextMenuToolbar @@ -9,7 +8,7 @@ from cone.app.browser.layout import ProtectedContentTile from cone.app.browser.utils import make_url from cone.app.browser.utils import request_property -from cone.app.model import Properties +from cone.app.model import AppRoot from cone.example.model import _ from cone.tile import tile import os @@ -62,40 +61,74 @@ def _configure_layout_configs(): @layout_config(DocumentLibrary, DocumentFolder) class DocumentContainerLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): - super().__init__(model=model, request=request) + super(DocumentContainerLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] @layout_config(Document) class DocumentLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): - super().__init__(model=model, request=request) + super(DocumentLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] @layout_config(ProjectBoard) class ProjectBoardLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): - super().__init__(model=model, request=request) + super(ProjectBoardLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] @layout_config(Task) class TaskLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): - super().__init__(model=model, request=request) + super(TaskLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] @layout_config(Wiki, WikiPage) class WikiLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): - super().__init__(model=model, request=request) + super(WikiLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] + @layout_config(AppRoot) + class RootLayoutConfig(DefaultLayoutConfig): + def __init__(self, model=None, request=None): + super(RootLayoutConfig, self).__init__(model=model, request=request) + self.sidebar_left = [] + self.limit_content_width = False + @layout_config(AjaxPlayground) class AjaxPlaygroundLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): - super().__init__(model=model, request=request) + super(AjaxPlaygroundLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = [] +############################################################################### +# Landing Page (Root Content Tile) +############################################################################### + +@tile(name='content', + path='cone.example.browser:templates/landing.pt', + interface=AppRoot, + permission='login') +class LandingPage(ProtectedContentTile): + + @property + def documents_url(self): + return make_url(self.request, node=self.model['documents']) + + @property + def projects_url(self): + return make_url(self.request, node=self.model['projects']) + + @property + def wiki_url(self): + return make_url(self.request, node=self.model['wiki']) + + @property + def ajax_url(self): + return make_url(self.request, node=self.model['ajax_playground']) + + ############################################################################### # Custom Personal Tools Action # 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 index e9f06360..1c855a18 100644 --- a/examples/cone.example/src/cone/example/browser/static/cone.example.css +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -46,6 +46,72 @@ tr.state-public td.title a { } +/* Example module header accents */ +.example-header { + border-left: 4px solid #dee2e6; +} +.example-header-documents { + border-left-color: #0d6efd; +} +.example-header-projects { + border-left-color: #fd7e14; +} +.example-header-wiki { + border-left-color: #198754; +} + +/* Feature badges per module */ +.badge.example-badge-documents { + background-color: #cfe2ff; + color: #084298; +} +.badge.example-badge-projects { + background-color: #fff3cd; + color: #664d03; +} +.badge.example-badge-wiki { + background-color: #d1e7dd; + color: #0f5132; +} + +/* Node type icons in listings */ +tr.node-type-document_folder td.title a::before { + content: "\f3d7"; + font-family: "bootstrap-icons"; + margin-right: 0.4em; + color: #fd7e14; +} +tr.node-type-document td.title a::before { + content: "\f38b"; + font-family: "bootstrap-icons"; + margin-right: 0.4em; + color: #0d6efd; +} +tr.node-type-project td.title a::before { + content: "\f290"; + font-family: "bootstrap-icons"; + margin-right: 0.4em; + color: #fd7e14; +} +tr.node-type-task td.title a::before { + content: "\f271"; + font-family: "bootstrap-icons"; + margin-right: 0.4em; + color: #6f42c1; +} +tr.node-type-wiki_page td.title a::before { + content: "\f444"; + font-family: "bootstrap-icons"; + margin-right: 0.4em; + color: #198754; +} + +/* Landing page */ +.landing-hero { + border-bottom: 1px solid #dee2e6; + margin-bottom: 1rem; +} + :root { /* --navbar-height: 100px; */ /* Example of custom navbar height */ } \ No newline at end of file diff --git a/examples/cone.example/src/cone/example/browser/static/images/icon.svg b/examples/cone.example/src/cone/example/browser/static/images/icon.svg new file mode 100644 index 00000000..dbbb6e0a --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/static/images/icon.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/examples/cone.example/src/cone/example/browser/templates/landing.pt b/examples/cone.example/src/cone/example/browser/templates/landing.pt new file mode 100644 index 00000000..ff679e36 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/landing.pt @@ -0,0 +1,133 @@ + + +
+

+ cone.example Logo + cone.example +

+

+ A collection of example modules demonstrating cone.app features + and patterns. +

+
+ +
+ +
+
+
+
+ Document Library +
+

+ Hierarchical folders and documents with workflow states, + protected properties, and translation support. +

+
+ Workflow + Folders +
+ + Explore + + +
+
+
+ +
+
+
+
+ Project Board +
+

+ Project and task management using FactoryNode, + AdapterNode, UUIDAsName, and batched listings. +

+
+ FactoryNode + AdapterNode +
+ + Explore + + +
+
+
+ +
+
+
+
+ Wiki +
+

+ Wiki pages with cross-references via the reference browser + widget, categories, and UUID-aware nodes. +

+
+ References + Categories +
+ + Explore + + +
+
+
+ +
+
+
+
+ AJAX Playground +
+

+ Interactive demo of all AJAX continuation operations: + path, action, event, message, and overlay. +

+
+ + AjaxPath + + + AjaxAction + +
+ + Explore + + +
+
+
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/document/browser.py b/examples/cone.example/src/cone/example/document/browser.py index 5922a3af..35af80fd 100644 --- a/examples/cone.example/src/cone/example/document/browser.py +++ b/examples/cone.example/src/cone/example/document/browser.py @@ -21,16 +21,21 @@ from yafowil.persistence import write_mapping_writer -# View tiles for DocumentLibrary and DocumentFolder (container view) +# View tile for DocumentLibrary @tile(name='view', - path='cone.example.browser:templates/view.pt', + path='cone.example.document:templates/library_view.pt', interface=DocumentLibrary, permission='login') +class DocumentLibraryView(ProtectedContentTile): + pass + + +# View tile for DocumentFolder @tile(name='view', path='cone.example.browser:templates/view.pt', interface=DocumentFolder, permission='login') -class DocumentContainerView(ProtectedContentTile): +class DocumentFolderView(ProtectedContentTile): pass @@ -46,7 +51,8 @@ class DocumentContainerView(ProtectedContentTile): interface=Document, permission='view', text=_('view', default='View'), - icon='bi-eye') + icon='bi-eye', + css='dropdown-item') class DocumentView(ProtectedContentTile): pass @@ -63,7 +69,8 @@ class DocumentView(ProtectedContentTile): interface=Document, permission='edit', text=_('source', default='Source'), - icon='bi-code') + icon='bi-code', + css='dropdown-item') class DocumentSourceView(ProtectedContentTile): pass @@ -185,7 +192,7 @@ class DocumentAddForm(DocumentForm): def save(self, widget, data): add_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(DocumentAddForm, self).save(widget, data) parent = self.model.parent parent[choose_name(parent, self.model.metadata.title)] = self.model @@ -195,7 +202,7 @@ class DocumentEditForm(DocumentForm): def save(self, widget, data): update_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(DocumentEditForm, self).save(widget, data) @plumbing(AddFormTarget) @@ -203,7 +210,7 @@ class FolderAddForm(FolderForm): def save(self, widget, data): add_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(FolderAddForm, self).save(widget, data) parent = self.model.parent parent[choose_name(parent, self.model.metadata.title)] = self.model @@ -213,7 +220,7 @@ class FolderEditForm(FolderForm): def save(self, widget, data): update_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(FolderEditForm, self).save(widget, data) # Add forms diff --git a/examples/cone.example/src/cone/example/document/model.py b/examples/cone.example/src/cone/example/document/model.py index 19269131..9fc1cab6 100644 --- a/examples/cone.example/src/cone/example/document/model.py +++ b/examples/cone.example/src/cone/example/document/model.py @@ -24,7 +24,7 @@ class DocumentLibrary(BaseContainer): @property def properties(self): - props = super().properties + props = super(DocumentLibrary, self).properties props.mainmenu_display_children = False return props @@ -38,7 +38,7 @@ class DocumentFolder(BaseContainer): @property def properties(self): - props = super().properties + props = super(DocumentFolder, self).properties props.action_delete = True return props diff --git a/examples/cone.example/src/cone/example/document/templates/document_source.pt b/examples/cone.example/src/cone/example/document/templates/document_source.pt index ab0e25e6..a1edfca6 100644 --- a/examples/cone.example/src/cone/example/document/templates/document_source.pt +++ b/examples/cone.example/src/cone/example/document/templates/document_source.pt @@ -45,7 +45,7 @@ Path - path + path diff --git a/examples/cone.example/src/cone/example/document/templates/library_view.pt b/examples/cone.example/src/cone/example/document/templates/library_view.pt new file mode 100644 index 00000000..0733744e --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/library_view.pt @@ -0,0 +1,36 @@ + + + + +
+ +
+ +

+ + + Title + +

+ + + +

+ Hierarchical document management with workflow states + and permission-protected fields. +

+ +
+ Workflow + Folders + Protected Properties + Translation +
+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/populate.py b/examples/cone.example/src/cone/example/populate.py new file mode 100644 index 00000000..7ec07933 --- /dev/null +++ b/examples/cone.example/src/cone/example/populate.py @@ -0,0 +1,218 @@ +from datetime import datetime + +from cone.example.model import Translation + + +def make_translation(en_value, de_value): + t = Translation() + t['en'] = en_value + t['de'] = de_value + return t + + +def populate_documents(library): + """Populate DocumentLibrary with example nodes.""" + if len(library) > 0: + return + + from cone.example.document.model import Document + from cone.example.document.model import DocumentFolder + + # Folder with a published document inside + folder = DocumentFolder() + folder.attrs['title'] = make_translation('Reports', 'Berichte') + folder.attrs['description'] = make_translation( + 'Quarterly and annual reports', 'Vierteljährliche und jährliche Berichte') + folder.attrs['creator'] = 'admin' + folder.attrs['created'] = datetime(2025, 1, 15) + folder.attrs['modified'] = datetime(2025, 1, 15) + library['reports'] = folder + + report = Document() + report.attrs['title'] = make_translation('Q4 Sales Report', 'Q4 Verkaufsbericht') + report.attrs['description'] = make_translation( + 'Fourth quarter sales summary', 'Zusammenfassung der Verkäufe im vierten Quartal') + report.attrs['body'] = ( + 'Total revenue: $1.2M\n' + 'Growth: 15% YoY\n' + 'Top region: EMEA' + ) + report.attrs['creator'] = 'admin' + report.attrs['created'] = datetime(2025, 1, 10) + report.attrs['modified'] = datetime(2025, 1, 12) + report.attrs['state'] = 'published' + folder['q4-sales-report'] = report + + # Draft document at root + guide = Document() + guide.attrs['title'] = make_translation('Getting Started Guide', 'Einsteigeranleitung') + guide.attrs['description'] = make_translation( + 'How to use the document library', 'So verwenden Sie die Dokumentebibliothek') + guide.attrs['body'] = ( + 'Welcome to the Document Library.\n\n' + 'This example demonstrates:\n' + '- Hierarchical folders and documents\n' + '- Workflow states (draft, review, published, archived)\n' + '- Protected properties (body requires edit permission)\n' + '- Translation-aware title and description fields' + ) + guide.attrs['creator'] = 'admin' + guide.attrs['created'] = datetime(2025, 2, 1) + guide.attrs['modified'] = datetime(2025, 2, 1) + # state stays 'draft' (initial) + library['getting-started'] = guide + + # Document in review + notes = Document() + notes.attrs['title'] = make_translation('Release Notes v2.0', 'Versionshinweise v2.0') + notes.attrs['description'] = make_translation( + 'Upcoming release notes pending review', + 'Ausstehende Versionshinweise zur Überprüfung' + ) + notes.attrs['body'] = ( + 'New features:\n' + '- Improved search\n' + '- Batch operations\n' + '- Reference browser widget' + ) + notes.attrs['creator'] = 'editor' + notes.attrs['created'] = datetime(2025, 3, 5) + notes.attrs['modified'] = datetime(2025, 3, 8) + notes.attrs['state'] = 'review' + library['release-notes-v2'] = notes + + +def populate_wiki(wiki): + """Populate Wiki with example pages.""" + if len(wiki) > 0: + return + + from cone.example.wiki.model import WikiPage + + getting_started = WikiPage() + getting_started.attrs['title'] = make_translation('Getting Started', 'Erste Schritte') + getting_started.attrs['description'] = make_translation( + 'Introduction to the wiki system', 'Einführung in das Wiki System') + getting_started.attrs['body'] = ( + 'Welcome to the Wiki.\n\n' + 'This example demonstrates:\n' + '- Reference browser widget for linking pages\n' + '- Categories (General, Technical, How-To)\n' + '- UUID-aware nodes' + ) + getting_started.attrs['creator'] = 'admin' + getting_started.attrs['created'] = datetime(2025, 1, 1) + getting_started.attrs['modified'] = datetime(2025, 1, 1) + wiki['getting-started'] = getting_started + + api_ref = WikiPage() + api_ref.attrs['title'] = make_translation('API Reference', 'API Referenz') + api_ref.attrs['description'] = make_translation( + 'Technical API documentation', 'Technische API Dokumentation') + api_ref.attrs['body'] = ( + 'cone.app API\n\n' + 'Model:\n' + '- node_info: Declare node type metadata\n' + '- BaseNode / FactoryNode: Node base classes\n' + '- AdapterNode: Wrap external data models\n\n' + 'Browser:\n' + '- tile: Register view tiles\n' + '- layout_config: Configure page layout' + ) + api_ref.attrs['creator'] = 'admin' + api_ref.attrs['created'] = datetime(2025, 1, 5) + api_ref.attrs['modified'] = datetime(2025, 1, 10) + wiki['api-reference'] = api_ref + + deploy = WikiPage() + deploy.attrs['title'] = make_translation('How to Deploy', 'So wird deployed') + deploy.attrs['description'] = make_translation( + 'Step-by-step deployment guide', 'Schritt-für-Schritt Anleitung zum Deployment') + deploy.attrs['body'] = ( + 'Deployment Steps:\n\n' + '1. Install dependencies: pip install -e .\n' + '2. Configure mx.ini\n' + '3. Run: pserve mx.ini' + ) + deploy.attrs['creator'] = 'admin' + deploy.attrs['created'] = datetime(2025, 2, 1) + deploy.attrs['modified'] = datetime(2025, 2, 1) + # Set references to other pages + refs = [] + if hasattr(getting_started, 'uuid') and getting_started.uuid: + refs.append(str(getting_started.uuid)) + if hasattr(api_ref, 'uuid') and api_ref.uuid: + refs.append(str(api_ref.uuid)) + if refs: + deploy.attrs['references'] = refs + wiki['how-to-deploy'] = deploy + + +def populate_projects(): + """Register example project factories on ProjectBoard. + + FactoryNode children are defined via class-level ``factories`` dict. + Each factory creates a pre-configured Project with Tasks. + """ + from cone.example.project.model import ProjectBoard + + if ProjectBoard.factories: + return + + def make_website_redesign(name=None, parent=None): + from cone.example.project.model import Project, Task, TaskData + project = Project() + project.attrs['title'] = make_translation('Website Redesign', 'Website Neugestaltung') + project.attrs['description'] = make_translation( + 'Redesign the company website with modern UI', 'Neugestaltung der Firmenwebsite mit modernem UI') + project.attrs['creator'] = 'admin' + project.attrs['created'] = datetime(2025, 1, 1) + project.attrs['modified'] = datetime(2025, 3, 1) + + task1 = Task(TaskData(), None, None) + task1.attrs['title'] = make_translation('Design mockups', 'Gestaltung von Mockups') + task1.attrs['description'] = make_translation( + 'Create wireframes and visual mockups', 'Erstellung von Wireframes und Visual Mockups') + task1.attrs['creator'] = 'designer' + task1.attrs['created'] = datetime(2025, 1, 5) + task1.attrs['modified'] = datetime(2025, 2, 1) + task1.attrs['state'] = 'in_progress' + project[str(task1.uuid)] = task1 + + task2 = Task(TaskData(), None, None) + task2.attrs['title'] = make_translation('Implement frontend', 'Frontend implementieren') + task2.attrs['description'] = make_translation( + 'Build responsive frontend components', 'Bauen von responsive Frontend-Komponenten') + task2.attrs['creator'] = 'developer' + task2.attrs['created'] = datetime(2025, 2, 1) + task2.attrs['modified'] = datetime(2025, 2, 1) + project[str(task2.uuid)] = task2 + + return project + + def make_mobile_app(name=None, parent=None): + from cone.example.project.model import Project, Task, TaskData + project = Project() + project.attrs['title'] = make_translation('Mobile App', 'Mobile App') + project.attrs['description'] = make_translation( + 'Native mobile application for iOS and Android', 'Native Mobile Applikation für iOs und Android') + project.attrs['creator'] = 'admin' + project.attrs['created'] = datetime(2025, 2, 15) + project.attrs['modified'] = datetime(2025, 3, 10) + + task = Task(TaskData(), None, None) + task.attrs['title'] = make_translation('Setup CI/CD pipeline', 'CI/CD-Pipeline einrichten') + task.attrs['description'] = make_translation( + 'Configure automated builds and deployment', + 'Automatisierte Builds und Deployment konfigurieren' + ) + task.attrs['creator'] = 'devops' + task.attrs['created'] = datetime(2025, 2, 20) + task.attrs['modified'] = datetime(2025, 3, 5) + task.attrs['state'] = 'done' + project[str(task.uuid)] = task + + return project + + ProjectBoard.factories['website-redesign'] = make_website_redesign + ProjectBoard.factories['mobile-app'] = make_mobile_app diff --git a/examples/cone.example/src/cone/example/project/browser.py b/examples/cone.example/src/cone/example/project/browser.py index bde0f199..7e8e610d 100644 --- a/examples/cone.example/src/cone/example/project/browser.py +++ b/examples/cone.example/src/cone/example/project/browser.py @@ -23,7 +23,7 @@ # View tiles @tile(name='view', - path='cone.example.browser:templates/view.pt', + path='cone.example.project:templates/board_view.pt', interface=ProjectBoard, permission='login') class ProjectBoardView(ProtectedContentTile): @@ -230,7 +230,7 @@ class TaskAddForm(TaskForm): def save(self, widget, data): add_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(TaskAddForm, self).save(widget, data) parent = self.model.parent # UUIDAsName: name is determined by UUID parent[self.model.__name__] = self.model @@ -241,7 +241,7 @@ class TaskEditForm(TaskForm): def save(self, widget, data): update_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(TaskEditForm, self).save(widget, data) @tile(name='addform', interface=Project, permission='add') diff --git a/examples/cone.example/src/cone/example/project/model.py b/examples/cone.example/src/cone/example/project/model.py index 8f7c9d9a..4305cebe 100644 --- a/examples/cone.example/src/cone/example/project/model.py +++ b/examples/cone.example/src/cone/example/project/model.py @@ -169,6 +169,9 @@ def properties(self): props.action_sharing = True return props + def __call__(self): + ... + @property def metadata(self): md = Metadata() diff --git a/examples/cone.example/src/cone/example/project/templates/board_view.pt b/examples/cone.example/src/cone/example/project/templates/board_view.pt new file mode 100644 index 00000000..3f44a4dd --- /dev/null +++ b/examples/cone.example/src/cone/example/project/templates/board_view.pt @@ -0,0 +1,37 @@ + + + + +
+ +
+ +

+ + + Title + +

+ + + +

+ Project and task management with advanced node patterns + and batched listings. +

+ +
+ FactoryNode + AdapterNode + UUIDAsName + BatchedItems + Task Workflow +
+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/project/templates/task_view.pt b/examples/cone.example/src/cone/example/project/templates/task_view.pt index 7482c5f4..9a6fb8a1 100644 --- a/examples/cone.example/src/cone/example/project/templates/task_view.pt +++ b/examples/cone.example/src/cone/example/project/templates/task_view.pt @@ -28,7 +28,7 @@ tal:content="str(getattr(model, 'uuid', 'N/A'))">uuid
Path
path
+ tal:content="'/'.join([_ for _ in model.path if _ is not None])">path diff --git a/examples/cone.example/src/cone/example/wiki/browser.py b/examples/cone.example/src/cone/example/wiki/browser.py index 84a8fde4..2a1c9122 100644 --- a/examples/cone.example/src/cone/example/wiki/browser.py +++ b/examples/cone.example/src/cone/example/wiki/browser.py @@ -27,7 +27,7 @@ # View tile for Wiki container @tile(name='view', - path='cone.example.browser:templates/view.pt', + path='cone.example.wiki:templates/wiki_container_view.pt', interface=Wiki, permission='login') class WikiContainerView(ProtectedContentTile): @@ -170,7 +170,7 @@ class WikiPageAddForm(WikiPageForm): def save(self, widget, data): add_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(WikiPageAddForm, self).save(widget, data) parent = self.model.parent parent[choose_name(parent, self.model.metadata.title)] = self.model @@ -180,7 +180,7 @@ class WikiPageEditForm(WikiPageForm): def save(self, widget, data): update_creation_metadata(self.request, self.model.attrs) - super().save(widget, data) + super(WikiPageEditForm, self).save(widget, data) @tile(name='addform', interface=WikiPage, permission='add') diff --git a/examples/cone.example/src/cone/example/wiki/model.py b/examples/cone.example/src/cone/example/wiki/model.py index 9a623787..d6182d4a 100644 --- a/examples/cone.example/src/cone/example/wiki/model.py +++ b/examples/cone.example/src/cone/example/wiki/model.py @@ -34,7 +34,7 @@ class Wiki(BaseContainer): @property def properties(self): - props = super().properties + props = super(Wiki, self).properties props.mainmenu_display_children = False return props diff --git a/examples/cone.example/src/cone/example/wiki/templates/wiki_container_view.pt b/examples/cone.example/src/cone/example/wiki/templates/wiki_container_view.pt new file mode 100644 index 00000000..55438ba3 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/wiki_container_view.pt @@ -0,0 +1,35 @@ + + + + +
+ +
+ +

+ + + Title + +

+ + + +

+ Wiki pages with cross-references, categories, + and UUID-aware nodes. +

+ +
+ Reference Browser + Categories + UUIDAware +
+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt b/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt index 32665d36..99c4383f 100644 --- a/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt +++ b/examples/cone.example/src/cone/example/wiki/templates/wiki_view.pt @@ -34,7 +34,7 @@
Related Pages
From 7330a4339fd5d0107d3827846f04210b292003ab Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Tue, 27 Jan 2026 12:21:49 +0100 Subject: [PATCH 08/25] allow css for ContentViewAction --- src/cone/app/browser/content.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cone/app/browser/content.py b/src/cone/app/browser/content.py index fefb6fda..6496f04c 100644 --- a/src/cone/app/browser/content.py +++ b/src/cone/app/browser/content.py @@ -71,12 +71,13 @@ class ContentViewAction(LinkAction): """ def __init__(self, name, interface=None, - permission=None, text=None, icon=None): + permission=None, text=None, icon=None, css=None): self.name = name self.interface = interface self.permission = permission self.text = text self.icon = icon + self.css = css @property def display(self): @@ -104,13 +105,14 @@ class content_view_action(object): """ def __init__(self, name, tilename=None, interface=None, - permission=None, text=None, icon=None): + permission=None, text=None, icon=None, css=None): self.name = name self.tilename = tilename if tilename is not None else name self.interface = interface self.permission = permission self.text = text self.icon = icon + self.css = css def __call__(self, ob): context_menu['contentviews'][self.name] = ContentViewAction( @@ -118,6 +120,7 @@ def __call__(self, ob): interface=self.interface, permission=self.permission, text=self.text, - icon=self.icon + icon=self.icon, + css=self.css ) return ob From e5ba6144d7e16bb16b1750fe34058ec3373c4392 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Tue, 27 Jan 2026 14:02:44 +0100 Subject: [PATCH 09/25] sidebar_right: tutorial WIP --- .../src/cone/example/browser/__init__.py | 7 +++++ .../example/browser/static/cone.example.css | 5 ++++ .../example/browser/templates/tutorial.pt | 29 +++++++++++++++++++ .../browser/templates/tutorial_content.pt | 24 +++++++++++++++ .../src/cone/example/browser/tutorial.py | 29 +++++++++++++++++++ .../src/cone/example/document/browser.py | 28 ++++++++++++++++++ .../document/templates/tutorial_content.pt | 24 +++++++++++++++ 7 files changed, 146 insertions(+) create mode 100644 examples/cone.example/src/cone/example/browser/templates/tutorial.pt create mode 100644 examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt create mode 100644 examples/cone.example/src/cone/example/browser/tutorial.py create mode 100644 examples/cone.example/src/cone/example/document/templates/tutorial_content.pt diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index e7b3b0b7..1d871bd3 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -63,30 +63,35 @@ class DocumentContainerLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): super(DocumentContainerLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] + self.sidebar_right = ['tutorial'] @layout_config(Document) class DocumentLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): super(DocumentLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] + self.sidebar_right = ['tutorial'] @layout_config(ProjectBoard) class ProjectBoardLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): super(ProjectBoardLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] + self.sidebar_right = ['tutorial'] @layout_config(Task) class TaskLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): super(TaskLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] + self.sidebar_right = ['tutorial'] @layout_config(Wiki, WikiPage) class WikiLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): super(WikiLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = ['navtree'] + self.sidebar_right = ['tutorial'] @layout_config(AppRoot) class RootLayoutConfig(DefaultLayoutConfig): @@ -94,12 +99,14 @@ def __init__(self, model=None, request=None): super(RootLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = [] self.limit_content_width = False + self.sidebar_right = ['tutorial'] @layout_config(AjaxPlayground) class AjaxPlaygroundLayoutConfig(DefaultLayoutConfig): def __init__(self, model=None, request=None): super(AjaxPlaygroundLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = [] + self.sidebar_right = ['tutorial'] ############################################################################### 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 index 1c855a18..1c8c5419 100644 --- a/examples/cone.example/src/cone/example/browser/static/cone.example.css +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -1,3 +1,8 @@ +/* Sidebar right - Tutorial Bar */ +#sidebar_right { + background: linear-gradient(256deg, #c2d1ff, #bdbbff); +} + /* Document workflow states */ #contextmenu a.state-draft, tr.state-draft td.title a { diff --git a/examples/cone.example/src/cone/example/browser/templates/tutorial.pt b/examples/cone.example/src/cone/example/browser/templates/tutorial.pt new file mode 100644 index 00000000..94b707fd --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/tutorial.pt @@ -0,0 +1,29 @@ + + +
+ Tutorial +
+ +
+

+ Welcome to the +
+ cone.app demo page! +

+
+ +
+

+ Tutorial Title +

+
+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt new file mode 100644 index 00000000..3984ae07 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt @@ -0,0 +1,24 @@ + + +
+
+ Some Introduction +
+
+ Example Tutorial content +
+
+ +
+
+ A list of things +
+
    +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
+
+ +
diff --git a/examples/cone.example/src/cone/example/browser/tutorial.py b/examples/cone.example/src/cone/example/browser/tutorial.py new file mode 100644 index 00000000..7e25d43c --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/tutorial.py @@ -0,0 +1,29 @@ +from cone.tile import tile +from cone.tile import Tile +from cone.app import get_root +from cone.app.model import AppRoot + +@tile( + name='tutorial', + path='templates/tutorial.pt', + permission='view', + strict=False, +) +class SidebarTutorial(Tile): + + @property + def root_view(self): + root = get_root(self.model) + return self.model is root + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=AppRoot, + permission='view', + strict=False, +) +class DocumentTutorial(Tile): + ... \ No newline at end of file diff --git a/examples/cone.example/src/cone/example/document/browser.py b/examples/cone.example/src/cone/example/document/browser.py index 35af80fd..0fdd49c1 100644 --- a/examples/cone.example/src/cone/example/document/browser.py +++ b/examples/cone.example/src/cone/example/document/browser.py @@ -15,6 +15,7 @@ from cone.example.model import Translation from cone.example.model import _ from cone.tile import tile +from cone.tile import Tile from node.utils import UNSET from plumber import plumbing from yafowil.base import factory @@ -248,3 +249,30 @@ class DocumentContentEditForm(DocumentEditForm): @plumbing(ContentEditForm) class FolderContentEditForm(FolderEditForm): ... + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=Document, + permission='view', + strict=False, +) +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=DocumentFolder, + permission='view', + strict=False, +) +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=DocumentLibrary, + permission='view', + strict=False, +) +class DocumentTutorial(Tile): + ... diff --git a/examples/cone.example/src/cone/example/document/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/document/templates/tutorial_content.pt new file mode 100644 index 00000000..7a1bf9d9 --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/tutorial_content.pt @@ -0,0 +1,24 @@ + + +
+
+ Example Tutorial header +
+
+ Example Tutorial content +
+
+ +
+
+ A list of things +
+
    +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
+
+ +
From 92c8420634e6eef94c264cdc07de5509ececce73 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Thu, 29 Jan 2026 11:36:43 +0100 Subject: [PATCH 10/25] add dynamic layout configuration. further demo improvements --- .../cone.example/src/cone/example/__init__.py | 8 + .../src/cone/example/ajax/browser.py | 14 ++ .../ajax/templates/tutorial_content.pt | 45 ++++ .../src/cone/example/browser/__init__.py | 168 +++++++++++--- .../src/cone/example/browser/layout.py | 7 + .../example/browser/static/cone.example.css | 129 ++++++++--- .../cone/example/browser/templates/layout.pt | 206 ++++++++++++++++++ .../example/browser/templates/tutorial.pt | 19 +- .../browser/templates/tutorial_content.pt | 34 ++- .../src/cone/example/browser/tutorial.py | 29 ++- .../src/cone/example/document/browser.py | 18 +- .../document/templates/tutorial_content.pt | 24 -- .../document/templates/tutorial_document.pt | 34 +++ .../document/templates/tutorial_folder.pt | 26 +++ .../document/templates/tutorial_library.pt | 24 ++ .../src/cone/example/layout/__init__.py | 0 .../src/cone/example/layout/browser.py | 70 ++++++ .../src/cone/example/layout/model.py | 36 +++ .../example/layout/templates/layout_view.pt | 187 ++++++++++++++++ .../layout/templates/tutorial_content.pt | 37 ++++ .../cone.example/src/cone/example/model.py | 7 +- .../src/cone/example/project/browser.py | 36 +++ .../project/templates/tutorial_board.pt | 28 +++ .../project/templates/tutorial_project.pt | 36 +++ .../project/templates/tutorial_task.pt | 34 +++ .../src/cone/example/wiki/browser.py | 25 +++ .../example/wiki/templates/tutorial_page.pt | 38 ++++ .../example/wiki/templates/tutorial_wiki.pt | 28 +++ 28 files changed, 1236 insertions(+), 111 deletions(-) create mode 100644 examples/cone.example/src/cone/example/ajax/templates/tutorial_content.pt create mode 100644 examples/cone.example/src/cone/example/browser/layout.py create mode 100644 examples/cone.example/src/cone/example/browser/templates/layout.pt delete mode 100644 examples/cone.example/src/cone/example/document/templates/tutorial_content.pt create mode 100644 examples/cone.example/src/cone/example/document/templates/tutorial_document.pt create mode 100644 examples/cone.example/src/cone/example/document/templates/tutorial_folder.pt create mode 100644 examples/cone.example/src/cone/example/document/templates/tutorial_library.pt create mode 100644 examples/cone.example/src/cone/example/layout/__init__.py create mode 100644 examples/cone.example/src/cone/example/layout/browser.py create mode 100644 examples/cone.example/src/cone/example/layout/model.py create mode 100644 examples/cone.example/src/cone/example/layout/templates/layout_view.pt create mode 100644 examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt create mode 100644 examples/cone.example/src/cone/example/project/templates/tutorial_board.pt create mode 100644 examples/cone.example/src/cone/example/project/templates/tutorial_project.pt create mode 100644 examples/cone.example/src/cone/example/project/templates/tutorial_task.pt create mode 100644 examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt create mode 100644 examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt diff --git a/examples/cone.example/src/cone/example/__init__.py b/examples/cone.example/src/cone/example/__init__.py index 0b26e379..5aadbb9f 100644 --- a/examples/cone.example/src/cone/example/__init__.py +++ b/examples/cone.example/src/cone/example/__init__.py @@ -4,6 +4,7 @@ from cone.example.browser import _configure_layout_configs from cone.example.browser import configure_resources from cone.example.model import LiveSearch +from pyramid.session import SignedCookieSessionFactory @main_hook @@ -13,6 +14,10 @@ def example_main_hook(config, global_config, settings): Registers all entry nodes, settings, search adapters, static resources, and scans browser packages for tiles and views. """ + # Configure session factory for storing user preferences + session_factory = SignedCookieSessionFactory('cone.example.secret') + config.set_session_factory(session_factory) + # Register live search adapter config.registry.registerAdapter(LiveSearch) @@ -21,6 +26,7 @@ def example_main_hook(config, global_config, settings): # Register entry nodes in the main menu from cone.example.document.model import DocumentLibrary + from cone.example.layout.model import LayoutDemo from cone.example.project.model import ProjectBoard from cone.example.wiki.model import Wiki from cone.example.ajax.browser import AjaxPlayground @@ -44,6 +50,7 @@ def make_wiki(): register_entry('documents', make_document_library) register_entry('projects', ProjectBoard) register_entry('wiki', make_wiki) + register_entry('layout', LayoutDemo) register_entry('ajax_playground', AjaxPlayground) # Register settings node @@ -74,5 +81,6 @@ def make_wiki(): config.scan('cone.example.document.browser') config.scan('cone.example.project.browser') config.scan('cone.example.wiki.browser') + config.scan('cone.example.layout.browser') config.scan('cone.example.settings.browser') config.scan('cone.example.ajax.browser') diff --git a/examples/cone.example/src/cone/example/ajax/browser.py b/examples/cone.example/src/cone/example/ajax/browser.py index 04fe56b7..b5e6e643 100644 --- a/examples/cone.example/src/cone/example/ajax/browser.py +++ b/examples/cone.example/src/cone/example/ajax/browser.py @@ -13,6 +13,7 @@ from cone.app.utils import node_path from cone.example.model import _ from cone.tile import tile +from cone.tile import Tile from node.utils import instance_property @@ -161,3 +162,16 @@ def render(self): AjaxEvent(url, 'contextchanged', '#layout'), ]) return '' + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=AjaxPlayground, + permission='view', + strict=False, +) +class AjaxTutorial(Tile): + ... diff --git a/examples/cone.example/src/cone/example/ajax/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/ajax/templates/tutorial_content.pt new file mode 100644 index 00000000..5e2a39f0 --- /dev/null +++ b/examples/cone.example/src/cone/example/ajax/templates/tutorial_content.pt @@ -0,0 +1,45 @@ + + +
+
AjaxPath
+
+

Updates browser URL without reload. Fires event for content update.

+
AjaxPath(
+    path='/node/path',
+    target=url,
+    event='contextchanged:#layout'
+)
+
+
+ +
+
AjaxAction
+
+

Renders a tile and inserts into DOM. Modes: inner, replace, before, after.

+
AjaxAction(
+    url, 'tile_name', 'inner', '#target'
+)
+
+
+ +
+
AjaxMessage
+
+

Shows notification. Flavors: info, warning, error, success.

+
ajax_message(request, 'Hello!', 'info')
+
+
+ +
+
ajax_continue
+
+

Chain multiple operations in one response.

+
ajax_continue(request, [
+    AjaxAction(...),
+    AjaxMessage(...),
+])
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index 1d871bd3..786a8730 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -1,6 +1,8 @@ from cone.app import DefaultLayoutConfig from cone.app import layout_config from cone.app.browser.actions import LinkAction +from cone.app.browser.ajax import ajax_continue +from cone.app.browser.ajax import AjaxEvent from cone.app.browser.contextmenu import context_menu_group from cone.app.browser.contextmenu import context_menu_item from cone.app.browser.contextmenu import ContextMenuToolbar @@ -11,6 +13,7 @@ from cone.app.model import AppRoot from cone.example.model import _ from cone.tile import tile +from cone.tile import Tile import os import webresource as wr @@ -47,11 +50,128 @@ def configure_resources(config, settings): # Import model classes here to avoid circular imports at module level. # layout_config decorators register factories looked up by model class. + +# Default layout settings for LayoutDemo page (stored in session) +LAYOUT_DEMO_DEFAULTS = { + 'mainmenu': True, + 'livesearch': True, + 'personaltools': True, + 'pathbar': True, + 'sidebar_left': ['navtree'], + 'sidebar_right': ['tutorial'], +} + + +class ExampleLayoutConfig(DefaultLayoutConfig): + """Base layout config with standard sidebar configuration.""" + + def __init__(self, model=None, request=None): + super(ExampleLayoutConfig, self).__init__(model=model, request=request) + self.sidebar_left = ['navtree'] + self.sidebar_right = ['tutorial'] + + +class DynamicLayoutConfig(DefaultLayoutConfig): + """Layout config that reads all settings from session. + + Used only for the LayoutDemo page to demonstrate dynamic layout changes. + """ + + def __init__(self, model=None, request=None): + super(DynamicLayoutConfig, self).__init__(model=model, request=request) + if request: + session = request.session + self.mainmenu = session.get('layout.mainmenu', LAYOUT_DEMO_DEFAULTS['mainmenu']) + self.livesearch = session.get('layout.livesearch', LAYOUT_DEMO_DEFAULTS['livesearch']) + self.personaltools = session.get('layout.personaltools', LAYOUT_DEMO_DEFAULTS['personaltools']) + self.pathbar = session.get('layout.pathbar', LAYOUT_DEMO_DEFAULTS['pathbar']) + self.sidebar_left = session.get('layout.sidebar_left', LAYOUT_DEMO_DEFAULTS['sidebar_left']) + self.sidebar_right = session.get('layout.sidebar_right', LAYOUT_DEMO_DEFAULTS['sidebar_right']) + + +@tile(name='toggle_tutorial', permission='view') +class ToggleTutorialTile(Tile): + """Toggle tutorial sidebar visibility via session (for LayoutDemo page only).""" + + def render(self): + session = self.request.session + current = session.get('layout.sidebar_right', LAYOUT_DEMO_DEFAULTS['sidebar_right']) + if 'tutorial' in current: + session['layout.sidebar_right'] = [] + else: + session['layout.sidebar_right'] = ['tutorial'] + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxEvent(url, 'contextchanged', '#layout') + ]) + return '' + + +@tile(name='toggle_layout_bool', permission='view') +class ToggleLayoutBoolTile(Tile): + """Toggle a boolean layout setting via session (for LayoutDemo page only).""" + + def render(self): + setting = self.request.params.get('setting') + if setting in ('mainmenu', 'livesearch', 'personaltools', 'pathbar'): + session = self.request.session + key = f'layout.{setting}' + current = session.get(key, LAYOUT_DEMO_DEFAULTS.get(setting, True)) + session[key] = not current + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxEvent(url, 'contextchanged', '#layout') + ]) + return '' + + +@tile(name='toggle_sidebar_tile', permission='view') +class ToggleSidebarTileTile(Tile): + """Toggle a tile in sidebar_left or sidebar_right (for LayoutDemo page only).""" + + def render(self): + sidebar = self.request.params.get('sidebar') # 'left' or 'right' + tile_name = self.request.params.get('tile') + if sidebar in ('left', 'right') and tile_name: + session = self.request.session + key = f'layout.sidebar_{sidebar}' + default = LAYOUT_DEMO_DEFAULTS.get(f'sidebar_{sidebar}', []) + current = list(session.get(key, default)) + if tile_name in current: + current.remove(tile_name) + else: + current.append(tile_name) + session[key] = current + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxEvent(url, 'contextchanged', '#layout') + ]) + return '' + + +@tile(name='reset_layout', permission='view') +class ResetLayoutTile(Tile): + """Reset all layout settings to defaults (for LayoutDemo page only).""" + + def render(self): + session = self.request.session + for key in list(session.keys()): + if key.startswith('layout.'): + del session[key] + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxEvent(url, 'contextchanged', '#layout') + ]) + return '' + + def _configure_layout_configs(): """Register layout configs after model classes are available.""" from cone.example.document.model import Document from cone.example.document.model import DocumentFolder from cone.example.document.model import DocumentLibrary + from cone.example.layout.model import LayoutDemo + from cone.example.project.model import Project from cone.example.project.model import ProjectBoard from cone.example.project.model import Task from cone.example.wiki.model import Wiki @@ -59,54 +179,38 @@ def _configure_layout_configs(): from cone.example.ajax.browser import AjaxPlayground @layout_config(DocumentLibrary, DocumentFolder) - class DocumentContainerLayoutConfig(DefaultLayoutConfig): - def __init__(self, model=None, request=None): - super(DocumentContainerLayoutConfig, self).__init__(model=model, request=request) - self.sidebar_left = ['navtree'] - self.sidebar_right = ['tutorial'] + class DocumentContainerLayoutConfig(ExampleLayoutConfig): + pass @layout_config(Document) - class DocumentLayoutConfig(DefaultLayoutConfig): - def __init__(self, model=None, request=None): - super(DocumentLayoutConfig, self).__init__(model=model, request=request) - self.sidebar_left = ['navtree'] - self.sidebar_right = ['tutorial'] + class DocumentLayoutConfig(ExampleLayoutConfig): + pass - @layout_config(ProjectBoard) - class ProjectBoardLayoutConfig(DefaultLayoutConfig): - def __init__(self, model=None, request=None): - super(ProjectBoardLayoutConfig, self).__init__(model=model, request=request) - self.sidebar_left = ['navtree'] - self.sidebar_right = ['tutorial'] - - @layout_config(Task) - class TaskLayoutConfig(DefaultLayoutConfig): - def __init__(self, model=None, request=None): - super(TaskLayoutConfig, self).__init__(model=model, request=request) - self.sidebar_left = ['navtree'] - self.sidebar_right = ['tutorial'] + @layout_config(ProjectBoard, Project, Task) + class ProjectLayoutConfig(ExampleLayoutConfig): + pass @layout_config(Wiki, WikiPage) - class WikiLayoutConfig(DefaultLayoutConfig): - def __init__(self, model=None, request=None): - super(WikiLayoutConfig, self).__init__(model=model, request=request) - self.sidebar_left = ['navtree'] - self.sidebar_right = ['tutorial'] + class WikiLayoutConfig(ExampleLayoutConfig): + pass + + @layout_config(LayoutDemo) + class LayoutDemoLayoutConfig(DynamicLayoutConfig): + """Layout config for LayoutDemo that reads settings from session.""" + pass @layout_config(AppRoot) - class RootLayoutConfig(DefaultLayoutConfig): + class RootLayoutConfig(ExampleLayoutConfig): def __init__(self, model=None, request=None): super(RootLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = [] self.limit_content_width = False - self.sidebar_right = ['tutorial'] @layout_config(AjaxPlayground) - class AjaxPlaygroundLayoutConfig(DefaultLayoutConfig): + class AjaxPlaygroundLayoutConfig(ExampleLayoutConfig): def __init__(self, model=None, request=None): super(AjaxPlaygroundLayoutConfig, self).__init__(model=model, request=request) self.sidebar_left = [] - self.sidebar_right = ['tutorial'] ############################################################################### diff --git a/examples/cone.example/src/cone/example/browser/layout.py b/examples/cone.example/src/cone/example/browser/layout.py new file mode 100644 index 00000000..24a6dfc0 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/layout.py @@ -0,0 +1,7 @@ +from cone.app.browser.layout import Layout +from cone.tile import tile + +@tile(name='layout', path='templates/layout.pt', permission='login') +class ExampleLayout(Layout): + """Override a Layout py extending or replacing an existing node.""" + ... \ No newline at end of file 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 index 1c8c5419..5676a63c 100644 --- a/examples/cone.example/src/cone/example/browser/static/cone.example.css +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -79,42 +79,109 @@ tr.state-public td.title a { color: #0f5132; } -/* Node type icons in listings */ -tr.node-type-document_folder td.title a::before { - content: "\f3d7"; - font-family: "bootstrap-icons"; - margin-right: 0.4em; - color: #fd7e14; -} -tr.node-type-document td.title a::before { - content: "\f38b"; - font-family: "bootstrap-icons"; - margin-right: 0.4em; - color: #0d6efd; +/* Landing page */ +.landing-hero { + border-bottom: 1px solid #dee2e6; + margin-bottom: 1rem; } -tr.node-type-project td.title a::before { - content: "\f290"; - font-family: "bootstrap-icons"; - margin-right: 0.4em; - color: #fd7e14; + +/* Tutorial toggler */ +#tutorial_toggle { + right: 0; + background-color: #bdbbff; + border-top-left-radius: var(--bs-border-radius); + border-bottom-left-radius: var(--bs-border-radius); + transition: background-color 150ms; } -tr.node-type-task td.title a::before { - content: "\f271"; - font-family: "bootstrap-icons"; - margin-right: 0.4em; - color: #6f42c1; +#tutorial_toggle:hover { + background-color: #7c79d5; } -tr.node-type-wiki_page td.title a::before { - content: "\f444"; - font-family: "bootstrap-icons"; - margin-right: 0.4em; - color: #198754; +#sidebar_right { + z-index: 2; } -/* Landing page */ -.landing-hero { - border-bottom: 1px solid #dee2e6; - margin-bottom: 1rem; +/* Layout Config demo */ +#layout_config_demo { + display: grid; + grid-template-rows: auto; + grid-template-columns: repeat(7, 1fr); +} +#layout_config_demo .mainmenu { + grid-column: span 4; +} +#layout_config_demo .breadcrumbs { + grid-column: span 7; +} +#layout_config_demo .content_area { + grid-column: span 5; + height: 30vh; +} +#layout_config_demo .footer { + grid-column: span 5; +} +#layout_config_demo .sidebar_left, #layout_config_demo .sidebar_right { + grid-row: span 2; +} +#layout_config_demo > .btn { + margin: .25rem; + background-color: var(--bs-info-bg-subtle); + border: 1px solid var(--bs-info-border-subtle); + position: relative; + text-decoration: none; + color: var(--bs-body-color); +} +/* Non-editable elements (div.btn) - no hover, default cursor */ +#layout_config_demo > div.btn { + cursor: default; + opacity: 0.7; +} +/* Editable elements (a.btn) - hover effect, pointer cursor */ +#layout_config_demo > a.btn { + cursor: pointer; +} +#layout_config_demo > a.btn:hover { + background-color: var(--bs-info-border-subtle); +} +#layout_config_demo > a.btn:not(.active) { + background-color: var(--bs-secondary-bg); + border-color: var(--bs-border-color); + opacity: 0.6; +} +#layout_config_demo > a.btn:not(.active):hover { + background-color: var(--bs-border-color); + opacity: 0.8; +} +/* Immediate tooltips for layout demo */ +#layout_config_demo > .btn[title] { + cursor: help; +} +#layout_config_demo > a.btn[title] { + cursor: pointer; +} +#layout_config_demo > .btn[title]:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.75rem; + white-space: nowrap; + z-index: 100; + margin-bottom: 4px; +} +#layout_config_demo > .btn[title]:hover::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); + z-index: 100; } :root { diff --git a/examples/cone.example/src/cone/example/browser/templates/layout.pt b/examples/cone.example/src/cone/example/browser/templates/layout.pt new file mode 100644 index 00000000..4773f4ea --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/layout.pt @@ -0,0 +1,206 @@ + + +
+ +
+ +
+ + +
+ + + + + + + +
+ + + + +
+ + + + +
+ + + + + + +
+ +
+ +
+
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/browser/templates/tutorial.pt b/examples/cone.example/src/cone/example/browser/templates/tutorial.pt index 94b707fd..6c152c9f 100644 --- a/examples/cone.example/src/cone/example/browser/templates/tutorial.pt +++ b/examples/cone.example/src/cone/example/browser/templates/tutorial.pt @@ -1,9 +1,19 @@ + xmlns:ajax="http://namespaces.conestack.org/ajax" + omit-tag="True"> -
- Tutorial + + Tutorial + + +
-

+

Tutorial Title

diff --git a/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt index 3984ae07..261c72bb 100644 --- a/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt +++ b/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt @@ -1,24 +1,34 @@ + omit-tag="True">
-
- Some Introduction -
+
cone.app Basics
- Example Tutorial content +

A Pyramid-based CMS framework using node trees and tiles.

+
@node_info(name='mynode', ...)
+class MyNode(BaseNode):
+    ...
-
- A list of things -
-
    -
  • An item
  • -
  • A second item
  • -
  • A third item
  • +
    Explore Sections
    +
      +
    • Documents - FileStorage, Workflows
    • +
    • Projects - FactoryNode, AdapterNode
    • +
    • Wiki - References, Categories
    • +
    • AJAX - Continuation operations
+
+
Tiles
+
+

UI components registered by name and interface.

+
@tile(name='view', interface=MyNode)
+class MyView(Tile):
+    ...
+
+
+
diff --git a/examples/cone.example/src/cone/example/browser/tutorial.py b/examples/cone.example/src/cone/example/browser/tutorial.py index 7e25d43c..923fdd4b 100644 --- a/examples/cone.example/src/cone/example/browser/tutorial.py +++ b/examples/cone.example/src/cone/example/browser/tutorial.py @@ -1,7 +1,22 @@ -from cone.tile import tile -from cone.tile import Tile from cone.app import get_root +from cone.app.browser.utils import make_url from cone.app.model import AppRoot +from cone.tile import tile +from cone.tile import Tile + +TUTORIAL_TITLES = { + 'document_library': 'Document Library', + 'document_folder': 'Document Folder', + 'document': 'Document', + 'project_board': 'Project Board', + 'project': 'Project', + 'task': 'Task', + 'wiki': 'Wiki', + 'wiki_page': 'Wiki Page', + 'layout_demo': 'Layout', + 'ajax_playground': 'AJAX', +} + @tile( name='tutorial', @@ -16,6 +31,16 @@ def root_view(self): root = get_root(self.model) return self.model is root + @property + def title(self): + name = getattr(self.model, 'node_info_name', '') + return TUTORIAL_TITLES.get(name, 'Tutorial') + + @property + def toggle_url(self): + return make_url(self.request, node=self.model) + + # tutorial @tile( diff --git a/examples/cone.example/src/cone/example/document/browser.py b/examples/cone.example/src/cone/example/document/browser.py index 0fdd49c1..2db48d57 100644 --- a/examples/cone.example/src/cone/example/document/browser.py +++ b/examples/cone.example/src/cone/example/document/browser.py @@ -255,22 +255,30 @@ class FolderContentEditForm(FolderEditForm): @tile( name='tutorial_content', - path='templates/tutorial_content.pt', - interface=Document, + path='templates/tutorial_library.pt', + interface=DocumentLibrary, permission='view', strict=False, ) +class DocumentLibraryTutorial(Tile): + ... + + @tile( name='tutorial_content', - path='templates/tutorial_content.pt', + path='templates/tutorial_folder.pt', interface=DocumentFolder, permission='view', strict=False, ) +class DocumentFolderTutorial(Tile): + ... + + @tile( name='tutorial_content', - path='templates/tutorial_content.pt', - interface=DocumentLibrary, + path='templates/tutorial_document.pt', + interface=Document, permission='view', strict=False, ) diff --git a/examples/cone.example/src/cone/example/document/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/document/templates/tutorial_content.pt deleted file mode 100644 index 7a1bf9d9..00000000 --- a/examples/cone.example/src/cone/example/document/templates/tutorial_content.pt +++ /dev/null @@ -1,24 +0,0 @@ - - -
-
- Example Tutorial header -
-
- Example Tutorial content -
-
- -
-
- A list of things -
-
    -
  • An item
  • -
  • A second item
  • -
  • A third item
  • -
-
- -
diff --git a/examples/cone.example/src/cone/example/document/templates/tutorial_document.pt b/examples/cone.example/src/cone/example/document/templates/tutorial_document.pt new file mode 100644 index 00000000..4934a497 --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/tutorial_document.pt @@ -0,0 +1,34 @@ + + +
+
Workflow
+
+

State machine with transitions and ACL per state.

+
class Document(WorkflowNode):
+    workflow_name = 'document_workflow'
+    # states: draft, published, ...
+
+
+ +
+
ProtectedProperties
+
+

Property access controlled by permissions.

+
props = ProtectedProperties(self,
+    permissions={'body': ['edit']})
+# body only visible with edit perm
+
+
+ +
+
OwnerSupport
+
+

Tracks creator. Grants owner role automatically.

+
@plumbing(OwnerSupport, ...)
+class Document(WorkflowNode):
+    # self.owner from attrs
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/document/templates/tutorial_folder.pt b/examples/cone.example/src/cone/example/document/templates/tutorial_folder.pt new file mode 100644 index 00000000..0067d975 --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/tutorial_folder.pt @@ -0,0 +1,26 @@ + + +
+
Nested Containers
+
+

Folders can contain folders and documents recursively.

+
@node_info(
+    name='document_folder',
+    addables=['document_folder', 'document'])
+
+
+ +
+
Properties
+
+

Control node behavior via properties.

+
@property
+def properties(self):
+    props = super().properties
+    props.action_delete = True
+    return props
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/document/templates/tutorial_library.pt b/examples/cone.example/src/cone/example/document/templates/tutorial_library.pt new file mode 100644 index 00000000..8809ec2d --- /dev/null +++ b/examples/cone.example/src/cone/example/document/templates/tutorial_library.pt @@ -0,0 +1,24 @@ + + +
+
BaseContainer
+
+

File-based storage container. Children persisted to disk.

+
class DocumentLibrary(BaseContainer):
+    # uses cone.filenode
+    # children as directories
+
+
+ +
+
addables
+
+

Defines which child types can be added.

+
@node_info(
+    name='document_library',
+    addables=['document_folder', 'document'])
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/layout/__init__.py b/examples/cone.example/src/cone/example/layout/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cone.example/src/cone/example/layout/browser.py b/examples/cone.example/src/cone/example/layout/browser.py new file mode 100644 index 00000000..17977107 --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/browser.py @@ -0,0 +1,70 @@ +from cone.app.browser.layout import ProtectedContentTile +from cone.app.browser.utils import make_url +from cone.example.browser import LAYOUT_DEMO_DEFAULTS +from cone.example.layout.model import LayoutDemo +from cone.tile import tile +from cone.tile import Tile + + +@tile( + name='content', + path='templates/layout_view.pt', + interface=LayoutDemo, + permission='login') +class LayoutDemoView(ProtectedContentTile): + + @property + def ajax_url(self): + return make_url(self.request, node=self.model) + + def get_setting(self, name): + """Get current layout setting from session.""" + return self.request.session.get( + f'layout.{name}', + LAYOUT_DEMO_DEFAULTS.get(name) + ) + + @property + def mainmenu(self): + return self.get_setting('mainmenu') + + @property + def livesearch(self): + return self.get_setting('livesearch') + + @property + def personaltools(self): + return self.get_setting('personaltools') + + @property + def pathbar(self): + return self.get_setting('pathbar') + + @property + def sidebar_left(self): + return self.get_setting('sidebar_left') or [] + + @property + def sidebar_right(self): + return self.get_setting('sidebar_right') or [] + + @property + def has_navtree(self): + return 'navtree' in self.sidebar_left + + @property + def has_tutorial(self): + return 'tutorial' in self.sidebar_right + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=LayoutDemo, + permission='view', + strict=False, +) +class LayoutDemoTutorial(Tile): + ... diff --git a/examples/cone.example/src/cone/example/layout/model.py b/examples/cone.example/src/cone/example/layout/model.py new file mode 100644 index 00000000..ae555646 --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/model.py @@ -0,0 +1,36 @@ +from cone.app.model import BaseNode +from cone.app.model import Metadata +from cone.app.model import node_info +from cone.app.model import Properties +from cone.example.model import _ + + +@node_info( + name='layout_demo', + title=_('layout_demo', default='Layout'), + icon='bi-layout-sidebar-inset-reverse') +class LayoutDemo(BaseNode): + """Demonstrates dynamic LayoutConfig changes. + + This node shows how to: + - Toggle sidebar visibility via session state + - Use ExampleLayoutConfig to check session + - Trigger layout refresh via AjaxEvent + """ + + @property + def properties(self): + props = Properties() + props.in_navtree = True + props.action_up = True + props.action_view = True + return props + + @property + def metadata(self): + md = Metadata() + md.title = _('layout_demo', default='Layout') + md.description = _('layout_demo_desc', + default='Demonstrates dynamic layout configuration') + md.icon = 'bi-layout-sidebar-inset-reverse' + return md diff --git a/examples/cone.example/src/cone/example/layout/templates/layout_view.pt b/examples/cone.example/src/cone/example/layout/templates/layout_view.pt new file mode 100644 index 00000000..e7da8571 --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/templates/layout_view.pt @@ -0,0 +1,187 @@ + + + + +
+ +
+ +

+ + Title +

+ +

+ Dynamic layout configuration with session-based element toggling. +

+ +
+ LayoutConfig + Session State + AjaxEvent +
+ +
+ +
+ +
+
+
Default Layout Elements
+
+ +
+ +
+
+
Layout Configuration
+ + Reset + +
+
+
+ +
+
Sidebar Tiles
+
+ sidebar_left: + +
+
+ sidebar_right: + +
+
+
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt new file mode 100644 index 00000000..ddd75e25 --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt @@ -0,0 +1,37 @@ + + +
+
LayoutConfig
+
+

Controls sidebar tiles, width limits, and more per node type.

+
@layout_config(MyNode)
+class MyLayoutConfig(DefaultLayoutConfig):
+    def __init__(self, model, request):
+        super().__init__(model, request)
+        self.sidebar_right = ['tutorial']
+
+
+ +
+
Session State
+
+

Store user preferences in session for dynamic layout.

+
if request.session.get('show_tutorial', True):
+    self.sidebar_right = ['tutorial']
+else:
+    self.sidebar_right = []
+
+
+ +
+
AjaxEvent
+
+

Trigger layout refresh after changing session state.

+
ajax_continue(request, [
+    AjaxEvent(url, 'contextchanged', '#layout')
+])
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/model.py b/examples/cone.example/src/cone/example/model.py index 237efa5b..2bb32465 100644 --- a/examples/cone.example/src/cone/example/model.py +++ b/examples/cone.example/src/cone/example/model.py @@ -127,7 +127,12 @@ def metadata(self): md = Metadata() md.icon = self.nodeinfo.icon title = self.attrs.get('title') - md.title = title.value if title else self.name + if title: + md.title = title.value + elif self.nodeinfo.title: + md.title = self.nodeinfo.title + else: + md.title = self.name description = self.attrs.get('description') md.description = description.value if description else '' md.creator = self.attrs.get('creator', '') diff --git a/examples/cone.example/src/cone/example/project/browser.py b/examples/cone.example/src/cone/example/project/browser.py index 7e8e610d..e063b1d3 100644 --- a/examples/cone.example/src/cone/example/project/browser.py +++ b/examples/cone.example/src/cone/example/project/browser.py @@ -15,6 +15,7 @@ from cone.example.project.model import Task from cone.example.project.model import TaskData from cone.tile import tile +from cone.tile import Tile from node.utils import UNSET from plumber import plumbing from yafowil.base import factory @@ -266,3 +267,38 @@ class TaskContentAddForm(TaskAddForm): @plumbing(ContentEditForm) class TaskContentEditForm(TaskEditForm): ... + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_board.pt', + interface=ProjectBoard, + permission='view', + strict=False, +) +class ProjectBoardTutorial(Tile): + ... + + +@tile( + name='tutorial_content', + path='templates/tutorial_project.pt', + interface=Project, + permission='view', + strict=False, +) +class ProjectTutorial(Tile): + ... + + +@tile( + name='tutorial_content', + path='templates/tutorial_task.pt', + interface=Task, + permission='view', + strict=False, +) +class TaskTutorial(Tile): + ... diff --git a/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt b/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt new file mode 100644 index 00000000..e813329e --- /dev/null +++ b/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt @@ -0,0 +1,28 @@ + + +
+
FactoryNode
+
+

Lazily creates children from a factories dict. Children are volatile.

+
class ProjectBoard(FactoryNode):
+    factories = odict()
+
+
+ +
+
BatchedItems
+
+

Paginated listings with search. Override slice_items and item_count.

+
class ProjectBatchedItems(BatchedItems):
+    slice_template = '...items.pt'
+    show_filter = True
+
+    @property
+    def slice_items(self):
+        start, end = self.current_slice
+        return items[start:end]
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt b/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt new file mode 100644 index 00000000..2a0d7131 --- /dev/null +++ b/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt @@ -0,0 +1,36 @@ + + +
+
NamespaceUUID
+
+

UUID calculated from node path + namespace. Deterministic.

+
@plumbing(NamespaceUUID, ...)
+class Project:
+    # uuid = uuid5(namespace, path)
+
+
+ +
+
Categories
+
+

Categorization with i18n translation strings.

+
categories = [
+    _('cat_development', default='Development'),
+    _('cat_design', default='Design')
+]
+
+
+ +
+
PrincipalACL
+
+

Per-node role assignments. Enables sharing UI.

+
@plumbing(PrincipalACL, ...)
+class Project:
+    role_inheritance = True
+    default_acl = [...]
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt b/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt new file mode 100644 index 00000000..ed22dd1f --- /dev/null +++ b/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt @@ -0,0 +1,34 @@ + + +
+
AdapterNode
+
+

Wraps a data node, proxying attributes. Separates storage from UI.

+
class Task(AdapterNode):
+    # wraps TaskData instance
+    # self.attrs proxies to model.attrs
+
+
+ +
+
UUIDAsName
+
+

Node's __name__ is its UUID string.

+
@plumbing(UUIDAsName, ...)
+class Task(AdapterNode):
+    # self.__name__ == str(self.uuid)
+
+
+ +
+
WorkflowState
+
+

State machine. State stored in attrs['state'].

+
@plumbing(WorkflowState, WorkflowACL)
+class Task(AdapterNode):
+    workflow_name = 'task_workflow'
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/wiki/browser.py b/examples/cone.example/src/cone/example/wiki/browser.py index 2a1c9122..0656fe20 100644 --- a/examples/cone.example/src/cone/example/wiki/browser.py +++ b/examples/cone.example/src/cone/example/wiki/browser.py @@ -17,6 +17,7 @@ from cone.example.wiki.model import Wiki from cone.example.wiki.model import WikiPage from cone.tile import tile +from cone.tile import Tile from node.utils import UNSET from plumber import plumbing from pyramid.i18n import TranslationStringFactory @@ -246,3 +247,27 @@ def prepare(self): def save(self, widget, data): update_creation_metadata(self.request, self.model.attrs) data.write(self.model.attrs) + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_wiki.pt', + interface=Wiki, + permission='view', + strict=False, +) +class WikiContainerTutorial(Tile): + ... + + +@tile( + name='tutorial_content', + path='templates/tutorial_page.pt', + interface=WikiPage, + permission='view', + strict=False, +) +class WikiPageTutorial(Tile): + ... diff --git a/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt b/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt new file mode 100644 index 00000000..dbf72a49 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt @@ -0,0 +1,38 @@ + + +
+
Reference Browser
+
+

Widget for selecting related nodes by UUID.

+
form['references'] = factory(
+    'field:...:reference',
+    props={
+        'multivalued': True,
+        'referencable': 'wiki_page',
+        'root': '/wiki',
+    })
+
+
+ +
+
UUIDAttributeAware
+
+

UUID stored in node attrs. Auto-generated on first access.

+
@plumbing(UUIDAttributeAware, ...)
+class WikiPage:
+    # self.uuid reads/writes attrs
+
+
+ +
+
INavigationLeaf
+
+

Marks node as leaf - no children shown in navtree.

+
@implementer(INavigationLeaf)
+class WikiPage:
+    ...
+
+
+ +
diff --git a/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt b/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt new file mode 100644 index 00000000..4b4d1742 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt @@ -0,0 +1,28 @@ + + +
+
Wiki Container
+
+

Entry container for wiki pages. Acts as reference browser root.

+
@node_info(
+    name='wiki',
+    addables=['wiki_page'])
+class Wiki(BaseContainer):
+    ...
+
+
+ +
+
node_info
+
+

Registers node type with metadata and allowed children.

+
@node_info(
+    name='wiki',
+    title=_('wiki', default='Wiki'),
+    icon='bi-book',
+    addables=['wiki_page'])
+
+
+ +
From a2bf46eec6ad594ceee1f11567ad776a45f65ee3 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Thu, 29 Jan 2026 11:37:13 +0100 Subject: [PATCH 11/25] allow translatable titles for root nodes --- src/cone/app/model.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/cone/app/model.py b/src/cone/app/model.py index bbca9c6d..6a41894c 100644 --- a/src/cone/app/model.py +++ b/src/cone/app/model.py @@ -157,11 +157,14 @@ def properties(self): @default @instance_property def metadata(self): - name = self.name - if not name: - name = _('no_title', default='No Title') metadata = Metadata() - metadata.title = name + nodeinfo = self.nodeinfo + if nodeinfo.title: + metadata.title = nodeinfo.title + elif self.name: + metadata.title = self.name + else: + metadata.title = _('no_title', default='No Title') return metadata @default From 0a26ca5bec2af6194240d573aa159665d1d9f722 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Thu, 29 Jan 2026 12:51:06 +0100 Subject: [PATCH 12/25] add center_content and sidebar static flags. --- src/cone/app/__init__.py | 3 +++ src/cone/app/browser/static/cone/cone.app.css | 14 ++++++++++++++ src/cone/app/browser/static/cone/cone.app.js | 3 ++- src/cone/app/browser/templates/layout.pt | 7 ++++--- src/cone/app/interfaces.py | 3 +++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/cone/app/__init__.py b/src/cone/app/__init__.py index ebb8ff4f..9f1e9c34 100644 --- a/src/cone/app/__init__.py +++ b/src/cone/app/__init__.py @@ -71,13 +71,16 @@ def __init__(self, model=None, request=None): self.livesearch = True self.personaltools = True self.limit_content_width = True + self.center_content = False self.pathbar = True self.sidebar_left_mode = 'stacked' # 'toggle' or 'stacked' self.sidebar_left_min_width = 150 + self.sidebar_left_static = False self.sidebar_left = ['navtree'] self.sidebar_right = [] self.sidebar_right_mode = 'stacked' # 'toggle' or 'stacked' self.sidebar_right_min_width = 150 + self.sidebar_right_static = False def import_from_string(path): diff --git a/src/cone/app/browser/static/cone/cone.app.css b/src/cone/app/browser/static/cone/cone.app.css index 035a85e6..c3d6515f 100644 --- a/src/cone/app/browser/static/cone/cone.app.css +++ b/src/cone/app/browser/static/cone/cone.app.css @@ -450,6 +450,20 @@ tr.selectable td { max-height: 100%; user-select: none; } + +/* Static sidebar - overlays content instead of pushing it */ +#sidebar_left.static, #sidebar_right.static { + position: absolute; + z-index: 10; + top: 0; + bottom: 0; +} +#sidebar_left.static { + left: 0; +} +#sidebar_right.static { + right: 0; +} #sidebar_left ul.list-group, #sidebar_left ul.list-group li, #sidebar_right ul.list-group, #sidebar_right ul.list-group li { background-color: inherit; } diff --git a/src/cone/app/browser/static/cone/cone.app.js b/src/cone/app/browser/static/cone/cone.app.js index e49996e2..73ecee40 100644 --- a/src/cone/app/browser/static/cone/cone.app.js +++ b/src/cone/app/browser/static/cone/cone.app.js @@ -1118,7 +1118,8 @@ var cone = (function (exports, $, ts) { super(elem); this.elem = elem; this.min_width = elem.data('min-width') || 115; - elem.css('width', this.sidebar_width + 'px'); + const width = Math.max(this.min_width, this.sidebar_width); + elem.css('width', width + 'px'); this.moving = false; this.trigger_event = this.trigger_event.bind(this); this.scrollbar = ts.query_elem('.scrollable-y', elem).data('scrollbar'); diff --git a/src/cone/app/browser/templates/layout.pt b/src/cone/app/browser/templates/layout.pt index 862c16f8..ce46f488 100644 --- a/src/cone/app/browser/templates/layout.pt +++ b/src/cone/app/browser/templates/layout.pt @@ -69,7 +69,7 @@ diff --git a/examples/cone.example/src/cone/example/layout/browser.py b/examples/cone.example/src/cone/example/layout/browser.py index b0a22265..96fe14f9 100644 --- a/examples/cone.example/src/cone/example/layout/browser.py +++ b/examples/cone.example/src/cone/example/layout/browser.py @@ -1,6 +1,7 @@ from cone.app.browser.layout import ProtectedContentTile from cone.app.browser.utils import make_url from cone.example.browser import LAYOUT_DEMO_DEFAULTS +from cone.example.browser.utils import code_block from cone.example.layout.model import LayoutDemo from cone.tile import tile from cone.tile import Tile @@ -90,4 +91,30 @@ def center_content(self): strict=False, ) class LayoutDemoTutorial(Tile): - ... + + code_layout_config = """\ +@layout_config(MyNode) +class MyLayoutConfig(DefaultLayoutConfig): + def __init__(self, model, request): + super().__init__(model, request) + self.sidebar_right = ['tutorial']""" + + code_session_state = """\ +if request.session.get('show_tutorial', True): + self.sidebar_right = ['tutorial'] +else: + self.sidebar_right = []""" + + code_ajax_event = """\ +ajax_continue(request, [ + AjaxEvent(url, 'contextchanged', '#layout') +])""" + + def example_layout_config(self): + return code_block(self.code_layout_config, 'python') + + def example_session_state(self): + return code_block(self.code_session_state, 'python') + + def example_ajax_event(self): + return code_block(self.code_ajax_event, 'python') diff --git a/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt index f26c00f5..ce4dea46 100644 --- a/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt +++ b/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt @@ -5,11 +5,7 @@
LayoutConfig

Controls sidebar tiles, width limits, and more per node type.

-
@layout_config(MyNode)
-class MyLayoutConfig(DefaultLayoutConfig):
-    def __init__(self, model, request):
-        super().__init__(model, request)
-        self.sidebar_right = ['tutorial']
+
@@ -17,10 +13,7 @@ class MyLayoutConfig(DefaultLayoutConfig):
Session State

Store user preferences in session for dynamic layout.

-
if request.session.get('show_tutorial', True):
-    self.sidebar_right = ['tutorial']
-else:
-    self.sidebar_right = []
+
@@ -28,9 +21,7 @@ else:
AjaxEvent

Trigger layout refresh after changing session state.

-
ajax_continue(request, [
-    AjaxEvent(url, 'contextchanged', '#layout')
-])
+
diff --git a/examples/cone.example/src/cone/example/project/browser.py b/examples/cone.example/src/cone/example/project/browser.py index e063b1d3..f3c70a89 100644 --- a/examples/cone.example/src/cone/example/project/browser.py +++ b/examples/cone.example/src/cone/example/project/browser.py @@ -8,6 +8,7 @@ from cone.app.browser.utils import make_url from cone.app.utils import add_creation_metadata from cone.app.utils import update_creation_metadata +from cone.example.browser.utils import code_block from cone.example.model import Translation from cone.example.model import _ from cone.example.project.model import Project @@ -279,7 +280,26 @@ class TaskContentEditForm(TaskEditForm): strict=False, ) class ProjectBoardTutorial(Tile): - ... + + code_factory_node = """\ +class ProjectBoard(FactoryNode): + factories = odict()""" + + code_batched_items = """\ +class ProjectBatchedItems(BatchedItems): + slice_template = '...items.pt' + show_filter = True + + @property + def slice_items(self): + start, end = self.current_slice + return items[start:end]""" + + def example_factory_node(self): + return code_block(self.code_factory_node, 'python') + + def example_batched_items(self): + return code_block(self.code_batched_items, 'python') @tile( @@ -290,7 +310,32 @@ class ProjectBoardTutorial(Tile): strict=False, ) class ProjectTutorial(Tile): - ... + + code_namespace_uuid = """\ +@plumbing(NamespaceUUID, ...) +class Project: + # uuid = uuid5(namespace, path)""" + + code_categories = """\ +categories = [ + _('cat_development', default='Development'), + _('cat_design', default='Design') +]""" + + code_principal_acl = """\ +@plumbing(PrincipalACL, ...) +class Project: + role_inheritance = True + default_acl = [...]""" + + def example_namespace_uuid(self): + return code_block(self.code_namespace_uuid, 'python') + + def example_categories(self): + return code_block(self.code_categories, 'python') + + def example_principal_acl(self): + return code_block(self.code_principal_acl, 'python') @tile( @@ -301,4 +346,27 @@ class ProjectTutorial(Tile): strict=False, ) class TaskTutorial(Tile): - ... + + code_adapter_node = """\ +class Task(AdapterNode): + # wraps TaskData instance + # self.attrs proxies to model.attrs""" + + code_uuid_as_name = """\ +@plumbing(UUIDAsName, ...) +class Task(AdapterNode): + # self.__name__ == str(self.uuid)""" + + code_workflow_state = """\ +@plumbing(WorkflowState, WorkflowACL) +class Task(AdapterNode): + workflow_name = 'task_workflow'""" + + def example_adapter_node(self): + return code_block(self.code_adapter_node, 'python') + + def example_uuid_as_name(self): + return code_block(self.code_uuid_as_name, 'python') + + def example_workflow_state(self): + return code_block(self.code_workflow_state, 'python') diff --git a/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt b/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt index 7e9d6341..180e1524 100644 --- a/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt +++ b/examples/cone.example/src/cone/example/project/templates/tutorial_board.pt @@ -5,23 +5,15 @@
FactoryNode

Lazily creates children from a factories dict. Children are volatile.

-
class ProjectBoard(FactoryNode):
-    factories = odict()
+
BatchedItems
-

Paginated listings with search. Override slice_items and item_count.

-
class ProjectBatchedItems(BatchedItems):
-    slice_template = '...items.pt'
-    show_filter = True
-
-    @property
-    def slice_items(self):
-        start, end = self.current_slice
-        return items[start:end]
+

Paginated listings with search. Override slice_items and item_count.

+
diff --git a/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt b/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt index 3588c7c1..1e1844c2 100644 --- a/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt +++ b/examples/cone.example/src/cone/example/project/templates/tutorial_project.pt @@ -5,9 +5,7 @@
NamespaceUUID

UUID calculated from node path + namespace. Deterministic.

-
@plumbing(NamespaceUUID, ...)
-class Project:
-    # uuid = uuid5(namespace, path)
+
@@ -15,10 +13,7 @@ class Project:
Categories

Categorization with i18n translation strings.

-
categories = [
-    _('cat_development', default='Development'),
-    _('cat_design', default='Design')
-]
+
@@ -26,10 +21,7 @@ class Project:
PrincipalACL

Per-node role assignments. Enables sharing UI.

-
@plumbing(PrincipalACL, ...)
-class Project:
-    role_inheritance = True
-    default_acl = [...]
+
diff --git a/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt b/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt index c656171b..8b34072b 100644 --- a/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt +++ b/examples/cone.example/src/cone/example/project/templates/tutorial_task.pt @@ -5,29 +5,23 @@
AdapterNode

Wraps a data node, proxying attributes. Separates storage from UI.

-
class Task(AdapterNode):
-    # wraps TaskData instance
-    # self.attrs proxies to model.attrs
+
UUIDAsName
-

Node's __name__ is its UUID string.

-
@plumbing(UUIDAsName, ...)
-class Task(AdapterNode):
-    # self.__name__ == str(self.uuid)
+

Node's __name__ is its UUID string.

+
WorkflowState
-

State machine. State stored in attrs['state'].

-
@plumbing(WorkflowState, WorkflowACL)
-class Task(AdapterNode):
-    workflow_name = 'task_workflow'
+

State machine. State stored in attrs['state'].

+
diff --git a/examples/cone.example/src/cone/example/wiki/browser.py b/examples/cone.example/src/cone/example/wiki/browser.py index 0656fe20..c3b66ce7 100644 --- a/examples/cone.example/src/cone/example/wiki/browser.py +++ b/examples/cone.example/src/cone/example/wiki/browser.py @@ -12,6 +12,7 @@ from cone.app.browser.utils import make_url from cone.app.utils import add_creation_metadata from cone.app.utils import update_creation_metadata +from cone.example.browser.utils import code_block from cone.example.model import Translation from cone.example.model import _ from cone.example.wiki.model import Wiki @@ -259,7 +260,26 @@ def save(self, widget, data): strict=False, ) class WikiContainerTutorial(Tile): - ... + + code_wiki_container = """\ +@node_info( + name='wiki', + addables=['wiki_page']) +class Wiki(BaseContainer): + ...""" + + code_node_info = """\ +@node_info( + name='wiki', + title=_('wiki', default='Wiki'), + icon='bi-book', + addables=['wiki_page'])""" + + def example_wiki_container(self): + return code_block(self.code_wiki_container, 'python') + + def example_node_info(self): + return code_block(self.code_node_info, 'python') @tile( @@ -270,4 +290,31 @@ class WikiContainerTutorial(Tile): strict=False, ) class WikiPageTutorial(Tile): - ... + + code_reference_browser = """\ +form['references'] = factory( + 'field:...:reference', + props={ + 'multivalued': True, + 'referencable': 'wiki_page', + 'root': '/wiki', + })""" + + code_uuid_attribute_aware = """\ +@plumbing(UUIDAttributeAware, ...) +class WikiPage: + # self.uuid reads/writes attrs""" + + code_navigation_leaf = """\ +@implementer(INavigationLeaf) +class WikiPage: + ...""" + + def example_reference_browser(self): + return code_block(self.code_reference_browser, 'python') + + def example_uuid_attribute_aware(self): + return code_block(self.code_uuid_attribute_aware, 'python') + + def example_navigation_leaf(self): + return code_block(self.code_navigation_leaf, 'python') diff --git a/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt b/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt index 089e16ff..29035c23 100644 --- a/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt @@ -5,13 +5,7 @@
Reference Browser

Widget for selecting related nodes by UUID.

-
form['references'] = factory(
-    'field:...:reference',
-    props={
-        'multivalued': True,
-        'referencable': 'wiki_page',
-        'root': '/wiki',
-    })
+
@@ -19,9 +13,7 @@
UUIDAttributeAware

UUID stored in node attrs. Auto-generated on first access.

-
@plumbing(UUIDAttributeAware, ...)
-class WikiPage:
-    # self.uuid reads/writes attrs
+
@@ -29,9 +21,7 @@ class WikiPage:
INavigationLeaf

Marks node as leaf - no children shown in navtree.

-
@implementer(INavigationLeaf)
-class WikiPage:
-    ...
+
diff --git a/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt b/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt index 73e05950..ada2b3c5 100644 --- a/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt @@ -5,11 +5,7 @@
Wiki Container

Entry container for wiki pages. Acts as reference browser root.

-
@node_info(
-    name='wiki',
-    addables=['wiki_page'])
-class Wiki(BaseContainer):
-    ...
+
@@ -17,11 +13,7 @@ class Wiki(BaseContainer):
node_info

Registers node type with metadata and allowed children.

-
@node_info(
-    name='wiki',
-    title=_('wiki', default='Wiki'),
-    icon='bi-book',
-    addables=['wiki_page'])
+
From e0ceee0aba557ea7e6683e24c99e84c2ccd92d42 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Fri, 30 Jan 2026 12:58:26 +0100 Subject: [PATCH 18/25] update mainmenu active item color for dark mode --- scss/colors.scss | 8 ++++++++ scss/header.scss | 3 --- src/cone/app/browser/static/cone/cone.app.css | 15 ++++++++++++--- src/cone/app/browser/static/cone/cone.app.min.css | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scss/colors.scss b/scss/colors.scss index ef0c3d70..4a75947c 100644 --- a/scss/colors.scss +++ b/scss/colors.scss @@ -54,6 +54,10 @@ $color-mode-type: data; .bg-primary-100 { background-color: $primary-100-dark; } + + #mainmenu li.nav-item.active .nav-link { + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); + } } /* light mode */ @@ -92,6 +96,10 @@ $color-mode-type: data; .bg-primary-100 { background-color: $primary-100; } + + #mainmenu li.nav-item.active .nav-link { + color: $primary; + } } /* import everything where we need changed bootstrap variables for here */ diff --git a/scss/header.scss b/scss/header.scss index 88de477c..54d092db 100644 --- a/scss/header.scss +++ b/scss/header.scss @@ -146,9 +146,6 @@ padding-left: 8px; white-space: nowrap; } - .nav-item.active .nav-link { - color: $primary; - } } #personaltools { diff --git a/src/cone/app/browser/static/cone/cone.app.css b/src/cone/app/browser/static/cone/cone.app.css index 1f665e1b..3d1c726b 100644 --- a/src/cone/app/browser/static/cone/cone.app.css +++ b/src/cone/app/browser/static/cone/cone.app.css @@ -33,6 +33,9 @@ [data-bs-theme=dark] .bg-primary-100 { background-color: #2c4265; } +[data-bs-theme=dark] #mainmenu li.nav-item.active .nav-link { + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); +} /* light mode */ [data-bs-theme=light] #content_area { @@ -62,6 +65,9 @@ [data-bs-theme=light] .bg-primary-100 { background-color: #dde9fb; } +[data-bs-theme=light] #mainmenu li.nav-item.active .nav-link { + color: #4171b6; +} /* import everything where we need changed bootstrap variables for here */ :root { @@ -283,9 +289,6 @@ tr.selectable td { padding-left: 8px; white-space: nowrap; } -#main-area #header-main #mainmenu .nav-item.active .nav-link { - color: #4171b6; -} #main-area #header-main #personaltools #language-dropdown > div.dropdown-toggle > a { height: 20px; } @@ -413,6 +416,9 @@ tr.selectable td { [data-bs-theme=dark] .bg-primary-100 { background-color: #2c4265; } +[data-bs-theme=dark] #mainmenu li.nav-item.active .nav-link { + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); +} /* light mode */ [data-bs-theme=light] #content_area { @@ -442,6 +448,9 @@ tr.selectable td { [data-bs-theme=light] .bg-primary-100 { background-color: #dde9fb; } +[data-bs-theme=light] #mainmenu li.nav-item.active .nav-link { + color: #4171b6; +} /* import everything where we need changed bootstrap variables for here */ /* sidebar */ diff --git a/src/cone/app/browser/static/cone/cone.app.min.css b/src/cone/app/browser/static/cone/cone.app.min.css index fb44996e..16f56a51 100644 --- a/src/cone/app/browser/static/cone/cone.app.min.css +++ b/src/cone/app/browser/static/cone/cone.app.min.css @@ -1 +1 @@ -:root{--primary-100: #dde9fb;--primary-100-dark: #2c4265}[data-bs-theme=dark] #content_area{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #navbar-content-wrapper{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #sidebar_left{background-color:#343b45}[data-bs-theme=dark] #footer{color:#f8f9fa}[data-bs-theme=dark] #contextmenu{color:#f8f9fa;background-color:#343a40}[data-bs-theme=dark] tr.selectable.selected td{background-color:#344555}[data-bs-theme=dark] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#485f76}[data-bs-theme=dark] .bg-primary-100{background-color:#2c4265}[data-bs-theme=light] #content_area{color:#212529;background-color:#f8f9fa}[data-bs-theme=light] #navbar-content-wrapper{color:#212529;background-color:#fff}[data-bs-theme=light] #sidebar_left{background-color:#414c5b}[data-bs-theme=light] #footer{color:#212529}[data-bs-theme=light] #contextmenu{color:#212529;background-color:#fff}[data-bs-theme=light] tr.selectable.selected td{background-color:#dfeefb}[data-bs-theme=light] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#cfdce7}[data-bs-theme=light] .bg-primary-100{background-color:#dde9fb}:root{--sidebar-width: 300px;--navbar-height: 50px}html{height:100%;width:100%}body{height:100%;overflow-y:hidden !important}#footer{z-index:1}.referencebrowser_trigger{cursor:pointer}select.form-control.referencebrowser[multiple=multiple]+.referencebrowser_trigger{max-width:max-content;margin-top:.5rem;margin-bottom:1rem}#referencebrowser{border-bottom:0;border-left:0;border-right:0;border-radius:0;left:calc(-1*var(--bs-modal-padding));width:calc(100% + var(--bs-modal-padding)*2)}#referencebrowser td.title{width:100%}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:rgba(0,0,0,0)}::-webkit-scrollbar-thumb{background:var(--bs-primary);border-radius:3px}@supports(-moz-appearance: none){*{scrollbar-width:auto;scrollbar-color:var(--bs-primary) rgba(0,0,0,0)}}#footer-spacer{flex:1}#content{container:main-content/inline-size;height:100%;width:100%;position:relative}#content_area{height:100%}#content_area.has-pathbar{height:calc(100% - 40px)}#main-area{width:0 !important}tr.selectable td{user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}#main-area.full .batched_items_slice_size{max-width:max-content}#main-area.full .batched_items_filter{max-width:300px}#input-contentform-save{margin-right:1rem}#header-logo{z-index:4}#header-logo>span,#header-logo-placeholder>span{font-size:1.5rem !important}#main-area.compact:not(.super-compact) #header-logo{order:0}#main-area.compact:not(.super-compact) #personaltools{order:1;margin-left:auto}#main-area.compact:not(.super-compact) .navbar-toggler{order:2}#main-area.compact:not(.super-compact) #navbar-content-wrapper{order:3}#main-area.compact #header-main.mobile-menu-open #navbar-content-wrapper{max-height:calc(100vh - 50px);position:relative;overflow:hidden;padding:0 !important;z-index:3}@media screen and (max-width: 768px){#main-area.compact #header-main.mobile-menu-open #navbar-content-wrapper{z-index:1200}}#main-area.compact #header-main.mobile-menu-open #mainmenu{align-items:baseline}#main-area.compact #header-main.mobile-menu-open #mainmenu .scrollbar{display:none !important}#main-area.compact #header-main.mobile-menu-open #mainmenu .scrollable-content{right:0 !important}#main-area.compact #header-main.mobile-menu-open #mainmenu .dropdown-menu{border:none}#main-area.compact #header-main.mobile-menu-open #mainmenu .dropdown-menu>li .dropdown-item{padding:8px 20px}#main-area.compact #header-main.mobile-menu-open #mainmenu .dropdown-menu>li:first-child .dropdown-item{padding-top:0}#main-area.super-compact #personaltools{height:50px;justify-content:space-between;margin:auto 20px}#main-area.super-compact #personaltools #colortoggler{padding-left:0}#main-area.full #header-logo{order:1}#main-area.full #mainmenu{height:var(--navbar-height)}#main-area.full #mainmenu .dropdown-menu{position:fixed}#main-area.full #personaltools{height:100%}#main-area #header-main{height:var(--navbar-height)}#main-area #header-main.navbar-expand #header-content{flex-direction:row-reverse}#main-area #header-main.navbar-expand #navbar-content-wrapper{box-shadow:none !important;background:none}#main-area #header-main:not(.navbar-expand) #header-content{flex-direction:row}#main-area #header-main:not(.navbar-expand) #navbar-content{flex-direction:column}#main-area #header-main:not(.navbar-expand) #livesearch{order:0;width:100%;padding-left:55px !important;padding-right:15px !important}#main-area #header-main:not(.navbar-expand) #mainmenu{order:1;margin-left:10px !important;margin-top:20px;margin-bottom:20px}#main-area #header-main:not(.navbar-expand) #mainmenu ul>li{display:block !important}#main-area #header-main #navbar-content-wrapper{min-width:0;z-index:1}#main-area #header-main #navbar-content-wrapper #navbar-content{min-width:0}#main-area #header-main #mainmenu>ul{padding-left:8px;white-space:nowrap}#main-area #header-main #mainmenu .nav-item.active .nav-link{color:#4171b6}#main-area #header-main #personaltools #language-dropdown>div.dropdown-toggle>a{height:20px}#main-area #header-main #personaltools #language-dropdown>div.dropdown-toggle>a img{vertical-align:baseline}#main-area #header-main #personaltools #colortoggler #colortoggle-switch{width:2rem;height:1rem}#personaltools-dropdown .dropdown-item a{text-decoration:none;color:var(--bs-secondary-color)}#pathbar{z-index:5}#form-loginform{display:flex;flex-direction:column;justify-content:center;align-items:center;width:100%;height:100%}#form-loginform #field-loginform-user,#form-loginform #field-loginform-password{display:grid;grid-template-columns:1fr 2fr;margin-bottom:.5rem;gap:1rem}@media(max-width: 400px){#form-loginform #field-loginform-user,#form-loginform #field-loginform-password{grid-template-columns:auto;gap:0}}#form-loginform #input-loginform-login{margin-top:1.5rem;width:200px}[data-bs-theme=light] #header-logo #header-logo-light{display:none}[data-bs-theme=dark] #header-logo #header-logo-dark{display:none}.scrollable-x,.scrollable-y{position:relative;overflow:hidden}.scrollable-content{position:relative}.scrollbar{display:none;position:absolute;background:#dee2e6;border-radius:3px;z-index:1000}.scrollbar .scroll-handle{position:relative;border-radius:3px;background:#4171b6}.scrollbar-top .scrollbar{top:0}.scrollbar-bottom .scrollbar{bottom:0}.scrollbar-left .scrollbar{left:0}.scrollbar-right .scrollbar{right:0}:root{--primary-100: #dde9fb;--primary-100-dark: #2c4265}[data-bs-theme=dark] #content_area{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #navbar-content-wrapper{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #sidebar_left{background-color:#343b45}[data-bs-theme=dark] #footer{color:#f8f9fa}[data-bs-theme=dark] #contextmenu{color:#f8f9fa;background-color:#343a40}[data-bs-theme=dark] tr.selectable.selected td{background-color:#344555}[data-bs-theme=dark] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#485f76}[data-bs-theme=dark] .bg-primary-100{background-color:#2c4265}[data-bs-theme=light] #content_area{color:#212529;background-color:#f8f9fa}[data-bs-theme=light] #navbar-content-wrapper{color:#212529;background-color:#fff}[data-bs-theme=light] #sidebar_left{background-color:#414c5b}[data-bs-theme=light] #footer{color:#212529}[data-bs-theme=light] #contextmenu{color:#212529;background-color:#fff}[data-bs-theme=light] tr.selectable.selected td{background-color:#dfeefb}[data-bs-theme=light] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#cfdce7}[data-bs-theme=light] .bg-primary-100{background-color:#dde9fb}#sidebar_left,#sidebar_right{height:100%;max-height:100%;user-select:none}#sidebar_left ul.list-group,#sidebar_left ul.list-group li,#sidebar_right ul.list-group,#sidebar_right ul.list-group li{background-color:inherit}#sidebar_left .sidebar-controls,#sidebar_right .sidebar-controls{position:absolute;bottom:0}#sidebar_left .sidebar-controls .sidebar-control,#sidebar_right .sidebar-controls .sidebar-control{position:relative;z-index:10;border-bottom-left-radius:0;border-bottom-right-radius:0}#sidebar_left .lock-state,#sidebar_right .lock-state{position:relative;top:calc(100% - 2rem);width:min-content;height:0;z-index:100}@media(max-width: 767.98px){#sidebar_left .lock-state,#sidebar_right .lock-state{display:none}}#sidebar_left .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn,#sidebar_right .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn{background-color:var(--bs-border-color);border-color:rgba(0,0,0,0);color:var(--bs-body-color)}#sidebar_left .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn i.bi-lock,#sidebar_right .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn i.bi-lock{display:none}#sidebar_left .lock-state input[type=checkbox]:checked+.lock-state-btn,#sidebar_right .lock-state input[type=checkbox]:checked+.lock-state-btn{background-color:var(--bs-dark-border-subtle);border-color:rgba(0,0,0,0);color:var(--bs-body-color)}#sidebar_left .lock-state input[type=checkbox]:checked+.lock-state-btn i.bi-unlock,#sidebar_right .lock-state input[type=checkbox]:checked+.lock-state-btn i.bi-unlock{display:none}#sidebar_left #navtree,#sidebar_right #navtree{max-width:100%}#sidebar_left #navtree .nav-link,#sidebar_right #navtree .nav-link{z-index:1000;white-space:nowrap}#sidebar_left #navtree .nav-link.active,#sidebar_right #navtree .nav-link.active{background-color:rgba(0,0,0,0);font-weight:bold}#sidebar_left #navtree .nav-link i.btn-closed,#sidebar_right #navtree .nav-link i.btn-closed{vertical-align:text-bottom}#sidebar_left #navtree .nav-link span,#sidebar_right #navtree .nav-link span{display:inline-block;width:calc(100% - 40px);text-overflow:ellipsis;overflow-x:hidden;vertical-align:text-bottom}#sidebar_left #navtree #navigation-collapse.no-collapse>ul>li,#sidebar_right #navtree #navigation-collapse.no-collapse>ul>li{padding-left:0 !important}#sidebar_left #sidebar_resize,#sidebar_right #sidebar_resize{position:relative;width:0;height:0}#sidebar_left #sidebar_resize #sidebar_resizer,#sidebar_right #sidebar_resize #sidebar_resizer{position:absolute;z-index:3;height:100vh;width:20px;transform:translateX(-50%);cursor:col-resize}#sidebar_left #sidebar_collapse,#sidebar_right #sidebar_collapse{position:relative;top:100%;width:0;height:0}#sidebar_left #sidebar_collapse .collapse_btn,#sidebar_right #sidebar_collapse .collapse_btn{position:absolute;top:100%;border-radius:100%;z-index:1000}#sidebar_left.collapsed,#sidebar_right.collapsed{width:0px !important;min-width:0 !important}#sidebar_left.collapsed #sidebar_resize,#sidebar_right.collapsed #sidebar_resize{display:none}#sidebar_left.collapsed #sidebar_collapse .collapse_btn .btn-open,#sidebar_right.collapsed #sidebar_collapse .collapse_btn .btn-open{display:none}#sidebar_left.collapsed .sidebar-controls,#sidebar_right.collapsed .sidebar-controls{display:none}#sidebar_left.responsive-collapsed:not(.expanded) .sidebar-controls,#sidebar_right.responsive-collapsed:not(.expanded) .sidebar-controls{display:none}#sidebar_left.expanded #sidebar_collapse .collapse_btn .btn-closed,#sidebar_right.expanded #sidebar_collapse .collapse_btn .btn-closed{display:none}@media(min-width: 1400px){#sidebar_left:not(.collapsed) #sidebar_collapse .collapse_btn,#sidebar_right:not(.collapsed) #sidebar_collapse .collapse_btn{transform:translateX(-20px)}#sidebar_left:not(.collapsed) #sidebar_collapse .collapse_btn .btn-closed,#sidebar_right:not(.collapsed) #sidebar_collapse .collapse_btn .btn-closed{display:none}}@media(max-width: 1399.98px){#sidebar_left:not(.expanded),#sidebar_right:not(.expanded){width:0 !important;min-width:0 !important;border:none !important}#sidebar_left:not(.expanded) #sidebar_resize,#sidebar_right:not(.expanded) #sidebar_resize{display:none}#sidebar_left:not(.expanded) #sidebar_collapse .collapse_btn .btn-open,#sidebar_right:not(.expanded) #sidebar_collapse .collapse_btn .btn-open{display:none}}@media(min-width: 768px)and (max-width: 1399.98px){#sidebar_left.expanded #sidebar_collapse .collapse_btn,#sidebar_right.expanded #sidebar_collapse .collapse_btn{transform:translateX(-20px)}}@media(max-width: 767.98px){#sidebar_left #sidebar_resize,#sidebar_right #sidebar_resize{display:none}#sidebar_left.expanded,#sidebar_right.expanded{width:100% !important;z-index:3}}#sidebar_left #sidebar_resize{left:100%}#sidebar_left #sidebar_collapse{left:100%}@media(max-width: 767.98px){#sidebar_left.expanded #sidebar_collapse{left:calc(100% - 50px)}}#sidebar_left.responsive-expanded .lock-state{left:100%;transform:translateX(-10px)}#sidebar_left.expanded .lock-state{left:100% !important;transform:translateX(-10px) !important}#sidebar_left.responsive-collapsed .lock-state{left:calc(100% + 10px)}#sidebar_left.collapsed .lock-state{left:calc(100% + 10px) !important;transform:none !important}#sidebar_right{background-color:var(--bs-body-bg);border-left:1px solid var(--bs-border-color)}#sidebar_right .sidebar-controls{right:0}#sidebar_right #sidebar_collapse{left:0}#sidebar_right.collapsed{border:0}#sidebar_right.collapsed .collapse_btn{right:0}#sidebar_right.responsive-expanded .lock-state{transform:translateX(-10px)}#sidebar_right.expanded .lock-state{transform:translateX(-10px) !important}#sidebar_right.responsive-collapsed .lock-state{transform:translateX(calc(-100% - 10px))}#sidebar_right.collapsed .lock-state{transform:translateX(calc(-100% - 10px)) !important}@media(max-width: 1399.98px){#sidebar_right:not(.expanded) #sidebar_collapse .collapse_btn{right:0}}@media(max-width: 767.98px){#sidebar_right.expanded{transform:translateX(-2rem)}}#sidebar_left.static,#sidebar_right.static{position:absolute;z-index:10;top:0;bottom:0}#sidebar_left.static{left:0}#sidebar_right.static{right:0}.table_length,.table_filter{width:unset}#contents>div.card-body div.row>div{flex:1 0 auto;flex-wrap:nowrap}.table-footer{gap:1rem 0}.table-footer .table_info{white-space:nowrap}#main-area.full .table_length{max-width:max-content}#main-area.full .table_filter{max-width:300px}#settings-grid-container .settings-grid a i{font-size:44px}@media(max-width: 450px){#settings-grid-container .settings-grid a i{font-size:30px}} +:root{--primary-100: #dde9fb;--primary-100-dark: #2c4265}[data-bs-theme=dark] #content_area{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #navbar-content-wrapper{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #sidebar_left{background-color:#343b45}[data-bs-theme=dark] #footer{color:#f8f9fa}[data-bs-theme=dark] #contextmenu{color:#f8f9fa;background-color:#343a40}[data-bs-theme=dark] tr.selectable.selected td{background-color:#344555}[data-bs-theme=dark] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#485f76}[data-bs-theme=dark] .bg-primary-100{background-color:#2c4265}[data-bs-theme=dark] #mainmenu li.nav-item.active .nav-link{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1))}[data-bs-theme=light] #content_area{color:#212529;background-color:#f8f9fa}[data-bs-theme=light] #navbar-content-wrapper{color:#212529;background-color:#fff}[data-bs-theme=light] #sidebar_left{background-color:#414c5b}[data-bs-theme=light] #footer{color:#212529}[data-bs-theme=light] #contextmenu{color:#212529;background-color:#fff}[data-bs-theme=light] tr.selectable.selected td{background-color:#dfeefb}[data-bs-theme=light] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#cfdce7}[data-bs-theme=light] .bg-primary-100{background-color:#dde9fb}[data-bs-theme=light] #mainmenu li.nav-item.active .nav-link{color:#4171b6}:root{--sidebar-width: 300px;--navbar-height: 50px}html{height:100%;width:100%}body{height:100%;overflow-y:hidden !important}#footer{z-index:1}.referencebrowser_trigger{cursor:pointer}select.form-control.referencebrowser[multiple=multiple]+.referencebrowser_trigger{max-width:max-content;margin-top:.5rem;margin-bottom:1rem}#referencebrowser{border-bottom:0;border-left:0;border-right:0;border-radius:0;left:calc(-1*var(--bs-modal-padding));width:calc(100% + var(--bs-modal-padding)*2)}#referencebrowser td.title{width:100%}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:rgba(0,0,0,0)}::-webkit-scrollbar-thumb{background:var(--bs-primary);border-radius:3px}@supports(-moz-appearance: none){*{scrollbar-width:auto;scrollbar-color:var(--bs-primary) rgba(0,0,0,0)}}#footer-spacer{flex:1}#content{container:main-content/inline-size;height:100%;width:100%;position:relative}#content_area{height:100%}#content_area.has-pathbar{height:calc(100% - 40px)}#main-area{width:0 !important}tr.selectable td{user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}#main-area.full .batched_items_slice_size{max-width:max-content}#main-area.full .batched_items_filter{max-width:300px}#input-contentform-save{margin-right:1rem}#header-logo{z-index:4}#header-logo>span,#header-logo-placeholder>span{font-size:1.5rem !important}#main-area.compact:not(.super-compact) #header-logo{order:0}#main-area.compact:not(.super-compact) #personaltools{order:1;margin-left:auto}#main-area.compact:not(.super-compact) .navbar-toggler{order:2}#main-area.compact:not(.super-compact) #navbar-content-wrapper{order:3}#main-area.compact #header-main.mobile-menu-open #navbar-content-wrapper{max-height:calc(100vh - 50px);position:relative;overflow:hidden;padding:0 !important;z-index:3}@media screen and (max-width: 768px){#main-area.compact #header-main.mobile-menu-open #navbar-content-wrapper{z-index:1200}}#main-area.compact #header-main.mobile-menu-open #mainmenu{align-items:baseline}#main-area.compact #header-main.mobile-menu-open #mainmenu .scrollbar{display:none !important}#main-area.compact #header-main.mobile-menu-open #mainmenu .scrollable-content{right:0 !important}#main-area.compact #header-main.mobile-menu-open #mainmenu .dropdown-menu{border:none}#main-area.compact #header-main.mobile-menu-open #mainmenu .dropdown-menu>li .dropdown-item{padding:8px 20px}#main-area.compact #header-main.mobile-menu-open #mainmenu .dropdown-menu>li:first-child .dropdown-item{padding-top:0}#main-area.super-compact #personaltools{height:50px;justify-content:space-between;margin:auto 20px}#main-area.super-compact #personaltools #colortoggler{padding-left:0}#main-area.full #header-logo{order:1}#main-area.full #mainmenu{height:var(--navbar-height)}#main-area.full #mainmenu .dropdown-menu{position:fixed}#main-area.full #personaltools{height:100%}#main-area #header-main{height:var(--navbar-height)}#main-area #header-main.navbar-expand #header-content{flex-direction:row-reverse}#main-area #header-main.navbar-expand #navbar-content-wrapper{box-shadow:none !important;background:none}#main-area #header-main:not(.navbar-expand) #header-content{flex-direction:row}#main-area #header-main:not(.navbar-expand) #navbar-content{flex-direction:column}#main-area #header-main:not(.navbar-expand) #livesearch{order:0;width:100%;padding-left:55px !important;padding-right:15px !important}#main-area #header-main:not(.navbar-expand) #mainmenu{order:1;margin-left:10px !important;margin-top:20px;margin-bottom:20px}#main-area #header-main:not(.navbar-expand) #mainmenu ul>li{display:block !important}#main-area #header-main #navbar-content-wrapper{min-width:0;z-index:1}#main-area #header-main #navbar-content-wrapper #navbar-content{min-width:0}#main-area #header-main #mainmenu>ul{padding-left:8px;white-space:nowrap}#main-area #header-main #personaltools #language-dropdown>div.dropdown-toggle>a{height:20px}#main-area #header-main #personaltools #language-dropdown>div.dropdown-toggle>a img{vertical-align:baseline}#main-area #header-main #personaltools #colortoggler #colortoggle-switch{width:2rem;height:1rem}#personaltools-dropdown .dropdown-item a{text-decoration:none;color:var(--bs-secondary-color)}#pathbar{z-index:5}#form-loginform{display:flex;flex-direction:column;justify-content:center;align-items:center;width:100%;height:100%}#form-loginform #field-loginform-user,#form-loginform #field-loginform-password{display:grid;grid-template-columns:1fr 2fr;margin-bottom:.5rem;gap:1rem}@media(max-width: 400px){#form-loginform #field-loginform-user,#form-loginform #field-loginform-password{grid-template-columns:auto;gap:0}}#form-loginform #input-loginform-login{margin-top:1.5rem;width:200px}[data-bs-theme=light] #header-logo #header-logo-light{display:none}[data-bs-theme=dark] #header-logo #header-logo-dark{display:none}.scrollable-x,.scrollable-y{position:relative;overflow:hidden}.scrollable-content{position:relative}.scrollbar{display:none;position:absolute;background:#dee2e6;border-radius:3px;z-index:1000}.scrollbar .scroll-handle{position:relative;border-radius:3px;background:#4171b6}.scrollbar-top .scrollbar{top:0}.scrollbar-bottom .scrollbar{bottom:0}.scrollbar-left .scrollbar{left:0}.scrollbar-right .scrollbar{right:0}:root{--primary-100: #dde9fb;--primary-100-dark: #2c4265}[data-bs-theme=dark] #content_area{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #navbar-content-wrapper{color:#f8f9fa;background-color:#212529}[data-bs-theme=dark] #sidebar_left{background-color:#343b45}[data-bs-theme=dark] #footer{color:#f8f9fa}[data-bs-theme=dark] #contextmenu{color:#f8f9fa;background-color:#343a40}[data-bs-theme=dark] tr.selectable.selected td{background-color:#344555}[data-bs-theme=dark] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#485f76}[data-bs-theme=dark] .bg-primary-100{background-color:#2c4265}[data-bs-theme=dark] #mainmenu li.nav-item.active .nav-link{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1))}[data-bs-theme=light] #content_area{color:#212529;background-color:#f8f9fa}[data-bs-theme=light] #navbar-content-wrapper{color:#212529;background-color:#fff}[data-bs-theme=light] #sidebar_left{background-color:#414c5b}[data-bs-theme=light] #footer{color:#212529}[data-bs-theme=light] #contextmenu{color:#212529;background-color:#fff}[data-bs-theme=light] tr.selectable.selected td{background-color:#dfeefb}[data-bs-theme=light] .table-striped>tbody>tr.selectable.selected:nth-child(2n+1)>td{background-color:#cfdce7}[data-bs-theme=light] .bg-primary-100{background-color:#dde9fb}[data-bs-theme=light] #mainmenu li.nav-item.active .nav-link{color:#4171b6}#sidebar_left,#sidebar_right{height:100%;max-height:100%;user-select:none}#sidebar_left ul.list-group,#sidebar_left ul.list-group li,#sidebar_right ul.list-group,#sidebar_right ul.list-group li{background-color:inherit}#sidebar_left .sidebar-controls,#sidebar_right .sidebar-controls{position:absolute;bottom:0}#sidebar_left .sidebar-controls .sidebar-control,#sidebar_right .sidebar-controls .sidebar-control{position:relative;z-index:10;border-bottom-left-radius:0;border-bottom-right-radius:0}#sidebar_left .lock-state,#sidebar_right .lock-state{position:relative;top:calc(100% - 2rem);width:min-content;height:0;z-index:100}@media(max-width: 767.98px){#sidebar_left .lock-state,#sidebar_right .lock-state{display:none}}#sidebar_left .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn,#sidebar_right .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn{background-color:var(--bs-border-color);border-color:rgba(0,0,0,0);color:var(--bs-body-color)}#sidebar_left .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn i.bi-lock,#sidebar_right .lock-state input[type=checkbox]:not(:checked)+.lock-state-btn i.bi-lock{display:none}#sidebar_left .lock-state input[type=checkbox]:checked+.lock-state-btn,#sidebar_right .lock-state input[type=checkbox]:checked+.lock-state-btn{background-color:var(--bs-dark-border-subtle);border-color:rgba(0,0,0,0);color:var(--bs-body-color)}#sidebar_left .lock-state input[type=checkbox]:checked+.lock-state-btn i.bi-unlock,#sidebar_right .lock-state input[type=checkbox]:checked+.lock-state-btn i.bi-unlock{display:none}#sidebar_left #navtree,#sidebar_right #navtree{max-width:100%}#sidebar_left #navtree .nav-link,#sidebar_right #navtree .nav-link{z-index:1000;white-space:nowrap}#sidebar_left #navtree .nav-link.active,#sidebar_right #navtree .nav-link.active{background-color:rgba(0,0,0,0);font-weight:bold}#sidebar_left #navtree .nav-link i.btn-closed,#sidebar_right #navtree .nav-link i.btn-closed{vertical-align:text-bottom}#sidebar_left #navtree .nav-link span,#sidebar_right #navtree .nav-link span{display:inline-block;width:calc(100% - 40px);text-overflow:ellipsis;overflow-x:hidden;vertical-align:text-bottom}#sidebar_left #navtree #navigation-collapse.no-collapse>ul>li,#sidebar_right #navtree #navigation-collapse.no-collapse>ul>li{padding-left:0 !important}#sidebar_left #sidebar_resize,#sidebar_right #sidebar_resize{position:relative;width:0;height:0}#sidebar_left #sidebar_resize #sidebar_resizer,#sidebar_right #sidebar_resize #sidebar_resizer{position:absolute;z-index:3;height:100vh;width:20px;transform:translateX(-50%);cursor:col-resize}#sidebar_left #sidebar_collapse,#sidebar_right #sidebar_collapse{position:relative;top:100%;width:0;height:0}#sidebar_left #sidebar_collapse .collapse_btn,#sidebar_right #sidebar_collapse .collapse_btn{position:absolute;top:100%;border-radius:100%;z-index:1000}#sidebar_left.collapsed,#sidebar_right.collapsed{width:0px !important;min-width:0 !important}#sidebar_left.collapsed #sidebar_resize,#sidebar_right.collapsed #sidebar_resize{display:none}#sidebar_left.collapsed #sidebar_collapse .collapse_btn .btn-open,#sidebar_right.collapsed #sidebar_collapse .collapse_btn .btn-open{display:none}#sidebar_left.collapsed .sidebar-controls,#sidebar_right.collapsed .sidebar-controls{display:none}#sidebar_left.responsive-collapsed:not(.expanded) .sidebar-controls,#sidebar_right.responsive-collapsed:not(.expanded) .sidebar-controls{display:none}#sidebar_left.expanded #sidebar_collapse .collapse_btn .btn-closed,#sidebar_right.expanded #sidebar_collapse .collapse_btn .btn-closed{display:none}@media(min-width: 1400px){#sidebar_left:not(.collapsed) #sidebar_collapse .collapse_btn,#sidebar_right:not(.collapsed) #sidebar_collapse .collapse_btn{transform:translateX(-20px)}#sidebar_left:not(.collapsed) #sidebar_collapse .collapse_btn .btn-closed,#sidebar_right:not(.collapsed) #sidebar_collapse .collapse_btn .btn-closed{display:none}}@media(max-width: 1399.98px){#sidebar_left:not(.expanded),#sidebar_right:not(.expanded){width:0 !important;min-width:0 !important;border:none !important}#sidebar_left:not(.expanded) #sidebar_resize,#sidebar_right:not(.expanded) #sidebar_resize{display:none}#sidebar_left:not(.expanded) #sidebar_collapse .collapse_btn .btn-open,#sidebar_right:not(.expanded) #sidebar_collapse .collapse_btn .btn-open{display:none}}@media(min-width: 768px)and (max-width: 1399.98px){#sidebar_left.expanded #sidebar_collapse .collapse_btn,#sidebar_right.expanded #sidebar_collapse .collapse_btn{transform:translateX(-20px)}}@media(max-width: 767.98px){#sidebar_left #sidebar_resize,#sidebar_right #sidebar_resize{display:none}#sidebar_left.expanded,#sidebar_right.expanded{width:100% !important;z-index:3}}#sidebar_left #sidebar_resize{left:100%}#sidebar_left #sidebar_collapse{left:100%}@media(max-width: 767.98px){#sidebar_left.expanded #sidebar_collapse{left:calc(100% - 50px)}}#sidebar_left.responsive-expanded .lock-state{left:100%;transform:translateX(-10px)}#sidebar_left.expanded .lock-state{left:100% !important;transform:translateX(-10px) !important}#sidebar_left.responsive-collapsed .lock-state{left:calc(100% + 10px)}#sidebar_left.collapsed .lock-state{left:calc(100% + 10px) !important;transform:none !important}#sidebar_right{background-color:var(--bs-body-bg);border-left:1px solid var(--bs-border-color)}#sidebar_right .sidebar-controls{right:0}#sidebar_right #sidebar_collapse{left:0}#sidebar_right.collapsed{border:0}#sidebar_right.collapsed .collapse_btn{right:0}#sidebar_right.responsive-expanded .lock-state{transform:translateX(-10px)}#sidebar_right.expanded .lock-state{transform:translateX(-10px) !important}#sidebar_right.responsive-collapsed .lock-state{transform:translateX(calc(-100% - 10px))}#sidebar_right.collapsed .lock-state{transform:translateX(calc(-100% - 10px)) !important}@media(max-width: 1399.98px){#sidebar_right:not(.expanded) #sidebar_collapse .collapse_btn{right:0}}@media(max-width: 767.98px){#sidebar_right.expanded{transform:translateX(-2rem)}}#sidebar_left.static,#sidebar_right.static{position:absolute;z-index:10;top:0;bottom:0}#sidebar_left.static{left:0}#sidebar_right.static{right:0}.table_length,.table_filter{width:unset}#contents>div.card-body div.row>div{flex:1 0 auto;flex-wrap:nowrap}.table-footer{gap:1rem 0}.table-footer .table_info{white-space:nowrap}#main-area.full .table_length{max-width:max-content}#main-area.full .table_filter{max-width:300px}#settings-grid-container .settings-grid a i{font-size:44px}@media(max-width: 450px){#settings-grid-container .settings-grid a i{font-size:30px}} From 4ce226ae102fb118d3253a088403936f6206dbce Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Fri, 30 Jan 2026 13:25:02 +0100 Subject: [PATCH 19/25] example test suite WIP --- examples/cone.example/pyproject.toml | 13 +- .../src/cone/example/browser/__init__.py | 4 +- .../example/browser/static/cone.example.css | 4 +- .../src/cone/example/tests/__init__.py | 17 ++ .../src/cone/example/tests/test_ajax.py | 118 +++++++++++ .../src/cone/example/tests/test_browser.py | 145 ++++++++++++++ .../src/cone/example/tests/test_document.py | 175 ++++++++++++++++ .../src/cone/example/tests/test_layout.py | 168 ++++++++++++++++ .../src/cone/example/tests/test_model.py | 168 ++++++++++++++++ .../src/cone/example/tests/test_project.py | 187 ++++++++++++++++++ .../src/cone/example/tests/test_settings.py | 74 +++++++ .../src/cone/example/tests/test_wiki.py | 144 ++++++++++++++ 12 files changed, 1212 insertions(+), 5 deletions(-) create mode 100644 examples/cone.example/src/cone/example/tests/test_ajax.py create mode 100644 examples/cone.example/src/cone/example/tests/test_browser.py create mode 100644 examples/cone.example/src/cone/example/tests/test_document.py create mode 100644 examples/cone.example/src/cone/example/tests/test_layout.py create mode 100644 examples/cone.example/src/cone/example/tests/test_model.py create mode 100644 examples/cone.example/src/cone/example/tests/test_project.py create mode 100644 examples/cone.example/src/cone/example/tests/test_settings.py create mode 100644 examples/cone.example/src/cone/example/tests/test_wiki.py diff --git a/examples/cone.example/pyproject.toml b/examples/cone.example/pyproject.toml index 70075843..e561dc11 100644 --- a/examples/cone.example/pyproject.toml +++ b/examples/cone.example/pyproject.toml @@ -12,5 +12,16 @@ dependencies = [ "pygments" ] +[project.optional-dependencies] +test = [ + "pytest", + "zope.pytestlayer", +] + [tool.setuptools.packages.find] -where = ["src"] \ No newline at end of file +where = ["src"] + +[tool.pytest.ini_options] +consider_namespace_packages = true +addopts = ["--import-mode=importlib"] +pythonpath = "src" diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index 5eaed573..ee84926b 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -234,9 +234,7 @@ def __init__(self, model=None, request=None): @layout_config(AjaxPlayground) class AjaxPlaygroundLayoutConfig(ExampleLayoutConfig): - def __init__(self, model=None, request=None): - super(AjaxPlaygroundLayoutConfig, self).__init__(model=model, request=request) - self.sidebar_left = [] + pass ############################################################################### 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 index 114e50cf..a66bbabf 100644 --- a/examples/cone.example/src/cone/example/browser/static/cone.example.css +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -208,10 +208,12 @@ tr.state-public td.title a { /* Pygments code highlighting */ .highlight { padding: 0.75rem 1rem; + border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius); + box-shadow: inset 1px 1px 0px 0px #00000012; font-size: 0.85rem; overflow-x: auto; } .highlight pre { margin: 0; -} +} \ No newline at end of file diff --git a/examples/cone.example/src/cone/example/tests/__init__.py b/examples/cone.example/src/cone/example/tests/__init__.py index e69de29b..3d062221 100644 --- a/examples/cone.example/src/cone/example/tests/__init__.py +++ b/examples/cone.example/src/cone/example/tests/__init__.py @@ -0,0 +1,17 @@ +"""cone.example test suite. + +This package contains tests for the cone.example demonstration application. +Tests are organized by module: + +- test_model.py: Base model classes (Translation, WorkflowNode, ContainerNode, etc.) +- test_document.py: Document module (DocumentLibrary, DocumentFolder, Document) +- test_project.py: Project module (ProjectBoard, Project, Task) +- test_wiki.py: Wiki module (Wiki, WikiPage) +- test_layout.py: Layout configuration and demo +- test_settings.py: Settings module +- test_browser.py: Main browser components (landing page, context menu) +- test_ajax.py: AJAX playground demonstrations + +Run tests with pytest: + pytest examples/cone.example/src/cone/example/tests/ +""" diff --git a/examples/cone.example/src/cone/example/tests/test_ajax.py b/examples/cone.example/src/cone/example/tests/test_ajax.py new file mode 100644 index 00000000..0283017a --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_ajax.py @@ -0,0 +1,118 @@ +from cone.app import get_root +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import get_node_info +from cone.example import testing +from cone.example.ajax.browser import AjaxPlayground +from cone.tile import render_tile +from cone.tile.tests import TileTestCase + + +class TestAjaxModel(TileTestCase): + layer = testing.security + + def test_AjaxPlayground_node_info(self): + info = get_node_info('ajax_playground') + self.assertEqual(info.name, 'ajax_playground') + self.assertEqual(info.icon, 'bi-lightning') + + def test_AjaxPlayground(self): + playground = AjaxPlayground() + playground.__name__ = 'ajax_playground' + # Properties + props = playground.properties + self.assertIsInstance(props, Properties) + self.assertTrue(props.in_navtree) + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + # Metadata + md = playground.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-lightning') + + +class TestAjaxBrowser(TileTestCase): + layer = testing.security + + def test_ajax_playground_content_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'content') + self.assertIsNotNone(result) + # Content should contain AJAX demo elements + self.assertIn('ajax', result.lower()) + + def test_ajax_demo_content_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_demo_content') + self.assertIsNotNone(result) + + def test_ajax_path_demo_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_path_demo') + # Result is empty string (tile triggers ajax_continue) + self.assertEqual(result, '') + + def test_ajax_path_result_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_path_result') + self.assertIsNotNone(result) + + def test_ajax_event_demo_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_event_demo') + self.assertEqual(result, '') + + def test_ajax_message_demo_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_message_demo') + self.assertEqual(result, '') + + def test_ajax_action_demo_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_action_demo') + self.assertEqual(result, '') + + def test_ajax_combined_demo_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_combined_demo') + self.assertEqual(result, '') + + def test_ajax_combined_result_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'ajax_combined_result') + self.assertIsNotNone(result) + + def test_tutorial_content_tile(self): + root = get_root() + playground = root['ajax_playground'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(playground, request, 'tutorial_content') + self.assertIsNotNone(result) diff --git a/examples/cone.example/src/cone/example/tests/test_browser.py b/examples/cone.example/src/cone/example/tests/test_browser.py new file mode 100644 index 00000000..25546882 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_browser.py @@ -0,0 +1,145 @@ +from cone.app import get_root +from cone.app.browser import render_main_template +from cone.example import testing +from cone.example.browser import ExampleContextAction +from cone.example.browser import ExampleToolsToolbar +from cone.example.browser import LandingPage +from cone.example.browser.utils import code_block +from cone.tile import render_tile +from cone.tile.tests import TileTestCase + + +class TestBrowserResources(TileTestCase): + layer = testing.security + + def test_code_block_python(self): + # code_block generates syntax-highlighted HTML + code = 'def hello():\n return "world"' + result = code_block(code, 'python') + # Result should contain highlight class + self.assertIn('highlight', result) + # Result should contain span elements for syntax + self.assertIn(' -1) + + def test_render_main_template_unauthenticated(self): + root = get_root() + request = self.layer.new_request() + res = render_main_template(root, request) + # Unauthenticated users should not see mainmenu + self.assertFalse(res.text.find('id="mainmenu"') > -1) + + +class TestEntryNodes(TileTestCase): + layer = testing.security + + def test_entry_nodes_registered(self): + # Verify all entry nodes are registered in root + root = get_root() + # Layout demo + self.assertIn('layout', root) + # Documents + self.assertIn('documents', root) + # Projects + self.assertIn('projects', root) + # Wiki + self.assertIn('wiki', root) + # AJAX playground + self.assertIn('ajax_playground', root) + # Settings + self.assertIn('settings', root) + + def test_entry_nodes_types(self): + from cone.example.document.model import DocumentLibrary + from cone.example.layout.model import LayoutDemo + from cone.example.project.model import ProjectBoard + from cone.example.wiki.model import Wiki + from cone.example.ajax.browser import AjaxPlayground + root = get_root() + self.assertIsInstance(root['layout'], LayoutDemo) + self.assertIsInstance(root['documents'], DocumentLibrary) + self.assertIsInstance(root['projects'], ProjectBoard) + self.assertIsInstance(root['wiki'], Wiki) + self.assertIsInstance(root['ajax_playground'], AjaxPlayground) diff --git a/examples/cone.example/src/cone/example/tests/test_document.py b/examples/cone.example/src/cone/example/tests/test_document.py new file mode 100644 index 00000000..42425794 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_document.py @@ -0,0 +1,175 @@ +from cone.app import get_root +from cone.app.interfaces import INavigationLeaf +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import get_node_info +from cone.example import testing +from cone.example.document.model import Document +from cone.example.document.model import DocumentFolder +from cone.example.document.model import DocumentLibrary +from cone.example.model import Translation +from cone.tile import render_tile +from cone.tile.tests import TileTestCase +from datetime import datetime + + +class TestDocumentModel(TileTestCase): + layer = testing.security + + def test_DocumentLibrary_node_info(self): + info = get_node_info('document_library') + self.assertEqual(info.name, 'document_library') + self.assertEqual(info.icon, 'bi-collection') + self.assertEqual(info.addables, ['document_folder', 'document']) + + def test_DocumentLibrary(self): + library = DocumentLibrary() + library.__name__ = 'documents' + # Properties + props = library.properties + self.assertIsInstance(props, Properties) + self.assertFalse(props.mainmenu_display_children) + self.assertTrue(props.in_navtree) + self.assertEqual(props.default_content_tile, 'listing') + # Can add folders and documents + folder = DocumentFolder() + folder.__name__ = 'folder1' + library['folder1'] = folder + self.assertIn('folder1', library) + + def test_DocumentFolder_node_info(self): + info = get_node_info('document_folder') + self.assertEqual(info.name, 'document_folder') + self.assertEqual(info.icon, 'bi-folder') + self.assertEqual(info.addables, ['document_folder', 'document']) + + def test_DocumentFolder(self): + folder = DocumentFolder() + folder.__name__ = 'folder' + # Properties + props = folder.properties + self.assertTrue(props.action_delete) + self.assertTrue(props.in_navtree) + # Can nest folders + subfolder = DocumentFolder() + subfolder.__name__ = 'subfolder' + folder['subfolder'] = subfolder + self.assertIn('subfolder', folder) + # Can add documents + doc = Document() + doc.__name__ = 'doc' + folder['doc'] = doc + self.assertIn('doc', folder) + + def test_Document_node_info(self): + info = get_node_info('document') + self.assertEqual(info.name, 'document') + self.assertEqual(info.icon, 'bi-file-earmark-text') + # Documents are leaf nodes - empty addables + self.assertEqual(info.addables, []) + + def test_Document(self): + doc = Document() + doc.__name__ = 'testdoc' + # Document implements INavigationLeaf + self.assertTrue(INavigationLeaf.providedBy(doc)) + # Has workflow + self.assertEqual(doc.workflow_name, 'document_workflow') + # Properties + props = doc.properties + self.assertTrue(props.in_navtree) + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + self.assertTrue(props.action_edit) + self.assertTrue(props.action_delete) + self.assertTrue(props.action_sharing) + # role_inheritance enabled + self.assertTrue(doc.role_inheritance) + # principal_roles is empty dict + self.assertEqual(doc.principal_roles, {}) + + def test_Document_metadata(self): + doc = Document() + doc.__name__ = 'testdoc' + # Set attributes + title = Translation() + title['en'] = 'Test Document' + doc.attrs['title'] = title + description = Translation() + description['en'] = 'A test document' + doc.attrs['description'] = description + doc.attrs['creator'] = 'testuser' + doc.attrs['created'] = datetime(2024, 1, 1, 12, 0, 0) + doc.attrs['modified'] = datetime(2024, 1, 2, 12, 0, 0) + # Get metadata + md = doc.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-file-earmark-text') + self.assertEqual(md.creator, 'testuser') + self.assertEqual(md.created, datetime(2024, 1, 1, 12, 0, 0)) + self.assertEqual(md.modified, datetime(2024, 1, 2, 12, 0, 0)) + + def test_Document_protected_properties(self): + from cone.app.model import ProtectedProperties + root = get_root() + request = self.layer.new_request() + # Document has OwnerSupport which requires request context + with self.layer.authenticated('max'): + doc = Document() + doc.__name__ = 'testdoc' + doc.__parent__ = root['documents'] + doc.attrs['body'] = 'Document body content' + # protected_properties returns a ProtectedProperties instance + pp = doc.protected_properties + self.assertIsInstance(pp, ProtectedProperties) + # Body attribute exists on protected_properties + self.assertTrue(hasattr(pp, 'body')) + + def test_Document_uuid(self): + # Document uses UUIDAttributeAware - UUID stored in attrs + doc = Document() + doc.__name__ = 'testdoc' + # Access uuid triggers creation + uuid = doc.uuid + self.assertIsNotNone(uuid) + # UUID is stored in attrs + self.assertIn('uuid', doc.attrs) + + +class TestDocumentBrowser(TileTestCase): + layer = testing.security + + def test_library_view_tile(self): + root = get_root() + library = root['documents'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(library, request, 'view') + # View tile should render without error + self.assertIsNotNone(result) + + def test_document_content_tile(self): + root = get_root() + library = root['documents'] + # Create a document + doc = Document() + doc.__name__ = 'testdoc' + title = Translation() + title['en'] = 'Test Doc' + doc.attrs['title'] = title + library['testdoc'] = doc + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(doc, request, 'content') + self.assertIsNotNone(result) + # Clean up + del library['testdoc'] + + def test_tutorial_content_tile(self): + root = get_root() + library = root['documents'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(library, request, 'tutorial_content') + # Tutorial content should render code examples + self.assertIsNotNone(result) diff --git a/examples/cone.example/src/cone/example/tests/test_layout.py b/examples/cone.example/src/cone/example/tests/test_layout.py new file mode 100644 index 00000000..80216f8a --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_layout.py @@ -0,0 +1,168 @@ +from cone.app import get_root +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import get_node_info +from cone.example import testing +from cone.example.browser import DynamicLayoutConfig +from cone.example.browser import ExampleLayoutConfig +from cone.example.browser import LAYOUT_DEMO_DEFAULTS +from cone.example.layout.model import LayoutDemo +from cone.tile import render_tile +from cone.tile.tests import TileTestCase + + +class TestLayoutModel(TileTestCase): + layer = testing.security + + def test_LayoutDemo_node_info(self): + info = get_node_info('layout_demo') + self.assertEqual(info.name, 'layout_demo') + self.assertEqual(info.icon, 'bi-layout-sidebar-inset-reverse') + # LayoutDemo is a leaf node - empty addables + self.assertEqual(info.addables, []) + + def test_LayoutDemo(self): + demo = LayoutDemo() + demo.__name__ = 'layout' + # Properties + props = demo.properties + self.assertIsInstance(props, Properties) + self.assertTrue(props.in_navtree) + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + # Metadata + md = demo.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-layout-sidebar-inset-reverse') + + +class TestLayoutConfig(TileTestCase): + layer = testing.security + + def test_ExampleLayoutConfig(self): + config = ExampleLayoutConfig() + # Default sidebar configuration + self.assertEqual(config.sidebar_left, ['navtree']) + self.assertEqual(config.sidebar_right, ['tutorial']) + self.assertEqual(config.sidebar_right_min_width, 400) + + def test_DynamicLayoutConfig_defaults(self): + # Without request, DynamicLayoutConfig uses defaults + config = DynamicLayoutConfig() + # These are set in __init__ only if request is provided + # Without request, values should be from parent class defaults + self.assertTrue(hasattr(config, 'mainmenu')) + + def test_DynamicLayoutConfig_with_session(self): + request = self.layer.new_request() + with self.layer.authenticated('max'): + # Set session values + request.session['layout.mainmenu'] = False + request.session['layout.livesearch'] = False + request.session['layout.pathbar'] = False + request.session['layout.sidebar_left'] = [] + request.session['layout.sidebar_right'] = [] + # Create config with request + config = DynamicLayoutConfig(request=request) + # Config should read from session + self.assertFalse(config.mainmenu) + self.assertFalse(config.livesearch) + self.assertFalse(config.pathbar) + self.assertEqual(config.sidebar_left, []) + self.assertEqual(config.sidebar_right, []) + + def test_LAYOUT_DEMO_DEFAULTS(self): + # Verify default values + self.assertTrue(LAYOUT_DEMO_DEFAULTS['mainmenu']) + self.assertTrue(LAYOUT_DEMO_DEFAULTS['livesearch']) + self.assertTrue(LAYOUT_DEMO_DEFAULTS['personaltools']) + self.assertTrue(LAYOUT_DEMO_DEFAULTS['pathbar']) + self.assertEqual(LAYOUT_DEMO_DEFAULTS['sidebar_left'], ['navtree']) + self.assertFalse(LAYOUT_DEMO_DEFAULTS['sidebar_left_static']) + self.assertEqual(LAYOUT_DEMO_DEFAULTS['sidebar_left_min_width'], 150) + self.assertEqual(LAYOUT_DEMO_DEFAULTS['sidebar_right'], ['tutorial']) + self.assertFalse(LAYOUT_DEMO_DEFAULTS['sidebar_right_static']) + self.assertEqual(LAYOUT_DEMO_DEFAULTS['sidebar_right_min_width'], 150) + self.assertTrue(LAYOUT_DEMO_DEFAULTS['limit_content_width']) + self.assertFalse(LAYOUT_DEMO_DEFAULTS['center_content']) + + +class TestLayoutBrowser(TileTestCase): + layer = testing.security + + def test_layout_demo_content_tile(self): + root = get_root() + layout = root['layout'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(layout, request, 'content') + self.assertIsNotNone(result) + + def test_toggle_layout_bool_tile(self): + root = get_root() + layout = root['layout'] + request = self.layer.new_request() + request.params['setting'] = 'mainmenu' + with self.layer.authenticated('max'): + # Initial state from defaults + initial = request.session.get( + 'layout.mainmenu', + LAYOUT_DEMO_DEFAULTS['mainmenu'] + ) + # Toggle + result = render_tile(layout, request, 'toggle_layout_bool') + # State should be toggled + new_state = request.session.get('layout.mainmenu') + self.assertEqual(new_state, not initial) + + def test_toggle_sidebar_tile_tile(self): + root = get_root() + layout = root['layout'] + request = self.layer.new_request() + request.params['sidebar'] = 'left' + request.params['tile'] = 'navtree' + with self.layer.authenticated('max'): + # Initial state + initial = request.session.get( + 'layout.sidebar_left', + LAYOUT_DEMO_DEFAULTS['sidebar_left'] + ) + # Toggle + result = render_tile(layout, request, 'toggle_sidebar_tile') + # navtree should be removed + new_state = request.session.get('layout.sidebar_left', []) + self.assertNotIn('navtree', new_state) + + def test_set_layout_number_tile(self): + root = get_root() + layout = root['layout'] + request = self.layer.new_request() + request.params['setting'] = 'sidebar_left_min_width' + request.params['value'] = '200' + with self.layer.authenticated('max'): + result = render_tile(layout, request, 'set_layout_number') + # Value should be set + new_value = request.session.get('layout.sidebar_left_min_width') + self.assertEqual(new_value, 200) + + def test_reset_layout_tile(self): + root = get_root() + layout = root['layout'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + # Set some session values + request.session['layout.mainmenu'] = False + request.session['layout.pathbar'] = False + # Reset + result = render_tile(layout, request, 'reset_layout') + # Session should no longer have layout keys + self.assertNotIn('layout.mainmenu', request.session) + self.assertNotIn('layout.pathbar', request.session) + + def test_tutorial_content_tile(self): + root = get_root() + layout = root['layout'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(layout, request, 'tutorial_content') + self.assertIsNotNone(result) diff --git a/examples/cone.example/src/cone/example/tests/test_model.py b/examples/cone.example/src/cone/example/tests/test_model.py new file mode 100644 index 00000000..6b085c10 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_model.py @@ -0,0 +1,168 @@ +from cone.app import get_root +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.example import testing +from cone.example.model import BaseContainer +from cone.example.model import ContainerNode +from cone.example.model import DEFAULT_EXAMPLE_ACL +from cone.example.model import LiveSearch +from cone.example.model import Translation +from cone.example.model import WorkflowNode +from node.tests import NodeTestCase +from pyramid.security import ALL_PERMISSIONS + + +class TestModel(NodeTestCase): + layer = testing.security + + def test_DEFAULT_EXAMPLE_ACL(self): + # Check that ACL has expected structure + self.assertEqual(len(DEFAULT_EXAMPLE_ACL), 7) + # system.Authenticated gets view + self.assertEqual(DEFAULT_EXAMPLE_ACL[0], ( + 'Allow', 'system.Authenticated', ['view'] + )) + # role:viewer gets view, list + self.assertEqual(DEFAULT_EXAMPLE_ACL[1], ( + 'Allow', 'role:viewer', ['view', 'list'] + )) + # role:editor gets expanded permissions + self.assertEqual(DEFAULT_EXAMPLE_ACL[2][0], 'Allow') + self.assertEqual(DEFAULT_EXAMPLE_ACL[2][1], 'role:editor') + self.assertIn('add', DEFAULT_EXAMPLE_ACL[2][2]) + self.assertIn('edit', DEFAULT_EXAMPLE_ACL[2][2]) + # role:admin gets delete and manage_permissions + self.assertEqual(DEFAULT_EXAMPLE_ACL[3][1], 'role:admin') + self.assertIn('delete', DEFAULT_EXAMPLE_ACL[3][2]) + self.assertIn('manage_permissions', DEFAULT_EXAMPLE_ACL[3][2]) + # role:manager gets manage + self.assertEqual(DEFAULT_EXAMPLE_ACL[4][1], 'role:manager') + self.assertIn('manage', DEFAULT_EXAMPLE_ACL[4][2]) + # Everyone gets login + self.assertEqual(DEFAULT_EXAMPLE_ACL[5], ( + 'Allow', 'system.Everyone', ['login'] + )) + # Deny all other permissions + self.assertEqual(DEFAULT_EXAMPLE_ACL[6], ( + 'Deny', 'system.Everyone', ALL_PERMISSIONS + )) + + def test_Translation(self): + # Translation is a mapping-like object that stores values per language + trans = Translation() + # Set translations + trans['en'] = 'Hello' + trans['de'] = 'Hallo' + # Verify storage + self.assertEqual(trans['en'], 'Hello') + self.assertEqual(trans['de'], 'Hallo') + # Translation behaves like a mapping + self.assertIn('en', trans) + self.assertIn('de', trans) + self.assertEqual(len(trans), 2) + + def test_WorkflowNode(self): + # WorkflowNode is base class for nodes with workflow support + # Note: WorkflowNode has workflow_name=None, which causes initialization + # to fail when trying to lookup workflow. In practice, subclasses + # always set workflow_name to a valid workflow name. + # Test the class attributes instead + self.assertIsNone(WorkflowNode.workflow_name) + self.assertEqual(WorkflowNode.default_acl, DEFAULT_EXAMPLE_ACL) + # Test with a concrete subclass that has a workflow (Document) + from cone.example.document.model import Document + doc = Document() + doc.__name__ = 'testdoc' + self.assertEqual(doc.workflow_name, 'document_workflow') + self.assertEqual(doc.default_acl, DEFAULT_EXAMPLE_ACL) + # Has attributes storage + doc.attrs['test'] = 'value' + self.assertEqual(doc.attrs['test'], 'value') + + def test_ContainerNode(self): + # ContainerNode is base class without workflow + node = ContainerNode() + # Has default_acl + self.assertEqual(node.default_acl, DEFAULT_EXAMPLE_ACL) + # Can store children + child = ContainerNode() + node['child'] = child + self.assertIn('child', node) + # Has attributes + node.attrs['key'] = 'value' + self.assertEqual(node.attrs['key'], 'value') + # Supports ordering + node['second'] = ContainerNode() + self.assertEqual(list(node.keys()), ['child', 'second']) + + def test_BaseContainer(self): + # BaseContainer adds PrincipalACL and CopySupport + node = BaseContainer() + # Check role_inheritance + self.assertTrue(node.role_inheritance) + # principal_roles is empty dict by default + self.assertEqual(node.principal_roles, {}) + # Has properties + props = node.properties + self.assertIsInstance(props, Properties) + self.assertTrue(props.in_navtree) + self.assertEqual(props.default_content_tile, 'listing') + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + self.assertTrue(props.action_edit) + self.assertTrue(props.action_list) + self.assertTrue(props.action_sharing) + self.assertTrue(props.action_move) + self.assertTrue(props.action_add) + + def test_BaseContainer_metadata(self): + # Test metadata property of BaseContainer + node = BaseContainer() + node.__name__ = 'testnode' + # Set title and description translations + title = Translation() + title['en'] = 'Test Title' + node.attrs['title'] = title + description = Translation() + description['en'] = 'Test Description' + node.attrs['description'] = description + node.attrs['creator'] = 'testuser' + # Get metadata + md = node.metadata + self.assertIsInstance(md, Metadata) + # Without request, title.value may not resolve, but structure exists + self.assertIsNotNone(md.creator) + self.assertEqual(md.creator, 'testuser') + + def test_LiveSearch(self): + # LiveSearch adapter searches child nodes + root = get_root() + # Create a container with children + container = BaseContainer() + container.__name__ = 'container' + container.__parent__ = root + # Add child with title + child = BaseContainer() + child.__name__ = 'child' + child.__parent__ = container + title = Translation() + title['en'] = 'Searchable Title' + child.attrs['title'] = title + container['child'] = child + # Create search adapter + adapter = LiveSearch(container) + self.assertIs(adapter.model, container) + # Search requires request + request = self.layer.new_request() + with self.layer.authenticated('max'): + results = adapter.search(request, 'Searchable') + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['value'], 'Searchable Title') + # Search is case-insensitive + with self.layer.authenticated('max'): + results = adapter.search(request, 'searchable') + self.assertEqual(len(results), 1) + # No match returns empty + with self.layer.authenticated('max'): + results = adapter.search(request, 'nonexistent') + self.assertEqual(len(results), 0) diff --git a/examples/cone.example/src/cone/example/tests/test_project.py b/examples/cone.example/src/cone/example/tests/test_project.py new file mode 100644 index 00000000..5e0c4c87 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_project.py @@ -0,0 +1,187 @@ +from cone.app import get_root +from cone.app.interfaces import INavigationLeaf +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import get_node_info +from cone.example import testing +from cone.example.model import Translation +from cone.example.project.model import Project +from cone.example.project.model import ProjectBoard +from cone.example.project.model import Task +from cone.example.project.model import TaskData +from cone.tile import render_tile +from cone.tile.tests import TileTestCase +from datetime import datetime + + +class TestProjectModel(TileTestCase): + layer = testing.security + + def test_ProjectBoard_node_info(self): + info = get_node_info('project_board') + self.assertEqual(info.name, 'project_board') + self.assertEqual(info.icon, 'bi-kanban') + self.assertEqual(info.addables, ['project']) + + def test_ProjectBoard(self): + board = ProjectBoard() + board.__name__ = 'projects' + # ProjectBoard is a FactoryNode + # Properties + props = board.properties + self.assertIsInstance(props, Properties) + self.assertTrue(props.in_navtree) + self.assertEqual(props.default_content_tile, 'listing') + self.assertFalse(props.action_edit) + self.assertTrue(props.action_add) + # Metadata + md = board.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-kanban') + + def test_ProjectBoard_factories(self): + # ProjectBoard.factories contains factory callables + # In the example, these are populated by populate_projects() + root = get_root() + board = root['projects'] + # Board should exist and be a ProjectBoard + self.assertIsInstance(board, ProjectBoard) + # After population, factories may contain entries + # (depends on whether populate was called) + self.assertIsInstance(board.factories, dict) + + def test_Project_node_info(self): + info = get_node_info('project') + self.assertEqual(info.name, 'project') + self.assertEqual(info.icon, 'bi-clipboard') + self.assertEqual(info.addables, ['task']) + + def test_Project(self): + project = Project() + project.__name__ = 'testproject' + # Has categories + self.assertEqual(len(project.categories), 2) + # role_inheritance enabled + self.assertTrue(project.role_inheritance) + # principal_roles is empty dict + self.assertEqual(project.principal_roles, {}) + # Properties + props = project.properties + self.assertTrue(props.in_navtree) + self.assertEqual(props.default_content_tile, 'listing') + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + self.assertTrue(props.action_edit) + self.assertTrue(props.action_delete) + self.assertTrue(props.action_list) + self.assertTrue(props.action_sharing) + self.assertTrue(props.action_add) + + def test_Project_metadata(self): + project = Project() + project.__name__ = 'testproject' + # Set attributes + title = Translation() + title['en'] = 'Test Project' + project.attrs['title'] = title + description = Translation() + description['en'] = 'A test project' + project.attrs['description'] = description + project.attrs['creator'] = 'testuser' + project.attrs['created'] = datetime(2024, 1, 1, 12, 0, 0) + # Get metadata + md = project.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-clipboard') + self.assertEqual(md.creator, 'testuser') + + def test_Project_uuid(self): + # Project uses NamespaceUUID - UUID calculated from path + namespace + project = Project() + project.__name__ = 'testproject' + uuid = project.uuid + self.assertIsNotNone(uuid) + + def test_TaskData(self): + # TaskData is the internal data model wrapped by Task + data = TaskData() + # Has attributes storage + data.attrs['test'] = 'value' + self.assertEqual(data.attrs['test'], 'value') + + def test_Task_node_info(self): + info = get_node_info('task') + self.assertEqual(info.name, 'task') + self.assertEqual(info.icon, 'bi-check2-square') + # Tasks are leaf nodes - empty addables + self.assertEqual(info.addables, []) + + def test_Task(self): + # Task wraps TaskData via AdapterNode + data = TaskData() + task = Task(model=data) + task.__name__ = 'testtask' + # Task implements INavigationLeaf + self.assertTrue(INavigationLeaf.providedBy(task)) + # Has workflow + self.assertEqual(task.workflow_name, 'task_workflow') + # Has categories + self.assertEqual(len(task.categories), 2) + # role_inheritance enabled + self.assertTrue(task.role_inheritance) + # Properties + props = task.properties + self.assertTrue(props.in_navtree) + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + self.assertTrue(props.action_edit) + self.assertTrue(props.action_delete) + self.assertTrue(props.action_sharing) + + def test_Task_metadata(self): + data = TaskData() + task = Task(model=data) + task.__name__ = 'testtask' + # Set attributes via task (proxied to data) + title = Translation() + title['en'] = 'Test Task' + task.attrs['title'] = title + description = Translation() + description['en'] = 'A test task' + task.attrs['description'] = description + task.attrs['creator'] = 'testuser' + # Get metadata + md = task.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-check2-square') + self.assertEqual(md.creator, 'testuser') + + def test_Task_uuid_as_name(self): + # Task uses UUIDAsName - __name__ is UUID string + data = TaskData() + task = Task(model=data) + # After UUID is set, name becomes UUID string + uuid = task.uuid + self.assertIsNotNone(uuid) + # The task's name should be the UUID string + self.assertEqual(task.__name__, str(uuid)) + + +class TestProjectBrowser(TileTestCase): + layer = testing.security + + def test_board_view_tile(self): + root = get_root() + board = root['projects'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(board, request, 'view') + self.assertIsNotNone(result) + + def test_tutorial_content_tile(self): + root = get_root() + board = root['projects'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(board, request, 'tutorial_content') + self.assertIsNotNone(result) diff --git a/examples/cone.example/src/cone/example/tests/test_settings.py b/examples/cone.example/src/cone/example/tests/test_settings.py new file mode 100644 index 00000000..4299fc37 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_settings.py @@ -0,0 +1,74 @@ +from cone.app import get_root +from cone.app.model import Metadata +from cone.app.model import get_node_info +from cone.example import testing +from cone.example.settings.model import ExampleSettings +from cone.tile import render_tile +from cone.tile.tests import TileTestCase +import os +import shutil +import tempfile + + +class TestSettingsModel(TileTestCase): + layer = testing.security + + def test_ExampleSettings_node_info(self): + info = get_node_info('example_settings') + self.assertEqual(info.name, 'example_settings') + self.assertEqual(info.icon, 'bi-sliders') + + def test_ExampleSettings(self): + settings = ExampleSettings() + settings.__name__ = 'example_settings' + # Has category + self.assertIsNotNone(settings.category) + # Metadata + md = settings.metadata + self.assertIsInstance(md, Metadata) + + def test_ExampleSettings_config_properties(self): + # Set up temp directory for config + config_dir = tempfile.mkdtemp() + os.environ['CONE_EXAMPLE_CONFIG_DIR'] = config_dir + try: + settings = ExampleSettings() + settings.__name__ = 'example_settings' + # Get config properties + props = settings.config_properties + # Check default values + self.assertEqual(props.items_per_page, '15') + self.assertEqual(props.enable_notifications, 'true') + self.assertEqual(props.default_language, 'en') + # Modify a value and save + props.items_per_page = '20' + props() # Save to file + # Verify the config file was written + config_path = os.path.join(config_dir, 'example_settings.cfg') + self.assertTrue(os.path.exists(config_path)) + finally: + # Clean up + del os.environ['CONE_EXAMPLE_CONFIG_DIR'] + shutil.rmtree(config_dir) + + +class TestSettingsBrowser(TileTestCase): + layer = testing.security + + def test_settings_registered(self): + root = get_root() + settings_root = root['settings'] + # ExampleSettings should be registered + self.assertIn('example_settings', settings_root) + settings = settings_root['example_settings'] + self.assertIsInstance(settings, ExampleSettings) + + def test_settings_display(self): + root = get_root() + settings = root['settings']['example_settings'] + # Settings display requires manager permission + request = self.layer.new_request() + self.assertFalse(settings.display) + with self.layer.authenticated('manager'): + # With manager auth, display should be True + self.assertTrue(settings.display) diff --git a/examples/cone.example/src/cone/example/tests/test_wiki.py b/examples/cone.example/src/cone/example/tests/test_wiki.py new file mode 100644 index 00000000..28620404 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_wiki.py @@ -0,0 +1,144 @@ +from cone.app import get_root +from cone.app.interfaces import INavigationLeaf +from cone.app.model import Metadata +from cone.app.model import Properties +from cone.app.model import get_node_info +from cone.example import testing +from cone.example.model import Translation +from cone.example.wiki.model import Wiki +from cone.example.wiki.model import WikiPage +from cone.tile import render_tile +from cone.tile.tests import TileTestCase +from datetime import datetime + + +class TestWikiModel(TileTestCase): + layer = testing.security + + def test_Wiki_node_info(self): + info = get_node_info('wiki') + self.assertEqual(info.name, 'wiki') + self.assertEqual(info.icon, 'bi-book') + self.assertEqual(info.addables, ['wiki_page']) + + def test_Wiki(self): + wiki = Wiki() + wiki.__name__ = 'wiki' + # Properties + props = wiki.properties + self.assertIsInstance(props, Properties) + self.assertFalse(props.mainmenu_display_children) + self.assertTrue(props.in_navtree) + self.assertEqual(props.default_content_tile, 'listing') + # Can add wiki pages + page = WikiPage() + page.__name__ = 'page1' + wiki['page1'] = page + self.assertIn('page1', wiki) + + def test_WikiPage_node_info(self): + info = get_node_info('wiki_page') + self.assertEqual(info.name, 'wiki_page') + self.assertEqual(info.icon, 'bi-journal-text') + # Wiki pages are leaf nodes - empty addables + self.assertEqual(info.addables, []) + + def test_WikiPage(self): + page = WikiPage() + page.__name__ = 'testpage' + # WikiPage implements INavigationLeaf + self.assertTrue(INavigationLeaf.providedBy(page)) + # Has categories + self.assertEqual(len(page.categories), 3) + # role_inheritance enabled + self.assertTrue(page.role_inheritance) + # principal_roles is empty dict + self.assertEqual(page.principal_roles, {}) + # Properties + props = page.properties + self.assertTrue(props.in_navtree) + self.assertTrue(props.action_up) + self.assertTrue(props.action_view) + self.assertTrue(props.action_edit) + self.assertTrue(props.action_delete) + self.assertTrue(props.action_sharing) + + def test_WikiPage_metadata(self): + page = WikiPage() + page.__name__ = 'testpage' + # Set attributes + title = Translation() + title['en'] = 'Test Page' + page.attrs['title'] = title + description = Translation() + description['en'] = 'A test wiki page' + page.attrs['description'] = description + page.attrs['creator'] = 'testuser' + page.attrs['created'] = datetime(2024, 1, 1, 12, 0, 0) + page.attrs['modified'] = datetime(2024, 1, 2, 12, 0, 0) + # Get metadata + md = page.metadata + self.assertIsInstance(md, Metadata) + self.assertEqual(md.icon, 'bi-journal-text') + self.assertEqual(md.creator, 'testuser') + self.assertEqual(md.created, datetime(2024, 1, 1, 12, 0, 0)) + + def test_WikiPage_uuid(self): + # WikiPage uses UUIDAttributeAware - UUID stored in attrs + page = WikiPage() + page.__name__ = 'testpage' + uuid = page.uuid + self.assertIsNotNone(uuid) + # UUID is stored in attrs + self.assertIn('uuid', page.attrs) + + def test_WikiPage_references(self): + # WikiPage can store references to other pages + page = WikiPage() + page.__name__ = 'testpage' + # References are stored as a list of UUIDs + page.attrs['references'] = [] + self.assertEqual(page.attrs['references'], []) + # Add a reference UUID + import uuid + ref_uuid = str(uuid.uuid4()) + page.attrs['references'].append(ref_uuid) + self.assertEqual(len(page.attrs['references']), 1) + + +class TestWikiBrowser(TileTestCase): + layer = testing.security + + def test_wiki_view_tile(self): + root = get_root() + wiki = root['wiki'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(wiki, request, 'view') + self.assertIsNotNone(result) + + def test_wiki_page_content_tile(self): + root = get_root() + wiki = root['wiki'] + # Create a wiki page + page = WikiPage() + page.__name__ = 'testpage' + title = Translation() + title['en'] = 'Test Page' + page.attrs['title'] = title + page.attrs['references'] = [] + wiki['testpage'] = page + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(page, request, 'content') + self.assertIsNotNone(result) + # Clean up + del wiki['testpage'] + + def test_tutorial_content_tile(self): + root = get_root() + wiki = root['wiki'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(wiki, request, 'tutorial_content') + self.assertIsNotNone(result) From 60aee31bc31f2c252061bb3f58c65954abe73e20 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Fri, 30 Jan 2026 13:33:41 +0100 Subject: [PATCH 20/25] Fix ExampleSettingsForm: Add missing EditFormTarget --- .../src/cone/example/settings/browser.py | 3 ++- .../src/cone/example/tests/test_settings.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/cone.example/src/cone/example/settings/browser.py b/examples/cone.example/src/cone/example/settings/browser.py index 54ab85e8..19deaf74 100644 --- a/examples/cone.example/src/cone/example/settings/browser.py +++ b/examples/cone.example/src/cone/example/settings/browser.py @@ -1,3 +1,4 @@ +from cone.app.browser.form import EditFormTarget from cone.app.browser.form import Form from cone.app.browser.settings import SettingsForm from cone.app.browser.settings import settings_form @@ -8,7 +9,7 @@ @settings_form(interface=ExampleSettings) -@plumbing(SettingsForm) +@plumbing(SettingsForm, EditFormTarget) class ExampleSettingsForm(Form): """Settings form demonstrating the settings_form decorator. diff --git a/examples/cone.example/src/cone/example/tests/test_settings.py b/examples/cone.example/src/cone/example/tests/test_settings.py index 4299fc37..7d5f2947 100644 --- a/examples/cone.example/src/cone/example/tests/test_settings.py +++ b/examples/cone.example/src/cone/example/tests/test_settings.py @@ -72,3 +72,16 @@ def test_settings_display(self): with self.layer.authenticated('manager'): # With manager auth, display should be True self.assertTrue(settings.display) + + def test_settings_form_tile(self): + root = get_root() + settings = root['settings']['example_settings'] + request = self.layer.new_request() + # Settings form requires manager permission + with self.layer.authenticated('manager'): + result = render_tile(settings, request, 'editform') + self.assertIsNotNone(result) + # Form should contain expected fields + self.assertIn('items_per_page', result) + self.assertIn('enable_notifications', result) + self.assertIn('default_language', result) From cc68f880a5e4a9ee4c6f9f0cba90bfaabc21e03c Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Fri, 30 Jan 2026 13:57:37 +0100 Subject: [PATCH 21/25] fix python tests --- src/cone/app/model.py | 6 +++--- src/cone/app/tests/test_browser_ajax.py | 6 ++++-- src/cone/app/tests/test_browser_sharing.py | 9 ++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/cone/app/model.py b/src/cone/app/model.py index 6a41894c..f19fdf6f 100644 --- a/src/cone/app/model.py +++ b/src/cone/app/model.py @@ -158,9 +158,9 @@ def properties(self): @instance_property def metadata(self): metadata = Metadata() - nodeinfo = self.nodeinfo - if nodeinfo.title: - metadata.title = nodeinfo.title + info = get_node_info(self.node_info_name) + if info and info.title: + metadata.title = info.title elif self.name: metadata.title = self.name else: diff --git a/src/cone/app/tests/test_browser_ajax.py b/src/cone/app/tests/test_browser_ajax.py index 8f948748..1919d3ae 100644 --- a/src/cone/app/tests/test_browser_ajax.py +++ b/src/cone/app/tests/test_browser_ajax.py @@ -163,7 +163,8 @@ def test_AjaxMessage(self): 'type': 'message', 'payload': payload, 'flavor': flavor, - 'selector': selector + 'selector': selector, + 'title': None }) def test_AjaxOverlay(self): @@ -388,7 +389,8 @@ def test_AjaxFormContinue(self): 'payload': 'Some info message', 'flavor': 'info', 'selector': 'None', - 'css': '' + 'css': '', + 'title': None }, { 'type': 'overlay', 'action': 'someaction', diff --git a/src/cone/app/tests/test_browser_sharing.py b/src/cone/app/tests/test_browser_sharing.py index 606f0aa5..456b0854 100644 --- a/src/cone/app/tests/test_browser_sharing.py +++ b/src/cone/app/tests/test_browser_sharing.py @@ -212,7 +212,8 @@ def test_add_role(self): 'flavor': 'error', 'type': 'message', 'payload': u"Can not add role 'manager' for principal 'viewer'", - 'selector': None + 'selector': None, + 'title': None }], 'payload': u'', 'mode': 'NONE', @@ -280,7 +281,8 @@ def test_remove_role(self): 'flavor': 'error', 'type': 'message', 'payload': u"Can not remove role 'inexistent' for principal 'viewer'", - 'selector': None + 'selector': None, + 'title': None }], 'payload': u'', 'mode': 'NONE', @@ -302,7 +304,8 @@ def test_remove_role(self): 'flavor': 'error', 'type': 'message', 'payload': u"Can not remove role 'manager' for principal 'foo'", - 'selector': None + 'selector': None, + 'title': None }], 'payload': u'', 'mode': 'NONE', From bc6ad8fc21029fef3c1cb07402d1a3000edebfc2 Mon Sep 17 00:00:00 2001 From: Lena Daxenbichler Date: Mon, 2 Feb 2026 11:22:53 +0100 Subject: [PATCH 22/25] collapse static sidebars by default. add limit_page_width flag. --- js/src/sidebar.js | 18 ++++++-- src/cone/app/__init__.py | 1 + src/cone/app/browser/layout.py | 6 +-- src/cone/app/browser/static/cone/cone.app.js | 18 ++++++-- .../app/browser/static/cone/cone.app.min.js | 2 +- src/cone/app/browser/templates/layout.pt | 16 ++++--- src/cone/app/browser/templates/pathbar.pt | 42 +++++++++++-------- src/cone/app/interfaces.py | 1 + src/cone/app/tests/test_app.py | 1 + 9 files changed, 70 insertions(+), 35 deletions(-) diff --git a/js/src/sidebar.js b/js/src/sidebar.js index bba71a00..0d98b68e 100644 --- a/js/src/sidebar.js +++ b/js/src/sidebar.js @@ -101,6 +101,8 @@ export class Sidebar extends ResizeAware(ts.Motion) { 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'); @@ -199,12 +201,20 @@ export class Sidebar extends ResizeAware(ts.Motion) { this.elem.removeClass('collapsed'); this.elem.removeClass('expanded'); } - if (this.collapsed) { + if (this.static) { + if (!this.collapsed) { + this.collapse(); + } this.elem.removeClass('responsive-expanded'); - this.elem.addClass('responsive-collapsed'); - } else { - this.elem.addClass('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) { diff --git a/src/cone/app/__init__.py b/src/cone/app/__init__.py index 9f1e9c34..d1df0328 100644 --- a/src/cone/app/__init__.py +++ b/src/cone/app/__init__.py @@ -71,6 +71,7 @@ def __init__(self, model=None, request=None): self.livesearch = True self.personaltools = True self.limit_content_width = True + self.limit_page_width = False self.center_content = False self.pathbar = True self.sidebar_left_mode = 'stacked' # 'toggle' or 'stacked' diff --git a/src/cone/app/browser/layout.py b/src/cone/app/browser/layout.py index 278ce73d..7725f129 100644 --- a/src/cone/app/browser/layout.py +++ b/src/cone/app/browser/layout.py @@ -19,16 +19,16 @@ from cone.app.utils import node_path from cone.tile import render_template from cone.tile import render_tile -from cone.tile import Tile from cone.tile import tile +from cone.tile import Tile from node.utils import LocationIterator from node.utils import safe_decode from odict import odict from pyramid.i18n import get_localizer from pyramid.i18n import negotiate_locale_name from pyramid.i18n import TranslationStringFactory -import warnings import json +import warnings _ = TranslationStringFactory('cone.app') @@ -285,7 +285,7 @@ def create_item(self, node, empty_title, selected): path='templates/pathbar.pt', permission='view', strict=False) -class PathBar(Tile): +class PathBar(LayoutConfigTile): @property def items(self): diff --git a/src/cone/app/browser/static/cone/cone.app.js b/src/cone/app/browser/static/cone/cone.app.js index 73ecee40..137cdfdd 100644 --- a/src/cone/app/browser/static/cone/cone.app.js +++ b/src/cone/app/browser/static/cone/cone.app.js @@ -1120,6 +1120,8 @@ var cone = (function (exports, $, ts) { 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'); @@ -1187,12 +1189,20 @@ var cone = (function (exports, $, ts) { this.elem.removeClass('collapsed'); this.elem.removeClass('expanded'); } - if (this.collapsed) { + if (this.static) { + if (!this.collapsed) { + this.collapse(); + } this.elem.removeClass('responsive-expanded'); - this.elem.addClass('responsive-collapsed'); - } else { - this.elem.addClass('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; diff --git a/src/cone/app/browser/static/cone/cone.app.min.js b/src/cone/app/browser/static/cone/cone.app.min.js index 1fe835c5..1899509b 100644 --- a/src/cone/app/browser/static/cone/cone.app.min.js +++ b/src/cone/app/browser/static/cone/cone.app.min.js @@ -1 +1 @@ -var cone=function(e,t,s){"use strict";class i{constructor(e,t){this.elem=e,this.name=t}set_filter(e){let t=this.elem,i=s.ajax.parse_target(t.attr("ajax:target")),o=t.attr("ajax:event");if(i.params[this.name]=e,t.attr("ajax:path")){let l=t.attr("ajax:path-event");l||(l=o),s.ajax.path({path:i.path+i.query+"&"+this.name+"="+e,event:l,target:i})}let l=o.split(":");s.ajax.trigger({name:l[0],selector:l[1],target:i})}}class o extends i{static initialize(e,s=".batched_items_slice_size select"){t(s,e).each(function(){new o(t(this))})}constructor(e){super(e,"size"),e.off("change").on("change",this.change_handle.bind(this))}change_handle(e){let s=t("option:selected",this.elem).first();this.set_filter(s.val())}}class l extends i{static initialize(e,s=".batched_items_filter input",i="term"){t(s,e).each(function(){new l(t(this),i)})}constructor(e,t){super(e,t),e.off("focus").on("focus",this.focus_handle.bind(this)),e.off("keypress").on("keypress",this.keypress_handle.bind(this)),e.off("keyup").on("keyup",this.keyup_handle.bind(this)),e.off("change").on("change",this.change_handle.bind(this))}focus_handle(e){let t=this.elem;t.hasClass("empty_filter")&&(t.val(""),t.removeClass("empty_filter"))}keypress_handle(e){13==e.keyCode&&e.preventDefault()}keyup_handle(e){13==e.keyCode&&(e.preventDefault(),this.set_filter(this.elem.val()))}change_handle(e){e.preventDefault(),this.set_filter(this.elem.val())}}class r{static get media_query(){return window.matchMedia("(prefers-color-scheme: dark)")}static get stored_theme(){return localStorage.getItem("cone-app-color-theme")}static set stored_theme(e){localStorage.setItem("cone-app-color-theme",e)}static get preferred_theme(){return this.stored_theme?this.stored_theme:this.media_query.matches?"dark":"light"}static watch(e){this.media_query.addEventListener("change",e)}static set_theme(e){const t=document.documentElement;"auto"===e&&this.media_query.matches?t.setAttribute("data-bs-theme","dark"):t.setAttribute("data-bs-theme",e)}constructor(){r.bind(),r.set_theme(r.preferred_theme)}static bind(){this.boundCallback=this.callback.bind(this),this.watch(this.boundCallback)}static callback(){const e=this.stored_theme;"light"!==e&&"dark"!==e&&r.set_theme(r.preferred_theme)}static unbind(){this.boundCallback&&(this.media_query.removeEventListener("change",this.boundCallback),document.documentElement.removeAttribute("data-bs-theme"))}}class a extends s.ChangeListener{static initialize(e){const t=s.query_elem("#colortoggle-switch",e);t&&new a(t)}constructor(e){super({elem:e}),this.update=this.update.bind(this),this.update(),r.watch(this.update())}update(){const e=r.preferred_theme,t=this.elem,s=t.is(":checked");"dark"!==e||s?"light"===e&&s&&t.prop("checked",!1):t.prop("checked",!0)}on_change(){const e=this.elem.is(":checked")?"dark":"light";r.set_theme(e),r.stored_theme=e}}class n{static initialize(e){new n(e)}constructor(e){this.cut_cookie="cone.app.copysupport.cut",this.copy_cookie="cone.app.copysupport.copy",this.context=e,this.paste_action=t("a#toolbaraction-paste",e),this.paste_action.off("click").on("click",this.handle_paste.bind(this)),this.copyable=t("table tr.selectable.copysupportitem",e),this.copyable.length&&(this.cut_action=t("a#toolbaraction-cut",e),this.cut_action.off("click").on("click",this.handle_cut.bind(this)),this.copy_action=t("a#toolbaraction-copy",e),this.copy_action.off("click").on("click",this.handle_copy.bind(this)),this.selectable=this.copyable.selectable({on_firstclick:this.on_firstclick.bind(this),on_select:this.on_select.bind(this)}).data("selectable"),this.read_selected_from_cookie(this.cut_cookie,"copysupport_cut"),this.read_selected_from_cookie(this.copy_cookie,""))}on_firstclick(e,t){}on_select(e){}write_selected_to_cookie(e){let i=t(this.selectable.selected),o=new Array;i.each(function(){o.push(t(this).attr("ajax:target"))});let l=o.join("::");s.create_cookie(e,l),l.length?t(this.paste_action).removeClass("disabled"):t(this.paste_action).addClass("disabled")}read_selected_from_cookie(e,i){let o=s.read_cookie(e);if(!o)return;let l,r,a=o.split("::"),n=this;t("table tr.selectable",this.context).each(function(){l=t(this),r=l.attr("ajax:target");for(let e in a)if(a[e]==r){l.addClass("selected"),i&&l.addClass(i),n.selectable.add(l.get(0));break}})}handle_cut(e){e.preventDefault(),s.create_cookie(this.copy_cookie,"",0),this.write_selected_to_cookie(this.cut_cookie),this.copyable.removeClass("copysupport_cut"),t(this.selectable.selected).addClass("copysupport_cut")}handle_copy(e){e.preventDefault(),s.create_cookie(this.cut_cookie,"",0),this.write_selected_to_cookie(this.copy_cookie),this.copyable.removeClass("copysupport_cut")}handle_paste(e){e.preventDefault();let i=t(e.currentTarget);if(i.hasClass("disabled"))return;let o=s.ajax.parse_target(i.attr("ajax:target"));s.ajax.action({name:"paste",mode:"NONE",selector:"NONE",url:o.url,params:o.params})}}let h={shift_down:!1,ctrl_down:!1};class c{constructor(){t(window).on("keydown",this.key_down.bind(this)),t(window).on("keyup",this.key_up.bind(this))}key_down(e){switch(e.keyCode||e.which){case 16:h.shift_down=!0;break;case 17:h.ctrl_down=!0}}key_up(e){switch(e.keyCode||e.which){case 16:h.shift_down=!1;break;case 17:h.ctrl_down=!1}}}class d{static initialize(e,t=null){const i=s.query_elem("input#search-text",e);i&&(null===t&&(t=cone.LiveSearch),new t(i))}constructor(e){this.elem=e,this.target=`${e.data("search-target")}/livesearch`,this.content=t("#content"),this.result=null,this._term="",this._minlen=3,this._delay=250,this._timeout_event=null,this._in_progress=!1,this.on_keydown=this.on_keydown.bind(this),this.on_change=this.on_change.bind(this),this.on_result=this.on_result.bind(this),e.on("keydown",this.on_keydown),e.on("change",this.on_change)}search(){this._in_progress=!0,s.http_request({url:this.target,params:{term:this._term},type:"json",success:this.on_result}),this._in_progress=!1}render_no_results(){s.compile_template(this,'\n
No search results
\n ',this.result)}render_suggestion(e){s.compile_template(this,`\n
\n
\n \n \n ${e.value}\n \n
\n

\n ${void 0===e.description?"":e.description}\n

\n
\n `,this.result)}on_result(e,t,i){if(this.content.empty(),s.compile_template(this,`\n
\n
\n

Search results for "${this._term}"

\n
\n
\n `,this.content),e.length){s.compile_template(this,`\n

\n ${e.length} Results\n

\n `,this.result);for(const t of e)this.render_suggestion(t)}else this.render_no_results();this.result.tsajax()}on_keydown(e){13!==e.keyCode&&s.clock.schedule_frame(()=>{this._term!==this.elem.val()&&this.elem.trigger("change")})}on_change(e){if(this._in_progress)return;const t=this.elem.val();this._term!==t&&(this._term=t,this._term.length{this._timeout_event=null,this.search()},this._delay)))}}class _{static initialize(e){if(!e)return;let s=e.parents("div.modal");if(!s.length)return;let i=s.data("overlay"),o=i.ref_target;t("a.addreference",e).each(function(){new p(t(this),o,i)}),t("a.removereference",e).each(function(){new m(t(this),o,i)})}constructor(e,t,s){this.elem=e,this.target=t,this.target_tag=t.get(0).tagName,this.overlay=s}single_value(){return"INPUT"==this.target_tag}multi_value(){return"SELECT"==this.target_tag}toggle_enabled(e){t("a",e.parent()).toggleClass("disabled")}reset_selected(e){let s=new Array;this.single_value()&&s.push(e.attr("value")),this.multi_value()&&t("[selected=selected]",e).each(function(){s.push(t(this).attr("value"))}),this.set_selected_on_ajax_target(e.parent(),s);let i=this.overlay,o=this;t("div.referencebrowser a",i.elem).each(function(){let e=t(this);e.attr("ajax:target")&&o.set_selected_on_ajax_target(e,s)})}set_selected_on_ajax_target(e,t){let i=s.ajax.parse_target(e.attr("ajax:target"));i.params.selected=t.join(",");let o=new Array;for(let e in i.params)o.push(e+"="+i.params[e]);e.attr("ajax:target",i.url+"?"+o.join("&"))}}class p extends _{constructor(e,t,s){super(e,t,s),e.off("click").on("click",this.add_reference.bind(this))}add_reference(e){e.preventDefault();let s=this.elem,i=this.target,o=s.attr("id");o=o.substring(4,o.length);let l=t(".reftitle",s.parent()).html();if(this.single_value()){i.attr("value",l);let e='[name="'+i.attr("name")+'.uid"]';return t(e).attr("value",o),this.set_selected_on_ajax_target(i.parent(),[o]),void this.overlay.close()}if(this.multi_value()){if(t('[value="'+o+'"]',i.parent()).length)return;let e=t("");e.val(o).html(l).attr("selected","selected"),i.append(e),i.trigger("change")}this.reset_selected(i),this.toggle_enabled(s)}}class m extends _{constructor(e,t,s){super(e,t,s),e.off("click").on("click",this.remove_reference.bind(this))}remove_reference(e){e.preventDefault();let s=this.elem,i=this.target,o=s.attr("id");if(o=o.substring(4,o.length),this.single_value()){i.attr("value","");let e='[name="'+i.attr("name")+'.uid"]';t(e).attr("value","")}if(this.multi_value()){let e='[value="'+o+'"]';if(!t(e,i.parent()).length)return;t(e,i).remove(),i.trigger("change")}this.reset_selected(i),this.toggle_enabled(s)}}class u{static initialize(e){t(".referencebrowser_trigger",e).each(function(){new u(t(this))})}constructor(e){this.wrapper=e.parent();let s=`[name="${e.data("reference-name")}"]`;this.target=t(s,this.wrapper),e.off("click").on("click",this.load_ref_browser.bind(this))}load_ref_browser(e){e.preventDefault(),s.ajax.overlay({action:"referencebrowser",title:"Referencebrowser",css:"modal-lg",target:this.wrapper.attr("ajax:target"),on_complete:this.on_complete.bind(this)}).ref_target=this.target}on_complete(e){let s=this.target;t("a.addreference",e.elem).each(function(){new p(t(this),s,e)}),t("a.removereference",e.elem).each(function(){new m(t(this),s,e)})}}function b(e,t){u.initialize(t)}function g(e,s,i){t(".referencebrowser_trigger",s).each(function(){let s=t(this),o=s.data("reference-name"),l=e.base_id.replace(/\-/g,".");s.data("reference-name",e.set_value_index(o,l,i,"."))})}t(function(){void 0!==window.yafowil_array&&(yafowil_array.on_array_event("on_add",b),yafowil_array.on_array_event("on_index",g))});class f extends s.Events{on_sidebar_left_resize(e){}on_sidebar_right_resize(e){}on_main_area_mode(e){}}const w=new f;class v extends s.Events{static initialize(e){const t=s.query_elem("#main-area",e);t&&new v(t)}constructor(e){super(),this.elem=e,new s.Property(this,"is_compact",null),new s.Property(this,"is_super_compact",null),this.set_mode=this.set_mode.bind(this),w.on("on_sidebar_left_resize",this.set_mode),w.on("on_sidebar_right_resize",this.set_mode),t(window).on("resize",this.set_mode),this.set_mode(),s.ajax.attach(this,e)}destroy(){t(window).off("resize",this.set_mode),w.off("on_sidebar_left_resize",this.set_mode),w.off("on_sidebar_right_resize",this.set_mode)}set_mode(){this.is_compact=this.elem.outerWidth()<992,this.is_super_compact=this.elem.outerWidth()<576}on_is_compact(e){e?(this.elem.removeClass("full"),this.elem.addClass("compact")):(this.elem.removeClass("compact"),this.elem.addClass("full")),w.trigger("on_main_area_mode",this)}on_is_super_compact(e){e?this.elem.addClass("super-compact"):this.elem.removeClass("super-compact"),w.trigger("on_main_area_mode",this)}}class k extends s.Events{constructor(e){super(),this.elem=e,new s.Property(this,"is_compact",null),new s.Property(this,"is_super_compact",null),new s.Property(this,"is_sidebar_collapsed",null),this.set_mode=this.set_mode.bind(this),w.on("on_main_area_mode",this.set_mode),this.on_sidebar_left_resize=this.on_sidebar_left_resize.bind(this),w.on("on_sidebar_left_resize",this.on_sidebar_left_resize),w.on("on_sidebar_right_resize",this.on_sidebar_left_resize),s.ajax.attach(this,e)}destroy(){w.off("on_main_area_mode",this.set_mode),w.off("on_sidebar_left_resize",this.on_sidebar_left_resize),w.off("on_sidebar_right_resize",this.on_sidebar_left_resize)}set_mode(e,t){this.is_compact=t.is_compact,this.is_super_compact=t.is_super_compact}on_is_compact(e){e?(this.elem.removeClass("full"),this.elem.addClass("compact")):(this.elem.removeClass("compact"),this.elem.addClass("full"))}on_is_super_compact(e){e?this.elem.addClass("super-compact"):this.elem.removeClass("super-compact")}on_sidebar_left_resize(e,t){this.is_sidebar_left_collapsed=t.collapsed}on_sidebar_right_resize(e,t){this.is_sidebar_right_collapsed=t.collapsed}on_is_sidebar_left_collapsed(e){}on_is_sidebar_right_collapsed(e){}}const z=e=>class extends e{constructor(...e){super(...e),this.elem&&s.ajax.attach(this,this.elem),this.on_window_resize=this.on_window_resize.bind(this),t(window).on("resize",this.on_window_resize)}on_window_resize(e){super.on_window_resize&&super.on_window_resize(e)}destroy(){try{super.destroy()}catch(e){console.warn(e)}finally{t(window).off("resize",this.on_window_resize)}}};class y extends s.Motion{static initialize(e){t(".scrollable-x",e).each(function(){new x(t(this))}),t(".scrollable-y",e).each(function(){new C(t(this))})}constructor(e){if(super(e),this.elem=e,this.elem.data("scrollbar"))return void console.warn("cone.app: Only one Scrollbar can be bound to each element.");this.elem.data("scrollbar",this),this.content=s.query_elem("> .scrollable-content",e),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,new s.Property(this,"disabled",!1),s.clock.schedule_frame(()=>this.render());const i=t(window).width()<=768;new s.Property(this,"is_mobile",i)}on_window_resize(e){this.is_mobile=t(window).innerWidth()<=768,this.position=this.safe_position(this.position),this.render()}get position(){return this._position||0}set position(e){this._position=this.safe_position(e),this.update(),this.trigger("on_position",this._position)}get pointer_events(){return"all"===this.elem.css("pointer-events")}set pointer_events(e){this.elem.css("pointer-events",e?"all":"none")}fade_timer(){this.scrollbar.is(":visible")||this.scrollbar.fadeIn("fast"),this.fade_out_timeout&&clearTimeout(this.fade_out_timeout),this.fade_out_timeout=setTimeout(()=>{this.scrollbar.fadeOut("slow")},700)}on_is_mobile(e){e&&this.contentsize>this.scrollsize?(this.scrollbar.stop(!0,!0).show(),this.elem.off("mouseenter mouseleave",this.on_hover)):(this.scrollbar.stop(!0,!0).hide(),this.elem.on("mouseenter mouseleave",this.on_hover))}bind(){this.pointer_events=!0,this.elem.on("mousewheel wheel",this.on_scroll),this.scrollbar.on("click",this.on_click),this.set_scope(this.thumb,t(document),this.elem)}unbind(){this.elem.off("mousewheel wheel",this.on_scroll),this.elem.off("mouseenter mouseleave",this.on_hover),this.scrollbar.off("click",this.on_click),t(this.thumb).off("mousedown",this._down_handle)}destroy(){this.fade_out_timeout&&clearTimeout(this.fade_out_timeout),this.unbind(),this.elem.removeData("scrollbar")}compile(){s.compile_template(this,'\n
\n
\n
\n
\n ',this.elem)}render(e){this.scrollbar.css(e,this.scrollsize),this.contentsize<=this.scrollsize?this.thumbsize=this.scrollsize:this.thumbsize=Math.pow(this.scrollsize,2)/this.contentsize,this.thumb.css(e,this.thumbsize),this.update(),this.position=this.safe_position(this.position)}safe_position(e){if("number"!=typeof e)throw new Error(`Scrollbar position must be a Number, position is: "${e}".`);if(this.contentsize<=this.scrollsize)return 0;const t=this.contentsize-this.scrollsize;return e>=t?e=t:e<=0&&(e=0),e}on_disabled(e){e?this.unbind():this.bind()}on_hover(e){e.preventDefault(),e.stopPropagation();const t=this.elem;(t.has(e.target).length>0||t.is(e.target))&&this.contentsize>this.scrollsize&&("mouseenter"===e.type?this.scrollbar.stop(!0,!0).fadeIn():"mouseleave"===e.type&&e.relatedTarget!==t.get(0)&&this.scrollbar.stop(!0,!0).fadeOut())}on_scroll(e){if(this.contentsize<=this.scrollsize)return;let t=e.originalEvent;"number"==typeof t.deltaY&&(t.deltaY>0?this.position+=this.scroll_step:t.deltaY<0&&(this.position-=this.scroll_step))}on_click(e){e.preventDefault(),this.thumb.addClass("active");let t=this.pos_from_evt(e)-this.offset-this.thumbsize/2;this.position=this.contentsize*t/this.scrollsize,this.thumb.removeClass("active")}touchstart(e){const t=e.originalEvent.touches[0];this._touch_pos=this.pos_from_evt(t),this._start_position=this.position}touchmove(e){if(this.contentsize<=this.scrollsize)return;const t=e.originalEvent.touches[0],s=this.pos_from_evt(t)-this._touch_pos;this.position=this._start_position-s,this.fade_timer()}touchend(e){delete this._touch_pos,delete this._start_position}down(e){this._mouse_pos=this.pos_from_evt(e)-this.offset,this._thumb_pos=this.position/(this.contentsize/this.scrollsize),this.elem.off("mouseenter mouseleave",this.on_hover),this.thumb.addClass("active")}move(e){let t=this.pos_from_evt(e)-this.offset,s=this._thumb_pos+t-this._mouse_pos;this.position=this.contentsize*s/this.scrollsize}up(e){delete this._mouse_pos,delete this._thumb_pos,this.elem.on("mouseenter mouseleave",this.on_hover),this.thumb.removeClass("active")}}class x extends(z(y)){get offset(){return this.elem.offset().left}get contentsize(){return this.content.outerWidth()}get scrollsize(){const e=parseFloat(this.elem.css("padding-right")),t=parseFloat(this.elem.css("padding-left"));return this.elem.outerWidth()-t-e}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)}render(){super.render("width")}update(){let e=this.position/(this.contentsize/this.scrollsize);this.content.css("right",this.position+"px"),this.thumb.css("left",e+"px")}pos_from_evt(e){return e.pageX}}class C extends(z(y)){get offset(){return this.elem.offset().top}get contentsize(){return this.content.outerHeight()}get scrollsize(){const e=parseFloat(this.elem.css("padding-top")),t=parseFloat(this.elem.css("padding-bottom"));return this.elem.outerHeight()-e-t}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)}render(){super.render("height")}update(){let e=this.position/(this.contentsize/this.scrollsize);this.content.css("bottom",this.position+"px"),this.thumb.css("top",e+"px")}pos_from_evt(e){return e.pageY}}class j{static initialize(e){new j(e)}constructor(e){t("input.add_remove_role_for_principal",e).off("change").on("change",this.set_principal_role)}set_principal_role(e){e.preventDefault();let i,o=t(this);i=this.checked?"add_principal_role":"remove_principal_role";let l=o.parent().attr("ajax:target"),r={id:o.attr("name"),role:o.attr("value")};s.ajax.action({name:i,mode:"NONE",selector:"NONE",url:l,params:r})}}class S extends s.Events{constructor(e,i){super(i),this.elem=i;const o=this.target=i.data("target");this.parent=e,this.related_tile=t(`[data-tile="${o}"]`,this.parent.tiles_container),this.on_click=this.on_click.bind(this),this.compile(),s.ajax.attach(this,i)}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)}}class q extends s.Events{constructor(e,s){super(s),this.sidebar=e,this.elem=s,this.mode=e.elem.data("mode")||"stacked",this.tiles=e.elem.data("tiles")??[],this.navigation=t(".sidebar-controls",this.sidebar.elem),this.tiles_container=t(".sidebar-tiles",this.elem),this.controls=[],this.compile(),"toggle"===this.mode&&this.controls.length>0&&this.activate_tile(this.controls[0])}compile(){"toggle"===this.mode&&this.tiles.length>1?t(".sidebar-control",this.navigation).each((e,s)=>{this.controls.push(new S(this,t(s)))}):this.navigation.addClass("d-none")}activate_tile(e){"stacked"!==this.mode&&(this.deactivate_all(),e.activate_tile())}deactivate_all(){for(const e of this.controls){if("stacked"===this.mode)return;e.deactivate_tile()}}}class I extends(z(s.Motion)){constructor(e){super(e),this.elem=e,this.min_width=e.data("min-width")||115;const i=Math.max(this.min_width,this.sidebar_width);if(e.css("width",i+"px"),this.moving=!1,this.trigger_event=this.trigger_event.bind(this),this.scrollbar=s.query_elem(".scrollable-y",e).data("scrollbar"),this.on_click=this.on_click.bind(this),this.collapse_elem=s.query_elem("#sidebar_collapse",e),this.collapse_elem.on("click",this.on_click),this.on_lock=this.on_lock.bind(this),this.lock_input=s.query_elem(".lock-state-input",e),this.lock_elem=s.query_elem(".lock-state-btn",e),this.lock_elem.on("click",this.on_lock),this.resizer_elem=s.query_elem("#sidebar_resizer",e),this.set_scope(this.resizer_elem,t(document)),this.responsive_toggle=this.responsive_toggle.bind(this),this.responsive_toggle(),void 0!==this.locked&&null!==this.locked){if(this.lock_input.prop("checked",!0).trigger("change"),this.disable_lock)return;this.locked.collapsed?this.collapse():this.expand()}this.disable_or_enable_interaction=this.disable_or_enable_interaction.bind(this),this.disable_or_enable_interaction(),t("html, body").css("overscroll-behavior","auto");const o=t(".sidebar-content",e);this.sidebar_content=new q(this,o),s.ajax.attach(this,e)}get collapsed(){return this.elem.outerWidth()<=0}on_window_resize(e){this.responsive_toggle()}on_lock(e){!this.lock_input.get(0).checked?this.set_state():(this.unset_state(),this.elem.removeClass("collapsed"),this.elem.removeClass("expanded")),this.collapsed?this.elem.addClass("collapsed"):this.elem.addClass("expanded"),this.disable_or_enable_interaction()}disable_or_enable_interaction(){this.locked&&!this.disable_lock?(t(".collapse_btn",this.collapse_elem).addClass("disabled"),this.resizer_elem.addClass("d-none")):(t(".collapse_btn",this.collapse_elem).removeClass("disabled"),this.resizer_elem.removeClass("d-none"))}responsive_toggle(){this.locked||(this.elem.removeClass("collapsed"),this.elem.removeClass("expanded")),this.collapsed?(this.elem.removeClass("responsive-expanded"),this.elem.addClass("responsive-collapsed")):(this.elem.addClass("responsive-expanded"),this.elem.removeClass("responsive-collapsed")),this.collapsed!==this.responsive_collapsed&&(this.responsive_collapsed=this.collapsed,this.trigger_event()),t(window).width()<768?(this.disable_lock=!0,this.locked&&!this.locked.collapsed&&this.collapse()):(this.disable_lock=!1,this.locked&&!this.locked.collapsed&&this.collapsed?this.expand():this.locked&&this.locked.collapsed&&!this.collapsed&&this.collapse()),this.locked&&this.disable_or_enable_interaction()}collapse(){t("html, body").css("overscroll-behavior","auto"),this.elem.removeClass("expanded").addClass("collapsed"),this.trigger_event()}expand(){t("html, body").css("overscroll-behavior","none"),this.elem.removeClass("collapsed").addClass("expanded"),this.trigger_event()}on_click(e){this.collapsed?this.expand():this.collapse(),void 0===this.locked||null===this.locked||this.disable_lock||this.set_state()}move(e){this.locked||(this.moving=!0,this.scrollbar.pointer_events=!1,this.sidebar_width=this.get_width_from_event(e),this.elem.css("width",this.sidebar_width),this.trigger_event())}up(){this.scrollbar.pointer_events=!0,this.trigger_event(),this.moving=!1}on_sibling_sidebar_resize(e,s){const i=t(window).width()-this.elem.outerWidth()-300,o=t(window).width()<768,l=this.locked&&!this.locked.collapsed,r=s.elem.outerWidth()>=i;!s.collapsed&&o?(this.collapse(),this.elem.addClass("d-none")):s.collapsed||!r||s.moving&&this.locked?s.collapsed&&l&&this.collapsed&&!o?(this.expand(),this.elem.removeClass("d-none")):s.collapsed&&this.elem.removeClass("d-none"):this.collapse()}destroy(){this.reset_state(),t(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)}}class E extends I{static initialize(e){const t=s.query_elem("#sidebar_left",e);t&&new E(t)}constructor(e){super(e),this.on_sidebar_right_resize=this.on_sidebar_right_resize.bind(this),w.on("on_sidebar_right_resize",this.on_sidebar_right_resize)}get locked(){return JSON.parse(localStorage.getItem("cone.app.sidebar_left.locked"))}get sidebar_width(){return localStorage.getItem("cone-app-sidebar-left-width")||300}set sidebar_width(e){localStorage.setItem("cone-app-sidebar-left-width",e)}trigger_event(){w.trigger("on_sidebar_left_resize",this)}get_width_from_event(e){let s=e.pageX,i=0;t("#sidebar_right").length>0&&(i=t("#sidebar_right").outerWidth());const o=t(window).width()-i-300;return s=Math.max(this.min_width,Math.min(s,o)),parseInt(s)}on_sidebar_right_resize(e,t){this.on_sibling_sidebar_resize(e,t)}set_state(){localStorage.setItem("cone.app.sidebar_left.locked",JSON.stringify({collapsed:this.collapsed}))}unset_state(){localStorage.removeItem("cone.app.sidebar_left.locked")}destroy(){super.destroy(),w.off("on_sidebar_right_resize",this.on_sidebar_right_resize)}}class N extends I{static initialize(e){const t=s.query_elem("#sidebar_right",e);t&&new N(t)}constructor(e){super(e),this.on_sidebar_left_resize=this.on_sidebar_left_resize.bind(this),w.on("on_sidebar_left_resize",this.on_sidebar_left_resize)}get locked(){return JSON.parse(localStorage.getItem("cone.app.sidebar_right.locked"))}get sidebar_width(){return localStorage.getItem("cone-app-sidebar-right-width")||300}set sidebar_width(e){localStorage.setItem("cone-app-sidebar-right-width",e)}trigger_event(){w.trigger("on_sidebar_right_resize",this)}get_width_from_event(e){let s=t(window).outerWidth()-e.pageX,i=0;t("#sidebar_left").length>0&&(i=t("#sidebar_left").outerWidth());const o=t(window).width()-i-300;return s=Math.max(this.min_width,Math.min(s,o)),parseInt(s)}on_sidebar_left_resize(e,t){this.on_sibling_sidebar_resize(e,t)}set_state(){localStorage.setItem("cone.app.sidebar_right.locked",JSON.stringify({collapsed:this.collapsed}))}unset_state(){localStorage.removeItem("cone.app.sidebar_right.locked")}destroy(){super.destroy(),w.off("on_sidebar_left_resize",this.on_sidebar_left_resize)}}class D{static initialize(e){o.initialize(e,".table_length select"),l.initialize(e,".table_filter input")}}class T{static initialize(e){t(".translation-nav",e).each(function(){new T(t(this))})}constructor(e){t("div.invalid-feedback",e.parent()).show(),this.nav_elem=e,this.fields_elem=e.next(),this.show_lang_handle=this.show_lang_handle.bind(this),t("li > a",e).on("click",this.show_lang_handle),t("li.error",e).length?t("li.error:first > a",e).trigger("click"):t("li > a.active",e).trigger("click"),this.fields_elem.show()}show_lang_handle(e){e.preventDefault(),t("li > a",this.nav_elem).removeClass("active"),this.fields_elem.children().hide();let s=t(e.currentTarget);s.addClass("active"),t(s.attr("href"),this.fields_elem).show()}}class M extends k{static initialize(e){const t=s.query_elem("#mainmenu",e);t&&new M(t)}constructor(e){super(e),this.elem=e,this.scrollbar=e.data("scrollbar"),this.elems=t(".nav-link.dropdown-toggle",e),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)}get height(){return this.elem.outerHeight(!0)}on_sidebar_left_resize(e,t){super.on_sidebar_left_resize(e,t),requestAnimationFrame(()=>{this.scrollbar.render()})}on_is_compact(e){this.hide_dropdowns(),e?(this.scrollbar.off("on_position",this.hide_dropdowns),this.bind_dropdowns_mobile()):(this.bind_dropdowns_desktop(),this.scrollbar.on("on_position",this.hide_dropdowns))}on_show_dropdown_desktop(e){const s=e.target;this.open_dropdown=s;t(s).siblings("ul.dropdown-menu").css({top:this.height-1+"px",left:`${t(s).offset().left}px`})}on_hide_dropdown_desktop(e){const t=e.target;this.open_dropdown===t&&(this.open_dropdown=null)}bind_dropdowns_desktop(){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)}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)}hide_dropdowns(){this.elems.each((e,s)=>{t(s).dropdown("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()}}class O extends k{static initialize(e){const t=s.query_elem("#header-main",e);t&&new O(t)}constructor(e){super(e),this.elem=e,this.header_content=s.query_elem("#header-content",e),this.navbar_content_wrapper=s.query_elem("#navbar-content-wrapper",e),this.navbar_content=s.query_elem("#navbar-content",e),this.navbar_toggler=s.query_elem("#navbar-toggler",this.elem),this.personal_tools=s.query_elem("#personaltools",e),this.mainmenu=s.query_elem("#mainmenu",e),this.mainmenu_elems=t(".nav-link.dropdown-toggle",this.mainmenu),this.render_mobile_scrollbar=this.render_mobile_scrollbar.bind(this),this.mainmenu_elems.each((e,s)=>{t(s).on("shown.bs.dropdown",this.render_mobile_scrollbar),t(s).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()}destroy(){super.destroy(),this.mobile_scrollbar&&(this.mobile_scrollbar.destroy(),this.mobile_scrollbar=null),this.mainmenu_elems.each((e,s)=>{t(s).off("shown.bs.dropdown",this.render_mobile_scrollbar),t(s).off("hidden.bs.dropdown",this.render_mobile_scrollbar)});const e=this.navbar_content_wrapper;e.off("show.bs.collapse shown.bs.collapse",this.set_mobile_menu_open),e.off("hide.bs.collapse hidden.bs.collapse",this.set_mobile_menu_closed)}render_mobile_scrollbar(){this.is_compact&&this.mobile_scrollbar&&this.mobile_scrollbar.render()}bind(){const e=this.navbar_content_wrapper;e.on("show.bs.collapse shown.bs.collapse",this.set_mobile_menu_open),e.on("hidden.bs.collapse",this.set_mobile_menu_closed)}set_mobile_menu_open(){this.elem.addClass("mobile-menu-open")}set_mobile_menu_closed(){this.elem.removeClass("mobile-menu-open")}on_is_compact(e){this.mobile_scrollbar&&(this.navbar_content.removeClass("scrollable-content"),this.mobile_scrollbar.destroy(),this.mobile_scrollbar=null),e?(this.elem.removeClass("full").removeClass("navbar-expand"),this.elem.addClass("compact"),this.navbar_content.addClass("scrollable-content"),this.mobile_scrollbar=new C(this.navbar_content_wrapper),this.navbar_content_wrapper.on("shown.bs.collapse",()=>{t("html, body").css("overscroll-behavior","none"),this.mobile_scrollbar.render()}),this.navbar_content_wrapper.on("hide.bs.collapse",()=>{t("html, body").css("overscroll-behavior","auto"),this.mobile_scrollbar.scrollbar.hide()})):(this.elem.removeClass("compact"),this.elem.addClass("full").addClass("navbar-expand"))}on_is_super_compact(e){const i=null!==s.query_elem("#personaltools",this.navbar_content);e?i||this.personal_tools.detach().appendTo(this.navbar_content):(i&&this.personal_tools.detach().prependTo(this.header_content),t(".dropdown-menu.show").removeClass("show"))}}class ${static initialize(e){const t=s.query_elem("#navtree",e);t&&new $(t)}constructor(e){this.elem=e,this.dropdown_elem=t("#navigation-collapse",e),this.dropdown_elem.hasClass("no-collapse")||(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),s.ajax.attach(this,e))}set_menu_open(e){localStorage.setItem("cone.app.navtree.open","true")}set_menu_closed(e){localStorage.removeItem("cone.app.navtree.open")}destroy(){this.dropdown_elem.off(),this.elem.off()}}class A{constructor(e){this.options=e,this.selected=[],this.select_direction=0,this.firstclick=!0}reset(){this.selected=[]}add(e){this.remove(e),this.selected.push(e)}remove(e){let s=t.grep(this.selected,function(t,s){return t!==e});this.selected=s}select_no_key(e,t){e.children().removeClass("selected"),t.addClass("selected"),this.reset(),this.add(t.get(0))}select_ctrl_down(e){e.toggleClass("selected"),e.hasClass("selected")?this.add(e.get(0)):this.remove(e.get(0))}get_nearest(e,s){let i,o,l=e.children(".selected"),r=-1;return t(l).each(function(){o=t(this),i=o.index(),-1==r?r=i:s>i?this.select_direction>0?ir&&(r=i):sNo search results\n ',this.result)}render_suggestion(e){s.compile_template(this,`\n
\n
\n \n \n ${e.value}\n \n
\n

\n ${void 0===e.description?"":e.description}\n

\n
\n `,this.result)}on_result(e,t,i){if(this.content.empty(),s.compile_template(this,`\n
\n
\n

Search results for "${this._term}"

\n
\n
\n `,this.content),e.length){s.compile_template(this,`\n

\n ${e.length} Results\n

\n `,this.result);for(const t of e)this.render_suggestion(t)}else this.render_no_results();this.result.tsajax()}on_keydown(e){13!==e.keyCode&&s.clock.schedule_frame(()=>{this._term!==this.elem.val()&&this.elem.trigger("change")})}on_change(e){if(this._in_progress)return;const t=this.elem.val();this._term!==t&&(this._term=t,this._term.length{this._timeout_event=null,this.search()},this._delay)))}}class _{static initialize(e){if(!e)return;let s=e.parents("div.modal");if(!s.length)return;let i=s.data("overlay"),o=i.ref_target;t("a.addreference",e).each(function(){new p(t(this),o,i)}),t("a.removereference",e).each(function(){new m(t(this),o,i)})}constructor(e,t,s){this.elem=e,this.target=t,this.target_tag=t.get(0).tagName,this.overlay=s}single_value(){return"INPUT"==this.target_tag}multi_value(){return"SELECT"==this.target_tag}toggle_enabled(e){t("a",e.parent()).toggleClass("disabled")}reset_selected(e){let s=new Array;this.single_value()&&s.push(e.attr("value")),this.multi_value()&&t("[selected=selected]",e).each(function(){s.push(t(this).attr("value"))}),this.set_selected_on_ajax_target(e.parent(),s);let i=this.overlay,o=this;t("div.referencebrowser a",i.elem).each(function(){let e=t(this);e.attr("ajax:target")&&o.set_selected_on_ajax_target(e,s)})}set_selected_on_ajax_target(e,t){let i=s.ajax.parse_target(e.attr("ajax:target"));i.params.selected=t.join(",");let o=new Array;for(let e in i.params)o.push(e+"="+i.params[e]);e.attr("ajax:target",i.url+"?"+o.join("&"))}}class p extends _{constructor(e,t,s){super(e,t,s),e.off("click").on("click",this.add_reference.bind(this))}add_reference(e){e.preventDefault();let s=this.elem,i=this.target,o=s.attr("id");o=o.substring(4,o.length);let l=t(".reftitle",s.parent()).html();if(this.single_value()){i.attr("value",l);let e='[name="'+i.attr("name")+'.uid"]';return t(e).attr("value",o),this.set_selected_on_ajax_target(i.parent(),[o]),void this.overlay.close()}if(this.multi_value()){if(t('[value="'+o+'"]',i.parent()).length)return;let e=t("");e.val(o).html(l).attr("selected","selected"),i.append(e),i.trigger("change")}this.reset_selected(i),this.toggle_enabled(s)}}class m extends _{constructor(e,t,s){super(e,t,s),e.off("click").on("click",this.remove_reference.bind(this))}remove_reference(e){e.preventDefault();let s=this.elem,i=this.target,o=s.attr("id");if(o=o.substring(4,o.length),this.single_value()){i.attr("value","");let e='[name="'+i.attr("name")+'.uid"]';t(e).attr("value","")}if(this.multi_value()){let e='[value="'+o+'"]';if(!t(e,i.parent()).length)return;t(e,i).remove(),i.trigger("change")}this.reset_selected(i),this.toggle_enabled(s)}}class u{static initialize(e){t(".referencebrowser_trigger",e).each(function(){new u(t(this))})}constructor(e){this.wrapper=e.parent();let s=`[name="${e.data("reference-name")}"]`;this.target=t(s,this.wrapper),e.off("click").on("click",this.load_ref_browser.bind(this))}load_ref_browser(e){e.preventDefault(),s.ajax.overlay({action:"referencebrowser",title:"Referencebrowser",css:"modal-lg",target:this.wrapper.attr("ajax:target"),on_complete:this.on_complete.bind(this)}).ref_target=this.target}on_complete(e){let s=this.target;t("a.addreference",e.elem).each(function(){new p(t(this),s,e)}),t("a.removereference",e.elem).each(function(){new m(t(this),s,e)})}}function b(e,t){u.initialize(t)}function g(e,s,i){t(".referencebrowser_trigger",s).each(function(){let s=t(this),o=s.data("reference-name"),l=e.base_id.replace(/\-/g,".");s.data("reference-name",e.set_value_index(o,l,i,"."))})}t(function(){void 0!==window.yafowil_array&&(yafowil_array.on_array_event("on_add",b),yafowil_array.on_array_event("on_index",g))});class f extends s.Events{on_sidebar_left_resize(e){}on_sidebar_right_resize(e){}on_main_area_mode(e){}}const w=new f;class v extends s.Events{static initialize(e){const t=s.query_elem("#main-area",e);t&&new v(t)}constructor(e){super(),this.elem=e,new s.Property(this,"is_compact",null),new s.Property(this,"is_super_compact",null),this.set_mode=this.set_mode.bind(this),w.on("on_sidebar_left_resize",this.set_mode),w.on("on_sidebar_right_resize",this.set_mode),t(window).on("resize",this.set_mode),this.set_mode(),s.ajax.attach(this,e)}destroy(){t(window).off("resize",this.set_mode),w.off("on_sidebar_left_resize",this.set_mode),w.off("on_sidebar_right_resize",this.set_mode)}set_mode(){this.is_compact=this.elem.outerWidth()<992,this.is_super_compact=this.elem.outerWidth()<576}on_is_compact(e){e?(this.elem.removeClass("full"),this.elem.addClass("compact")):(this.elem.removeClass("compact"),this.elem.addClass("full")),w.trigger("on_main_area_mode",this)}on_is_super_compact(e){e?this.elem.addClass("super-compact"):this.elem.removeClass("super-compact"),w.trigger("on_main_area_mode",this)}}class k extends s.Events{constructor(e){super(),this.elem=e,new s.Property(this,"is_compact",null),new s.Property(this,"is_super_compact",null),new s.Property(this,"is_sidebar_collapsed",null),this.set_mode=this.set_mode.bind(this),w.on("on_main_area_mode",this.set_mode),this.on_sidebar_left_resize=this.on_sidebar_left_resize.bind(this),w.on("on_sidebar_left_resize",this.on_sidebar_left_resize),w.on("on_sidebar_right_resize",this.on_sidebar_left_resize),s.ajax.attach(this,e)}destroy(){w.off("on_main_area_mode",this.set_mode),w.off("on_sidebar_left_resize",this.on_sidebar_left_resize),w.off("on_sidebar_right_resize",this.on_sidebar_left_resize)}set_mode(e,t){this.is_compact=t.is_compact,this.is_super_compact=t.is_super_compact}on_is_compact(e){e?(this.elem.removeClass("full"),this.elem.addClass("compact")):(this.elem.removeClass("compact"),this.elem.addClass("full"))}on_is_super_compact(e){e?this.elem.addClass("super-compact"):this.elem.removeClass("super-compact")}on_sidebar_left_resize(e,t){this.is_sidebar_left_collapsed=t.collapsed}on_sidebar_right_resize(e,t){this.is_sidebar_right_collapsed=t.collapsed}on_is_sidebar_left_collapsed(e){}on_is_sidebar_right_collapsed(e){}}const z=e=>class extends e{constructor(...e){super(...e),this.elem&&s.ajax.attach(this,this.elem),this.on_window_resize=this.on_window_resize.bind(this),t(window).on("resize",this.on_window_resize)}on_window_resize(e){super.on_window_resize&&super.on_window_resize(e)}destroy(){try{super.destroy()}catch(e){console.warn(e)}finally{t(window).off("resize",this.on_window_resize)}}};class y extends s.Motion{static initialize(e){t(".scrollable-x",e).each(function(){new x(t(this))}),t(".scrollable-y",e).each(function(){new C(t(this))})}constructor(e){if(super(e),this.elem=e,this.elem.data("scrollbar"))return void console.warn("cone.app: Only one Scrollbar can be bound to each element.");this.elem.data("scrollbar",this),this.content=s.query_elem("> .scrollable-content",e),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,new s.Property(this,"disabled",!1),s.clock.schedule_frame(()=>this.render());const i=t(window).width()<=768;new s.Property(this,"is_mobile",i)}on_window_resize(e){this.is_mobile=t(window).innerWidth()<=768,this.position=this.safe_position(this.position),this.render()}get position(){return this._position||0}set position(e){this._position=this.safe_position(e),this.update(),this.trigger("on_position",this._position)}get pointer_events(){return"all"===this.elem.css("pointer-events")}set pointer_events(e){this.elem.css("pointer-events",e?"all":"none")}fade_timer(){this.scrollbar.is(":visible")||this.scrollbar.fadeIn("fast"),this.fade_out_timeout&&clearTimeout(this.fade_out_timeout),this.fade_out_timeout=setTimeout(()=>{this.scrollbar.fadeOut("slow")},700)}on_is_mobile(e){e&&this.contentsize>this.scrollsize?(this.scrollbar.stop(!0,!0).show(),this.elem.off("mouseenter mouseleave",this.on_hover)):(this.scrollbar.stop(!0,!0).hide(),this.elem.on("mouseenter mouseleave",this.on_hover))}bind(){this.pointer_events=!0,this.elem.on("mousewheel wheel",this.on_scroll),this.scrollbar.on("click",this.on_click),this.set_scope(this.thumb,t(document),this.elem)}unbind(){this.elem.off("mousewheel wheel",this.on_scroll),this.elem.off("mouseenter mouseleave",this.on_hover),this.scrollbar.off("click",this.on_click),t(this.thumb).off("mousedown",this._down_handle)}destroy(){this.fade_out_timeout&&clearTimeout(this.fade_out_timeout),this.unbind(),this.elem.removeData("scrollbar")}compile(){s.compile_template(this,'\n
\n
\n
\n
\n ',this.elem)}render(e){this.scrollbar.css(e,this.scrollsize),this.contentsize<=this.scrollsize?this.thumbsize=this.scrollsize:this.thumbsize=Math.pow(this.scrollsize,2)/this.contentsize,this.thumb.css(e,this.thumbsize),this.update(),this.position=this.safe_position(this.position)}safe_position(e){if("number"!=typeof e)throw new Error(`Scrollbar position must be a Number, position is: "${e}".`);if(this.contentsize<=this.scrollsize)return 0;const t=this.contentsize-this.scrollsize;return e>=t?e=t:e<=0&&(e=0),e}on_disabled(e){e?this.unbind():this.bind()}on_hover(e){e.preventDefault(),e.stopPropagation();const t=this.elem;(t.has(e.target).length>0||t.is(e.target))&&this.contentsize>this.scrollsize&&("mouseenter"===e.type?this.scrollbar.stop(!0,!0).fadeIn():"mouseleave"===e.type&&e.relatedTarget!==t.get(0)&&this.scrollbar.stop(!0,!0).fadeOut())}on_scroll(e){if(this.contentsize<=this.scrollsize)return;let t=e.originalEvent;"number"==typeof t.deltaY&&(t.deltaY>0?this.position+=this.scroll_step:t.deltaY<0&&(this.position-=this.scroll_step))}on_click(e){e.preventDefault(),this.thumb.addClass("active");let t=this.pos_from_evt(e)-this.offset-this.thumbsize/2;this.position=this.contentsize*t/this.scrollsize,this.thumb.removeClass("active")}touchstart(e){const t=e.originalEvent.touches[0];this._touch_pos=this.pos_from_evt(t),this._start_position=this.position}touchmove(e){if(this.contentsize<=this.scrollsize)return;const t=e.originalEvent.touches[0],s=this.pos_from_evt(t)-this._touch_pos;this.position=this._start_position-s,this.fade_timer()}touchend(e){delete this._touch_pos,delete this._start_position}down(e){this._mouse_pos=this.pos_from_evt(e)-this.offset,this._thumb_pos=this.position/(this.contentsize/this.scrollsize),this.elem.off("mouseenter mouseleave",this.on_hover),this.thumb.addClass("active")}move(e){let t=this.pos_from_evt(e)-this.offset,s=this._thumb_pos+t-this._mouse_pos;this.position=this.contentsize*s/this.scrollsize}up(e){delete this._mouse_pos,delete this._thumb_pos,this.elem.on("mouseenter mouseleave",this.on_hover),this.thumb.removeClass("active")}}class x extends(z(y)){get offset(){return this.elem.offset().left}get contentsize(){return this.content.outerWidth()}get scrollsize(){const e=parseFloat(this.elem.css("padding-right")),t=parseFloat(this.elem.css("padding-left"));return this.elem.outerWidth()-t-e}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)}render(){super.render("width")}update(){let e=this.position/(this.contentsize/this.scrollsize);this.content.css("right",this.position+"px"),this.thumb.css("left",e+"px")}pos_from_evt(e){return e.pageX}}class C extends(z(y)){get offset(){return this.elem.offset().top}get contentsize(){return this.content.outerHeight()}get scrollsize(){const e=parseFloat(this.elem.css("padding-top")),t=parseFloat(this.elem.css("padding-bottom"));return this.elem.outerHeight()-e-t}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)}render(){super.render("height")}update(){let e=this.position/(this.contentsize/this.scrollsize);this.content.css("bottom",this.position+"px"),this.thumb.css("top",e+"px")}pos_from_evt(e){return e.pageY}}class j{static initialize(e){new j(e)}constructor(e){t("input.add_remove_role_for_principal",e).off("change").on("change",this.set_principal_role)}set_principal_role(e){e.preventDefault();let i,o=t(this);i=this.checked?"add_principal_role":"remove_principal_role";let l=o.parent().attr("ajax:target"),a={id:o.attr("name"),role:o.attr("value")};s.ajax.action({name:i,mode:"NONE",selector:"NONE",url:l,params:a})}}class S extends s.Events{constructor(e,i){super(i),this.elem=i;const o=this.target=i.data("target");this.parent=e,this.related_tile=t(`[data-tile="${o}"]`,this.parent.tiles_container),this.on_click=this.on_click.bind(this),this.compile(),s.ajax.attach(this,i)}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)}}class q extends s.Events{constructor(e,s){super(s),this.sidebar=e,this.elem=s,this.mode=e.elem.data("mode")||"stacked",this.tiles=e.elem.data("tiles")??[],this.navigation=t(".sidebar-controls",this.sidebar.elem),this.tiles_container=t(".sidebar-tiles",this.elem),this.controls=[],this.compile(),"toggle"===this.mode&&this.controls.length>0&&this.activate_tile(this.controls[0])}compile(){"toggle"===this.mode&&this.tiles.length>1?t(".sidebar-control",this.navigation).each((e,s)=>{this.controls.push(new S(this,t(s)))}):this.navigation.addClass("d-none")}activate_tile(e){"stacked"!==this.mode&&(this.deactivate_all(),e.activate_tile())}deactivate_all(){for(const e of this.controls){if("stacked"===this.mode)return;e.deactivate_tile()}}}class I extends(z(s.Motion)){constructor(e){super(e),this.elem=e,this.min_width=e.data("min-width")||115;const i=Math.max(this.min_width,this.sidebar_width);e.css("width",i+"px");const o=this.elem.data("static");if(this.static=!0===o||"True"===o,this.moving=!1,this.trigger_event=this.trigger_event.bind(this),this.scrollbar=s.query_elem(".scrollable-y",e).data("scrollbar"),this.on_click=this.on_click.bind(this),this.collapse_elem=s.query_elem("#sidebar_collapse",e),this.collapse_elem.on("click",this.on_click),this.on_lock=this.on_lock.bind(this),this.lock_input=s.query_elem(".lock-state-input",e),this.lock_elem=s.query_elem(".lock-state-btn",e),this.lock_elem.on("click",this.on_lock),this.resizer_elem=s.query_elem("#sidebar_resizer",e),this.set_scope(this.resizer_elem,t(document)),this.responsive_toggle=this.responsive_toggle.bind(this),this.responsive_toggle(),void 0!==this.locked&&null!==this.locked){if(this.lock_input.prop("checked",!0).trigger("change"),this.disable_lock)return;this.locked.collapsed?this.collapse():this.expand()}this.disable_or_enable_interaction=this.disable_or_enable_interaction.bind(this),this.disable_or_enable_interaction(),t("html, body").css("overscroll-behavior","auto");const l=t(".sidebar-content",e);this.sidebar_content=new q(this,l),s.ajax.attach(this,e)}get collapsed(){return this.elem.outerWidth()<=0}on_window_resize(e){this.responsive_toggle()}on_lock(e){!this.lock_input.get(0).checked?this.set_state():(this.unset_state(),this.elem.removeClass("collapsed"),this.elem.removeClass("expanded")),this.collapsed?this.elem.addClass("collapsed"):this.elem.addClass("expanded"),this.disable_or_enable_interaction()}disable_or_enable_interaction(){this.locked&&!this.disable_lock?(t(".collapse_btn",this.collapse_elem).addClass("disabled"),this.resizer_elem.addClass("d-none")):(t(".collapse_btn",this.collapse_elem).removeClass("disabled"),this.resizer_elem.removeClass("d-none"))}responsive_toggle(){this.locked||(this.elem.removeClass("collapsed"),this.elem.removeClass("expanded")),this.static?(this.collapsed||this.collapse(),this.elem.removeClass("responsive-expanded"),this.elem.removeClass("responsive-collapsed")):this.collapsed?(this.elem.removeClass("responsive-expanded"),this.elem.addClass("responsive-collapsed")):(this.elem.addClass("responsive-expanded"),this.elem.removeClass("responsive-collapsed")),this.collapsed!==this.responsive_collapsed&&(this.responsive_collapsed=this.collapsed,this.trigger_event()),t(window).width()<768?(this.disable_lock=!0,this.locked&&!this.locked.collapsed&&this.collapse()):(this.disable_lock=!1,this.locked&&!this.locked.collapsed&&this.collapsed?this.expand():this.locked&&this.locked.collapsed&&!this.collapsed&&this.collapse()),this.locked&&this.disable_or_enable_interaction()}collapse(){t("html, body").css("overscroll-behavior","auto"),this.elem.removeClass("expanded").addClass("collapsed"),this.trigger_event()}expand(){t("html, body").css("overscroll-behavior","none"),this.elem.removeClass("collapsed").addClass("expanded"),this.trigger_event()}on_click(e){this.collapsed?this.expand():this.collapse(),void 0===this.locked||null===this.locked||this.disable_lock||this.set_state()}move(e){this.locked||(this.moving=!0,this.scrollbar.pointer_events=!1,this.sidebar_width=this.get_width_from_event(e),this.elem.css("width",this.sidebar_width),this.trigger_event())}up(){this.scrollbar.pointer_events=!0,this.trigger_event(),this.moving=!1}on_sibling_sidebar_resize(e,s){const i=t(window).width()-this.elem.outerWidth()-300,o=t(window).width()<768,l=this.locked&&!this.locked.collapsed,a=s.elem.outerWidth()>=i;!s.collapsed&&o?(this.collapse(),this.elem.addClass("d-none")):s.collapsed||!a||s.moving&&this.locked?s.collapsed&&l&&this.collapsed&&!o?(this.expand(),this.elem.removeClass("d-none")):s.collapsed&&this.elem.removeClass("d-none"):this.collapse()}destroy(){this.reset_state(),t(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)}}class E extends I{static initialize(e){const t=s.query_elem("#sidebar_left",e);t&&new E(t)}constructor(e){super(e),this.on_sidebar_right_resize=this.on_sidebar_right_resize.bind(this),w.on("on_sidebar_right_resize",this.on_sidebar_right_resize)}get locked(){return JSON.parse(localStorage.getItem("cone.app.sidebar_left.locked"))}get sidebar_width(){return localStorage.getItem("cone-app-sidebar-left-width")||300}set sidebar_width(e){localStorage.setItem("cone-app-sidebar-left-width",e)}trigger_event(){w.trigger("on_sidebar_left_resize",this)}get_width_from_event(e){let s=e.pageX,i=0;t("#sidebar_right").length>0&&(i=t("#sidebar_right").outerWidth());const o=t(window).width()-i-300;return s=Math.max(this.min_width,Math.min(s,o)),parseInt(s)}on_sidebar_right_resize(e,t){this.on_sibling_sidebar_resize(e,t)}set_state(){localStorage.setItem("cone.app.sidebar_left.locked",JSON.stringify({collapsed:this.collapsed}))}unset_state(){localStorage.removeItem("cone.app.sidebar_left.locked")}destroy(){super.destroy(),w.off("on_sidebar_right_resize",this.on_sidebar_right_resize)}}class N extends I{static initialize(e){const t=s.query_elem("#sidebar_right",e);t&&new N(t)}constructor(e){super(e),this.on_sidebar_left_resize=this.on_sidebar_left_resize.bind(this),w.on("on_sidebar_left_resize",this.on_sidebar_left_resize)}get locked(){return JSON.parse(localStorage.getItem("cone.app.sidebar_right.locked"))}get sidebar_width(){return localStorage.getItem("cone-app-sidebar-right-width")||300}set sidebar_width(e){localStorage.setItem("cone-app-sidebar-right-width",e)}trigger_event(){w.trigger("on_sidebar_right_resize",this)}get_width_from_event(e){let s=t(window).outerWidth()-e.pageX,i=0;t("#sidebar_left").length>0&&(i=t("#sidebar_left").outerWidth());const o=t(window).width()-i-300;return s=Math.max(this.min_width,Math.min(s,o)),parseInt(s)}on_sidebar_left_resize(e,t){this.on_sibling_sidebar_resize(e,t)}set_state(){localStorage.setItem("cone.app.sidebar_right.locked",JSON.stringify({collapsed:this.collapsed}))}unset_state(){localStorage.removeItem("cone.app.sidebar_right.locked")}destroy(){super.destroy(),w.off("on_sidebar_left_resize",this.on_sidebar_left_resize)}}class T{static initialize(e){o.initialize(e,".table_length select"),l.initialize(e,".table_filter input")}}class D{static initialize(e){t(".translation-nav",e).each(function(){new D(t(this))})}constructor(e){t("div.invalid-feedback",e.parent()).show(),this.nav_elem=e,this.fields_elem=e.next(),this.show_lang_handle=this.show_lang_handle.bind(this),t("li > a",e).on("click",this.show_lang_handle),t("li.error",e).length?t("li.error:first > a",e).trigger("click"):t("li > a.active",e).trigger("click"),this.fields_elem.show()}show_lang_handle(e){e.preventDefault(),t("li > a",this.nav_elem).removeClass("active"),this.fields_elem.children().hide();let s=t(e.currentTarget);s.addClass("active"),t(s.attr("href"),this.fields_elem).show()}}class M extends k{static initialize(e){const t=s.query_elem("#mainmenu",e);t&&new M(t)}constructor(e){super(e),this.elem=e,this.scrollbar=e.data("scrollbar"),this.elems=t(".nav-link.dropdown-toggle",e),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)}get height(){return this.elem.outerHeight(!0)}on_sidebar_left_resize(e,t){super.on_sidebar_left_resize(e,t),requestAnimationFrame(()=>{this.scrollbar.render()})}on_is_compact(e){this.hide_dropdowns(),e?(this.scrollbar.off("on_position",this.hide_dropdowns),this.bind_dropdowns_mobile()):(this.bind_dropdowns_desktop(),this.scrollbar.on("on_position",this.hide_dropdowns))}on_show_dropdown_desktop(e){const s=e.target;this.open_dropdown=s;t(s).siblings("ul.dropdown-menu").css({top:this.height-1+"px",left:`${t(s).offset().left}px`})}on_hide_dropdown_desktop(e){const t=e.target;this.open_dropdown===t&&(this.open_dropdown=null)}bind_dropdowns_desktop(){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)}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)}hide_dropdowns(){this.elems.each((e,s)=>{t(s).dropdown("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()}}class O extends k{static initialize(e){const t=s.query_elem("#header-main",e);t&&new O(t)}constructor(e){super(e),this.elem=e,this.header_content=s.query_elem("#header-content",e),this.navbar_content_wrapper=s.query_elem("#navbar-content-wrapper",e),this.navbar_content=s.query_elem("#navbar-content",e),this.navbar_toggler=s.query_elem("#navbar-toggler",this.elem),this.personal_tools=s.query_elem("#personaltools",e),this.mainmenu=s.query_elem("#mainmenu",e),this.mainmenu_elems=t(".nav-link.dropdown-toggle",this.mainmenu),this.render_mobile_scrollbar=this.render_mobile_scrollbar.bind(this),this.mainmenu_elems.each((e,s)=>{t(s).on("shown.bs.dropdown",this.render_mobile_scrollbar),t(s).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()}destroy(){super.destroy(),this.mobile_scrollbar&&(this.mobile_scrollbar.destroy(),this.mobile_scrollbar=null),this.mainmenu_elems.each((e,s)=>{t(s).off("shown.bs.dropdown",this.render_mobile_scrollbar),t(s).off("hidden.bs.dropdown",this.render_mobile_scrollbar)});const e=this.navbar_content_wrapper;e.off("show.bs.collapse shown.bs.collapse",this.set_mobile_menu_open),e.off("hide.bs.collapse hidden.bs.collapse",this.set_mobile_menu_closed)}render_mobile_scrollbar(){this.is_compact&&this.mobile_scrollbar&&this.mobile_scrollbar.render()}bind(){const e=this.navbar_content_wrapper;e.on("show.bs.collapse shown.bs.collapse",this.set_mobile_menu_open),e.on("hidden.bs.collapse",this.set_mobile_menu_closed)}set_mobile_menu_open(){this.elem.addClass("mobile-menu-open")}set_mobile_menu_closed(){this.elem.removeClass("mobile-menu-open")}on_is_compact(e){this.mobile_scrollbar&&(this.navbar_content.removeClass("scrollable-content"),this.mobile_scrollbar.destroy(),this.mobile_scrollbar=null),e?(this.elem.removeClass("full").removeClass("navbar-expand"),this.elem.addClass("compact"),this.navbar_content.addClass("scrollable-content"),this.mobile_scrollbar=new C(this.navbar_content_wrapper),this.navbar_content_wrapper.on("shown.bs.collapse",()=>{t("html, body").css("overscroll-behavior","none"),this.mobile_scrollbar.render()}),this.navbar_content_wrapper.on("hide.bs.collapse",()=>{t("html, body").css("overscroll-behavior","auto"),this.mobile_scrollbar.scrollbar.hide()})):(this.elem.removeClass("compact"),this.elem.addClass("full").addClass("navbar-expand"))}on_is_super_compact(e){const i=null!==s.query_elem("#personaltools",this.navbar_content);e?i||this.personal_tools.detach().appendTo(this.navbar_content):(i&&this.personal_tools.detach().prependTo(this.header_content),t(".dropdown-menu.show").removeClass("show"))}}class ${static initialize(e){const t=s.query_elem("#navtree",e);t&&new $(t)}constructor(e){this.elem=e,this.dropdown_elem=t("#navigation-collapse",e),this.dropdown_elem.hasClass("no-collapse")||(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),s.ajax.attach(this,e))}set_menu_open(e){localStorage.setItem("cone.app.navtree.open","true")}set_menu_closed(e){localStorage.removeItem("cone.app.navtree.open")}destroy(){this.dropdown_elem.off(),this.elem.off()}}class A{constructor(e){this.options=e,this.selected=[],this.select_direction=0,this.firstclick=!0}reset(){this.selected=[]}add(e){this.remove(e),this.selected.push(e)}remove(e){let s=t.grep(this.selected,function(t,s){return t!==e});this.selected=s}select_no_key(e,t){e.children().removeClass("selected"),t.addClass("selected"),this.reset(),this.add(t.get(0))}select_ctrl_down(e){e.toggleClass("selected"),e.hasClass("selected")?this.add(e.get(0)):this.remove(e.get(0))}get_nearest(e,s){let i,o,l=e.children(".selected"),a=-1;return t(l).each(function(){o=t(this),i=o.index(),-1==a?a=i:s>i?this.select_direction>0?ia&&(a=i):s
+ tal:define="css 'h-100 px-0 '; + css css + ('container-xxl' if config.limit_page_width else 'container-fluid'); + css css + ('' if config.center_content else ' ms-0');" + tal:attributes="class css"> @@ -72,7 +75,8 @@ tal:attributes="class 'd-flex flex-column flex-shrink-0 pt-1 px-0 pb-5' + (' static' if config.sidebar_left_static else '')" data-min-width="${config.sidebar_left_min_width}" data-mode="${config.sidebar_left_mode}" - data-tiles="${context.dump(config.sidebar_left)}"> + data-tiles="${context.dump(config.sidebar_left)}" + data-static="${config.sidebar_left_static}">