Our blazing fast Grid component built with pure JavaScript


Post by vadim_g »

Hi Bryntum team.

We have been looking for about a week into B internals and source code, attempting to understand how classes and configs work. We appreciate what we have seen so far – good quality code, well-architected widgets with numerous options and configurations, class and widget lifecycles, good parity with ExtJs, and more. Additionally, every widget has fewer than twice as many DOM elements compared to ExtJs (specifically Modern, which we have checked). So, excellent work team, and btw, we are pleased to see former ExtJs engineers in the team.

Our app is currently based on ExtJs Modern, and we are exploring the best possible way to migrate to B. Some of the initial things we appear to be missing so far are the ViewControllers and config bindings (not the ViewModel, we will keep some state configs in controller for now).

We would still want ViewControllers to split the business logic of View internals, plus will make migration smoother. First possible idea we have tried is:

import InstancePlugin from '@bryntum/grid/source/lib/Core/mixin/InstancePlugin';

// Abstract class, to further add more stuff in it.
export default class ViewController extends InstancePlugin {
	static get $name() {
		return 'ViewController';
	}

static get configurable() {
	return {};
}
construct(view, config) {
	super.construct(view, config);
	this.view = view;
}
}

// Add new 'controller' config
Widget.setupConfigs(
	Widget.$meta,
	{
		controller: null,
	},
	false
);

// Automatically instantiate the controller and store the ref in widget.
Widget.prototype.changeController = function (controllerCtor) {
	if (controllerCtor) {
		InstancePlugin.initPlugins(this, controllerCtor);
		return this.plugins[controllerCtor.$$name];
	}
};

class BaseOverride {
	static get target() {
		return {
			class: Base,
		};
	}

resolveCallback(handler, thisObj = this, enforceCallability = true) {
	if (handler?.substring) {
		if (handler.startsWith('ctrl.')) {
			handler = handler.substring(5);

			const view = this.up((parent) => {
				if (parent.controller) {
					return true;
				}
			});

			if (view && view.controller[handler]) {
				thisObj = view.controller;
				handler = view.controller[handler];
			}
		}
	}

	return this._overridden.resolveCallback.call(
		this,
		handler,
		thisObj,
		enforceCallability
	);
}
}

Override.apply(BaseOverride);

We would want also config bindings, as the code it's more declarative, readable and probaly easier to be maintained. We still haven't figured out how to nicely propagate config values from one config/formula to widget's config (hidden, disabled), but for now can be done through FunctionHelper.after - is just and ugly idea (no cleanup yet after widgets destroy) to test the principle...but we will find a better way to do it (using events or proxies).


// Question, is this the correct and/or the only way to add configs to internal classes, and attach a change/update method  ?
Widget.setupConfigs(
	Widget.$meta,
	{
		bind: null,
	},
	false
);

Widget.prototype.changeBind = function (bindings) {
	if (bindings) {
		const view = this.up((parent) => {
			if (parent.controller) {
				return true;
			}
		});

	const controller = view?.controller;

	if (controller) {
		for (const field in bindings) {
			const flag = bindings[field];

			const { changer } = controller.$meta.configs[flag];

			FunctionHelper.after(controller, changer, (val) => {
				this[field] = val;
			});
		}
	}
}
};

Test example:

import ViewController from '@/bryntum/core/app/ViewController';
import Toolbar from '@bryntum/grid/source/lib/Core/widget/Toolbar';
import '@bryntum/grid/source/lib/Core/widget/Button';
import '@/bryntum/theme.scss';
import './WidgetOverride';

describe('Widget.bind', function () {
	class ToolbarController extends ViewController {
		static get configurable() {
			return {
				isButtonHidden: false,
				isButtonDisabled: false,
			};
		}

	construct(view, config) {
		super.construct(view, config);
	}

	onSelectionChange() {
		this.isButtonHidden = true;
	}

	changeIsButtonHidden(val) {
		return val;
	}

	changeIsButtonDisabled(val) {
		return val;
	}

	onHideButtonClick() {
		this.isButtonHidden = !this.isButtonHidden;
	}
}

it('Widget.bind', () => {
	new Toolbar({
		appendTo: document.body,
		controller: ToolbarController,
		width: 800,
		items: [
			{
				type: 'button',
				text: 'hide buttons',
				listeners: {
					click: 'ctrl.onHideButtonClick',
				},
			},
			{
				type: 'button',
				text: 'disabled buttons',
				onClick() {
					this.up().controller.isButtonDisabled =
						!this.up().controller.isButtonDisabled;
				},
			},
			{
				type: 'button',
				text: 'hide me 1',
				bind: {
					hidden: 'isButtonHidden',
				},
			},
			{
				type: 'button',
				text: 'hide me 2',
				bind: {
					hidden: 'isButtonHidden',
				},
			},
			{
				type: 'button',
				ref: 'buttonDisable',
				text: 'disable me 2',
				bind: {
					disabled: 'isButtonDisabled',
				},
			},
		],
	});
});
});

Everythign here is just a draft to test some ideas and also to learn B internals, so suggestions and constructive discussions are welcomed.

Thanks.
Vadim

Edit: feel free to move it to the appropiate forum group, if this one is not ok, thx.


