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..510fafdd --- /dev/null +++ b/examples/cone.example/pyproject.toml @@ -0,0 +1,27 @@ +[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", + "pygments" +] + +[project.optional-dependencies] +test = [ + "pytest", + "zope.pytestlayer" +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +consider_namespace_packages = true +addopts = ["--import-mode=importlib"] +pythonpath = "src" 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..016d64f7 100644 --- a/examples/cone.example/src/cone/example/__init__.py +++ b/examples/cone.example/src/cone/example/__init__.py @@ -1,27 +1,63 @@ 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 +from pyramid.session import SignedCookieSessionFactory @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 + # 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) - # 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.layout.model import LayoutDemo + from cone.example.wiki.model import Wiki + from cone.example.ajax.browser import AjaxPlayground + from cone.example.populate import populate_wiki + + def make_wiki(): + wiki = Wiki() + populate_wiki(wiki) + return wiki + + register_entry('layout', LayoutDemo) + register_entry('wiki', make_wiki) + register_entry('ajax_playground', AjaxPlayground) - # static resources + # Register settings node + from cone.example.settings.model import ExampleSettings + register_config('example_settings', ExampleSettings) + + # Static resources configure_resources(config, settings) - # scan browser package + # 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.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/__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..14b64b6a --- /dev/null +++ b/examples/cone.example/src/cone/example/ajax/browser.py @@ -0,0 +1,255 @@ +from cone.app.browser.ajax import ajax_continue +from cone.app.browser.ajax import ajax_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 AjaxPath +from cone.app.browser.layout import ProtectedContentTile +from cone.app.browser.utils import make_url +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.app.utils import node_path +from cone.example.browser.utils import code_block +from cone.example.model import _ +from cone.tile import tile +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): + # Simulate navigating to a "subsection" by appending to path + path = '/'.join(node_path(self.model)) + '/demo-section' + url = make_url(self.request, node=self.model) + ajax_continue(self.request, [ + AjaxPath(path=path, target=url, event=None), + AjaxAction(url, 'ajax_path_result', 'inner', '#ajax-demo-target'), + AjaxMessage('Browser URL updated! Check your address bar.', 'info', None), + ]) + return '' + + +# Result tile shown after AjaxPath demo +@tile(name='ajax_path_result', + interface=AjaxPlayground, + permission='view') +class AjaxPathResult(ProtectedContentTile): + + def render(self): + return '
' \ + 'AjaxPath worked! ' \ + 'The browser URL was updated without a page reload. ' \ + 'Check the address bar - it now shows "/demo-section" appended.
' + + +# 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) + # Trigger a custom event on the demo target element. + # The template includes JS that listens for this event. + ajax_continue(self.request, [ + AjaxEvent(url, 'demo:highlight', '#ajax-event-target'), + AjaxMessage( + 'AjaxEvent triggered! The target element received a custom event.', + 'info', + None + ), + ]) + 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', + 'modal-lg', + 'Demo Message' + ) + 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) + path = '/'.join(node_path(self.model)) + '/combined' + # Demonstrate multiple operations working together: + # 1. Update content in the demo target area + # 2. Show a success message + # 3. Update the browser URL + # 4. Trigger a custom event on another element + ajax_continue(self.request, [ + AjaxAction(url, 'ajax_combined_result', 'inner', '#ajax-demo-target'), + AjaxMessage('Combined operations executed successfully!', 'success', None), + AjaxPath(path=path, target=url, event=None), + AjaxEvent(url, 'demo:highlight', '#ajax-event-target'), + ]) + return '' + + +# Result tile shown after combined demo +@tile(name='ajax_combined_result', + interface=AjaxPlayground, + permission='view') +class AjaxCombinedResult(ProtectedContentTile): + + def render(self): + return '
' \ + 'Combined operations worked! ' \ + 'This response triggered: content update (here), ' \ + 'a message notification, URL path change, and a custom event.
' + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=AjaxPlayground, + permission='view', + strict=False, +) +class AjaxTutorial(Tile): + + code_ajax_path = """\ +AjaxPath( + path='/node/path', + target=url, + event=None # or 'eventname:#selector' +)""" + + code_ajax_action = """\ +AjaxAction( + url, 'tile_name', 'inner', '#target' +)""" + + code_ajax_event = """\ +AjaxEvent( + url, 'custom:event', '#target' +)""" + + code_ajax_message = """\ +ajax_message(request, 'Hello!', 'info')""" + + code_ajax_continue = """\ +ajax_continue(request, [ + AjaxAction(...), + AjaxMessage(...), + AjaxPath(...), +])""" + + def example_ajax_path(self): + return code_block(self.code_ajax_path, 'python') + + def example_ajax_action(self): + return code_block(self.code_ajax_action, 'python') + + def example_ajax_event(self): + return code_block(self.code_ajax_event, 'python') + + def example_ajax_message(self): + return code_block(self.code_ajax_message, 'python') + + def example_ajax_continue(self): + return code_block(self.code_ajax_continue, 'python') 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..66ad0ca1 --- /dev/null +++ b/examples/cone.example/src/cone/example/ajax/templates/ajax_playground.pt @@ -0,0 +1,173 @@ + + + + +
+
+

+ + 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 + + +
+ + Event target - will highlight when event received +
+
+
+
+ + +
+
+
+
+ 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/ajax/templates/tutorial_content.pt b/examples/cone.example/src/cone/example/ajax/templates/tutorial_content.pt new file mode 100644 index 00000000..6b68dde2 --- /dev/null +++ b/examples/cone.example/src/cone/example/ajax/templates/tutorial_content.pt @@ -0,0 +1,44 @@ + + +
+
AjaxPath
+
+

Updates browser URL without reload. Optionally fires event.

+ +
+
+ +
+
AjaxAction
+
+

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

+ +
+
+ +
+
AjaxEvent
+
+

Triggers a custom JS event on a DOM element for inter-component communication.

+ +
+
+ +
+
AjaxMessage
+
+

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

+ +
+
+ +
+
ajax_continue
+
+

Chain multiple operations in one response.

+ +
+
+ +
diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index db562d34..2919f38c 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -1,28 +1,25 @@ -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.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 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 AppRoot +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 +from cone.tile import Tile 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( @@ -34,111 +31,267 @@ name='cone-example-css', resource='cone.example.css' )) +# Pygments theme switcher script +# Dynamically loads pygments-light.css or pygments-dark.css based on data-bs-theme +cone_example_resources.add(wr.ScriptResource( + name='pygments-theme-js', + resource='pygments-theme.js' +)) def configure_resources(config, settings): config.register_resource(cone_example_resources) config.set_resource_include('cone-example-css', 'authenticated') + config.set_resource_include('pygments-theme-js', 'authenticated') -@tile(name='view', - path='templates/view.pt', - interface=EntryFolder, - permission='login') -@tile(name='view', - path='templates/view.pt', - interface=Folder, - permission='login') +############################################################################### +# 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. + + +# Default layout settings for LayoutDemo page (stored in session) +LAYOUT_DEMO_DEFAULTS = { + 'mainmenu': True, + 'livesearch': True, + 'personaltools': True, + 'pathbar': True, + 'sidebar_left': ['navtree'], + 'sidebar_left_static': False, + 'sidebar_left_min_width': 150, + 'sidebar_right': ['tutorial'], + 'sidebar_right_static': False, + 'sidebar_right_min_width': 150, + 'limit_content_width': True, + 'limit_page_width': False, + 'center_content': False +} + + +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'] + self.sidebar_right_min_width = 400 + + +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_left_static = session.get('layout.sidebar_left_static', LAYOUT_DEMO_DEFAULTS['sidebar_left_static']) + self.sidebar_left_min_width = session.get('layout.sidebar_left_min_width', LAYOUT_DEMO_DEFAULTS['sidebar_left_min_width']) + self.sidebar_right = session.get('layout.sidebar_right', LAYOUT_DEMO_DEFAULTS['sidebar_right']) + self.sidebar_right_static = session.get('layout.sidebar_right_static', LAYOUT_DEMO_DEFAULTS['sidebar_right_static']) + self.sidebar_right_min_width = session.get('layout.sidebar_right_min_width', LAYOUT_DEMO_DEFAULTS['sidebar_right_min_width']) + self.limit_content_width = session.get('layout.limit_content_width', LAYOUT_DEMO_DEFAULTS['limit_content_width']) + self.limit_page_width = session.get('layout.limit_page_width', LAYOUT_DEMO_DEFAULTS['limit_page_width']) + self.center_content = session.get('layout.center_content', LAYOUT_DEMO_DEFAULTS['center_content']) + + +@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', + 'limit_content_width', 'limit_page_width', 'sidebar_left_static', + 'sidebar_right_static', 'center_content'): + 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='set_layout_number', permission='view') +class SetLayoutNumberTile(Tile): + """Set a numeric layout setting via session (for LayoutDemo page only).""" + + def render(self): + setting = self.request.params.get('setting') + value = self.request.params.get('value') + if setting in ('sidebar_left_min_width', 'sidebar_right_min_width') and value: + try: + value = int(value) + session = self.request.session + key = f'layout.{setting}' + session[key] = value + except ValueError: + pass + 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.layout.model import LayoutDemo + from cone.example.wiki.model import Wiki + from cone.example.wiki.model import WikiFolder + from cone.example.wiki.model import WikiPage + from cone.example.ajax.browser import AjaxPlayground + + @layout_config(Wiki, WikiFolder, WikiPage) + 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(ExampleLayoutConfig): + 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(ExampleLayoutConfig): + pass + + +############################################################################### +# Landing Page (Root Content Tile) +############################################################################### + @tile(name='content', - path='templates/view.pt', - interface=Item, + path='cone.example.browser:templates/landing.pt', + interface=AppRoot, 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): - ... +class LandingPage(ProtectedContentTile): + + @property + def layout_url(self): + return make_url(self.request, node=self.model['layout']) + + @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 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/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 a53f4db8..3a923759 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,69 @@ +/* Sidebar right - Tutorial Bar */ +#sidebar_right { + background: linear-gradient(256deg, #c2d1ff, #bdbbff); + color: var(--bs-primary); +} +[data-bs-theme="dark"] #sidebar_right { + background: none; + background-color: #252b3c; + color: var(--bs-gray-100); +} +[data-bs-theme="dark"] [data-tile="tutorial"] h2.bg-light { + --bs-bg-opacity: .12; +} + +/* Document workflow states */ +#contextmenu a.state-draft, +tr.state-draft td.title a { + color: #6c757d; +} +#navtree li.state-draft > a { + color: #aad8ff!important; +} +#contextmenu a.state-review, +#navtree li.state-review > a, +tr.state-review td.title a { + color: #fd7e14; +} +#navtree li.state-review > a { + color: #ffc595!important; +} +#contextmenu a.state-published, +#navtree li.state-published > a, +tr.state-published td.title a { + color: #198754; +} +#navtree li.state-published > a { + color: #5cffb2!important; +} +#contextmenu a.state-archived, +tr.state-archived td.title a { + color: #6c757d; + text-decoration: line-through; +} +#navtree li.state-archived > a { + color: #95a2ae!important; +} + +/* 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; @@ -6,3 +72,162 @@ tr.state-private td.title a { tr.state-public td.title a { color: green; } + + +/* Example module header accents */ +.example-header { + border-left: 4px solid #dee2e6; +} +.example-header-layout { + border-left-color: #ffe000; +} +.example-header-documents { + border-left-color: #0d6efd; +} +.example-header-projects { + border-left-color: #fd7e14; +} +.example-header-wiki { + border-left-color: #18be71; +} +.example-header-ajax { + border-left-color: #7b33ff; +} + +/* Feature badges per module */ +.badge.example-badge-layout { + background-color: #fff3cd; + color: #664d03; +} +.badge.example-badge-documents { + background-color: #cfe2ff; + color: #084298; +} +.badge.example-badge-projects { + background-color: #ffdbcd; + color: #661f03; +} +.badge.example-badge-wiki { + background-color: #d1e7dd; + color: #0f5132; +} +.badge.example-badge-ajax { + background-color: #e2d9f3; + color: #432874; +} + +/* Landing page */ +.landing-hero { + border-bottom: 1px solid var(--bs-border-color); + 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.tooltip-left:hover::after { + left: 0; + transform: none; +} +#layout_config_demo > .btn.tooltip-right:hover::after { + right: 0; + left: auto; + transform: none; +} +#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 { + /* --navbar-height: 100px; */ /* Example of custom navbar height */ +} + +/* 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/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/static/pygments-dark.css b/examples/cone.example/src/cone/example/browser/static/pygments-dark.css new file mode 100644 index 00000000..60e43ba9 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/static/pygments-dark.css @@ -0,0 +1,85 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight .hll { background-color: #49483e } +.highlight { background: #272822; color: #F8F8F2 } +.highlight .c { color: #959077 } /* Comment */ +.highlight .err { color: #ED007E; background-color: #1E0010 } /* Error */ +.highlight .esc { color: #F8F8F2 } /* Escape */ +.highlight .g { color: #F8F8F2 } /* Generic */ +.highlight .k { color: #66D9EF } /* Keyword */ +.highlight .l { color: #AE81FF } /* Literal */ +.highlight .n { color: #F8F8F2 } /* Name */ +.highlight .o { color: #FF4689 } /* Operator */ +.highlight .x { color: #F8F8F2 } /* Other */ +.highlight .p { color: #F8F8F2 } /* Punctuation */ +.highlight .ch { color: #959077 } /* Comment.Hashbang */ +.highlight .cm { color: #959077 } /* Comment.Multiline */ +.highlight .cp { color: #959077 } /* Comment.Preproc */ +.highlight .cpf { color: #959077 } /* Comment.PreprocFile */ +.highlight .c1 { color: #959077 } /* Comment.Single */ +.highlight .cs { color: #959077 } /* Comment.Special */ +.highlight .gd { color: #FF4689 } /* Generic.Deleted */ +.highlight .ge { color: #F8F8F2; font-style: italic } /* Generic.Emph */ +.highlight .ges { color: #F8F8F2; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.highlight .gr { color: #F8F8F2 } /* Generic.Error */ +.highlight .gh { color: #F8F8F2 } /* Generic.Heading */ +.highlight .gi { color: #A6E22E } /* Generic.Inserted */ +.highlight .go { color: #66D9EF } /* Generic.Output */ +.highlight .gp { color: #FF4689; font-weight: bold } /* Generic.Prompt */ +.highlight .gs { color: #F8F8F2; font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #959077 } /* Generic.Subheading */ +.highlight .gt { color: #F8F8F2 } /* Generic.Traceback */ +.highlight .kc { color: #66D9EF } /* Keyword.Constant */ +.highlight .kd { color: #66D9EF } /* Keyword.Declaration */ +.highlight .kn { color: #FF4689 } /* Keyword.Namespace */ +.highlight .kp { color: #66D9EF } /* Keyword.Pseudo */ +.highlight .kr { color: #66D9EF } /* Keyword.Reserved */ +.highlight .kt { color: #66D9EF } /* Keyword.Type */ +.highlight .ld { color: #E6DB74 } /* Literal.Date */ +.highlight .m { color: #AE81FF } /* Literal.Number */ +.highlight .s { color: #E6DB74 } /* Literal.String */ +.highlight .na { color: #A6E22E } /* Name.Attribute */ +.highlight .nb { color: #F8F8F2 } /* Name.Builtin */ +.highlight .nc { color: #A6E22E } /* Name.Class */ +.highlight .no { color: #66D9EF } /* Name.Constant */ +.highlight .nd { color: #A6E22E } /* Name.Decorator */ +.highlight .ni { color: #F8F8F2 } /* Name.Entity */ +.highlight .ne { color: #A6E22E } /* Name.Exception */ +.highlight .nf { color: #A6E22E } /* Name.Function */ +.highlight .nl { color: #F8F8F2 } /* Name.Label */ +.highlight .nn { color: #F8F8F2 } /* Name.Namespace */ +.highlight .nx { color: #A6E22E } /* Name.Other */ +.highlight .py { color: #F8F8F2 } /* Name.Property */ +.highlight .nt { color: #FF4689 } /* Name.Tag */ +.highlight .nv { color: #F8F8F2 } /* Name.Variable */ +.highlight .ow { color: #FF4689 } /* Operator.Word */ +.highlight .pm { color: #F8F8F2 } /* Punctuation.Marker */ +.highlight .w { color: #F8F8F2 } /* Text.Whitespace */ +.highlight .mb { color: #AE81FF } /* Literal.Number.Bin */ +.highlight .mf { color: #AE81FF } /* Literal.Number.Float */ +.highlight .mh { color: #AE81FF } /* Literal.Number.Hex */ +.highlight .mi { color: #AE81FF } /* Literal.Number.Integer */ +.highlight .mo { color: #AE81FF } /* Literal.Number.Oct */ +.highlight .sa { color: #E6DB74 } /* Literal.String.Affix */ +.highlight .sb { color: #E6DB74 } /* Literal.String.Backtick */ +.highlight .sc { color: #E6DB74 } /* Literal.String.Char */ +.highlight .dl { color: #E6DB74 } /* Literal.String.Delimiter */ +.highlight .sd { color: #E6DB74 } /* Literal.String.Doc */ +.highlight .s2 { color: #E6DB74 } /* Literal.String.Double */ +.highlight .se { color: #AE81FF } /* Literal.String.Escape */ +.highlight .sh { color: #E6DB74 } /* Literal.String.Heredoc */ +.highlight .si { color: #E6DB74 } /* Literal.String.Interpol */ +.highlight .sx { color: #E6DB74 } /* Literal.String.Other */ +.highlight .sr { color: #E6DB74 } /* Literal.String.Regex */ +.highlight .s1 { color: #E6DB74 } /* Literal.String.Single */ +.highlight .ss { color: #E6DB74 } /* Literal.String.Symbol */ +.highlight .bp { color: #F8F8F2 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #A6E22E } /* Name.Function.Magic */ +.highlight .vc { color: #F8F8F2 } /* Name.Variable.Class */ +.highlight .vg { color: #F8F8F2 } /* Name.Variable.Global */ +.highlight .vi { color: #F8F8F2 } /* Name.Variable.Instance */ +.highlight .vm { color: #F8F8F2 } /* Name.Variable.Magic */ +.highlight .il { color: #AE81FF } /* Literal.Number.Integer.Long */ diff --git a/examples/cone.example/src/cone/example/browser/static/pygments-light.css b/examples/cone.example/src/cone/example/browser/static/pygments-light.css new file mode 100644 index 00000000..141ce3ea --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/static/pygments-light.css @@ -0,0 +1,75 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight .hll { background-color: #ffffcc } +.highlight { background: #ffffff; } +.highlight .c { color: #888 } /* Comment */ +.highlight .err { color: #F00; background-color: #FAA } /* Error */ +.highlight .k { color: #080; font-weight: bold } /* Keyword */ +.highlight .o { color: #333 } /* Operator */ +.highlight .ch { color: #888 } /* Comment.Hashbang */ +.highlight .cm { color: #888 } /* Comment.Multiline */ +.highlight .cp { color: #579 } /* Comment.Preproc */ +.highlight .cpf { color: #888 } /* Comment.PreprocFile */ +.highlight .c1 { color: #888 } /* Comment.Single */ +.highlight .cs { color: #C00; font-weight: bold } /* Comment.Special */ +.highlight .gd { color: #A00000 } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.highlight .gr { color: #F00 } /* Generic.Error */ +.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.highlight .gi { color: #00A000 } /* Generic.Inserted */ +.highlight .go { color: #888 } /* Generic.Output */ +.highlight .gp { color: #C65D09; font-weight: bold } /* Generic.Prompt */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.highlight .gt { color: #04D } /* Generic.Traceback */ +.highlight .kc { color: #080; font-weight: bold } /* Keyword.Constant */ +.highlight .kd { color: #080; font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { color: #080; font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { color: #038; font-weight: bold } /* Keyword.Pseudo */ +.highlight .kr { color: #080; font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #339; font-weight: bold } /* Keyword.Type */ +.highlight .m { color: #60E; font-weight: bold } /* Literal.Number */ +.highlight .s { background-color: #FFF0F0 } /* Literal.String */ +.highlight .na { color: #00C } /* Name.Attribute */ +.highlight .nb { color: #007020 } /* Name.Builtin */ +.highlight .nc { color: #B06; font-weight: bold } /* Name.Class */ +.highlight .no { color: #036; font-weight: bold } /* Name.Constant */ +.highlight .nd { color: #555; font-weight: bold } /* Name.Decorator */ +.highlight .ni { color: #800; font-weight: bold } /* Name.Entity */ +.highlight .ne { color: #F00; font-weight: bold } /* Name.Exception */ +.highlight .nf { color: #06B; font-weight: bold } /* Name.Function */ +.highlight .nl { color: #970; font-weight: bold } /* Name.Label */ +.highlight .nn { color: #0E84B5; font-weight: bold } /* Name.Namespace */ +.highlight .nt { color: #070 } /* Name.Tag */ +.highlight .nv { color: #963 } /* Name.Variable */ +.highlight .ow { color: #000; font-weight: bold } /* Operator.Word */ +.highlight .w { color: #BBB } /* Text.Whitespace */ +.highlight .mb { color: #60E; font-weight: bold } /* Literal.Number.Bin */ +.highlight .mf { color: #60E; font-weight: bold } /* Literal.Number.Float */ +.highlight .mh { color: #058; font-weight: bold } /* Literal.Number.Hex */ +.highlight .mi { color: #00D; font-weight: bold } /* Literal.Number.Integer */ +.highlight .mo { color: #40E; font-weight: bold } /* Literal.Number.Oct */ +.highlight .sa { background-color: #FFF0F0 } /* Literal.String.Affix */ +.highlight .sb { background-color: #FFF0F0 } /* Literal.String.Backtick */ +.highlight .sc { color: #04D } /* Literal.String.Char */ +.highlight .dl { background-color: #FFF0F0 } /* Literal.String.Delimiter */ +.highlight .sd { color: #D42 } /* Literal.String.Doc */ +.highlight .s2 { background-color: #FFF0F0 } /* Literal.String.Double */ +.highlight .se { color: #666; font-weight: bold; background-color: #FFF0F0 } /* Literal.String.Escape */ +.highlight .sh { background-color: #FFF0F0 } /* Literal.String.Heredoc */ +.highlight .si { background-color: #EEE } /* Literal.String.Interpol */ +.highlight .sx { color: #D20; background-color: #FFF0F0 } /* Literal.String.Other */ +.highlight .sr { color: #000; background-color: #FFF0FF } /* Literal.String.Regex */ +.highlight .s1 { background-color: #FFF0F0 } /* Literal.String.Single */ +.highlight .ss { color: #A60 } /* Literal.String.Symbol */ +.highlight .bp { color: #007020 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #06B; font-weight: bold } /* Name.Function.Magic */ +.highlight .vc { color: #369 } /* Name.Variable.Class */ +.highlight .vg { color: #D70; font-weight: bold } /* Name.Variable.Global */ +.highlight .vi { color: #33B } /* Name.Variable.Instance */ +.highlight .vm { color: #963 } /* Name.Variable.Magic */ +.highlight .il { color: #00D; font-weight: bold } /* Literal.Number.Integer.Long */ diff --git a/examples/cone.example/src/cone/example/browser/static/pygments-theme.js b/examples/cone.example/src/cone/example/browser/static/pygments-theme.js new file mode 100644 index 00000000..11256d43 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/static/pygments-theme.js @@ -0,0 +1,100 @@ +/** + * Pygments theme switcher for cone.example. + * + * Switches between light (colorful) and dark (monokai) Pygments themes + * based on the Bootstrap 5 data-bs-theme attribute. + */ +(function() { + 'use strict'; + + var LINK_ID = 'pygments-theme-css'; + var LIGHT_THEME = 'pygments-light.css'; + var DARK_THEME = 'pygments-dark.css'; + + /** + * Get the base URL for example static resources. + * Looks for an existing link with href containing 'example/' to determine the path. + */ + function getBaseUrl() { + var links = document.querySelectorAll('link[rel="stylesheet"]'); + for (var i = 0; i < links.length; i++) { + var href = links[i].getAttribute('href'); + if (href && href.indexOf('/example/') !== -1) { + // Extract base path up to and including 'example/' + var idx = href.indexOf('/example/'); + return href.substring(0, idx + '/example/'.length); + } + } + // Fallback: try to construct from current location + return '/resources/example/'; + } + + /** + * Get the current theme from the document. + */ + function getCurrentTheme() { + return document.documentElement.getAttribute('data-bs-theme') || 'light'; + } + + /** + * Get the appropriate Pygments CSS filename for the given theme. + */ + function getPygmentsCss(theme) { + return theme === 'dark' ? DARK_THEME : LIGHT_THEME; + } + + /** + * Create or update the Pygments stylesheet link. + */ + function updatePygmentsStylesheet() { + var theme = getCurrentTheme(); + var cssFile = getPygmentsCss(theme); + var baseUrl = getBaseUrl(); + var href = baseUrl + cssFile; + + var link = document.getElementById(LINK_ID); + if (!link) { + // Create new link element + link = document.createElement('link'); + link.id = LINK_ID; + link.rel = 'stylesheet'; + link.type = 'text/css'; + document.head.appendChild(link); + } + + // Update href if different + if (link.getAttribute('href') !== href) { + link.setAttribute('href', href); + } + } + + /** + * Initialize the Pygments theme switcher. + */ + function init() { + // Set initial stylesheet + updatePygmentsStylesheet(); + + // Watch for theme changes using MutationObserver + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.type === 'attributes' && + mutation.attributeName === 'data-bs-theme') { + updatePygmentsStylesheet(); + } + }); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-bs-theme'] + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); 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..1a75f269 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/landing.pt @@ -0,0 +1,103 @@ + + +
+

