Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
4 changes: 2 additions & 2 deletions modules/backend/assets/ui/js/build/vendor.js

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions modules/backend/formwidgets/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ class Relation extends FormWidgetBase
*/
public $order;

/**
* @var bool Define if the widget must be rendered has a displayTree.
*/
public $displayTree;

//
// Object properties
//
Expand All @@ -73,6 +78,7 @@ public function init()
'emptyOption',
'scope',
'order',
'displayTree',
]);

if (isset($this->config->select)) {
Expand All @@ -97,6 +103,14 @@ public function prepareVars()
$this->vars['field'] = $this->makeRenderFormField();
}

/**
* @inheritDoc
*/
protected function loadAssets()
{
$this->addJs('js/dist/relation.js', 'core');
}

/**
* Makes the form object used for rendering a simple field type
* @throws SystemException if an unsupported relation type is used.
Expand Down Expand Up @@ -156,8 +170,7 @@ protected function makeRenderFormField()
$nameFrom = 'selection';
$selectColumn = $usesTree ? '*' : $relationModel->getKeyName();
$result = $query->select($selectColumn, Db::raw($this->sqlSelect . ' AS ' . $nameFrom));
}
else {
} else {
$nameFrom = $this->nameFrom;
$result = $query->getQuery()->get();
}
Expand All @@ -172,6 +185,16 @@ protected function makeRenderFormField()
? $result->listsNested($nameFrom, $primaryKeyName)
: $result->lists($nameFrom, $primaryKeyName);

if ($usesTree) {
if ($this->displayTree) {
$field->options = $result->toNestedArray($nameFrom, $primaryKeyName);
} else {
$field->options = $result->listsNested($nameFrom, $primaryKeyName);
}
} else {
$field->options = $result->lists($nameFrom, $primaryKeyName);
}

return $field;
});
}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

248 changes: 248 additions & 0 deletions modules/backend/formwidgets/relation/assets/js/src/Relation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import '../../less/relation.less';

((Snowboard) => {
/**
* Relation form widget.
*
* Renders a checkbox list field to select model related relations
*
* @author Damien MATHIEU <[email protected]>
* @copyright 2025 Winter CMS
*/
class Relation extends Snowboard.PluginBase {
/**
* Constructor.
*
* @param {HTMLElement} element
*/
construct(element) {
this.element = element;
this.config = this.snowboard.dataConfig(this, element);

// Control elements
this.expandAllControl = element.querySelector('[data-field-checkboxlist-expand-all]');
this.collapseAllControl = element.querySelector('[data-field-checkboxlist-collapse-all]');
this.expandCheckedControl = element.querySelector('[data-field-checkboxlist-expand-checked]');

// Child elements
this.items = element.querySelectorAll('.checkboxlist-item');
this.toggles = element.querySelectorAll('.checkboxlist-item-toggle');

// Events
this.events = {
expandAll: () => this.onExpandAll(),
collapseAll: () => this.onCollapseAll(),
expandChecked: () => this.onExpandChecked(),
toggle: (el) => this.onToggle(el),
};

this.attachEvents();
}

/**
* Sets the default options for this widget.
*
* @returns {Object}
*/
defaults() {
return {};
}

/**
* Attaches event listeners for several interactions.
*/
attachEvents() {
this.expandAllControl.addEventListener('click', this.events.expandAll);
this.collapseAllControl.addEventListener('click', this.events.collapseAll);
this.expandCheckedControl.addEventListener('click', this.events.expandChecked);

this.toggles.forEach((toggle) => {
toggle.addEventListener('click', this.events.toggle)
});
}

/**
* Destructor.
*/
destruct() {
this.expandAllControl.removeEventListener('click', this.events.expandAll);
this.collapseAllControl.removeEventListener('click', this.events.collapseAll);
this.expandCheckedControl.removeEventListener('click', this.events.expandChecked);

this.toggles.forEach((toggle) => {
toggle.removeEventListener('click', this.events.toggle)
});
}

/**
* Open a single level of the tree
*
* @param {HTMLElement} el
*/
openLevel(el) {
el.classList.add('open');

let child = el.querySelectorAll('.checkboxlist-children')[0];
if (child) {
child.classList.add('open');
}
}

/**
* Close an signle level of the tree
*
* @param {HTMLElement} el
*/
closeLevel(el) {
el.classList.remove('open');

let child = el.querySelectorAll('.checkboxlist-children')[0];
if (child) {
child.classList.remove('open');
}
}

/**
* Expand all handler.
*
* Makes all nodes of the tree expanded.
*/
onExpandAll() {
const openPromise = new Promise((resolve, reject) => {
let animatedNodes = this.getExpandableNodes();

animatedNodes.forEach((item) => {
this.openLevel(item);
});

resolve([].slice.call(animatedNodes).pop());
});

openPromise.then((el) => {
this.updateScollBar(el);
});
}

/**
* Collapse all handler.
*
* Makes all nodes of the tree collapsed.
*/
onCollapseAll() {
const closePromise = new Promise((resolve, reject) => {
let animatedNodes = this.getOpenedNodes();

animatedNodes.forEach((item) => {
this.closeLevel(item);
});

resolve([].slice.call(animatedNodes).pop());
});

closePromise.then((el) => {
this.updateScollBar(el);
});
}

/**
* Expand checked handler.
*
* Makes all checked nodes of the tree expanded.
*/
onExpandChecked() {
this.onCollapseAll();

const selectedPromise = new Promise((resolve, reject) => {
let animatedNodes = this.getCheckedNodes();

animatedNodes.forEach((item) => {
this.openLevel(item);
});

resolve([].slice.call(animatedNodes).pop());
});

selectedPromise.then((el) => {
this.updateScollBar(el);
});
}

/**
* Toggle handler.
*
* Toggles a tree level expanded/collapsed.
*
* @param {HTMLElement} el
*/
onToggle(el) {
const tooglePromise = new Promise((resolve, reject) => {
let parent = el.target.parentElement;

if (parent.classList.contains('open')) {
this.closeLevel(parent);
} else {
this.openLevel(parent);
}

resolve(parent);
});

tooglePromise.then((parent) => {
this.updateScollBar(parent);
});
}

/**
* Update the sidebar height
*
* @param {HTMLElement} el The last animated node of the tree
*/
updateScollBar(el) {
if (el === undefined) {
return;
}

let openedLevel = el.classList.contains("checkboxlist-children") ? el : el.querySelector('.checkboxlist-children');

openedLevel.addEventListener("transitionend", () => {
$('[data-control=scrollbar]').data('oc.scrollbar').update();
}, {once: true});
}

/**
* Filter treeview nodes to get only those who have childs
*
* @returns {Array}
*/
getExpandableNodes() {
return Array.prototype.filter.call(this.items, function (level) {
return level.matches(':has(.checkboxlist-children)');
});
}

/**
* Filter treeview nodes to get only opened ones
*
* @returns {Array}
*/
getOpenedNodes() {
return Array.prototype.filter.call(this.items, function (level) {
return level.classList.contains("open")
});
}

/**
* Filter treeview nodes to get only those containing checked checkboxes
*
* @returns {Array}
*/
getCheckedNodes() {
return Array.prototype.filter.call(this.getExpandableNodes(), function (level) {
return level.matches(':has(input:checked)');
});
}
}

Snowboard.addPlugin('backend.formwidget.relation', Relation);
Snowboard['backend.ui.widgethandler']().register('relation', 'backend.formwidget.relation');
})(window.Snowboard);
84 changes: 84 additions & 0 deletions modules/backend/formwidgets/relation/assets/less/relation.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
@import "../../../../assets/less/core/boot.less";


.checkboxlist-item {


}

div[data-control="relation"] {

// Top widget controls
.field-checkboxlist .checkboxlist-controls > div:nth-child(even) {
margin-left: auto;
margin-right: 0;
}

.checkboxlist {

&-item {
--background-padding : 10px;

display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
flex-wrap: wrap;
margin-bottom: 10px;
margin-top: 15px;

.checkboxlist-item-toggle .icon-chevron-right {
display: block;
transition: transform 0.15s ease-in-out;
}

&:has(.checkboxlist-item).open {
background: linear-gradient(90deg, rgba(0, 0, 0, 0) calc(var(--background-padding) - 1px), rgba(0, 0, 0, 0.25) var(--background-padding), rgba(0, 0, 0, 0) calc(var(--background-padding) + 1px));

> .checkboxlist-item-toggle .icon-chevron-right {
transform: rotate(90deg);
}
}


& .custom-checkbox {
flex-grow: 1;
margin-top: 0;
margin-bottom: 0;
}

&-toggle {
margin: 0 calc(1rem + 15px) 0 1rem;

.icon-chevron-right {
pointer-events: none;
}
}
}

&-children {
width: 100%;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease-in-out;

&.open {
grid-template-rows: 1fr;
}
& > div {
overflow: hidden;
}
}
}

.checkboxlist-item .checkboxlist-item {
--background-padding: 20px;

margin-left: 10px;
padding-left: 10px;
}

.checkboxlist-item ~ .checkboxlist-item {
margin-top: 0;
}
}
Loading
Loading