Post by Animal »

If you configure your outermost Widget with an owner which is your controller object, then normal up.functionName will find it if specified on widgets. They all can follow the owner axis upwards.

The only problem with this approach is listeners on Stores. Stores are of course shareable, and do not have an owner. Listeners on that will have to be specified as functions.


Post by Animal »

For detecting config changes, the config system calls

me.onConfigChange({ name, value, was, config });

You could hook that


Post by vadim_g »

Hi Animal,

thanks for your suggestions.

  1. We would like to keep the "up" mechanism with owner strictly to widget to avoid any possible future conflicts with the framework, and be consistent with ExtJs as well, as we will upgrade slowly, probably having Modern and B in the same app for a while.

  2. We optimized above idea with me.onConfigChange and we trigger configChange event so to avoid over-hooking.

import Widget from '@bryntum/grid/source/lib/Core/widget/Widget';
import Base from '@bryntum/grid/source/lib/Core/Base';
import InstancePlugin from '@bryntum/grid/source/lib/Core/mixin/InstancePlugin';
import Override from '@bryntum/grid/source/lib/Core/mixin/Override';

class BaseOverride {
	static get target() {
		return {
			class: Base,
		};
	}

resolveCallback(handler, thisObj = this, enforceCallability = true) {
	if (handler?.substring && handler.startsWith('ctrl.')) {
		const controller = this.getController();

		if (controller) {
			handler = handler.substring(5);

			if (controller[handler]) {
				thisObj = controller;
				handler = controller[handler];
			}
		}
	}

	return this._overridden.resolveCallback.call(
		this,
		handler,
		thisObj,
		enforceCallability
	);
}
}

Override.apply(BaseOverride);

Widget.setupConfigs(
	Widget.$meta,
	{
		bind: null,
		controller: null,
	},
	false
);

Widget.prototype.getController = function () {
    let {controller} = this;

if (!controller) {
    this.up((parent) => {
        if (parent.controller) {
            ({controller} = parent);
            return true;
        }
    });
}
return controller;
};

Widget.prototype.changeController = function (controllerCtor) {
	if (controllerCtor) {
		InstancePlugin.initPlugins(this, controllerCtor);
		return this.plugins[controllerCtor.$$name];
	}
};

Widget.prototype.changeBind = function (bindings, oldBindings) {
	oldBindings?.$detacher?.();

if (!bindings) {
	return;
}

const controller = this.getController();

if (!controller) {
	return;
}

const bindingsMap = {};
const me = this;

for (const widgetConfig in bindings) {
	const controllerConfig = bindings[widgetConfig];

	if (!bindingsMap[controllerConfig]) {
		bindingsMap[controllerConfig] = [];
	}
	bindingsMap[controllerConfig].push(widgetConfig);
}

const detacher = controller.on('configChange', ({ name, value }) => {
	if (bindingsMap[name]) {
		bindingsMap[name].forEach((widgetConfig) => {
			me[widgetConfig] = value;
		});
	}
});

this.on('destroy', () => {
	bindingsMap.$detacher();
});

bindingsMap.$detacher = detacher;

return bindingsMap;
};
import InstancePlugin from '@bryntum/grid/source/lib/Core/mixin/InstancePlugin';

export default class ViewController extends InstancePlugin {
	static get $name() {
		return 'ViewController';
	}

static get configurable() {
	return {};
}

construct(view, config) {
	super.construct(view, config);
	this.view = view;
}

onConfigChange() {
	if (this.hasListener('configChange')) {
		this.trigger('configChange', ...arguments);
	}
}
}

We don't need Store listeners attached in that way, for us it's ok to be added in controller manually with on calls.

Seems to me that it's not difficult to add ViewController and one way bindings capabilities to B. Would be great to see them in the framework by default.

Thanks.
Vadim.


Post by vadim_g »

And with namedItems and handlers on controllers it's getting really nice:

describe('Container namedItems', function () {
	it('', () => {
		class ContainerController extends ViewController {
			static get configurable() {
				return {
					isButtonHidden: false,
				};
			}

		construct(view, config) {
			super.construct(view, config);
		}

		onHideButtonClick() {
			this.isButtonHidden = !this.isButtonHidden;
		}

		onRemoveRowClick() {
			console.log('Remove row: onRemoveRowClick');
		}
	}

	new Container({
		appendTo: document.body,
		controller: ContainerController,
		width: 800,
		height: 500,
		namedItems: {
			removeRow: {
				text: 'Remove row',
				onItem: 'ctrl.onRemoveRowClick',
				onClick: 'ctrl.onRemoveRowClick'
			},
		},
		items: [
			{
				type: 'toolbar',
				namedItems: 'up.namedItems',
				items: {
					removeRow: true,
					menuButton: {
						type: 'button',
						text: 'Menu',
						menu: {
							namedItems: 'up.namedItems',
							items: {
								removeRow: true,
							},
						},
					},
				},
			},
		],
	});
});
});

Post by alex.l »

Hi vadim_g,

Thanks for sharing your solution! Do you need further assistance in bounds of the current topic? Did I miss an unanswered question in your last replies?

All the best,
Alex


Post by vadim_g »

Hi Alex,

no...all good.

thx.
Vadim


Post Reply