În mod ideal, Kendo Property Grid ar trebui să rezolve elegant problema editării dinamice a proprietăților unui graf de obiecte. În practică însă, fie nu este inclus în pachetul Kendo (valabil pentru versiunile mai vechi), fie funcționalitatea lui produce — ca să folosesc un termen semitehnic — costuri psihiatrice neprevăzute.
În cazul meu, o situatie ce reunea două condiții simultan – un model de date ce nu mai era supus negocierii (de altfel, foarte potrivit scopului său) cu câteva elemente problematice, respectiv variabil în funcție de elementul editat (adică pentru fiecare tip de element exista câte o clasă configuratoare, proprietățile fiecăreia fiind ceea ce se dorea expus în formularul de adăugare ori modificare) – mi-a scos-o de pe radar după o lungă sesiune de navigat cu toate pânzele sus prin vechi subterane ale limbii.

Foto: Bogdan Stoica
Variabilitatea modelului a fost de departe cel mai mare impediment: deși fațada MVC a componentei Property Grid este relativ pricepută în a traduce o clasă dată într-o definiție rezonabilă a ceea ce trebuie mânuit tot rămâneau câteva retușuri de făcut în structura câmpurilor pentru fiecare clasă-n parte, deci un potențial de opt definiții complet separate.
Cât despre elementele problematice, acestea erau niște simpli vectori de șiruri de caractere (string[]) și n-am reușit neam să obțin o modalitate de-a le edita fără giumbușlucuri din partea utilizatorului, deci trebuie oricum scrisă o rutină specială.
Un pseudo-Property Grid folosind un Kendo Grid clasic
Din fericire, componenta Kendo Grid este suficient de versatilă încât să poată fi pusă la treabă ca un Property Grid: pe coloana din stânga etichetele proprietăților editabile, iar pe coloana din dreapta elementele folosite pentru captarea valorilor de la utilizator: casete text, bife, liste de selecție ș.a.m.d (să-i zicem generic editor).
Una din diferențele fundamentale între cele două este că – vorbind strict din punct de vedere conceptual – noțiunea de cap de tabel este complet diferită:
– pentru un Property Grid este orizontală, adică fiecare rând reprezintă un câmp din capul de tabel;
– pentru un Grid este verticală, adică fiecare coloană reprezintă un câmp din capul de tabel, ceea ce, în ultimă instanță, o face mult mai flexibilă.
Ca un prim corolar, un Grid este mult mai potrivit pentru o situație dinamică precum cea de față, întrucât trebuie să construiesc definiția componentei doar în termeni de două câmpuri, o etichetă și un editor (în practică mai multe, desigur, mai sunt și elemente ajutătoare, ideea este că avem totuși de-a face cu un set bine determinat), nu de un număr de Dumnezeu-știe-cât (practic nedeterminat).

