Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@
*/
class OptionsController
{
/** FQCN + short name. */
private const OPTION_TREE_CATEGORY_CLASS_KEYS = [
msCategory::class,
'msCategory',
];

private const OPTION_TREE_CONTAINER_CLASS_KEYS = [
modResource::class,
'MODX\\Revolution\\modDocument',
'MODX\\Revolution\\modWebLink',
'modResource',
'modDocument',
'modWebLink',
];

protected modX $modx;
protected OptionService $optionService;

Expand Down Expand Up @@ -319,6 +334,12 @@ public function getTypes(): array
* MODX resource tree for category selection. Optional ?option_id= to flag which
* categories already have the option linked (for checkbox UI).
*
* The tree intentionally includes navigation containers so category pickers work
* with nested or multi-store resource structures without system settings:
* - msCategory: visible and selectable;
* - MODX folder resources/web links: visible for navigation only;
* - msProduct and plain content resources: excluded.
*
* @param array $params parent (default 0), option_id (optional), categories[] (prechecked)
*/
public function getTree(array $params = []): array
Expand All @@ -339,19 +360,22 @@ public function getTree(array $params = []): array
$checkedSet[$catId] = true;
}

// Only msCategory nodes (same rule as legacy ExtJS Processors\Category\GetNodes).
// leaf is derived from a child-count subquery: a node is a leaf when it has no msCategory children.
$treeClassKeysSql = $this->quoteSqlStringList($this->getOptionTreeClassKeys());
$categoryClassKeysSql = $this->quoteSqlStringList(self::OPTION_TREE_CATEGORY_CLASS_KEYS);
$treeNodeWhere = $this->getOptionTreeNodeSqlFilter('modResource', $treeClassKeysSql, $categoryClassKeysSql);
$childNodeWhere = $this->getOptionTreeNodeSqlFilter('Child', $treeClassKeysSql, $categoryClassKeysSql);