+ cone.example Logo + cone.example +

+

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

+
+ +
+ +
+
+
+
+ Layout +
+

+ Layout Configuration using LayoutConfig. +

+
+ LayoutConfig +
+ + Explore + + +
+
+
+ +
+
+
+
+ Wiki +
+

+ Wiki pages with workflow states, folders for organization, + cross-references, categories, and protected properties. +

+
+ Workflow + Folders + References +
+ + 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/browser/templates/layout.pt b/examples/cone.example/src/cone/example/browser/templates/layout.pt new file mode 100644 index 00000000..067b391c --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/layout.pt @@ -0,0 +1,203 @@ + + +
+ +
+ +
+ + +
+ + + + + + + +
+ + + + +
+ + + + +
+ + +
+ +
+ +
+
+ +
+ +
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..fc0f99dd --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/tutorial.pt @@ -0,0 +1,30 @@ + + +
+ On this page +
+ +
+

+ 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..069684a5 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/tutorial_content.pt @@ -0,0 +1,31 @@ + + +
+
cone.app Basics
+
+

A Pyramid-based web application stub using node trees and tiles.

+ +
+
+ +
+
Explore Sections
+
    +
  • Layout - Layout Configuration
  • +
  • Documents - FileStorage, Workflows
  • +
  • Projects - FactoryNode, AdapterNode
  • +
  • Wiki - References, Categories
  • +
  • AJAX - Continuation operations
  • +
+
+ +
+
Tiles
+
+

UI components registered by name and interface.

+ +
+
+ +
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..06893048 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/tutorial.py @@ -0,0 +1,65 @@ +from cone.app import get_root +from cone.app.browser.utils import make_url +from cone.app.model import AppRoot +from cone.example.browser.utils import code_block +from cone.tile import tile +from cone.tile import Tile + +TUTORIAL_TITLES = { + 'wiki': 'Example Wiki', + 'wiki_folder': 'Wiki Folder', + 'wiki_page': 'Wiki Page', + 'layout_demo': 'Layout', + 'ajax_playground': 'AJAX', +} + + +@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 + + @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( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=AppRoot, + permission='view', + strict=False, +) +class AppRootTutorial(Tile): + + code_node_info = """\ +@node_info(name='mynode', ...) +class MyNode(BaseNode): + ...""" + + code_tiles = """\ +@tile(name='view', interface=MyNode) +class MyView(Tile): + ...""" + + def example_node_info(self): + return code_block(self.code_node_info, 'python') + + def example_tiles(self): + return code_block(self.code_tiles, 'python') \ No newline at end of file diff --git a/examples/cone.example/src/cone/example/browser/utils.py b/examples/cone.example/src/cone/example/browser/utils.py new file mode 100644 index 00000000..307a380f --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/utils.py @@ -0,0 +1,20 @@ +from pygments import highlight +from pygments.lexers import get_lexer_by_name, TextLexer +from pygments.formatters import HtmlFormatter + + +_formatter = HtmlFormatter(nowrap=False, cssclass='highlight') + + +def code_block(code, lang="python"): + """Highlight code using pygments. + + Returns HTML with .highlight wrapper for proper CSS styling. + The output works with both light and dark pygments themes. + """ + try: + lexer = get_lexer_by_name(lang) + except Exception: + lexer = TextLexer() + + return highlight(code, lexer, _formatter) diff --git a/examples/cone.example/src/cone/example/configure.zcml b/examples/cone.example/src/cone/example/configure.zcml index bad07151..62b64dcd 100644 --- a/examples/cone.example/src/cone/example/configure.zcml +++ b/examples/cone.example/src/cone/example/configure.zcml @@ -1,6 +1,6 @@ - + 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..823ba188 --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/browser.py @@ -0,0 +1,124 @@ +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 + + +@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 + + @property + def limit_content_width(self): + return self.get_setting('limit_content_width') + + @property + def limit_page_width(self): + return self.get_setting('limit_page_width') + + @property + def sidebar_left_static(self): + return self.get_setting('sidebar_left_static') + + @property + def sidebar_left_min_width(self): + return self.get_setting('sidebar_left_min_width') + + @property + def sidebar_right_static(self): + return self.get_setting('sidebar_right_static') + + @property + def sidebar_right_min_width(self): + return self.get_setting('sidebar_right_min_width') + + @property + def center_content(self): + return self.get_setting('center_content') + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_content.pt', + interface=LayoutDemo, + permission='view', + 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/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..a00a27ef --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/templates/layout_view.pt @@ -0,0 +1,266 @@ + + + + +
+ +
+ +

