import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; import TreeFilter, { emptyCriteria } from './TreeFilter.js'; import MenuSwitch from './MenuSwitch.js'; // The meta dates are SQL timestamps. Format them like the history tab does. function formatDay(value) { if (!value) return ''; const dt = luxon.DateTime.fromSQL(value); return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value; } // A day string of the meta value. The inputs deliver YYYY-MM-DD, so compare as text. function inRange(value, from, to) { if (!from && !to) return true; if (!value) return false; const day = value.substring(0, 10); if (from && day < from) return false; if (to && day > to) return false; return true; } // The meta fields that hold a number. The rest hold an SQL timestamp. const NUMERIC_FIELDS = ['childcount', 'contentlength']; // Sort key of one node. A content without meta sorts last in a descending list. function sortValue(meta, contentId, field) { const numeric = NUMERIC_FIELDS.indexOf(field) !== -1; const m = meta[contentId]; if (!m) return numeric ? -1 : ''; if (numeric) return m[field]; return m[field] || ''; } const TreeNode = { name: 'CmsTreeNode', inject: ['treeSelect', 'treeToggle'], props: { node: Object, activeContentId: Number, expandedKeys: Object, flat: Boolean, meta: Object, metric: String, // Ancestors of this node, from the top down. Only the nodes of the flat result // list get one; a child inside the subtree sits under its parent already. ancestors: Array, // A node that is context rather than a hit. It and its children draw back. muted: Boolean }, computed: { // Shows the value the active filter or sort works on, so the order is readable. badge() { if (!this.metric || !this.meta) return null; const m = this.meta[this.node.content_id]; if (!m) return null; if (NUMERIC_FIELDS.indexOf(this.metric) !== -1) return String(m[this.metric]); return formatDay(m[this.metric]) || null; }, badgeEmpty() { return NUMERIC_FIELDS.indexOf(this.metric) !== -1 && this.badge === '0'; }, hasChildren() { return this.node.children && this.node.children.length > 0; }, isExpanded() { return !!this.expandedKeys[this.node.content_id]; }, isActive() { return this.activeContentId != null && this.node.content_id === this.activeContentId; }, // The hit keeps its place; the path above it steps in one level at a time. indent() { return this.ancestors ? this.ancestors.length : 0; }, labelStyle() { const s = {}; if (this.isActive) { s.fontWeight = 'bold'; s.background = '#e8f0fe'; s.borderRadius = '3px'; s.padding = '0 4px'; } if (!this.node.aktiv) { s.opacity = '0.5'; } return s; } }, template: `
  • {{ a.titel || a.content_id }} ({{ a.content_id }}) {{ a.titel || a.content_id }} ({{ a.content_id }})
    {{ node.titel }} ({{ node.content_id }}) {{ node.titel }} ({{ node.content_id }}) {{ badge }}
  • ` }; export default { name: 'CmsTree', components: { 'cms-tree-node': TreeNode, 'tree-filter': TreeFilter, 'menu-switch': MenuSwitch }, props: { activeContentId: Number }, emits: ['select-content'], data() { const stored = localStorage.getItem('cms/menu'); return { menu: (stored === 'content' || stored === 'news') ? stored : 'content', filter: '', nodes: [], loading: false, expandedKeys: {}, criteria: emptyCriteria(), meta: null, metaLoading: false, // A flat list drops the context a generic title needs. The toggle puts the // way down to each hit back, and the choice survives a page switch. showHierarchy: localStorage.getItem('cms/treeHierarchie') === '1', ancestors: {}, ancestorsLoading: false }; }, computed: { // The news menu is a flat list without the meta fields. advanced() { return this.menu === 'content'; }, // Every field but the template comes from the meta request. needsMeta() { const c = this.criteria; return c.mine || !!c.updatedFrom || !!c.updatedTo || !!c.createdFrom || !!c.createdTo || c.minChildren !== '' || c.maxChildren !== '' || c.minLength !== '' || c.maxLength !== '' || c.emptyOnly || c.sort === 'updateamum' || c.sort === 'insertamum' || c.sort === 'childcount' || c.sort === 'contentlength'; }, narrowing() { const c = this.criteria; return !!c.template_kurzbz || c.mine || !!c.updatedFrom || !!c.updatedTo || !!c.createdFrom || !!c.createdTo || c.minChildren !== '' || c.maxChildren !== '' || c.minLength !== '' || c.maxLength !== '' || c.emptyOnly; }, active() { return this.advanced && (this.narrowing || this.criteria.sort !== ''); }, // A hierarchical tree already shows where a node sits. Only a flat list, from a // search or from a filter, needs the path spelled out. isFlat() { return this.active || this.menu === 'news' || !!this.filter; }, showPaths() { return this.showHierarchy && this.isFlat; }, // The value the badge shows. The filter drives it, the sort order falls back. metric() { const c = this.criteria; if (!this.active) return null; if (c.emptyOnly || c.minLength !== '' || c.maxLength !== '') return 'contentlength'; if (c.minChildren !== '' || c.maxChildren !== '') return 'childcount'; if (c.updatedFrom || c.updatedTo) return 'updateamum'; if (c.createdFrom || c.createdTo) return 'insertamum'; if (c.sort !== '' && c.sort !== 'titel') return c.sort; return null; }, // Flat on purpose: a match sits at any depth, so per-level sorting would hide it. visibleNodes() { if (!this.active) return this.nodes; const flatList = []; const walk = (nodes) => { for (const node of nodes) { flatList.push(node); if (node.children && node.children.length) walk(node.children); } }; walk(this.nodes); return this.sortNodes(flatList.filter(node => this.matches(node))); } }, provide() { return { treeSelect: (node) => { this.selectNode(node); }, treeToggle: (node) => { this.toggleNode(node); } }; }, watch: { menu() { this.loadTree(); }, activeContentId(id) { if (id != null) { this.expandPathTo(id); } } }, methods: { // Keeps the expanded branches. A reload must not fold the tree back up. loadTree() { this.loading = true; return this.$api .call(ApiCmsAdmin.getTree(this.menu, this.filter)) .then(result => { this.nodes = result.data; if (this.activeContentId != null) { this.expandPathTo(this.activeContentId); } }) .catch(this.$fhcAlert.handleSystemError) .finally(() => { this.loading = false; }); }, // --- Public: the parent calls these instead of rebuilding the component --- reload() { // A change moves a date or a child count. Drop the cached meta. this.meta = null; if (this.needsMeta) this.loadMeta(); return this.loadTree(); }, // Replaces one branch. Use it after a change to the children, the title, the // active flag or the organisational unit of that content. refreshNode(contentId) { // The news menu is a flat list of its own. Only a full reload fits it. if (this.menu === 'news' || contentId == null) return this.loadTree(); const slot = this.findSlot(this.nodes, contentId); // Not in the tree, so the change may add a new root. Rebuild. if (slot === null) return this.loadTree(); return this.$api .call(ApiCmsAdmin.getSubtree(contentId)) .then(result => { if (result.data) { slot.list.splice(slot.index, 1, result.data); } else { slot.list.splice(slot.index, 1); } if (this.activeContentId != null) { this.expandPathTo(this.activeContentId); } }) .catch(this.$fhcAlert.handleSystemError); }, // Writes known fields straight into one node. Groups do not change the structure, // so they need no request. patchNode(contentId, patch) { const slot = this.findSlot(this.nodes, contentId); if (slot === null) return; Object.assign(slot.node, patch); }, findSlot(nodes, contentId) { for (let i = 0; i < nodes.length; i++) { if (nodes[i].content_id === contentId) { return { list: nodes, index: i, node: nodes[i] }; } if (nodes[i].children && nodes[i].children.length) { const found = this.findSlot(nodes[i].children, contentId); if (found !== null) return found; } } return null; }, expandPathTo(targetId) { const search = (nodes) => { for (const node of nodes) { if (node.content_id === targetId) return true; if (node.children && node.children.length) { if (search(node.children)) { this.expandedKeys[node.content_id] = true; return true; } } } return false; }; search(this.nodes); }, toggleNode(node) { if (this.expandedKeys[node.content_id]) { delete this.expandedKeys[node.content_id]; } else { this.expandedKeys[node.content_id] = true; } }, selectNode(node) { if (node.entitled) { this.$emit('select-content', node.content_id); } }, matches(node) { const c = this.criteria; if (c.template_kurzbz && node.template_kurzbz !== c.template_kurzbz) return false; if (!this.needsMeta) return true; // The meta arrives after the tree. Hide nothing until it is there. if (this.meta === null) return true; const m = this.meta[node.content_id]; if (!m) return false; if (c.mine && !m.mine) return false; if (!inRange(m.updateamum, c.updatedFrom, c.updatedTo)) return false; if (!inRange(m.insertamum, c.createdFrom, c.createdTo)) return false; if (c.minChildren !== '' && m.childcount < Number(c.minChildren)) return false; if (c.maxChildren !== '' && m.childcount > Number(c.maxChildren)) return false; if (c.emptyOnly && m.contentlength !== 0) return false; if (c.minLength !== '' && m.contentlength < Number(c.minLength)) return false; if (c.maxLength !== '' && m.contentlength > Number(c.maxLength)) return false; return true; }, // Descending for the numbers and the dates: the largest and the newest come first. sortNodes(list) { const sort = this.criteria.sort; if (!sort) return list; if (sort === 'titel') { return list.slice().sort( (a, b) => (a.titel || '').localeCompare(b.titel || '') ); } const meta = this.meta || {}; return list.slice().sort((a, b) => { const va = sortValue(meta, a.content_id, sort); const vb = sortValue(meta, b.content_id, sort); if (va === vb) return 0; return va < vb ? 1 : -1; }); }, // One request for the whole tree. Cache it, the filter changes often. loadMeta() { if (this.meta !== null || this.metaLoading) return; this.metaLoading = true; this.$api .call(ApiCmsAdmin.getTreeMeta()) .then(result => { this.meta = result.data || {}; }) .catch(this.$fhcAlert.handleSystemError) .finally(() => { this.metaLoading = false; }); }, toggleHierarchy() { this.showHierarchy = !this.showHierarchy; localStorage.setItem('cms/treeHierarchie', this.showHierarchy ? '1' : '0'); if (!this.showHierarchy) return; // The point of the toggle is the context around a hit, and the subtree is half // of it. A collapsed node would show the way down to the hit and nothing under // it. Opening the hits leaves every caret free to collapse again. for (const node of this.visibleNodes) this.expandedKeys[node.content_id] = true; this.loadAncestors(); }, // One request for the whole list. Asking per node would be a request per hit. loadAncestors() { if (!this.showPaths) return; const ids = this.visibleNodes.map(node => node.content_id); if (!ids.length) { this.ancestors = {}; return; } this.ancestorsLoading = true; this.$api .call(ApiCmsAdmin.getAncestors(ids)) .then(result => { this.ancestors = result.data || {}; }) .catch(this.$fhcAlert.handleSystemError) .finally(() => { this.ancestorsLoading = false; }); }, pathFor(contentId) { return this.showPaths ? (this.ancestors[contentId] || []) : null; }, onCriteria(criteria) { this.criteria = criteria; if (this.needsMeta) this.loadMeta(); this.loadAncestors(); }, onFilter(text) { this.filter = text; this.loadTree().then(() => { this.loadAncestors(); }); }, createEntry(parentId) { this.$api .call(ApiCmsAdmin.postContent(parentId)) .then(result => { const newId = result.data.content_id; this.$fhcAlert.alertSuccess(this.$p.t('cms/eintragAngelegt')); // A new root is not under any branch, so only a reload shows it. const refreshed = parentId == null ? this.loadTree() : this.refreshNode(parentId); // Select after the node exists, or expandPathTo finds nothing. return refreshed.then(() => { this.$emit('select-content', newId); }); }) .catch(this.$fhcAlert.handleSystemError); } }, mounted() { this.loadTree(); }, template: `
    {{ $p.t('cms/nTreffer', [visibleNodes.length]) }}
    {{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
    ` };