$q = $this->modx->newQuery(modResource::class);
$q->leftJoin(modResource::class, 'Child', [
'modResource.id = Child.parent',
'Child.class_key' => msCategory::class,
'Child.deleted' => 0,
]);
$q->leftJoin(
modResource::class,
'Child',
"`modResource`.`id` = `Child`.`parent` AND `Child`.`deleted` = 0 AND {$childNodeWhere}"
);
$q->where([
'modResource.parent' => $parent,
'modResource.deleted' => 0,
'modResource.class_key' => msCategory::class,
]);
$q->where($treeNodeWhere);
$q->select('modResource.id, modResource.pagetitle, modResource.menutitle, '
. 'modResource.parent, modResource.published, modResource.hidemenu, modResource.class_key, '
. 'COUNT(Child.id) AS childrenCount');
Expand All @@ -362,11 +386,14 @@ public function getTree(array $params = []): array
if ($q->prepare() && $q->stmt->execute()) {
while ($row = $q->stmt->fetch(\PDO::FETCH_ASSOC)) {
$id = (int)$row['id'];
$selectable = $this->isOptionTreeCategoryClass((string)$row['class_key']);
$label = (string)($row['menutitle'] ?: $row['pagetitle']);
$nodes[] = [
'id' => $id,
'label' => $row['menutitle'] !== '' ? $row['menutitle'] : $row['pagetitle'],
'label' => $label,
'leaf' => (int)$row['childrenCount'] === 0,
'checked' => isset($checkedSet[$id]),
'checked' => $selectable && isset($checkedSet[$id]),
'selectable' => $selectable,
'class_key' => $row['class_key'],
'published' => (int)$row['published'],
'hidemenu' => (int)$row['hidemenu'],
Expand Down Expand Up @@ -599,6 +626,36 @@ protected function splitCategoriesPayload($payload): array
return [$enabled, $disabled];
}

private function getOptionTreeNodeSqlFilter(string $alias, string $treeClassKeysSql, string $categoryClassKeysSql): string
{
return "(`{$alias}`.`class_key` IN ({$treeClassKeysSql}) "
. "AND (`{$alias}`.`class_key` IN ({$categoryClassKeysSql}) OR `{$alias}`.`isfolder` = 1))";
}

/**
* @return string[]
*/
private function getOptionTreeClassKeys(): array
{
return array_values(array_unique(array_merge(
self::OPTION_TREE_CATEGORY_CLASS_KEYS,
self::OPTION_TREE_CONTAINER_CLASS_KEYS
)));
}

private function isOptionTreeCategoryClass(string $classKey): bool
{
return in_array($classKey, self::OPTION_TREE_CATEGORY_CLASS_KEYS, true);
}

/**
* @param string[] $values
*/
private function quoteSqlStringList(array $values): string
{
return implode(', ', array_map(fn(string $value): string => $this->modx->quote($value), $values));
}

/**
* Decode ids array whether it came as JSON string, comma string, or array.
*
Expand Down
39 changes: 26 additions & 13 deletions vueManager/src/components/CategoryOptionsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,36 @@ async function loadAvailableOptions() {
}

async function loadAvailableCategories() {
// Source categories for copy — all msCategory resources. We reuse the tree endpoint
// flattened: just fetch the first level of resources for now (limit to obvious set).
// A richer picker would use the full tree, but a flat Select is consistent with the
// legacy ExtJS behavior.
const r = await request.get('/api/mgr/options/tree', { parent: 0 })
const roots = r?.results || []
// Expand one level for convenience (walk all children of each msCategory root).
const expanded = [...roots]
for (const root of roots) {
const children = await request.get('/api/mgr/options/tree', { parent: root.id })
expanded.push(...(children?.results || []).map(c => ({ ...c, label: ` ${c.label}` })))
}
availableCategories.value = expanded
// Source categories for copy — all selectable msCategory resources from the semantic tree.
const categories = await collectSelectableCategories(0)
availableCategories.value = categories
.filter(c => c.id !== props.categoryId)
.map(c => ({ id: c.id, label: c.label }))
}

/** Must match OptionCategoryTree: absent `selectable` means legacy msCategory-only payload. */
function isTreeRowSelectable(row) {
return typeof row.selectable === 'boolean' ? row.selectable : true
}

async function collectSelectableCategories(parent = 0, level = 0) {
const r = await request.get('/api/mgr/options/tree', { parent })
const rows = r?.results || []
const categories = []
const indent = ' '.repeat(level)

for (const row of rows) {
if (isTreeRowSelectable(row)) {
categories.push({ ...row, label: `${indent}${row.label}` })
}
if (!row.leaf) {
categories.push(...(await collectSelectableCategories(row.id, level + 1)))
}
}

return categories
}

async function saveCellEdit(event) {
const { newData, field } = event
// category_caption / category_description are per-link overrides on msCategoryOption
Expand Down
44 changes: 39 additions & 5 deletions vueManager/src/components/OptionCategoryTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Pre-fetch checked state from the option's current links. Pass 0/null to skip. */
optionId: { type: [Number, String], default: 0 },
/** Root resource id. 0 = site root. */
rootParent: { type: Number, default: 0 },
})

const emit = defineEmits(['update:modelValue'])
Expand Down Expand Up @@ -93,6 +91,11 @@ async function fetchChildren(parent = 0) {
return results.map(row => normalizeNode(row))
}

/** Backend adds `selectable`; older responses without it were msCategory-only (all selectable). */
function isApiRowSelectable(row) {
return typeof row.selectable === 'boolean' ? row.selectable : true
}

function normalizeNode(row) {
const node = {
key: String(row.id),
Expand All @@ -101,6 +104,7 @@ function normalizeNode(row) {
leaf: !!row.leaf,
data: {
class_key: row.class_key,
selectable: isApiRowSelectable(row),
published: row.published,
hidemenu: row.hidemenu,
},
Expand All @@ -112,11 +116,18 @@ function normalizeNode(row) {
return node
}

function isSelectableNode(node) {
return !!node?.data?.selectable
}

function isChecked(node) {
return checkedSet.value.has(node.id)
}

function toggleNode(node, checked) {
if (!isSelectableNode(node)) {
return
}
const next = new Set(checkedSet.value)
if (checked) {
next.add(node.id)
Expand All @@ -130,7 +141,7 @@ function toggleNode(node, checked) {
async function loadRoot() {
loading.value = true
try {
nodes.value = await fetchChildren(props.rootParent)
nodes.value = await fetchChildren(0)
} finally {
loading.value = false
}
Expand Down Expand Up @@ -202,6 +213,12 @@ async function bulkToggleChecks(node, checked) {

const next = new Set(checkedSet.value)
function walk(n) {
if (!isSelectableNode(n)) {
if (Array.isArray(n.children)) {
n.children.forEach(walk)
}
return
}
if (checked) {
next.add(n.id)
} else {
Expand Down Expand Up @@ -267,15 +284,23 @@ defineExpose({
@node-expand="onNodeExpand"
>
<template #default="{ node }">
<span class="tree-node-row" @contextmenu.prevent="onNodeContextMenu($event, node)">
<span
class="tree-node-row"
:class="{ 'tree-node-row-navigation': !isSelectableNode(node) }"
@contextmenu.prevent="onNodeContextMenu($event, node)"
>
<Checkbox
v-if="isSelectableNode(node)"
:model-value="isChecked(node)"
:binary="true"
:input-id="'opt-cat-' + node.id"
class="tree-node-check"
@update:model-value="toggleNode(node, $event)"
/>
<label :for="'opt-cat-' + node.id" class="tree-node-label">{{ node.label }}</label>
<label v-if="isSelectableNode(node)" :for="'opt-cat-' + node.id" class="tree-node-label">
{{ node.label }}
</label>
<span v-else class="tree-node-label tree-node-label-navigation">{{ node.label }}</span>
</span>
</template>
</Tree>
Expand Down Expand Up @@ -310,4 +335,13 @@ defineExpose({
cursor: pointer;
user-select: none;
}

.vueApp .option-category-tree .tree-node-row-navigation {
opacity: 0.72;
}

.vueApp .option-category-tree .tree-node-label-navigation {
cursor: default;
font-style: italic;
}
</style>
5 changes: 4 additions & 1 deletion vueManager/src/components/OptionsGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,10 @@ onBeforeUnmount(() => {

<div class="dialog-tree">
<h4 class="pane-title">{{ _('ms3_categories') || 'Категории' }}</h4>
<OptionCategoryTree v-model="editingCategories" :option-id="editing.id || 0" />
<OptionCategoryTree
v-model="editingCategories"
:option-id="editing.id || 0"
/>
</div>
</div>

Expand Down