Добрый день всем!
Очень не хватает в D365FO (после всех версий AX) получить информацию в пользовательском режиме от контрола - к какому полю / метода какого датасорса (с указанием названия таблицы / представления).
По сравнению с предыдущими версиями AX получение информации усложняется потенциальным наличием пользовательских контролов (по типу DimensionEntryControl), которые описываются обычными классами на X++. Вдобавок немного поменялся подход к рисованию контекстных меню.
Решил попробовать сделать нечто подобное. Пока, к сожалению, нет возможности масштабно обкатать модификацию, поэтому прошу сообщать о выявленных ошибках и ситуациях.
Итак, хочется получить вот такие вот картинки:
Попутно я решил попробовать написать код так, чтобы добавление новых пользовательских контролов не привело бы к необходимости править мой код. Поэтому в программный код были добавлены делегаты и обработка пользовательских контролов осуществляется путем подписки на 3 делегата:
- делегат, который определяет, что выбранный контрол является пользовательским
- делегат, который вычисляет название датасорса с таблицей
- делегат, который вычисляет название поля
Образец написания подписчиков делегатов представлен на примере обработки контрола DimensionEntryControl для финансовых аналитик.
Поддерживаемые типы контролов:
- FormCheckBoxControl
- FormComboBoxControl
- FormDateControl
- FormDateTimeControl
- FormGuidControl
- FormInt64Control
- FormIntControl
- FormRadioControl
- FormRealControl
- FormRichTextControl
- FormStaticTextControl
- FormStringControl
- FormTimeControl
- FormWindowControl
- FormReferenceGroupControl
- FormSegmentedEntryControl / SegmentedEntryControl
Технически, система при открытии формы обходит все поддерживаемые контролы и добавляет в их контекстные меню - дополнительные пункты меню. Само собой - это заметно сказывается как на времени открытия формы, так и на времени отображения контекстного меню. Поэтому в параметрах пользователя предусмотрен флажок (по умолчанию выключенный), который включает данную функциональность для конкретного пользователя.
Код постарался подробно прокомментировать (умещается всё в одном классе)
X++:
// VSUH, 23.11.2020 Добавление названий источника данных (датасорс + поле / метод) в контекстное меню контрола на форме
class FormControlShowDevInfo
{
public FormRun formRun; // Обрабатываемая форма
const int cInfoDataSource = -100; // Код пункта меню с названием датасорса и таблицы
const int cInfoFieldMethod = -101; // Код пункта меню с названием поля / метода таблицы
#Properties
public const str cPropertyDataSource = #PropertyDataSource;
public const str cPropertyDataFieldName = #PropertyDataFieldName;
// Map для хранения контекстного меню, которое может быть переопределено разработчиком на форме.
// Если меню не переопределено разработчиком, то значение в Map пустое. Попутно выполняет роль перечня контролов,
// у которых переопределено контекстное меню данным классом (т.к. нельзя 2 раза вызвать метод registerOverride)
Map ctrlContextMenu;
// Map для хранения контролов, которые были созданы в контейнере пользовательского (Custom) контрола.
// Например, в контроле финансовых аналитик (DimensionEntryControl) в момент запуска формы добавляются FormStringControl-ы
// для вывода непосредственно значений аналитик. В данном Map хранятся эти FormStringControl-ы, с привязкой к родительскому
// пользовательскому контролу (он хранится в value). Все дочерние контролы автоматически получают информацию об источнике
// данных от своего родительского пользовательского контрола (в случае финансовых аналитик - DimensionEntryControl)
Map ctrlChildCustomControl;
// Родительский пользовательский контрол, если производится обход подчиненных контролов (например, для финансовых аналитик -
// это DimensionEntryControl)
FormContainerControl parentCustomCtrl;
/// <summary>
/// "Точка входа" в функционал. Метод вызывается после вызова super() в методе formRun.run()
/// </summary>
/// <param name = "_formInstance">
/// Объект открывшейся формы
/// </param>
[SubscribesTo(classStr(FormRun), staticDelegateStr(FormRun, onFormRunCompleted))]
public static void FormRun_onFormRunCompleted(FormRun _formInstance)
{
if (SysUserInfo::find().ShowFormControlDevInfo)
{
FormControlShowDevInfo::instance(_formInstance).run();
}
}
/// <summary>
/// Параметр для сохранения класса в глобальном кэше
/// </summary>
/// <param name = "_formRun"></param>
/// <returns></returns>
public static str globalCacheOwner(FormRun _formRun)
{
return strFmt("Form:%1", _formRun.name());
}
/// <summary>
/// Параметр для сохранения класса в глобальном кэше
/// </summary>
/// <param name = "_formRun"></param>
/// <returns></returns>
public static int globalCacheKey()
{
return classNum(FormControlShowDevInfo);
}
/// <summary>
/// Класс запускается в режиме "singleton", т.е. для каждой формы - свой единственный экземпляр. Запущенный экземпляр сохраняется в глобальном кэше
/// </summary>
/// <param name = "_formRun">
/// Экземпляр формы
/// </param>
/// <returns></returns>
public static FormControlShowDevInfo instance(FormRun _formRun)
{
FormControlShowDevInfo runClass;
if (appl.globalCache().isSet(FormControlShowDevInfo::globalCacheOwner(_formRun), FormControlShowDevInfo::globalCacheKey()))
{
runClass = appl.globalCache().get(FormControlShowDevInfo::globalCacheOwner(_formRun), FormControlShowDevInfo::globalCacheKey());
}
else
{
runClass = new FormControlShowDevInfo();
runClass.formRun = _formRun;
appl.globalCache().set(FormControlShowDevInfo::globalCacheOwner(_formRun), FormControlShowDevInfo::globalCacheKey(), runClass);
}
return runClass;
}
private void initCtrlContextMenuMap()
{
ctrlContextMenu = new Map(Types::Class, Types::String);
ctrlChildCustomControl = new Map(Types::Class, Types::Class);
}
/// <summary>
/// Стартовый метод запуска обработки формы
/// </summary>
public void run()
{
this.setContextMenuOnCtrl(formRun.design());
}
/// <summary>
/// Метод, определяющий тип контрола. У данного типа контрола свойство, содержащее в себе код поля называется
/// referenceField (в отличии от контролов, перечисленных в методе isSimpleControl())
/// У всех контролов обязательно должен быть метод registerOverrideMethod
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Возвращает истину, если контрол принадлежит к одному из типов, у которых есть метод referenceField
/// </returns>
public boolean isReferenceControl(FormControl _ctrl)
{
boolean ret;
switch (classIdGet(_ctrl))
{
case classNum(FormReferenceGroupControl):
case classNum(FormSegmentedEntryControl):
case classNum(SegmentedEntryControl):
ret = true;
break;
}
return ret;
}
/// <summary>
/// Метод, определяющий тип контрола. У данного типа контрола свойство, содержащее в себе код поля называется
/// dataField (в отличии от контролов, перечисленных в методе isReferenceControl())
/// У всех контролов обязательно должен быть метод registerOverrideMethod
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Возвращает истину, если контрол принадлежит к одному из типов, у которых есть метод dataField
/// </returns>
public boolean isSimpleControl(FormControl _ctrl)
{
boolean ret;
switch (classIdGet(_ctrl))
{
case classNum(FormCheckBoxControl):
case classNum(FormComboBoxControl):
case classNum(FormDateControl):
case classNum(FormDateTimeControl):
case classNum(FormGuidControl):
case classNum(FormInt64Control):
case classNum(FormIntControl):
case classNum(FormRadioControl):
case classNum(FormRealControl):
case classNum(FormRichTextControl):
case classNum(FormStaticTextControl):
case classNum(FormStringControl):
case classNum(FormTimeControl):
case classNum(FormWindowControl):
ret = true;
break;
}
return ret;
}
/// <summary>
/// Метод, определяющий тип контрола. Данный контрол является группирующим, т.е. у него есть дочерние контролы,
/// которые можно перебрать, используя методы controlCount() и controlNum()
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Возвращает истину, если контрол является группирующим
/// </returns>
public boolean isGroupControl(FormControl _ctrl)
{
boolean ret;
switch (classIdGet(_ctrl))
{
case classNum(FormGroupControl):
case classNum(FormGridControl):
case classNum(FormTabControl):
case classNum(FormTabPageControl):
ret = true;
break;
}
return ret;
}
/// <summary>
/// Метод, определяющий тип контрола. Данный контрол является пользовательским, т.е. он базируется на обычном
/// классе из АОТ из узла \Code\Classes. Пользовательских контролов может быть много, поэтому добавление обработки
/// нового пользовательского контрола осуществляется через делегаты
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Возвращает истину, если контрол является пользовательским
/// </returns>
public boolean isCustomControl(FormControl _ctrl)
{
boolean ret;
FormRunServiceArgs isStdCtrl = new FormRunServiceArgs();
this.checkIsCustomControl(_ctrl, isStdCtrl);
ret = isStdCtrl.cancelled();
return ret;
}
/// <summary>
/// Обход контролов на форме и переопределение метода getContextMenuOptions для добавления в контекстное меню пунктов
/// </summary>
/// <param name = "_groupControls">
/// Группирующий контрол
/// </param>
private void setContextMenuOnCtrl(Object _groupControls)
{
Object itemControl;
boolean canOverride;
;
for (int i = 1; i <= _groupControls.controlCount(); i++)
{
itemControl = _groupControls.controlNum(i);
// Рекурсия, если контрол группирующий
if (this.isGroupControl(itemControl))
{
this.setContextMenuOnCtrl(itemControl);
}
canOverride = (this.getInfoDataSourceStr(itemControl) && (this.getInfoFieldStr(itemControl) || this.getInfoMethodStr(itemControl))) || parentCustomCtrl;
// Переопределение контекстного меню выполняется только в случае, если контрол привязан к полю или методу. Либо является
// подчиненным контролом пользовательского контрола (например FormStringControl в контроле DimensionEntryControl)
// Т.о. для несвязанных (Unbound) контролов контекстное меню не переопределяется
if (canOverride)
{
if (!ctrlContextMenu)
{
this.initCtrlContextMenuMap();
}
if (!ctrlContextMenu.exists(itemControl))
{
ctrlContextMenu.insert(itemControl, itemControl.getContextMenuOptions());
if (parentCustomCtrl)
{
ctrlChildCustomControl.insert(itemControl, parentCustomCtrl);
}
itemControl.registerOverrideMethod(methodStr(FormControl, getContextMenuOptions), methodStr(FormControlShowDevInfo, getContextMenuOptions), FormControlShowDevInfo::instance(formRun));
// Анализируются подчиненные контролы пользовательского контрола только в случае, если пользовательский контрол отнаследован от класса FormContainerControl
if (this.isCustomControl(itemControl) && SysDictClass::isEqualOrSuperclass(classIdGet(itemControl), classNum(FormContainerControl)))
{
parentCustomCtrl = itemControl;
this.setContextMenuOnCtrl(itemControl);
parentCustomCtrl = null;
}
}
}
}
}
/// <summary>
/// Получение ссылки на датасорс формы (FormDataSource) на основе переданного контрола. Для контролов, являющихся
/// подчиненными контролами пользовательского контрола - возвращается ссылка на датасорс пользовательского контрола
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Ссылка на датасорс формы. Если ссылку определить не удалось - вернется null
/// </returns>
private FormDataSource getDataSource(Object _ctrl)
{
FormDataSource formDS;
// Для контролов из методов isSimpleControl и isReferenceControl датасорс определяется по идентификатору
if (this.isSimpleControl(_ctrl) || this.isReferenceControl(_ctrl))
{
if (ctrlChildCustomControl && ctrlChildCustomControl.exists(_ctrl))
{
FormContainerControl parentCustomControl = ctrlChildCustomControl.lookup(_ctrl);
return this.getDataSource(parentCustomControl);
}
if (_ctrl.dataSource())
{
for (int i = 1; i <= formRun.dataSourceCount(); i++)
{
if (formRun.dataSource(i).id() == _ctrl.dataSource())
{
formDS = formRun.dataSource(i);
break;
}
}
}
}
// В пользовательских контролах заранее неизвестно, какой метод возвращает название датасорса.
// Поэтому конкретный метод определяется в делегате getDataSourceCustomControl, а значение этого метода сохраняется в
// экземпляре класса FormProperty под названием, которое определено в константе cPropertyDataSource
if (this.isCustomControl(_ctrl))
{
FormPropertySet propertySet = new FormPropertySet();
this.getDataSourceCustomControl(_ctrl, propertySet);
FormProperty formProperty = propertySet.getProperty(FormControlShowDevInfo::cPropertyDataSource);
if (formProperty)
{
str dsName = formProperty.parmValue();
if (dsName)
{
formDS = formRun.dataSource(dsName);
}
}
}
return formDS;
}
/// <summary>
/// Формирование строки с названием датасорса и его таблицы для контрола
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Текстовая строка в формате <Название датасорса> (<Название таблицы / view>)
/// </returns>
private str getInfoDataSourceStr(Object _ctrl)
{
FormDataSource formDS = this.getDataSource(_ctrl);
str infoStr;
if (formDS)
{
infoStr = strFmt("%1 (%2)", formDS.name(), tableId2Name(formDS.table()));
}
return infoStr;
}
/// <summary>
/// Формирование строки с названием поля для контрола. Для контролов, являющихся подчиненными контролами
/// пользовательского контрола - возвращается название поля пользовательского контрола
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Текстовая строка с названием поля
/// </returns>
private str getInfoFieldStr(Object _ctrl)
{
FormDataSource formDS = this.getDataSource(_ctrl);
str infoStr;
if (formDS)
{
if (ctrlChildCustomControl && ctrlChildCustomControl.exists(_ctrl))
{
FormContainerControl parentCustomControl = ctrlChildCustomControl.lookup(_ctrl);
return this.getInfoFieldStr(parentCustomControl);
}
if (this.isSimpleControl(_ctrl))
{
if (_ctrl.dataField())
{
infoStr = fieldId2Name(formDS.table(), _ctrl.dataField());
}
}
if (this.isReferenceControl(_ctrl))
{
if (_ctrl.referenceField())
{
infoStr = fieldId2Name(formDS.table(), _ctrl.referenceField());
}
}
// В пользовательских контролах заранее неизвестно, какой метод возвращает название поля.
// Поэтому конкретный метод определяется в делегате getFieldNameCustomControl, а значение этого метода сохраняется в
// экземпляре класса FormProperty под названием, которое определено в константе cPropertyDataFieldName
if (this.isCustomControl(_ctrl))
{
FormPropertySet propertySet = new FormPropertySet();
this.getFieldNameCustomControl(_ctrl, propertySet);
FormProperty formProperty = propertySet.getProperty(FormControlShowDevInfo::cPropertyDataFieldName);
if (formProperty)
{
infoStr = formProperty.parmValue();
}
}
}
return infoStr;
}
/// <summary>
/// Формирование строки с названием метода для контрола. Для контролов, являющихся подчиненными контролами
/// пользовательского контрола - возвращается название метода пользовательского контрола
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <returns>
/// Текстовая строка с названием метода. Для расширений в этой текстовой строке будет представлено выражение,
/// содержащее в себе название класса-расширения и название его метода (либо в формате <класс-расширение>.<метод>,
/// если разработчик использует Chain Of Command, либо в формате <класс-расширение>::<метод>)
/// </returns>
private str getInfoMethodStr(Object _ctrl)
{
str infoStr;
if (this.isSimpleControl(_ctrl))
{
if (ctrlChildCustomControl && ctrlChildCustomControl.exists(_ctrl))
{
FormContainerControl parentCustomControl = ctrlChildCustomControl.lookup(_ctrl);
return this.getInfoMethodStr(parentCustomControl);
}
if (_ctrl.dataMethod())
{
infoStr = _ctrl.dataMethod() + "()";
}
}
return infoStr;
}
/// <summary>
/// Переопределенный метод контекстного меню, который формирует само контекстное меню
/// </summary>
/// <param name = "_ctrl">
/// Контрол, к которому формируется контекстное меню
/// </param>
/// <returns>
/// Возвращается перечень пунктов меню (исключая стандартных), которые добавлены в контекстное меню. Перечень
/// возвращается в виде строки JSON
/// </returns>
public str getContextMenuOptions(Object _ctrl)
{
ContextMenu menu;
List menuOptions;
str infoDataSourceStr, infoFieldStr, infoMethodStr;
str sourceMenu, ret;
// Если разработчик уже добавлял пункты контекстного меню, то их перечень необходимо восстановить
if (ctrlContextMenu && ctrlContextMenu.exists(_ctrl))
{
sourceMenu = ctrlContextMenu.lookup(_ctrl);
if (sourceMenu)
{
// Восстановление ранее добавленных пунктов меню
menu = FormJsonSerializer::deserializeObject(classNum(ContextMenu), sourceMenu);
sourceMenu = subStr(sourceMenu, strFind(sourceMenu, '[', 1, strLen(sourceMenu)), strLen(sourceMenu));
int endList = strFind(sourceMenu, ']', strLen(sourceMenu), -strLen(sourceMenu));
sourceMenu = subStr(sourceMenu, 1, endList);
if (sourceMenu)
{
menuOptions = FormJsonSerializer::deserializeCollection(classNum(List), sourceMenu, Types::Class, classStr(ContextMenuOption));
}
}
}
// Если пункты меню не добавлялись разработчиком свех стандартных, то перечень пунктов меню инициализируется заново
if (!menu || !menuOptions)
{
menu = new ContextMenu();
menuOptions = new List(Types::Class);
}
infoDataSourceStr = this.getInfoDataSourceStr(_ctrl);
infoFieldStr = this.getInfoFieldStr(_ctrl);
infoMethodStr = this.getInfoMethodStr(_ctrl);
// Если ссылка на датасорс не заполнена в контроле - то данный пункт меню не выводится. Актуально для
// display / edit-методов, которые определены на форме
if (infoDataSourceStr)
{
ContextMenuOption option = ContextMenuOption::Create(strFmt("%1: %2", "@ElectronicReporting:DataSource", infoDataSourceStr), cInfoDataSource);
menuOptions.addEnd(option);
}
// Если ссылка на поле не заполнена в контроле - то данный пункт меню не выводится. Актуально для
// display / edit-методов
if (infoFieldStr)
{
ContextMenuOption option = ContextMenuOption::Create(strFmt("%1: %2", "@ElectronicReporting:Field", infoFieldStr), cInfoFieldMethod);
menuOptions.addEnd(option);
}
// Если ссылка на метод не заполнена в контроле - то данный пункт меню не выводится. Актуально для
// контролов, привязанных к полям
if (infoMethodStr)
{
ContextMenuOption option = ContextMenuOption::Create(strFmt("%1: %2", "@ElectronicReporting:Method", infoMethodStr), cInfoFieldMethod);
menuOptions.addEnd(option);
}
menu.ContextMenuOptions(menuOptions);
ret = menu.Serialize();
return ret;
}
/// <summary>
/// Проверка контрола на его принадлежность к пользовательским контролам
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <param name = "_isStdCtrl">
/// Аргументы, через которые передается информация (Истина / Ложь). По умолчанию предполагается, что контрол
/// не является пользовательским, поэтому в обработчике необходимо явно вызвать метод cancel(), если передаваемый
/// контрол является пользовательским
/// </param>
delegate void checkIsCustomControl(Object _ctrl, FormRunServiceArgs _isStdCtrl)
{
}
/// <summary>
/// Обработчик делегата checkIsCustomControl для контрола DimensionEntryControl
/// </summary>
[SubscribesTo(classStr(FormControlShowDevInfo), delegateStr(FormControlShowDevInfo, checkIsCustomControl))]
public static void checkIsDimensionEntryControl(Object _ctrl, FormRunServiceArgs _isStdCtrl)
{
if (!(_ctrl is DimensionEntryControl))
{
return;
}
_isStdCtrl.cancel();
}
/// <summary>
/// Получение информации о названии датасорса, указанном в пользовательском контроле. Название датасорса необходимо
/// указать в классе FormPropertySet, добавив свойство (FormProperty) cPropertyDataSource, в котором и будет передано название
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <param name = "_propertySet">
/// Перечень свойств, через который будет передано название датасорса
/// </param>
delegate void getDataSourceCustomControl(Object _ctrl, FormPropertySet _propertySet)
{
}
/// <summary>
/// Обработчик делегата getDataSourceCustomControl для контрола DimensionEntryControl
/// </summary>
[SubscribesTo(classStr(FormControlShowDevInfo), delegateStr(FormControlShowDevInfo, getDataSourceCustomControl))]
public static void getDataSourceDimensionEntryControl(Object _ctrl, FormPropertySet _propertySet)
{
if (!(_ctrl is DimensionEntryControl))
{
return;
}
DimensionEntryControl dimCtrl = _ctrl;
DimensionEntryControlBuild dimCtrlBuild = dimCtrl.build();
_propertySet.addProperty(FormControlShowDevInfo::cPropertyDataSource, Types::String, dimCtrlBuild.parmDataSourceName());
}
/// <summary>
/// Получение информации о названии поля, указанного в пользовательском контроле. Название поля необходимо
/// указать в классе FormPropertySet, добавив свойство (FormProperty) cPropertyDataFieldName, в котором и будет передано название
/// </summary>
/// <param name = "_ctrl">
/// Анализируемый контрол
/// </param>
/// <param name = "_propertySet">
/// Перечень свойств, через который будет передано название поля
/// </param>
delegate void getFieldNameCustomControl(Object _ctrl, FormPropertySet _propertySet)
{
}
/// <summary>
/// Обработчик делегата getFieldNameCustomControl для контрола DimensionEntryControl
/// </summary>
[SubscribesTo(classStr(FormControlShowDevInfo), delegateStr(FormControlShowDevInfo, getFieldNameCustomControl))]
public static void getFieldNameDimensionEntryControl(Object _ctrl, FormPropertySet _propertySet)
{
if (!(_ctrl is DimensionEntryControl))
{
return;
}
DimensionEntryControl dimCtrl = _ctrl;
DimensionEntryControlBuild dimCtrlBuild = dimCtrl.build();
_propertySet.addProperty(FormControlShowDevInfo::cPropertyDataFieldName, Types::String, dimCtrlBuild.parmValueSetReferenceField());
}
}
Разработка велась на версии приложения 10.0.14 (10.0.605.10014) и платформы Update38 (7.0.5778.35626)
Прикрепляю проект в Visual Studio, axpp-файл проекта и axmodel-файл модели (файл модели удобен для быстрой установки)