+ + Title +

+ +

+ The Layout Attributes on this page are mapped to this Page's LayoutConfig. + Try changing the elements below! +

+ +
+ +
+ +
+
+
Default Layout Elements
+
+ +
+ +
+
+
Layout Configuration
+ + Reset + +
+
+
+ +
+
Sidebar Tiles
+
+ sidebar_left: + + +
+ min_width: +
+ - + 150 + + +
+
+
+
+ sidebar_right: + + +
+ min_width: +
+ - + 150 + + +
+
+
+
+
+
+
+ +
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..ce4dea46 --- /dev/null +++ b/examples/cone.example/src/cone/example/layout/templates/tutorial_content.pt @@ -0,0 +1,28 @@ + + +
+
LayoutConfig
+
+

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

+ +
+
+ +
+
Session State
+
+

Store user preferences in session for dynamic layout.

+ +
+
+ +
+
AjaxEvent
+
+

Trigger layout refresh after changing session state.

+ +
+
+ +
diff --git a/examples/cone.example/src/cone/example/model.py b/examples/cone.example/src/cone/example/model.py index 2d989507..2bb32465 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,28 @@ 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') + 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', '') + 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 +156,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/populate.py b/examples/cone.example/src/cone/example/populate.py new file mode 100644 index 00000000..7afae558 --- /dev/null +++ b/examples/cone.example/src/cone/example/populate.py @@ -0,0 +1,113 @@ +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_wiki(wiki): + """Populate Wiki with example pages and folders.""" + if len(wiki) > 0: + return + + from cone.example.wiki.model import WikiFolder + from cone.example.wiki.model import WikiPage + + # Getting started page at root level (published) + 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' + '- Workflow states (draft, review, published, archived)\n' + '- Folders for organizing pages hierarchically\n' + '- Reference browser widget for linking pages\n' + '- Categories (General, Technical, How-To)\n' + '- Protected properties (body requires edit permission)\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) + getting_started.attrs['state'] = 'published' + wiki['getting-started'] = getting_started + + # Folder with nested pages + docs_folder = WikiFolder() + docs_folder.attrs['title'] = make_translation('Documentation', 'Dokumentation') + docs_folder.attrs['description'] = make_translation( + 'Technical documentation and guides', + 'Technische Dokumentation und Anleitungen' + ) + docs_folder.attrs['creator'] = 'admin' + docs_folder.attrs['created'] = datetime(2025, 1, 5) + docs_folder.attrs['modified'] = datetime(2025, 1, 5) + wiki['docs'] = docs_folder + + # Page inside folder (published) + 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' + '- WorkflowNode: Nodes with workflow state support\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) + api_ref.attrs['state'] = 'published' + docs_folder['api-reference'] = api_ref + + # Page in review state + architecture = WikiPage() + architecture.attrs['title'] = make_translation('Architecture Overview', 'Architektur Übersicht') + architecture.attrs['description'] = make_translation( + 'System architecture and design patterns', + 'Systemarchitektur und Design Patterns' + ) + architecture.attrs['body'] = ( + 'Architecture Overview\n\n' + 'cone.app uses a layered architecture:\n' + '- Model layer: Node-based content tree\n' + '- Security layer: ACL and workflow permissions\n' + '- Browser layer: Tiles and forms' + ) + architecture.attrs['creator'] = 'admin' + architecture.attrs['created'] = datetime(2025, 1, 8) + architecture.attrs['modified'] = datetime(2025, 1, 8) + architecture.attrs['state'] = 'review' + docs_folder['architecture'] = architecture + + # Draft page at root + draft_page = WikiPage() + draft_page.attrs['title'] = make_translation('Work in Progress', 'In Arbeit') + draft_page.attrs['description'] = make_translation( + 'Draft page demonstrating workflow', + 'Entwurfsseite zur Demonstration des Workflows' + ) + draft_page.attrs['body'] = ( + 'This page is still in draft state.\n\n' + 'Use the workflow dropdown to transition through states:\n' + '- Submit for review\n' + '- Publish\n' + '- Archive' + ) + draft_page.attrs['creator'] = 'editor' + draft_page.attrs['created'] = datetime(2025, 3, 1) + draft_page.attrs['modified'] = datetime(2025, 3, 1) + draft_page.attrs['state'] = 'draft' + wiki['work-in-progress'] = draft_page 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..19deaf74 --- /dev/null +++ b/examples/cone.example/src/cone/example/settings/browser.py @@ -0,0 +1,84 @@ +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 +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, EditFormTarget) +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/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..0dd1f9cd --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/__init__.py @@ -0,0 +1,15 @@ +"""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_wiki.py: Wiki module (Wiki, WikiFolder, WikiPage with workflow) +- 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..a00d7149 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_browser.py @@ -0,0 +1,135 @@ +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) + # 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.layout.model import LayoutDemo + 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['wiki'], Wiki) + self.assertIsInstance(root['ajax_playground'], AjaxPlayground) 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..2fd8fba8 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_layout.py @@ -0,0 +1,169 @@ +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['limit_page_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..7c2f3499 --- /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 (WikiPage) + from cone.example.wiki.model import WikiPage + page = WikiPage() + page.__name__ = 'testpage' + self.assertEqual(page.workflow_name, 'wiki_workflow') + self.assertEqual(page.default_acl, DEFAULT_EXAMPLE_ACL) + # Has attributes storage + page.attrs['test'] = 'value' + self.assertEqual(page.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_settings.py b/examples/cone.example/src/cone/example/tests/test_settings.py new file mode 100644 index 00000000..7d5f2947 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_settings.py @@ -0,0 +1,87 @@ +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) + + 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) 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..2f1ddde9 --- /dev/null +++ b/examples/cone.example/src/cone/example/tests/test_wiki.py @@ -0,0 +1,234 @@ +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 ProtectedProperties +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 WikiFolder +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_folder', '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 and folders + page = WikiPage() + page.__name__ = 'page1' + wiki['page1'] = page + self.assertIn('page1', wiki) + folder = WikiFolder() + folder.__name__ = 'folder1' + wiki['folder1'] = folder + self.assertIn('folder1', wiki) + + def test_WikiFolder_node_info(self): + info = get_node_info('wiki_folder') + self.assertEqual(info.name, 'wiki_folder') + self.assertEqual(info.icon, 'bi-folder') + self.assertEqual(info.addables, ['wiki_folder', 'wiki_page']) + + def test_WikiFolder(self): + folder = WikiFolder() + folder.__name__ = 'testfolder' + # Properties + props = folder.properties + self.assertIsInstance(props, Properties) + self.assertTrue(props.in_navtree) + self.assertTrue(props.action_delete) + # Can nest folders and pages + subfolder = WikiFolder() + subfolder.__name__ = 'subfolder' + folder['subfolder'] = subfolder + self.assertIn('subfolder', folder) + page = WikiPage() + page.__name__ = 'page1' + folder['page1'] = page + self.assertIn('page1', folder) + + 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_workflow(self): + # WikiPage uses WorkflowNode with wiki_workflow + page = WikiPage() + page.__name__ = 'testpage' + self.assertEqual(page.workflow_name, 'wiki_workflow') + # Default state is draft + self.assertEqual(page.state, 'draft') + + def test_WikiPage_protected_properties(self): + # WikiPage has protected_properties with body requiring edit permission + page = WikiPage() + page.__name__ = 'testpage' + page.attrs['body'] = 'Test content' + props = page.protected_properties + self.assertIsInstance(props, ProtectedProperties) + self.assertEqual(props.body, 'Test content') + + 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_content_tile(self): + root = get_root() + wiki = root['wiki'] + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(wiki, request, 'content') + self.assertIsNotNone(result) + + def test_wiki_folder_content_tile(self): + root = get_root() + wiki = root['wiki'] + # Get an existing folder from populate + folder = wiki.get('technical') + if folder is None: + folder = WikiFolder() + folder.__name__ = 'testfolder' + title = Translation() + title['en'] = 'Test Folder' + folder.attrs['title'] = title + wiki['testfolder'] = folder + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(folder, request, 'content') + 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_wiki(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) + + def test_tutorial_content_tile_folder(self): + root = get_root() + wiki = root['wiki'] + folder = wiki.get('technical') + if folder is None: + folder = WikiFolder() + folder.__name__ = 'testfolder' + wiki['testfolder'] = folder + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(folder, request, 'tutorial_content') + self.assertIsNotNone(result) + + def test_tutorial_content_tile_page(self): + root = get_root() + wiki = root['wiki'] + page = wiki.get('getting-started') + if page is None: + page = WikiPage() + page.__name__ = 'testpage' + wiki['testpage'] = page + request = self.layer.new_request() + with self.layer.authenticated('max'): + result = render_tile(page, request, 'tutorial_content') + self.assertIsNotNone(result) 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..9ceabc86 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/browser.py @@ -0,0 +1,481 @@ +from cone.app.browser.authoring import ContentAddForm +from cone.app.browser.authoring import ContentEditForm +from cone.app.browser.form import AddFormTarget +from cone.app.browser.form import EditFormTarget +from cone.app.browser.form import Form +from cone.app.browser.layout import ProtectedContentTile +from cone.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.browser.utils import code_block +from cone.example.model import Translation +from cone.example.model import _ +from cone.example.wiki.model import Wiki +from cone.example.wiki.model import WikiFolder +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 yafowil.base import factory +from yafowil.persistence import write_mapping_writer + + +# View tile for Wiki container +@tile(name='content', + path='cone.example.wiki:templates/wiki_container_view.pt', + interface=Wiki, + permission='login') +class WikiContainerView(ProtectedContentTile): + pass + + +# View tile for WikiFolder +@tile(name='content', + path='cone.example.wiki:templates/wiki_folder_view.pt', + interface=WikiFolder, + permission='login') +class WikiFolderView(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._find_wiki_root() + if wiki: + self._collect_references(wiki, refs, result) + return result + + def _find_wiki_root(self): + """Find the wiki root container.""" + node = self.model.parent + while node is not None: + if isinstance(node, Wiki): + return node + node = getattr(node, 'parent', None) + return None + + def _collect_references(self, container, refs, result): + """Recursively collect referenced pages from container and subfolders.""" + for child in container.values(): + if isinstance(child, WikiFolder): + self._collect_references(child, refs, result) + elif hasattr(child, 'uuid') and str(child.uuid) in refs: + result.append({ + 'title': child.metadata.title, + 'target': make_url(self.request, node=child), + 'icon': child.metadata.icon or 'bi-journal-text', + }) + + +# WikiFolder form +class WikiFolderForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='wikifolderform', + 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': _('folder_title_help', default='Enter a folder 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': _('folder_desc_help', default='Short 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 WikiFolderAddForm(WikiFolderForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super(WikiFolderAddForm, self).save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class WikiFolderEditForm(WikiFolderForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super(WikiFolderEditForm, self).save(widget, data) + + +@tile(name='addform', interface=WikiFolder, permission='add') +@plumbing(ContentAddForm) +class WikiFolderContentAddForm(WikiFolderAddForm): + ... + + +@tile(name='editform', interface=WikiFolder, permission='edit') +@plumbing(ContentEditForm) +class WikiFolderContentEditForm(WikiFolderEditForm): + ... + + +# 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._find_wiki_root() + return '/' + '/'.join(wiki.path[1:]) if wiki else '/' + + def _find_wiki_root(self): + """Find the wiki root container.""" + node = self.model.parent + while node is not None: + if isinstance(node, Wiki): + return node + node = getattr(node, 'parent', None) + return self.model.parent + + def reference_lookup(self, uuid): + """Lookup label for a reference UUID.""" + wiki = self._find_wiki_root() + return self._lookup_in_container(wiki, uuid) + + def _lookup_in_container(self, container, uuid): + """Recursively lookup page by UUID in container and subfolders.""" + for child in container.values(): + if isinstance(child, WikiFolder): + result = self._lookup_in_container(child, uuid) + if result != uuid: + return result + elif hasattr(child, 'uuid') and str(child.uuid) == uuid: + return child.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(WikiPageAddForm, self).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(WikiPageEditForm, self).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) + + +# tutorial + +@tile( + name='tutorial_content', + path='templates/tutorial_wiki.pt', + interface=Wiki, + permission='view', + strict=False, +) +class WikiContainerTutorial(Tile): + + code_wiki_container = """\ +@node_info( + name='wiki', + addables=['wiki_folder', 'wiki_page']) +class Wiki(BaseContainer): + ...""" + + code_node_info = """\ +@node_info( + name='wiki', + title=_('wiki', default='Example Wiki'), + icon='bi-book', + addables=['wiki_folder', '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( + name='tutorial_content', + path='templates/tutorial_folder.pt', + interface=WikiFolder, + permission='view', + strict=False, +) +class WikiFolderTutorial(Tile): + + code_wiki_folder = """\ +@node_info( + name='wiki_folder', + title=_('wiki_folder', default='Wiki Folder'), + icon='bi-folder', + addables=['wiki_folder', 'wiki_page']) +class WikiFolder(BaseContainer): + # Nested containers for organization""" + + def example_wiki_folder(self): + return code_block(self.code_wiki_folder, 'python') + + +@tile( + name='tutorial_content', + path='templates/tutorial_page.pt', + interface=WikiPage, + permission='view', + strict=False, +) +class WikiPageTutorial(Tile): + + code_workflow = """\ +class WikiPage(WorkflowNode): + workflow_name = 'wiki_workflow' + # States: draft, review, published, archived""" + + code_protected_properties = """\ +@property +def protected_properties(self): + props = ProtectedProperties( + self, + permissions={'body': ['edit']}) + props.body = self.attrs.get('body', '') + return props""" + + 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_workflow(self): + return code_block(self.code_workflow, 'python') + + def example_protected_properties(self): + return code_block(self.code_protected_properties, 'python') + + 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/model.py b/examples/cone.example/src/cone/example/wiki/model.py new file mode 100644 index 00000000..050ad63f --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/model.py @@ -0,0 +1,123 @@ +from cone.app.interfaces import INavigationLeaf +from cone.app.model import Categories +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 DEFAULT_EXAMPLE_ACL +from cone.example.model import WorkflowNode +from node.utils import instance_property +from plumber import plumbing +from zope.interface import implementer + + +@node_info( + name='wiki', + title=_('wiki', default='Example Wiki'), + icon='bi-book', + addables=['wiki_folder', 'wiki_page']) +class Wiki(BaseContainer): + """Entry container for wiki pages and folders. + + Acts as the reference browser root for WikiPage references. + Demonstrates hierarchical content organization with workflow support. + """ + + @property + def properties(self): + props = super(Wiki, self).properties + props.mainmenu_display_children = False + return props + + +@node_info( + name='wiki_folder', + title=_('wiki_folder', default='Wiki Folder'), + icon='bi-folder', + addables=['wiki_folder', 'wiki_page']) +class WikiFolder(BaseContainer): + """Folder for organizing wiki pages hierarchically. + + Demonstrates nested container hierarchy within wiki. + """ + + @property + def properties(self): + props = super(WikiFolder, self).properties + props.action_delete = True + return props + + +@node_info( + name='wiki_page', + title=_('wiki_page', default='Wiki Page'), + icon='bi-journal-text') +@plumbing(OwnerSupport, PrincipalACL, UUIDAttributeAware, Categories) +@implementer(INavigationLeaf) +class WikiPage(WorkflowNode): + """Wiki page with workflow, categories, and reference support. + + Demonstrates: + - WorkflowNode: State management (draft, review, published, archived) + - Categories: Categorization with translation strings + - UUIDAttributeAware: UUID stored in node attributes + - OwnerSupport: Owner tracking on pages + - PrincipalACL: Permission/ACL support + - ProtectedProperties: Body field restricted by edit permission + - Reference browser: Link to other wiki pages + """ + workflow_name = 'wiki_workflow' + role_inheritance = True + + categories = [ + _('cat_general', default='General'), + _('cat_technical', default='Technical'), + _('cat_howto', default='How-To') + ] + + @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 + + @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 + + def __call__(self): + ... diff --git a/examples/cone.example/src/cone/example/wiki/templates/tutorial_folder.pt b/examples/cone.example/src/cone/example/wiki/templates/tutorial_folder.pt new file mode 100644 index 00000000..8468bdf7 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_folder.pt @@ -0,0 +1,12 @@ + + +
+
WikiFolder
+
+

