Add page/way to configure main menus
just the basics so far, index page routes and separators should be supported but nothing else. also "menus from config" is all or nothing, no way to mix config + code at this point
This commit is contained in:
parent
587a4daf7a
commit
74fecf553e
|
@ -156,19 +156,33 @@ def make_pyramid_config(settings, configure_csrf=True):
|
|||
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
|
||||
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
|
||||
|
||||
# and some similar magic for config views
|
||||
# and some similar magic for certain master views
|
||||
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
|
||||
config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page')
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def add_config_page(config, route_name, label):
|
||||
def add_index_page(config, route_name, label, permission):
|
||||
"""
|
||||
Register a config page for the app.
|
||||
"""
|
||||
def action():
|
||||
pages = config.get_settings().get('tailbone_index_pages', [])
|
||||
pages.append({'label': label, 'route': route_name,
|
||||
'permission': permission})
|
||||
config.add_settings({'tailbone_index_pages': pages})
|
||||
config.action(None, action)
|
||||
|
||||
|
||||
def add_config_page(config, route_name, label, permission):
|
||||
"""
|
||||
Register a config page for the app.
|
||||
"""
|
||||
def action():
|
||||
pages = config.get_settings().get('tailbone_config_pages', [])
|
||||
pages.append({'label': label, 'route': route_name})
|
||||
pages.append({'label': label, 'route': route_name,
|
||||
'permission': permission})
|
||||
config.add_settings({'tailbone_config_pages': pages})
|
||||
config.action(None, action)
|
||||
|
||||
|
|
|
@ -28,22 +28,28 @@ from __future__ import unicode_literals, absolute_import
|
|||
|
||||
import re
|
||||
|
||||
from rattail.util import import_module_path
|
||||
from rattail.util import import_module_path, prettify
|
||||
|
||||
from tailbone.db import Session
|
||||
|
||||
|
||||
def make_simple_menus(request):
|
||||
"""
|
||||
Build the main menu list for the app.
|
||||
"""
|
||||
menus_module = import_module_path(
|
||||
request.rattail_config.require('tailbone', 'menus'))
|
||||
# first try to make menus from config
|
||||
raw_menus = make_menus_from_config(request)
|
||||
if not raw_menus:
|
||||
|
||||
if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus):
|
||||
raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module))
|
||||
# no config, so import/invoke function to build them
|
||||
menus_module = import_module_path(
|
||||
request.rattail_config.require('tailbone', 'menus'))
|
||||
if not hasattr(menus_module, 'simple_menus') or not callable(menus_module.simple_menus):
|
||||
raise RuntimeError("module does not have a simple_menus() callable: {}".format(menus_module))
|
||||
raw_menus = menus_module.simple_menus(request)
|
||||
|
||||
# collect "simple" menus definition, but must refine that somewhat to
|
||||
# produce our final menus
|
||||
raw_menus = menus_module.simple_menus(request)
|
||||
# now we have "simple" (raw) menus definition, but must refine
|
||||
# that somewhat to produce our final menus
|
||||
mark_allowed(request, raw_menus)
|
||||
final_menus = []
|
||||
for topitem in raw_menus:
|
||||
|
@ -51,7 +57,7 @@ def make_simple_menus(request):
|
|||
if topitem['allowed']:
|
||||
|
||||
if topitem.get('type') == 'link':
|
||||
final_menus.append(make_menu_entry(topitem))
|
||||
final_menus.append(make_menu_entry(request, topitem))
|
||||
|
||||
else: # assuming 'menu' type
|
||||
|
||||
|
@ -65,7 +71,7 @@ def make_simple_menus(request):
|
|||
submenu_items = []
|
||||
for subitem in item['items']:
|
||||
if subitem['allowed']:
|
||||
submenu_items.append(make_menu_entry(subitem))
|
||||
submenu_items.append(make_menu_entry(request, subitem))
|
||||
menu_items.append({
|
||||
'type': 'submenu',
|
||||
'title': item['title'],
|
||||
|
@ -79,10 +85,10 @@ def make_simple_menus(request):
|
|||
# menu items (i.e. there is something to separate)
|
||||
# *and* the last menu item is not a sep (avoid doubles)
|
||||
if menu_items and not menu_items[-1]['is_sep']:
|
||||
menu_items.append(make_menu_entry(item))
|
||||
menu_items.append(make_menu_entry(request, item))
|
||||
|
||||
else: # standard menu item
|
||||
menu_items.append(make_menu_entry(item))
|
||||
menu_items.append(make_menu_entry(request, item))
|
||||
|
||||
# remove final separator if present
|
||||
if menu_items and menu_items[-1]['is_sep']:
|
||||
|
@ -105,14 +111,201 @@ def make_simple_menus(request):
|
|||
# code. so we are free to "invent" a (safe) key
|
||||
# for it, since that is only for editing config
|
||||
if not group['key']:
|
||||
group['key'] = re.sub(r'\W', '', topitem['title'].lower())
|
||||
group['key'] = make_menu_key(request.rattail_config,
|
||||
topitem['title'])
|
||||
|
||||
final_menus.append(group)
|
||||
|
||||
return final_menus
|
||||
|
||||
|
||||
def make_menu_entry(item):
|
||||
def make_menus_from_config(request):
|
||||
"""
|
||||
Try to build a complete menu set from config/settings.
|
||||
|
||||
This essentially checks for the top-level menu list in config; if
|
||||
found then it will build a full menu set from config. If this
|
||||
top-level list is not present in config then menus will be built
|
||||
purely from code instead. An example of this top-level list:
|
||||
|
||||
.. code-hightlight:: ini
|
||||
|
||||
[tailbone.menu]
|
||||
menus = first, second, third, admin
|
||||
|
||||
Obviously much more config would be needed to define those menus
|
||||
etc. but that is the option that determines whether the rest of
|
||||
menu config is even read, or not.
|
||||
"""
|
||||
config = request.rattail_config
|
||||
main_keys = config.getlist('tailbone.menu', 'menus')
|
||||
if not main_keys:
|
||||
return
|
||||
|
||||
menus = []
|
||||
|
||||
# menu definition can come either from config file or db settings,
|
||||
# but if the latter then we want to optimize with one big query
|
||||
if config.getbool('tailbone.menu', 'from_settings',
|
||||
default=False):
|
||||
app = config.get_app()
|
||||
model = config.get_model()
|
||||
|
||||
# fetch all menu-related settings at once
|
||||
query = Session().query(model.Setting)\
|
||||
.filter(model.Setting.name.like('tailbone.menu.%'))
|
||||
settings = app.cache_model(Session(), model.Setting,
|
||||
query=query, key='name',
|
||||
normalizer=lambda s: s.value)
|
||||
for key in main_keys:
|
||||
menus.append(make_single_menu_from_settings(request, key, settings))
|
||||
|
||||
else: # read from config file only
|
||||
for key in main_keys:
|
||||
menus.append(make_single_menu_from_config(request, key))
|
||||
|
||||
return menus
|
||||
|
||||
|
||||
def make_single_menu_from_config(request, key):
|
||||
"""
|
||||
Makes a single top-level menu dict from config file. Note that
|
||||
this will read from config file(s) *only* and avoids querying the
|
||||
database, for efficiency.
|
||||
"""
|
||||
config = request.rattail_config
|
||||
menu = {
|
||||
'key': key,
|
||||
'type': 'menu',
|
||||
'items': [],
|
||||
}
|
||||
|
||||
# title
|
||||
title = config.get('tailbone.menu',
|
||||
'menu.{}.label'.format(key),
|
||||
usedb=False)
|
||||
menu['title'] = title or prettify(key)
|
||||
|
||||
# items
|
||||
item_keys = config.getlist('tailbone.menu',
|
||||
'menu.{}.items'.format(key),
|
||||
usedb=False)
|
||||
for item_key in item_keys:
|
||||
item = {}
|
||||
|
||||
if item_key == 'SEP':
|
||||
item['type'] = 'sep'
|
||||
|
||||
else:
|
||||
item['type'] = 'item'
|
||||
item['key'] = item_key
|
||||
|
||||
# title
|
||||
title = config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.label'.format(key, item_key),
|
||||
usedb=False)
|
||||
item['title'] = title or prettify(item_key)
|
||||
|
||||
# route
|
||||
route = config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.route'.format(key, item_key),
|
||||
usedb=False)
|
||||
if route:
|
||||
item['route'] = route
|
||||
item['url'] = request.route_url(route)
|
||||
|
||||
else:
|
||||
|
||||
# url
|
||||
url = config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.url'.format(key, item_key),
|
||||
usedb=False)
|
||||
if not url:
|
||||
url = request.route_url(item_key)
|
||||
elif url.startswith('route:'):
|
||||
url = request.route_url(url[6:])
|
||||
item['url'] = url
|
||||
|
||||
# perm
|
||||
perm = config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.perm'.format(key, item_key),
|
||||
usedb=False)
|
||||
item['perm'] = perm or '{}.list'.format(item_key)
|
||||
|
||||
menu['items'].append(item)
|
||||
|
||||
return menu
|
||||
|
||||
|
||||
def make_single_menu_from_settings(request, key, settings):
|
||||
"""
|
||||
Makes a single top-level menu dict from DB settings.
|
||||
"""
|
||||
config = request.rattail_config
|
||||
menu = {
|
||||
'key': key,
|
||||
'type': 'menu',
|
||||
'items': [],
|
||||
}
|
||||
|
||||
# title
|
||||
title = settings.get('tailbone.menu.menu.{}.label'.format(key))
|
||||
menu['title'] = title or prettify(key)
|
||||
|
||||
# items
|
||||
item_keys = config.parse_list(
|
||||
settings.get('tailbone.menu.menu.{}.items'.format(key)))
|
||||
for item_key in item_keys:
|
||||
item = {}
|
||||
|
||||
if item_key == 'SEP':
|
||||
item['type'] = 'sep'
|
||||
|
||||
else:
|
||||
item['type'] = 'item'
|
||||
item['key'] = item_key
|
||||
|
||||
# title
|
||||
title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format(
|
||||
key, item_key))
|
||||
item['title'] = title or prettify(item_key)
|
||||
|
||||
# route
|
||||
route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format(
|
||||
key, item_key))
|
||||
if route:
|
||||
item['route'] = route
|
||||
item['url'] = request.route_url(route)
|
||||
|
||||
else:
|
||||
|
||||
# url
|
||||
url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format(
|
||||
key, item_key))
|
||||
if not url:
|
||||
url = request.route_url(item_key)
|
||||
if url.startswith('route:'):
|
||||
url = request.route_url(url[6:])
|
||||
item['url'] = url
|
||||
|
||||
# perm
|
||||
perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format(
|
||||
key, item_key))
|
||||
item['perm'] = perm or '{}.list'.format(item_key)
|
||||
|
||||
menu['items'].append(item)
|
||||
|
||||
return menu
|
||||
|
||||
|
||||
def make_menu_key(config, value):
|
||||
"""
|
||||
Generate a normalized menu key for the given value.
|
||||
"""
|
||||
return re.sub(r'\W', '', value.lower())
|
||||
|
||||
|
||||
def make_menu_entry(request, item):
|
||||
"""
|
||||
Convert a simple menu entry dict, into a proper menu-related object, for
|
||||
use in constructing final menu.
|
||||
|
@ -126,15 +319,24 @@ def make_menu_entry(item):
|
|||
}
|
||||
|
||||
# standard menu item
|
||||
return {
|
||||
entry = {
|
||||
'type': 'item',
|
||||
'title': item['title'],
|
||||
'url': item['url'],
|
||||
'perm': item.get('perm'),
|
||||
'target': item.get('target'),
|
||||
'is_link': True,
|
||||
'is_menu': False,
|
||||
'is_sep': False,
|
||||
}
|
||||
if item.get('route'):
|
||||
entry['route'] = item['route']
|
||||
entry['url'] = request.route_url(entry['route'])
|
||||
entry['key'] = entry['route']
|
||||
else:
|
||||
if item.get('url'):
|
||||
entry['url'] = item['url']
|
||||
entry['key'] = make_menu_key(request.rattail_config, entry['title'])
|
||||
return entry
|
||||
|
||||
|
||||
def is_allowed(request, item):
|
||||
|
|
424
tailbone/templates/configure-menus.mako
Normal file
424
tailbone/templates/configure-menus.mako
Normal file
|
@ -0,0 +1,424 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/configure.mako" />
|
||||
|
||||
<%def name="extra_styles()">
|
||||
${parent.extra_styles()}
|
||||
<style type="text/css">
|
||||
.topmenu-dropper {
|
||||
min-width: 0.8rem;
|
||||
}
|
||||
.topmenu-dropper:-moz-drag-over {
|
||||
background-color: blue;
|
||||
}
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
<%def name="form_content()">
|
||||
${h.hidden('menus', **{':value': 'JSON.stringify(allMenuData)'})}
|
||||
|
||||
<h3 class="is-size-3">Top-Level Menus</h3>
|
||||
<p class="block">Click on a menu to edit. Drag things around to rearrange.</p>
|
||||
|
||||
<b-field grouped>
|
||||
|
||||
<b-field grouped v-for="key in menuSequence"
|
||||
:key="key">
|
||||
<span class="topmenu-dropper control"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop="dropMenu($event, key)">
|
||||
|
||||
</span>
|
||||
<b-button :type="editingMenu && editingMenu.key == key ? 'is-primary' : null"
|
||||
class="control"
|
||||
@click="editMenu(key)"
|
||||
:disabled="editingMenu && editingMenu.key != key"
|
||||
:draggable="!editingMenu"
|
||||
@dragstart.native="topMenuStartDrag($event, key)">
|
||||
{{ allMenus[key].title }}
|
||||
</b-button>
|
||||
</b-field>
|
||||
|
||||
<div class="topmenu-dropper control"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop="dropMenu($event, '_last_')">
|
||||
|
||||
</div>
|
||||
<b-button v-show="!editingMenu"
|
||||
type="is-primary"
|
||||
icon-pack="fas"
|
||||
icon-left="plus"
|
||||
@click="editMenuNew()">
|
||||
Add
|
||||
</b-button>
|
||||
|
||||
</b-field>
|
||||
|
||||
<div v-if="editingMenu"
|
||||
style="max-width: 40%;">
|
||||
|
||||
<b-field grouped>
|
||||
|
||||
<b-field label="Label">
|
||||
<b-input v-model="editingMenu.title"
|
||||
ref="editingMenuTitleInput">
|
||||
</b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Actions">
|
||||
<div class="buttons">
|
||||
<b-button icon-pack="fas"
|
||||
icon-left="redo"
|
||||
@click="editMenuCancel()">
|
||||
Revert / Cancel
|
||||
</b-button>
|
||||
<b-button type="is-primary"
|
||||
icon-pack="fas"
|
||||
icon-left="save"
|
||||
@click="editMenuSave()">
|
||||
Save
|
||||
</b-button>
|
||||
<b-button type="is-danger"
|
||||
icon-pack="fas"
|
||||
icon-left="trash"
|
||||
@click="editMenuDelete()">
|
||||
Delete
|
||||
</b-button>
|
||||
</div>
|
||||
</b-field>
|
||||
|
||||
</b-field>
|
||||
|
||||
<b-field>
|
||||
<template #label>
|
||||
<span style="margin-right: 2rem;">Menu Items</span>
|
||||
<b-button type="is-primary"
|
||||
icon-pack="fas"
|
||||
icon-left="plus"
|
||||
@click="editMenuItemInitDialog()">
|
||||
Add
|
||||
</b-button>
|
||||
</template>
|
||||
<ul class="list">
|
||||
<li v-for="item in editingMenu.items"
|
||||
class="list-item"
|
||||
draggable
|
||||
@dragstart="menuItemStartDrag($event, item)"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop="menuItemDrop($event, item)">
|
||||
<span :class="item.type == 'sep' ? 'has-text-info' : null">
|
||||
{{ item.type == 'sep' ? "-- separator --" : item.title }}
|
||||
</span>
|
||||
<span class="is-pulled-right grid-action">
|
||||
<a href="#" @click.prevent="editMenuItemInitDialog(item)">
|
||||
<i class="fas fa-edit"></i>
|
||||
Edit
|
||||
</a>
|
||||
|
||||
<a href="#" class="has-text-danger"
|
||||
@click.prevent="editMenuItemDelete(item)">
|
||||
<i class="fas fa-trash"></i>
|
||||
Delete
|
||||
</a>
|
||||
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</b-field>
|
||||
|
||||
<b-modal has-modal-card
|
||||
:active.sync="editMenuItemShowDialog">
|
||||
<div class="modal-card">
|
||||
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ editingMenuItem.isNew ? "Add" : "Edit" }} Item</p>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
|
||||
<b-field label="Item Type">
|
||||
<b-select v-model="editingMenuItem.type">
|
||||
<option value="item">Route Link</option>
|
||||
<option value="sep">Separator</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Route"
|
||||
v-show="editingMenuItem.type == 'item'">
|
||||
<b-select v-model="editingMenuItem.route"
|
||||
@input="editingMenuItemRouteChanged">
|
||||
<option v-for="route in editMenuIndexRoutes"
|
||||
:key="route.route"
|
||||
:value="route.route">
|
||||
{{ route.label }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Label"
|
||||
v-show="editingMenuItem.type == 'item'">
|
||||
<b-input v-model="editingMenuItem.title">
|
||||
</b-input>
|
||||
</b-field>
|
||||
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<b-button @click="editMenuItemShowDialog = false">
|
||||
Cancel
|
||||
</b-button>
|
||||
<b-button type="is-primary"
|
||||
icon-pack="fas"
|
||||
icon-left="save"
|
||||
:disabled="editMenuItemSaveDisabled"
|
||||
@click="editMenuSaveItem()">
|
||||
Save
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
|
||||
</div>
|
||||
|
||||
</%def>
|
||||
|
||||
<%def name="modify_this_page_vars()">
|
||||
${parent.modify_this_page_vars()}
|
||||
<script type="text/javascript">
|
||||
|
||||
ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n}
|
||||
|
||||
ThisPageData.allMenus = {}
|
||||
% for topitem in menus:
|
||||
ThisPageData.allMenus['${topitem['key']}'] = ${json.dumps(topitem)|n}
|
||||
% endfor
|
||||
|
||||
ThisPageData.editMenuIndexRoutes = ${json.dumps(index_route_options)|n}
|
||||
|
||||
ThisPageData.editingMenu = null
|
||||
ThisPageData.editingMenuItem = {isNew: true}
|
||||
ThisPageData.editingMenuItemIndex = null
|
||||
|
||||
ThisPageData.editMenuItemShowDialog = false
|
||||
|
||||
// nb. this value is sent on form submit
|
||||
ThisPage.computed.allMenuData = function() {
|
||||
let menus = []
|
||||
for (key of this.menuSequence) {
|
||||
menus.push(this.allMenus[key])
|
||||
}
|
||||
return menus
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenu = function(key) {
|
||||
if (this.editingMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
// copy existing (original) menu to be edited
|
||||
let original = this.allMenus[key]
|
||||
this.editingMenu = {
|
||||
key: key,
|
||||
title: original.title,
|
||||
items: [],
|
||||
}
|
||||
|
||||
// and copy each item separately
|
||||
for (let item of original.items) {
|
||||
this.editingMenu.items.push({
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
route: item.route,
|
||||
url: item.url,
|
||||
perm: item.perm,
|
||||
type: item.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuNew = function() {
|
||||
|
||||
// editing brand new menu
|
||||
this.editingMenu = {items: []}
|
||||
|
||||
// focus title input
|
||||
this.$nextTick(() => {
|
||||
this.$refs.editingMenuTitleInput.focus()
|
||||
})
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuCancel = function(key) {
|
||||
this.editingMenu = null
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuSave = function() {
|
||||
|
||||
let key = this.editingMenu.key
|
||||
if (key) {
|
||||
|
||||
// update existing (original) menu with user edits
|
||||
this.allMenus[key] = this.editingMenu
|
||||
|
||||
} else {
|
||||
|
||||
// generate makeshift key
|
||||
key = this.editingMenu.title.replace(/\W/g, '')
|
||||
|
||||
// add new menu to data set
|
||||
this.allMenus[key] = this.editingMenu
|
||||
this.menuSequence.push(key)
|
||||
}
|
||||
|
||||
// no longer editing
|
||||
this.editingMenu = null
|
||||
this.settingsNeedSaved = true
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuDelete = function() {
|
||||
|
||||
if (confirm("Really delete this menu?")) {
|
||||
let key = this.editingMenu.key
|
||||
|
||||
// remove references from primary collections
|
||||
let i = this.menuSequence.indexOf(key)
|
||||
this.menuSequence.splice(i, 1)
|
||||
delete this.allMenus[key]
|
||||
|
||||
// no longer editing
|
||||
this.editingMenu = null
|
||||
this.settingsNeedSaved = true
|
||||
}
|
||||
}
|
||||
|
||||
## TODO: see also https://learnvue.co/2020/01/how-to-add-drag-and-drop-to-your-vuejs-project/#adding-drag-and-drop-functionality
|
||||
|
||||
## TODO: see also https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API
|
||||
|
||||
## TODO: maybe try out https://www.npmjs.com/package/vue-drag-drop
|
||||
|
||||
ThisPage.methods.topMenuStartDrag = function(event, key) {
|
||||
event.dataTransfer.setData('key', key)
|
||||
}
|
||||
|
||||
ThisPage.methods.dropMenu = function(event, target) {
|
||||
let key = event.dataTransfer.getData('key')
|
||||
if (target == key) {
|
||||
return // same target
|
||||
}
|
||||
|
||||
let i = this.menuSequence.indexOf(key)
|
||||
let j = this.menuSequence.indexOf(target)
|
||||
if (i + 1 == j) {
|
||||
return // same target
|
||||
}
|
||||
|
||||
if (target == '_last_') {
|
||||
if (this.menuSequence[this.menuSequence.length-1] != key) {
|
||||
this.menuSequence.splice(i, 1)
|
||||
this.menuSequence.push(key)
|
||||
this.settingsNeedSaved = true
|
||||
}
|
||||
} else {
|
||||
this.menuSequence.splice(i, 1)
|
||||
j = this.menuSequence.indexOf(target)
|
||||
this.menuSequence.splice(j, 0, key)
|
||||
this.settingsNeedSaved = true
|
||||
}
|
||||
}
|
||||
|
||||
ThisPage.methods.menuItemStartDrag = function(event, item) {
|
||||
let i = this.editingMenu.items.indexOf(item)
|
||||
event.dataTransfer.setData('itemIndex', i)
|
||||
}
|
||||
|
||||
ThisPage.methods.menuItemDrop = function(event, item) {
|
||||
let oldIndex = event.dataTransfer.getData('itemIndex')
|
||||
let pruned = this.editingMenu.items.splice(oldIndex, 1)
|
||||
let newIndex = this.editingMenu.items.indexOf(item)
|
||||
this.editingMenu.items.splice(newIndex, 0, pruned[0])
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuItemInitDialog = function(item) {
|
||||
|
||||
if (item === undefined) {
|
||||
this.editingMenuItemIndex = null
|
||||
|
||||
// create new item to edit
|
||||
this.editingMenuItem = {
|
||||
isNew: true,
|
||||
route: null,
|
||||
title: null,
|
||||
perm: null,
|
||||
type: 'item',
|
||||
}
|
||||
|
||||
} else {
|
||||
this.editingMenuItemIndex = this.editingMenu.items.indexOf(item)
|
||||
|
||||
// copy existing (original item to be edited
|
||||
this.editingMenuItem = {
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
route: item.route,
|
||||
url: item.url,
|
||||
perm: item.perm,
|
||||
type: item.type,
|
||||
}
|
||||
}
|
||||
|
||||
this.editMenuItemShowDialog = true
|
||||
}
|
||||
|
||||
ThisPage.methods.editingMenuItemRouteChanged = function(routeName) {
|
||||
for (let route of this.editMenuIndexRoutes) {
|
||||
if (route.route == routeName) {
|
||||
this.editingMenuItem.title = route.label
|
||||
this.editingMenuItem.perm = route.perm
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThisPage.computed.editMenuItemSaveDisabled = function() {
|
||||
if (this.editingMenuItem.type == 'item') {
|
||||
if (!this.editingMenuItem.route) {
|
||||
return true
|
||||
}
|
||||
if (!this.editingMenuItem.title) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuSaveItem = function() {
|
||||
|
||||
if (this.editingMenuItem.isNew) {
|
||||
this.editingMenu.items.push(this.editingMenuItem)
|
||||
|
||||
} else {
|
||||
this.editingMenu.items.splice(this.editingMenuItemIndex,
|
||||
1,
|
||||
this.editingMenuItem)
|
||||
}
|
||||
|
||||
this.editMenuItemShowDialog = false
|
||||
}
|
||||
|
||||
ThisPage.methods.editMenuItemDelete = function(item) {
|
||||
|
||||
if (confirm("Really delete this item?")) {
|
||||
|
||||
// remove item from editing menu
|
||||
let i = this.editingMenu.items.indexOf(item)
|
||||
this.editingMenu.items.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
|
||||
${parent.body()}
|
|
@ -190,7 +190,7 @@
|
|||
</div>
|
||||
</b-modal>
|
||||
|
||||
${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm')}
|
||||
${h.form(request.current_route_url(), enctype='multipart/form-data', ref='saveSettingsForm', **{'@submit': 'saveSettingsFormSubmit'})}
|
||||
${h.csrf_token(request)}
|
||||
${self.form_content()}
|
||||
${h.end_form()}
|
||||
|
@ -262,6 +262,14 @@
|
|||
this.$refs.saveSettingsForm.submit()
|
||||
}
|
||||
|
||||
// nb. this is here to avoid auto-submitting form when user
|
||||
// presses ENTER while some random input field has focus
|
||||
ThisPage.methods.saveSettingsFormSubmit = function(event) {
|
||||
if (!this.savingSettings) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// cf. https://stackoverflow.com/a/56551646
|
||||
ThisPage.methods.beforeWindowUnload = function(e) {
|
||||
if (this.settingsNeedSaved && !this.undoChanges) {
|
||||
|
|
|
@ -295,9 +295,15 @@
|
|||
</span>
|
||||
% endif
|
||||
% elif index_title:
|
||||
<span class="header-text">
|
||||
${index_title}
|
||||
</span>
|
||||
% if index_url:
|
||||
<span class="header-text">
|
||||
${h.link_to(index_title, index_url)}
|
||||
</span>
|
||||
% else:
|
||||
<span class="header-text">
|
||||
${index_title}
|
||||
</span>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
|
||||
|
@ -430,6 +436,14 @@
|
|||
% endfor
|
||||
% endif
|
||||
|
||||
% if request.session.peek_flash('warning'):
|
||||
% for msg in request.session.pop_flash('warning'):
|
||||
<b-notification type="is-warning">
|
||||
${msg}
|
||||
</b-notification>
|
||||
% endfor
|
||||
% endif
|
||||
|
||||
% if request.session.peek_flash():
|
||||
% for msg in request.session.pop_flash():
|
||||
<b-notification type="is-info">
|
||||
|
|
|
@ -4209,7 +4209,9 @@ class MasterView(View):
|
|||
if self.request.method == 'POST':
|
||||
if self.request.POST.get('remove_settings'):
|
||||
self.configure_remove_settings()
|
||||
self.request.session.flash("Settings have been removed.")
|
||||
self.request.session.flash("All settings for {} have been "
|
||||
"removed.".format(self.get_config_title()),
|
||||
'warning')
|
||||
return self.redirect(self.request.current_route_url())
|
||||
else:
|
||||
data = self.request.POST
|
||||
|
@ -4517,6 +4519,8 @@ class MasterView(View):
|
|||
config.add_view(cls, attr='index', route_name=route_prefix,
|
||||
permission='{}.list'.format(permission_prefix),
|
||||
**kwargs)
|
||||
config.add_tailbone_index_page(route_prefix, model_title_plural,
|
||||
'{}.list'.format(permission_prefix))
|
||||
|
||||
# download results
|
||||
# this is the "new" more flexible approach, but we only want to
|
||||
|
@ -4572,7 +4576,8 @@ class MasterView(View):
|
|||
route_name='{}.configure'.format(route_prefix),
|
||||
permission='{}.configure'.format(permission_prefix))
|
||||
config.add_tailbone_config_page('{}.configure'.format(route_prefix),
|
||||
config_title)
|
||||
config_title,
|
||||
'{}.configure'.format(permission_prefix))
|
||||
|
||||
# quickie (search)
|
||||
if cls.supports_quickie_search:
|
||||
|
|
191
tailbone/views/menus.py
Normal file
191
tailbone/views/menus.py
Normal file
|
@ -0,0 +1,191 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Base class for Config Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from tailbone.views import View
|
||||
from tailbone.db import Session
|
||||
from tailbone.menus import make_menu_key
|
||||
|
||||
|
||||
class MenuConfigView(View):
|
||||
"""
|
||||
View for configuring the main menu.
|
||||
"""
|
||||
|
||||
def configure(self):
|
||||
"""
|
||||
Main entry point to menu config views.
|
||||
"""
|
||||
if self.request.method == 'POST':
|
||||
if self.request.POST.get('remove_settings'):
|
||||
self.configure_remove_settings()
|
||||
self.request.session.flash("All settings for Menus have been removed.",
|
||||
'warning')
|
||||
return self.redirect(self.request.current_route_url())
|
||||
else:
|
||||
data = self.request.POST
|
||||
|
||||
# gather/save settings
|
||||
settings = self.configure_gather_settings(data)
|
||||
self.configure_remove_settings()
|
||||
self.configure_save_settings(settings)
|
||||
self.request.session.flash("Settings have been saved.")
|
||||
return self.redirect(self.request.current_route_url())
|
||||
|
||||
context = {
|
||||
'config_title': "Menus",
|
||||
'use_buefy': True,
|
||||
'index_title': "App Settings",
|
||||
'index_url': self.request.route_url('appsettings'),
|
||||
}
|
||||
|
||||
possible_index_options = sorted(
|
||||
self.request.registry.settings['tailbone_index_pages'],
|
||||
key=lambda p: p['label'])
|
||||
|
||||
index_options = []
|
||||
for option in possible_index_options:
|
||||
perm = option['permission']
|
||||
option['perm'] = perm
|
||||
option['url'] = self.request.route_url(option['route'])
|
||||
index_options.append(option)
|
||||
|
||||
context['index_route_options'] = index_options
|
||||
return context
|
||||
|
||||
def configure_gather_settings(self, data):
|
||||
settings = [{'name': 'tailbone.menu.from_settings',
|
||||
'value': 'true'}]
|
||||
|
||||
main_keys = []
|
||||
for topitem in json.loads(data['menus']):
|
||||
key = make_menu_key(self.rattail_config, topitem['title'])
|
||||
main_keys.append(key)
|
||||
|
||||
settings.extend([
|
||||
{'name': 'tailbone.menu.menu.{}.label'.format(key),
|
||||
'value': topitem['title']},
|
||||
])
|
||||
|
||||
item_keys = []
|
||||
for item in topitem['items']:
|
||||
item_type = item.get('type', 'item')
|
||||
if item_type == 'item':
|
||||
if item.get('route'):
|
||||
item_key = item['route']
|
||||
else:
|
||||
item_key = make_menu_key(self.rattail_config, item['title'])
|
||||
item_keys.append(item_key)
|
||||
|
||||
settings.extend([
|
||||
{'name': 'tailbone.menu.menu.{}.item.{}.label'.format(key, item_key),
|
||||
'value': item['title']},
|
||||
])
|
||||
|
||||
if item.get('route'):
|
||||
settings.extend([
|
||||
{'name': 'tailbone.menu.menu.{}.item.{}.route'.format(key, item_key),
|
||||
'value': item['route']},
|
||||
])
|
||||
|
||||
elif item.get('url'):
|
||||
settings.extend([
|
||||
{'name': 'tailbone.menu.menu.{}.item.{}.url'.format(key, item_key),
|
||||
'value': item['url']},
|
||||
])
|
||||
|
||||
if item.get('perm'):
|
||||
settings.extend([
|
||||
{'name': 'tailbone.menu.menu.{}.item.{}.perm'.format(key, item_key),
|
||||
'value': item['perm']},
|
||||
])
|
||||
|
||||
elif item_type == 'sep':
|
||||
item_keys.append('SEP')
|
||||
|
||||
settings.extend([
|
||||
{'name': 'tailbone.menu.menu.{}.items'.format(key),
|
||||
'value': ' '.join(item_keys)},
|
||||
])
|
||||
|
||||
settings.append({'name': 'tailbone.menu.menus',
|
||||
'value': ' '.join(main_keys)})
|
||||
return settings
|
||||
|
||||
def configure_remove_settings(self):
|
||||
model = self.model
|
||||
Session.query(model.Setting)\
|
||||
.filter(sa.or_(
|
||||
model.Setting.name == 'tailbone.menu.from_settings',
|
||||
model.Setting.name == 'tailbone.menu.menus',
|
||||
model.Setting.name.like('tailbone.menu.menu.%.label'),
|
||||
model.Setting.name.like('tailbone.menu.menu.%.items'),
|
||||
model.Setting.name.like('tailbone.menu.menu.%.item.%.label'),
|
||||
model.Setting.name.like('tailbone.menu.menu.%.item.%.route'),
|
||||
model.Setting.name.like('tailbone.menu.menu.%.item.%.perm'),
|
||||
model.Setting.name.like('tailbone.menu.menu.%.item.%.url')))\
|
||||
.delete(synchronize_session=False)
|
||||
|
||||
def configure_save_settings(self, settings):
|
||||
model = self.model
|
||||
session = Session()
|
||||
for setting in settings:
|
||||
session.add(model.Setting(name=setting['name'],
|
||||
value=setting['value']))
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _defaults(cls, config):
|
||||
|
||||
# configure menus
|
||||
config.add_route('configure_menus',
|
||||
'/configure-menus')
|
||||
config.add_view(cls, attr='configure',
|
||||
route_name='configure_menus',
|
||||
# nb. must be root to configure menus! b/c
|
||||
# otherwise some route options may be hidden
|
||||
permission='admin',
|
||||
renderer='/configure-menus.mako')
|
||||
config.add_tailbone_config_page('configure_menus', "Menus", 'admin')
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
MenuConfigView = kwargs.get('MenuConfigView', base['MenuConfigView'])
|
||||
MenuConfigView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
Loading…
Reference in a new issue