O mostră, aici este activat editorul pentru vectori de șiruri de caractere
Câteva cerințe
O interfață de calitate și bine cizelată presupune abordarea eficientă a următoarelor probleme:
– afișarea valorilor;
– inițializarea corectă a editorului potrivit pentru o anumită valoare;
– colectarea și validarea valorii introduse de utilizator, precum și prezentarea unui mesaj de eroare în caz că nu-i bună;
– afișarea unor informații ajutătoare pentru fiecare câmp editat (sub formă unor mici bule informative contextual, tooltips în engleză);
– un set de proprietăți trebuie să poată fi împărțite pe categorii pentru identificarea ușoară a celor dorite.
Și, pentru a face totul mai interesant, să menționez că în cazul meu au fost nevoie de două astfel de seturi de proprietăți, necesitând două componente Grid pe aceeași pagină, deci o soluție generică.
Definirea Kendo Grid
Așadar, pentru început, să vedem definiția unui Kendo Grid ce va putea satisface scenariul (spre final este și definiția modelului ce-alimentează toată confuzia de față, adică structura fiecărei poziții din vectorul data):
function initGrid(gridId: string, data: any[]) {
$("#" + gridId).kendoGrid({
columns: [
{ hidden: true, menu: false,
field: "Id" },
{ hidden: true, menu: false,
field: "AvailableValues" },
{ hidden: true, menu: false,
field: "IsCollection" },
{ hidden: true, menu: false,
field: "IsReadOnly" },
{ hidden: true, menu: false,
field: "IsRequired" },
{ hidden: true, menu: false,
field: 'MustBeNumberInterval' },
{ title: "Category", hidden: true, menu: false,
field: "Category", },
{ hidden: true, menu: false,
field: "Description" },
{
title: "Display Name",
width: "40%",
field: "DisplayName",
encoded: true
},
{
title: "Value",
//The way I am splitting this call is not exactly legal,
// just trying to control wrapping
template: "#=renderProperty(Id,
Value,
AvailableValues,
DisplayName,
Description)#",
field: "Value",
sortable: false,
encoded: true,
editable: isEditableProperty,
editor: editProperty
}
],
groupable: { enabled: true },
sortable: { mode: "single" },
scrollable: false,
editable: {
mode: "incell",
readonly: false,
template: null,
update: true,
destroy: false
},
dataBound: function () {
setupGridTooltips(gridId);
},
dataSource: {
group: [ { field: "Category", dir: "asc" } ],
schema: {
data: "Data",
total: "Total",
errors: "Errors",
model: {
id: "Id",
fields: {
Id: { type: "string" },
DisplayName: { editable: false, type: "string" },
Description: { type: "string" },
Category: { type: "string" },
Name: { type: "string" },
Value: { type: "object" },
AvailableValues: { type: "array" },
MustBeNumberInterval: { type: "object" },
IsCollection: { type: "boolean" },
IsReadOnly: { type: "boolean" },
IsRequired: { type: "boolean" }
},
}
},
data: {
Data: data || [],
Total: (data || []).length
}
}
});
}
Afișara valorilor
Pentru afișarea valorilor este nevoie de o funcție separată, renderProperty de mai sus, întrucât nu toate cazurile-s oable:
– dacă valoarea este de tip boolean, vrem să afișăm un text descriptiv, bunăoară Yes/No sau Da/Nu;
– dacă valoarea este un vector, trebuie să prezentăm toate valorile separate prin virgulă;
– dacă valoara face parte dintr-un set de opțiuni prezentate într-un editor de tip listă derulantă (ex. Kendo DropDownList), atunci trebuie să căutăm elementul complet pentru valoarea-n cauză și să afișăm eticheta descriptivă.
function renderProperty(id: string,
value: any,
availableValues: NameValueOption[],
displayName: string,
description: string) {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'string') {
//If we have some available values means this
// is chosen from a dropdownlist so we need
// to pick the label for the value
if (!!availableValues) {
const valueLabel: string = translateValueToLabel(value,
availableValues);
if (valueLabel !== null) {
return valueLabel;
}
}
return value;
}
//Quacks like a duck...
if (mightItBeArray(value)) {
return value.join(', ');
}
return '';
}
function translateValueToLabel(value: any,
availableValues: NameValueOption[]) {
const checkValue: string = (value || '').toString();
for (var i = 0; i < availableValues.length; i++) {
if (availableValues[ i ].Value == checkValue) {
return availableValues[ i ].Name;
}
}
return null;
}
function mightItBeArray(value: any) {
return !!value
&& !!value.join
&& !!value.length;
}
Modificarea valorilor
Mai înainte de-a vedea cum pot fi implementate editoarele propriu-zise, trebuie ridicată problema dacă o anumită valoare poate fi modificată au ba. Modificarea poate fi dezactivată fie dacă utilizatorul n-are neam drepturile corespunzătoare, fie dacă acea proprietate este marcată drept read-only (IsReadOnly = true pentru înregistrarea respectivă).
Funcția isEditableProperty() a fost angajată, așadar, drept paznic de zi și de noapte:
function isEditableProperty(model: kendo.data.Model): boolean {
cosnt isReadOnly: boolean = !!model.get('IsReadOnly');
//_canEditStuff() says whether or not current user can edit
return !isReadOnly && _canEditStuff();
}
Bun, acum să vedem cum șade treaba cu editoarele. Avem nevoie doar de patru:
– bifă pentru selectare da/nu (kendo.ui.CheckBox);
– listă de șiruri de caracter cu ajutorul unui kendo.ui.TextArea (practic una pe linie);
– listă derulantă cu selecție unică (kendo.ui.DropDownList);
– text ordinar (kendo.ui.TextBox).
Prin intermediul parametrului options (kendo.ui.GridColumnEditorOptions) avem acces la aproximativ tot ce avem nevoie: ne interesează-n mod deosebit options.model, înregistrarea corespunzătoare fiecărei proprietăți, de unde putem determina ce editor va fi necesar. Am să includ doar primele trei editoare, că-s mai interesante.
function editProperty($container: JQuery,
options: kendo.ui.GridColumnEditorOptions) {
const editorOptions: PropertyGridEditorOptions =
_createEditorOptions(options);
if (editorOptions.isYesNo) {
_setupYesNoEditor($container, editorOptions);
} else if (editorOptions.isCollection) {
_setupStringCollectionEditor($container, editorOptions);
} else if (editorOptions.isSelect) {
_setupSimpleDropdownListEditor($container, editorOptions);
} else {
_setupSimpleTextEditor($container, editorOptions);
}
}
function _createEditorOptions(options: kendo.ui.GridColumnEditorOptions)
: PropertyGridEditorOptions {
const id: string = options
.model[ "Id" ];
const value: any = options
.model[ "Value" ];
const availableValues: NameValueOption[] = options
.model[ "AvailableValues" ];
const isCollection: boolean = !!options
.model[ "IsCollection" ];
const isRequired: boolean = !!options
.model[ "IsRequired" ];
const isSelect: boolean = !!availableValues;
const isYesNo: boolean = typeof value
=== 'boolean';
const mustBeNumberInterval: any = options
.model[ "MustBeNumberInterval" ];
const fieldId: string = id + '_' + options.field;
return {
fieldId: fieldId,
fieldName: options.field,
value: value,
availableValues: availableValues,
isCollection: isCollection,
isRequired: isRequired,
isSelect: isSelect,
isYesNo: isYesNo,
mustBeNumberInterval: {
allowSingleValue: !!mustBeNumberInterval
&& !!mustBeNumberInterval.AllowSingleValue
},
model: options.model
};
}
function _setupYesNoEditor($container: JQuery,
editorOptions: PropertyGridEditorOptions) {
$('<input id="' + editorOptions.fieldId + '" />')
.appendTo($container)
.kendoCheckBox({
name: editorOptions.fieldName,
checked: !!editorOptions.value,
change: function (e: kendo.ui.CheckBoxChangeEvent) {
editorOptions.model.set('Value', e.checked);
}
});
}
function _setupStringCollectionEditor($container: JQuery,
editorOptions: PropertyGridEditorOptions) {
$('<textarea id="' + editorOptions.fieldId + '" />')
.appendTo($container)
.kendoTextArea({
name: editorOptions.fieldName,
value: (editorOptions.value || []).join("\n"),
change: function (e: kendo.ui.TextAreaChangeEvent) {
const newRawValue = e.sender.value();
const newValues: string[] =
_processRawStringListInput(newRawValue);
const validationMessage: string =
_validateStringListInput(newValues,
editorOptions.isRequired);
if (validationMessage === null) {
editorOptions.model.set('Value', newValues);
} else {
kendo.alert(validationMessage);
}
},
rows: 3
});
}
function _setupSimpleDropdownListEditor($container: JQuery,
editorOptions: PropertyGridEditorOptions) {
$('<select id="' + editorOptions.fieldId + '" />')
.appendTo($container)
.kendoDropDownList({
name: editorOptions.fieldName,
value: editorOptions.value,
dataTextField: 'Name',
dataValueField: 'Value',
change: function (e: kendo.ui.DropDownListChangeEvent) {
editorOptions.model.set('Value', e.sender.value());
},
dataSource: {
data: editorOptions.availableValues || []
}
});
}
Gestionarea evenimentului change este necesară pentru a putea colecta valoarea actualizat-n înregistrarea corespunzătoare proprietății. Tipurile PropertyGridEditorOptions și PropertyGridEditorValueIntervalInfo au exclusiv rolul de-a îndulci autocompletarea și n-am să le includ. Micile funcții ajutătoare de asemenea sunt neinteresante și-am să le las și pe dânsele deoparte.
Note informative
Ultimele două lucruri rămase de făcut sunt afișarea bulelor informative contextuale (tooltips) și salvarea informațiilor. Așadar, să aplaudăm mai întâi setupGridTooltips (în principiu, pentru fiecare celulă relevantă extrag fie câmpul Description, fie DisplayName pentru a genera conținutul):
function setupGridTooltips(gridId) {
const kGrid: kendo.ui.Grid = $("#" + gridId)
.data("kendoGrid");
$("#" + gridId + " tbody tr.k-master-row td.k-table-td")
.each(function () {
const $me: JQuery = $(this);
if ($me.hasClass('k-group-cell')) {
return;
}
$me.kendoTooltip({
position: 'left',
showAfter: 500,
width: 550,
show: function (e: kendo.ui.TooltipEvent) {
e.sender.popup.element
.addClass('unfuck-tooltip-wrapping');
},
content: function (e) {
const dataItem: kendo.data.ObservableObject =
kGrid.dataItem(e.target.closest("tr"));
let content: string = dataItem
.get('Description');
if (!content || !content.length) {
content = dataItem.get('DisplayName');
}
return content;
}
});
});
}
Iar clasa CSS cu nume dubios are rolul de-a repara oarecum comportamentul dubios al componentei kendo.ui.Tooltip:
.unfuck-tooltip-wrapping,
.unfuck-tooltip-wrapping .k-tooltip-content {
white-space: normal;
word-wrap: break-word;
height: auto;
max-width: 550px;
}
Colectarea tuturor valorilor
Și, în sfârșit, Jupuitu’ cu fonciirea:
function collectEditableProperties(gridId): any[] {
var properties = [];
var kGrid: kendo.ui.Grid = $("#" + gridId).data("kendoGrid");
kGrid.dataSource.data()
.forEach(function (item: kendo.data.ObservableObject) {
var value: any = item
.get("Value");
var isCollection: boolean = item
.get("IsCollection");
properties.push({
Id: item.get("Id"),
Value: isCollection
? _toStraightArray(value)
: value,
IsCollection: isCollection
});
});
return properties;
}
function _toStraightArray(fromKendoBogusArray: any[]) {
//Upon feeding an array value to a grid,
// even if it's a property of the actual data item
// kendo will convert it to something observable,
// which is probably for the best, but highly inconvenient
// when extracting the values in order to save them
var straightArray: any[] = [];
for (var i = 0; i < fromKendoBogusArray.length; i++) {
straightArray[ i ] = fromKendoBogusArray[ i ];
}
return straightArray;
}
Asta-i tot. Desigur, ar mai fi partea de răspundere a server-ului de-a genera datele în forma necesară, precum și de-a le procesa odată ce utilizatorul comandă salvarea lor, dar consider că, în acest punct, este complet neinteresant, rezolvabil cu puțină reflecție (atât din soiul metafizic, cât și din soiul tehnic).
În loc de concluzie
Folosirea unui Kendo Grid pe post de Kendo Property Grid nu este neapărat o idee intuitivă, dar într-un scenariu dinamic se dovedește surprinzător de eficientă. În loc să definim câmpuri fixe sau să ne luptăm cu limitările componentei standard, putem construi o soluție flexibilă și viabilă care se adaptează oricărui set de proprietăți.
O posibilă limitare a soluției descrise ar fi probabil ierarhizarea pe mai mult de un nivel, însă am să las soluționarea acestei probleme ca exercițiu pentru cine-o are.