Nested containers for organizing wiki pages hierarchically.

+ +
+
+ +
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..1f1f789c --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_page.pt @@ -0,0 +1,44 @@ + + +
+
WorkflowNode
+
+

Workflow states: draft, review, published, archived.

+ +
+
+ +
+
ProtectedProperties
+
+

Body field requires edit permission to view.

+ +
+
+ +
+
Reference Browser
+
+

Widget for selecting related nodes by UUID.

+ +
+
+ +
+
UUIDAttributeAware
+
+

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

+ +
+
+ +
+
INavigationLeaf
+
+

Marks node as leaf - no children shown in navtree.

+ +
+
+ +
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..ada2b3c5 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/tutorial_wiki.pt @@ -0,0 +1,20 @@ + + +
+
Wiki Container
+
+

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

+ +
+
+ +
+
node_info
+
+

Registers node type with metadata and allowed children.

+ +
+
+ +
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..92960d73 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/wiki_container_view.pt @@ -0,0 +1,37 @@ + + + + +
+ +
+ +

+ + + Title + +

+ + + +

+ Wiki pages with workflow states, cross-references, categories, + folders for organization, and protected properties. +

+ +
+ Workflow + Folders + Reference Browser + Categories + Protected Properties +
+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/wiki/templates/wiki_folder_view.pt b/examples/cone.example/src/cone/example/wiki/templates/wiki_folder_view.pt new file mode 100644 index 00000000..0903bb2b --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki/templates/wiki_folder_view.pt @@ -0,0 +1,35 @@ + + + + +
+ +
+ +

+ + + Title + +

+ + + +

+ Description +

+ +
+ Wiki Folder + Nested Hierarchy +
+ +
+ +
+ +
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..99c4383f --- /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
+
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/wiki_workflow.zcml b/examples/cone.example/src/cone/example/wiki_workflow.zcml new file mode 100644 index 00000000..3afb88e1 --- /dev/null +++ b/examples/cone.example/src/cone/example/wiki_workflow.zcml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mx.ini b/mx.ini index 7269e744..0db8f908 100644 --- a/mx.ini +++ b/mx.ini @@ -139,3 +139,10 @@ branch = master extras = test mxmake-test-path = src mxmake-source-path = src/cone/tile + +# sphinx theme +[sphinx-conestack-theme] +use = ${settings:checkout_packages} +url = ${settings:cs}/sphinx-conestack-theme.git +pushurl = ${settings:cs_push}/sphinx-conestack-theme.git +branch = master \ No newline at end of file diff --git a/scss/header.scss b/scss/header.scss index 54d092db..1da95171 100644 --- a/scss/header.scss +++ b/scss/header.scss @@ -135,7 +135,11 @@ #navbar-content-wrapper { min-width: 0; - z-index: 1; + z-index: 10; + + .dropdown-menu { + --bs-dropdown-spacer: 0; + } #navbar-content { min-width: 0; diff --git a/src/cone/app/browser/static/cone/cone.app.css b/src/cone/app/browser/static/cone/cone.app.css index 3d1c726b..3ca99746 100644 --- a/src/cone/app/browser/static/cone/cone.app.css +++ b/src/cone/app/browser/static/cone/cone.app.css @@ -280,7 +280,10 @@ tr.selectable td { } #main-area #header-main #navbar-content-wrapper { min-width: 0; - z-index: 1; + z-index: 10; +} +#main-area #header-main #navbar-content-wrapper .dropdown-menu { + --bs-dropdown-spacer: 0; } #main-area #header-main #navbar-content-wrapper #navbar-content { min-width: 0; 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 16f56a51..5d44885c 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=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}} +: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:10}#main-area #header-main #navbar-content-wrapper .dropdown-menu{--bs-dropdown-spacer: 0}#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}}