Skip to content
Open
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
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.

254 changes: 254 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,254 @@
import '../../less/relation.less';

((Snowboard) => {
/**
* Relation form widget.
*
* Renders a checkbox list field to select model related relations
*
* @author Damien MATHIEU <damsfx@gmail.com>
* @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() {
if (this.expandAllControl) {
this.expandAllControl.addEventListener('click', this.events.expandAll);
}
if (this.collapseAllControl) {
this.collapseAllControl.addEventListener('click', this.events.collapseAll);
}
if (this.expandCheckedControl) {
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);
78 changes: 78 additions & 0 deletions modules/backend/formwidgets/relation/assets/less/relation.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@import "../../../../assets/less/core/boot.less";

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