diff --git a/enferno/admin/templates/admin/jsapi.jinja2 b/enferno/admin/templates/admin/jsapi.jinja2 index 25226b0ff..7e25cdd52 100644 --- a/enferno/admin/templates/admin/jsapi.jinja2 +++ b/enferno/admin/templates/admin/jsapi.jinja2 @@ -756,6 +756,26 @@ thisFieldIsRequired_: "{{ _('This field is required.') }}", pleaseEnterAValidNumber_: "{{ _('Please enter a valid number.') }}", +} + +// shared by the label tree in the app bar and inside the entity dialogs +var labelTreeTranslations = { + labelStructure: "{{ _('Label Structure') }}", + readOnly: "{{ _('Read only') }}", + search: "{{ _('Search labels') }}", + close: "{{ _('Close') }}", + loadError: "{{ _('Could not load labels') }}", + retry: "{{ _('Retry') }}", + noLabels: "{{ _('No labels found') }}", + manageLabels: "{{ _('Manage Labels') }}", + bulletins: "{{ _('Bulletins') }}", + actors: "{{ _('Actors') }}", + incidents: "{{ _('Incidents') }}", + offline: "{{ _('Offline') }}", + labels: "{{ _('Labels') }}", + verifiedLabels: "{{ _('Verified Labels') }}", + groupingOnly: "{{ _('Grouping only, cannot be assigned') }}", + retired: "{{ _('Retired, kept for existing records only') }}", } // add localized configurations for lists with static values diff --git a/enferno/admin/templates/nav-bar.html b/enferno/admin/templates/nav-bar.html index e7c36c8ac..074344111 100644 --- a/enferno/admin/templates/nav-bar.html +++ b/enferno/admin/templates/nav-bar.html @@ -11,6 +11,12 @@ + {% if current_user.roles_in(['Admin', 'Mod', 'DA']) %} + + {% endif %} {% if config.OCR_PROVIDER == 'google_vision' %} diff --git a/enferno/admin/validation/models.py b/enferno/admin/validation/models.py index a29bb4562..c294df67a 100644 --- a/enferno/admin/validation/models.py +++ b/enferno/admin/validation/models.py @@ -829,6 +829,9 @@ class LabelValidationModel(StrictValidationModel): # sent by tree view edit (build_tree node shape), not used by from_json parent_label_id: Optional[int] = None children: Optional[List] = None + # computed by to_dict for hierarchy display, not used by from_json + path: Optional[str] = None + path_ar: Optional[str] = None class LabelRequestModel(BaseValidationModel): diff --git a/enferno/admin/views/labels.py b/enferno/admin/views/labels.py index dddfabcbe..cdfb0ca4f 100644 --- a/enferno/admin/views/labels.py +++ b/enferno/admin/views/labels.py @@ -108,7 +108,7 @@ def api_labels() -> Response: @admin.route("/api/labels/tree") -@roles_accepted("Admin", "Mod") +@roles_accepted("Admin", "Mod", "DA") def api_labels_tree() -> Response: """Return labels as nested tree structure for admin tree view.""" verified_param = request.args.get("verified", None) diff --git a/enferno/static/css/app.css b/enferno/static/css/app.css index 4db89966f..ea3217519 100644 --- a/enferno/static/css/app.css +++ b/enferno/static/css/app.css @@ -759,6 +759,143 @@ div.jsondiffpatch-child-node-type-array:after { max-height: calc(100vh - 80px - 0.75rem); } +.label-structure-panel { + width: min(720px, calc(100vw - 24px)); + font-size: 13px; +} + +.label-structure-panel .v-card-title { + min-height: 36px; + font-size: 0.95rem; +} + +.label-structure-panel .v-field, +.label-structure-panel .v-btn { + font-size: 13px; +} + +.label-structure-tree { + flex: 1 1 auto; + overflow-y: auto; +} + +/* both sit above Vuetify's overlay layer (2400) so they survive a fullscreen dialog */ +/* position comes from the component so it can be dragged. the app sets global + v-btn defaults (text variant, no elevation), so the shadow is restated here + rather than relying on the elevation prop */ +.label-structure-fab { + position: fixed; + z-index: 2600; + cursor: move; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14); +} + +.label-structure-dock { + position: fixed; + z-index: 2600; + max-height: min(80vh, 720px); + display: flex; + flex-direction: column; + /* native resize handle rather than a hand-rolled one, for the rare label that + still runs long */ + resize: horizontal; + overflow: hidden; + min-width: 360px; + max-width: calc(100vw - 24px); +} + +.label-structure-handle { + cursor: move; + user-select: none; +} + +.label-structure-tree .v-list-item { + min-height: 26px; + padding-inline-end: 6px; +} + +/* every level costs one indent step plus the width Vuetify reserves for the + prepend slot; both are trimmed so a six level path still fits */ +.label-structure-tree .v-list-group { + --list-indent-size: 12px; + --prepend-width: 24px; +} + +.label-structure-tree .v-list-item__spacer { + width: 4px; +} + +.label-structure-tree .v-list-item__prepend > .v-list-item-action { + margin-inline-end: 2px; +} + +/* the selected row's full path, pinned under the search box. this is the one + line he reads, so it outranks the tree rows in size and colour */ +.label-structure-path { + flex: 0 0 auto; + /* small enough that a six level path fits without scrolling sideways, which + is the point of showing it at all */ + font-size: 11.5px; + line-height: 1.05rem; + color: rgb(var(--v-theme-primary)); + background: rgba(var(--v-theme-primary), 0.1); + border-block: thin solid rgba(var(--v-theme-primary), 0.25); + overflow-x: auto; + white-space: nowrap; +} + +.label-structure-path-line { + display: flex; + align-items: center; + gap: 4px; +} + +/* dir=rtl orders the segments right to left; the line itself stays left aligned + so it reads as a second line under the English one */ +.label-structure-path-ar { + text-align: left; + white-space: nowrap; +} + +.label-structure-scope .v-btn { + height: 24px; + font-size: 11px; + letter-spacing: 0; + text-transform: none; +} + +.label-structure-crumb-sep { + opacity: 0.5; +} + +.label-structure-crumb-leaf { + font-weight: 600; +} + +.label-structure-tree .v-list-item-title, +.label-structure-title { + font-size: 12.5px; + line-height: 1.05rem; +} + +.label-structure-title-ar { + font-size: 10.5px; + line-height: 0.95rem; + /* dir=rtl keeps the bidi order correct; alignment follows the English line above */ + text-align: left; +} + +.label-structure-tree .v-chip { + height: 16px; + padding-inline: 4px; + font-size: 9px; +} + +.label-structure-panel .v-card-actions { + min-height: 32px; + padding: 2px 8px; +} + /* steppers enhancements */ .v-stepper > .v-window { diff --git a/enferno/static/js/components/LabelStructureNavigator.js b/enferno/static/js/components/LabelStructureNavigator.js new file mode 100644 index 000000000..192aa823d --- /dev/null +++ b/enferno/static/js/components/LabelStructureNavigator.js @@ -0,0 +1,378 @@ +const PANEL_WIDTH = 720; +const FAB_SIZE = 48; + +const LabelStructureNavigator = Vue.defineComponent({ + props: { + canManage: { + type: Boolean, + default: false, + }, + }, + data() { + const saved = JSON.parse(localStorage.getItem('labelTreePos') || 'null'); + const savedFab = JSON.parse(localStorage.getItem('labelTreeFabPos') || 'null'); + return { + translations: window.labelTreeTranslations, + open: localStorage.getItem('labelTreeOpen') === '1', + x: saved?.x ?? 24, + y: saved?.y ?? Math.max(72, window.innerHeight - 640), + dragOffset: null, + // sits in the app bar, left of the existing toolbar icons, and floats + // above dialogs so it stays reachable while editing. draggable anywhere. + fabX: savedFab?.x ?? window.innerWidth - 300, + fabY: savedFab?.y ?? 8, + fabDragged: false, + fabOffset: null, + fabPointer: null, + error: false, + loading: false, + // labels and verified labels are two separate hierarchies in one table, and + // merging them puts the same label at two different depths + verified: localStorage.getItem('labelTreeVerified') === '1', + // he opens the branch he wants, so the tree always starts fully collapsed + opened: [], + activated: [], + query: '', + treeItems: [], + cache: {}, + }; + }, + computed: { + filteredItems() { + const query = this.query.trim().toLocaleLowerCase(); + if (!query) return this.treeItems; + return this.filterTree(this.treeItems, query); + }, + // the title already is the full path, so the picked row is the whole crumb + activeItem() { + const id = this.activated[0]; + if (!id) return null; + const find = (items) => { + for (const item of items) { + if (item.id === id) return item; + const match = find(item.children || []); + if (match) return match; + } + }; + return find(this.treeItems) || null; + }, + // ancestors read as context, the label itself is what he came to read + activeCrumbs() { + return this.crumbs(this.activeItem?.title); + }, + // title_ar is stored as a full path too, with the Arabic comma + activeCrumbsAr() { + return this.crumbs(this.activeItem?.title_ar); + }, + }, + watch: { + open(open) { + localStorage.setItem('labelTreeOpen', open ? '1' : '0'); + if (open) { + this.clampToViewport(); + this.loadTree(); + } + }, + verified(verified) { + localStorage.setItem('labelTreeVerified', verified ? '1' : '0'); + this.opened = []; + this.activated = []; + this.loadTree(); + }, + query(value) { + // a search expands what it matched; clearing it folds the tree back up + this.opened = value.trim() ? this.collectParentIds(this.filteredItems) : []; + }, + }, + mounted() { + window.addEventListener('resize', this.clampToViewport); + this.clampFab(); + if (this.open) { + this.clampToViewport(); + this.loadTree(); + } + }, + beforeUnmount() { + window.removeEventListener('resize', this.clampToViewport); + this.stopDrag(); + }, + methods: { + // titles are stored as full comma-delimited paths ("Type, Martial, Air Strike"), + // so a row only needs the last segment: the tree itself shows the ancestry, and + // repeating it pushes the deeper levels off the panel + leafTitle(title) { + return String(title || '') + .split(/[,،]/) + .pop() + .trim(); + }, + crumbs(title) { + return String(title || '') + .split(/[,،]/) + .map((part) => part.trim()) + .filter(Boolean); + }, + collectParentIds(items) { + return items.flatMap((item) => [ + ...(item.children?.length ? [item.id] : []), + ...this.collectParentIds(item.children || []), + ]); + }, + filterTree(items, query) { + return items.reduce((matches, item) => { + const children = this.filterTree(item.children || [], query); + const title = `${item.title || ''} ${item.title_ar || ''}`.toLocaleLowerCase(); + + if (title.includes(query)) { + matches.push(item); + } else if (children.length) { + matches.push({...item, children}); + } + + return matches; + }, []); + }, + isAssignable(item) { + return Boolean( + item.for_bulletin || item.for_actor || item.for_incident || item.for_offline, + ); + }, + // not assignable + has children = a grouping node; without children it is a + // retired label that still carries historical tags + itemIcon(item) { + if (this.isAssignable(item)) return 'mdi-label'; + return item.children?.length ? 'mdi-folder-outline' : 'mdi-label-off-outline'; + }, + itemIconHint(item) { + if (this.isAssignable(item)) return ''; + return item.children?.length ? this.translations.groupingOnly : this.translations.retired; + }, + // keep at least a corner of the panel on screen after drags and window resizes + clampToViewport() { + const edge = 80; + this.x = Math.min(Math.max(this.x, edge - PANEL_WIDTH), window.innerWidth - edge); + this.y = Math.min(Math.max(this.y, 0), window.innerHeight - 48); + this.clampFab(); + }, + clampFab() { + this.fabX = Math.min(Math.max(this.fabX, 0), window.innerWidth - FAB_SIZE); + this.fabY = Math.min(Math.max(this.fabY, 0), window.innerHeight - FAB_SIZE); + }, + startFabDrag(event) { + this.fabDragged = false; + this.fabPointer = event.pointerId; + this.fabOffset = {x: event.clientX - this.fabX, y: event.clientY - this.fabY}; + event.currentTarget.setPointerCapture(event.pointerId); + }, + onFabDrag(event) { + if (this.fabPointer !== event.pointerId || !this.fabOffset) return; + const next = {x: event.clientX - this.fabOffset.x, y: event.clientY - this.fabOffset.y}; + // ignore jitter so a plain click is never read as a drag + if (Math.abs(next.x - this.fabX) + Math.abs(next.y - this.fabY) < 4) return; + this.fabDragged = true; + this.fabX = next.x; + this.fabY = next.y; + this.clampFab(); + }, + stopFabDrag(event) { + if (this.fabPointer !== event?.pointerId) return; + if (event.currentTarget?.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + this.fabPointer = null; + this.fabOffset = null; + if (this.fabDragged) { + localStorage.setItem('labelTreeFabPos', JSON.stringify({x: this.fabX, y: this.fabY})); + } + }, + // a drag ends in a click event too, so swallow that one + toggle() { + if (this.fabDragged) { + this.fabDragged = false; + return; + } + this.open = !this.open; + }, + startDrag(event) { + this.dragOffset = {x: event.clientX - this.x, y: event.clientY - this.y}; + window.addEventListener('pointermove', this.onDrag); + window.addEventListener('pointerup', this.stopDrag); + }, + onDrag(event) { + this.x = event.clientX - this.dragOffset.x; + this.y = event.clientY - this.dragOffset.y; + this.clampToViewport(); + }, + stopDrag() { + window.removeEventListener('pointermove', this.onDrag); + window.removeEventListener('pointerup', this.stopDrag); + if (this.dragOffset) { + localStorage.setItem('labelTreePos', JSON.stringify({x: this.x, y: this.y})); + this.dragOffset = null; + } + }, + async loadTree() { + const key = this.verified ? 'verified' : 'labels'; + if (this.cache[key]) { + this.treeItems = this.cache[key]; + return; + } + if (this.loading) return; + + this.error = false; + this.loading = true; + this.treeItems = []; + try { + const response = await api.get('/admin/api/labels/tree', { + params: {verified: this.verified}, + }); + this.cache[key] = response.data.items; + this.treeItems = response.data.items; + } catch (_error) { + this.error = true; + } finally { + this.loading = false; + } + }, + }, + template: ` + + + + + + + {{ translations.labelStructure }} + {{ translations.readOnly }} + + + {{ translations.labels }} + {{ translations.verifiedLabels }} + + + + + + + + + + + + + › + {{ crumb }} + + + + + › + {{ crumb }} + + + + + + + + + + + {{ translations.loadError }} + + {{ translations.retry }} + + + + + {{ translations.noLabels }} + + + + + + + + + {{ leafTitle(item.title) }} + {{ leafTitle(item.title_ar) }} + + + + + B + A + I + O + + + + + + + + + {{ translations.manageLabels }} + + + + + `, +}); diff --git a/enferno/static/js/mixins/global-mixin.js b/enferno/static/js/mixins/global-mixin.js index 0858050a3..c741227e7 100644 --- a/enferno/static/js/mixins/global-mixin.js +++ b/enferno/static/js/mixins/global-mixin.js @@ -5,6 +5,7 @@ const globalMixin = { 'Toast': Toast, 'ProfileDropdown': ProfileDropdown, 'UpdateBanner': UpdateBanner, + 'LabelStructureNavigator': LabelStructureNavigator, }, data: () => ({ snackbar: false, diff --git a/enferno/templates/layout.html b/enferno/templates/layout.html index cae303ec5..f827a0cc2 100644 --- a/enferno/templates/layout.html +++ b/enferno/templates/layout.html @@ -136,6 +136,7 @@ + diff --git a/tests/test_label_tree.py b/tests/test_label_tree.py new file mode 100644 index 000000000..ebe5321cd --- /dev/null +++ b/tests/test_label_tree.py @@ -0,0 +1,64 @@ +import pytest + +from enferno.admin.models import Label + + +@pytest.mark.parametrize( + ("client_fixture", "expected_status"), + [ + ("admin_client", 200), + ("mod_client", 200), + ("da_client", 200), + ("roled_client", 403), + ("anonymous_client", 302), + ], +) +def test_label_tree_access_by_role(request, client_fixture, expected_status): + client = request.getfixturevalue(client_fixture) + + response = client.get("/admin/api/labels/tree") + + assert response.status_code == expected_status + if expected_status == 200: + assert isinstance(response.json["data"]["items"], list) + + +def test_label_tree_includes_arabic_titles(admin_client, session): + label = Label(title="Detention", title_ar="الاحتجاز") + session.add(label) + session.commit() + label_id = label.id + + response = admin_client.get("/admin/api/labels/tree") + + assert response.status_code == 200 + item = next(item for item in response.json["data"]["items"] if item["id"] == label_id) + assert item["title_ar"] == "الاحتجاز" + + +@pytest.mark.parametrize( + ("client_fixture", "can_manage"), + [ + ("admin_client", "true"), + ("mod_client", "true"), + ("da_client", "false"), + ], +) +def test_label_navigator_is_rendered_for_label_assignment_roles( + request, client_fixture, can_manage +): + client = request.getfixturevalue(client_fixture) + + response = client.get("/", follow_redirects=True) + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "