Создание клиентского расширения с реализацией элемента управления
Непосредственно элемент управления для Web-клиента реализуется в клиентском расширении, и состоит из нескольких частей (см. пункт Разработка и публикация клиентского компонента элемента управления): класс параметров, интерфейс состояния, интерфейсный класс и класс реализации.
С точки зрения пользовательского интерфейса реализуемый элемент управления будет представлять собой поле для ввода текста, в котором по умолчанию будет отображаться значение из поля карточки, связанного со строкой Дела Справочника номенклатуры дел. Особенностью элемента управления является необходимость реализации дополнительного графического компонента, в котором будут отображаться данные для выбора значения из Справочника номенклатуры дел 5, а также возможность быстрого поиска дел по справочнику.
Клиентский сервис для работы со "Справочником номенклатуры дел"
Реализуемому элементу управления потребуется доступ к Справочнику номенклатуры дел, который предоставляют веб-методы, разработанные в серверном расширении. Взаимодействовать с веб-методами можно вызывая их напрямую в основном коде элемента или используя отдельный клиентский сервис.
Ниже представлен код такого сервиса, который будет использоваться далее.
RefCasesWebExtension\src\Services\RefCasesServices.ts
import { $RequestManager } from "@docsvision/webclient/System/$RequestManager";
import { urlStore } from "@docsvision/webclient/System/UrlStore";
import { Models } from "../Controls/RefCases/Data/CaseModel";
import { serviceName } from "@docsvision/webclient/System/ServiceUtils";
export class RefCasesService {
constructor(private services: $RequestManager) {
}
getYears(rootSectionID?: string): Promise<Models.RefCasesYearModel[]> { (1)
let url = urlStore.urlResolver.resolveApiUrl("GetYears", "RefCasesOperation");
url = url + "?rootSectionID=" + rootSectionID;
return this.services.requestManager.post<Models.RefCasesYearModel[]>(url, "");
}
getSections(yearID?: string, rootSectionID?: string): Promise<Models.RefCasesSectionModel[]> { (2)
let url = urlStore.urlResolver.resolveApiUrl("GetSections", "RefCasesOperation");
url = url + "?yearID=" + yearID + "&rootSectionID=" + rootSectionID;
return this.services.requestManager.post<Models.RefCasesSectionModel[]>(url, "");
}
getCases(sectionID?: string): Promise<Models.RefCasesCaseModel[]> { (3)
let url = urlStore.urlResolver.resolveApiUrl("GetCases", "RefCasesOperation");
url = url + "?sectionID=" + sectionID;
return this.services.requestManager.post<Models.RefCasesCaseModel[]>(url, "");
}
getCaseTitleName(caseID?: string): Promise<string> { (4)
let url = urlStore.urlResolver.resolveApiUrl("GetCaseDisplayName", "RefCasesOperation");
url = url + "?caseID=" + caseID;
return this.services.requestManager.post<string>(url, "");
}
searchCase(caseName: string, skipCount: number, maxCount: number, rootSectionID?: string): Promise<Models.CaseSearchResult> { (5)
let url = urlStore.urlResolver.resolveApiUrl("SearchCase", "RefCasesOperation");
url = url + "?caseName=" + caseName + "&skipCount=" + skipCount + "&maxCount=" + maxCount + "&rootSectionID=" + rootSectionID;
return this.services.requestManager.post<Models.CaseSearchResult>(url, "");
}
}
export type $RefCasesService = { refCasesService: RefCasesService };
export const $RefCasesService = serviceName((s: $RefCasesService) => s.refCasesService);
1 | Возвращает модель списка лет. |
2 | Возвращает модель списка (дерева) разделов. |
3 | Возвращает модель списка дел. |
4 | Возвращает отображаемое название дела. |
5 | Возвращает результат поиска дела по названию. В skipCount и maxCount будут передаваться ограничения для количества результатов, возвращаемых сервером. |
Класс параметров
В классе параметров объявляются свойства и события элемента управления, указанные в его описателе.
Данный элемент управления представляет собой поле для ввода текста, основанный на элементе InputBasedControl
, в котором уже объявлены стандартные свойства подобного элемента. Единственным новым свойством является свойство RootSection
, содержащее идентификатор корневого раздела справочника.
Ниже приведён код класса параметров, наследующий основной набор свойств от InputBasedControlParams
— класса параметров элемента InputBasedControl
. Значение элемента управления описывается моделью Models.RefCasesCaseDisplayModel
.
RefCasesWebExtension\src\Controls\RefCases\RefCases.tsx
export class RefCasesParams extends InputBasedControlParams<Models.RefCasesCaseDisplayModel> {
@r standardCssClass?: string = "ref-cases"; (1)
@rw rootSection?: string; (2)
@rw services?: $LayoutDirectoryDesignerController & $EditOperationStore & $LayoutInfo & $RefCasesService; (3)
}
1 | Стандартный CSS класс со стилями элемента управления Необходимо переопределить с собственным названием CSS класса. |
2 | Секция справочника, из которой разрешено выбирать дела.
Настраивается в программе Конструктор Web-разметок. Может быть пустым (ограничение отсутствует). |
3 | Клиентские сервисы. $RefCasesService — собственный сервис для работы с данными Справочника номенклатуры дел. |
Интерфейс состояния
В интерфейсе состояния нужно объявить свойства, описывающие внутреннее состояние элемента управления. В частности во внутреннем состоянии будет храниться "биндинг" элемента управления.
RefCasesWebExtension\src\Controls\RefCases\RefCasesImpl.tsx
(1)
export interface RefCasesState extends RefCasesParams, InputBasedControlState<Models.RefCasesCaseDisplayModel> {
binding: IBindingResult<Models.RefCasesCaseDisplayModel>; (2)
dialog: ModalWindow; (3)
requestHelper: RequestHelper; (4)
inputKeyDown: SimpleEvent<React.KeyboardEvent<any>>(5)
}
1 | @internal. |
2 | Используется для хранения биндинга. |
3 | Диалоговое окно выбора Дела из справочника. |
4 | Вспомогательный компонент, предоставляющий метод для обработки длительных операций (будет использоваться при получении данных из справочника). |
5 | Событие ввода значения в поле элемента управления. |
Интерфейсный класс
В интерфейсном классе необходимо создать экземпляр реализации элемента управления, загрузить "биндинг", а также реализовать метод getBindings, возвращающий "биндинг" с текущим значением.
RefCasesWebExtension\src\Controls\RefCases\RefCases.tsx
export class RefCases extends InputBasedControl<Models.RefCasesCaseDisplayModel, RefCasesParams, RefCasesState>
{
protected getServices(): $LayoutInfo {
return this.state.services;
}
protected createParams(): RefCasesParams { (1)
return new RefCasesParams();
}
@handler("binding") (2)
protected set binding(binding: IBindingResult<Models.RefCasesCaseDisplayModel>) {
this.value = binding && binding.value;
this.state.canEdit = editOperationAvailable(this.state.services, binding);
this.state.binding = binding;
}
protected getBindings() { (3)
let binding = cloneObject(this.state.binding);
return [getBindingResult(binding, this.params.value && this.params.value.id || null, () => at(RefCasesParams).labelText)];
}
protected createImpl() { (4)
return new RefCasesImpl(this.props, this.state);
}
}
1 | Инициализация параметров элемента управления. |
2 | Загружаем биндинг при инициализации элемента управления. |
3 | Возвращаем биндинги. |
4 | Предоставляем экземпляр реализации элемента управления Справочник номенклатуры дел. |
Класс реализации
В данном примере элемент управления представляет собой поле для ввода текста, дополненное функцией быстрого поиска, реализуемой с использованием компонента Typeahead.
Реализация класса в данном примере является достаточно большой: здесь код будет приведён частично.
RefCasesWebExtension\src\Controls\RefCases\RefCasesImpl.tsx
(1)
export class RefCasesImpl extends InputBasedControlImpl<Models.RefCasesCaseDisplayModel, RefCasesParams, RefCasesState>
{
private typeahead: Typeahead;
constructor(props: RefCasesParams, state: RefCasesState) {
super(props, state);
this.state.requestHelper = new RequestHelper(() => this.forceUpdate()); (2)
this.state.inputKeyDown = new SimpleEvent<React.KeyboardEvent<any>>(this);
this.findItems = this.findItems.bind(this);
this.showDictionary = this.showDictionary.bind(this);
this.onSelected = this.onSelected.bind(this);
this.attachTypeahead = this.attachTypeahead.bind(this); (3)
}
async showDictionary() { (4)
if (this.state.dialog && this.state.dialog.IsOpened) {
return;
}
let controlInModal: RefCasesSelectDialog; (5)
let params = new ModalWindowParams(); (6)
params.headerText = resources.RefCases_SelectFromDirectory;
params.content = "";
params.buttonOkShow = true;
params.buttonOkText = resources.Navigator_ButtonSelect;
let okFunction = () => { (7)
let selectedCase = cloneObject(controlInModal.selectedCase);
if (selectedCase) { (8)
this.state.services.refCasesService.getCaseTitleName(selectedCase.uniqueId).then((title) => {
let displayValue = {
id: selectedCase.uniqueId,
name: title
} as Models.RefCasesCaseDisplayModel;
this.setValue(displayValue, true);
});
if (this.state.dialog) {
this.state.dialog.Hide();
this.state.dialog = null;
}
}
};
params.buttonOkFunction = okFunction; (9)
const value = this.getValue(); (10)
this.state.dialog = new ModalWindow(params);
renderModalContent(this.state.dialog, ( (11)
<RefCasesSelectDialog key={this.state.name + "_Modal"} ref={(el) => controlInModal = el}
rootSectionId={this.state.rootSection}
services={this.state.services}
nodeSelected={(node) => { (12)
if (this.state.dialog) {
if (node) this.state.dialog.OkButtonElement.classList.remove("disabled");
else this.state.dialog.OkButtonElement.classList.add("disabled");
}
}}
nodeAccepted={okFunction} /> (13)
));
this.state.dialog.Show();
this.state.dialog.OkButtonElement.classList.add("disabled");
}
protected renderInputWithPlaceholder(): React.ReactNode { (14)
let buttons: IBoxWithButtonsButtonInfo[] = [ (15)
{
onClick: this.showDictionary,
name: "open-dictionary",
iconClassName: "open-dictionary-button-icon dv-ico dv-ico-dictionary",
visible: this.editAvailable,
title: resources.RefCases_SelectFromDirectory,
disabled: !this.editAvailable,
tabIndex: this.getTabIndex(),
}
];
return ( (16)
<Typeahead className={"universal-directory-box"} extraButtons={buttons}
findItems={this.findItems}
clearButton={this.hasValue()}
searchText={this.state.inputText}
afterOpenCallback={() => this.afterOpenCallback()}
popoverClassName={this.state.standardCssClass}
popoverAttributes={{ "data-control-name": this.state.name }}
inputKeyDown={this.state.inputKeyDown}
onSelected={this.onSelected}
disabled={!this.editAvailable}
ref={this.attachTypeahead}>
{super.renderInputWithPlaceholder()}
</Typeahead>
);
}
protected findItems(typeaheadQuery: ITypeaheadSearchQuery): Promise<ITypeaheadSearchResult> { (17)
return new Promise<ITypeaheadSearchResult>((resolve, reject) => {
this.state.services.refCasesService.searchCase(typeaheadQuery.searchText, typeaheadQuery.skipCount, typeaheadQuery.maxCount, this.state.rootSection).then(response => {
let result = {
items: response.items.map(item => new CaseTypeaheadVariant(item)), (18)
hasMore: response.hasMore (19)
} as ITypeaheadSearchResult;
resolve(result);
}).catch(reject);
});
}
}
1 | @internal. |
2 | Инициализация компонента для выполнения длительных операций. |
3 | Связывание обработчиков с контекстом. |
4 | Отрисовка основного элемента управления: поле для ввода текста, к которому добавляется стандартная кнопка выбора значения из справочника. |
5 | Кнопка открытия справочника. Отключается, если нет прав на операцию редактирования. |
6 | Формируем элемент с быстрым поиском. |
7 | Метод, отображающий диалоговое окно выбора Дела из справочника (реализовано в отдельном компоненте). |
8 | Компонент диалогового окна выбора из справочника. |
9 | Устанавливаем параметры диалогового окна. |
10 | Обработчик нажатия кнопки ОК в диалоговом окне. |
11 | Если выбрано Дело, его модель (RefCasesCaseDisplayModel ) устанавливается в значение элемента управления. |
12 | Устанавливается обработчик нажатия кнопки ОК. |
13 | Получаем текущее значение элемента управления. |
14 | Формируем диалоговое окно. |
15 | Кнопка ОК включается при выборе Дела. |
16 | Обработчик для двойного щелчка по делу — аналогично нажатию кнопки ОК. |
17 | Обработчик события быстрого поиска по справочнику. Результаты поиска отображаются в списке, выводимом под окном поля ввода. |
18 | Результаты поиска, должны быть приведены к ITypeaheadSearchResult . |
19 | Флаг, сообщающий о наличии результатов, не включенных в данный ответ. |
-
renderInputWithPlaceholder
— формирует поле для ввода текста с возможностью быстрого поиска, которая реализована в компонентеTypeahead
. -
showDictionary
— открывает диалоговое окно с элементами для выбора дела из справочника. -
findItems
— реализует функцию получения результатов поиска дел по справочнику.
Компонент Typeahead
умеет ограничивать кол-во результатов, получаемых за один раз. Данные ограничения регулируются с помощью его параметров:
-
firstPageSize
— максимальное кол-во результатов при первом запросе, по умолчанию — 8. -
nextPageSize
— максимальное кол-во результатов при следующих запросах, по умолчанию — 15.
При выполнении первого запроса в функцию findItems
передаётся аргумент ITypeaheadSearchQuery
со значениями skipCount = 0
и maxCount = firstPageSize
. При следующем запросе — при нажатии пользователем кнопки Показать ещё, в findItems
передаются значения skipCount = firstPageSize
и maxCount = nextPageSize
. Таким образом клиент может запросить у сервера недостающие данные, если после первого запроса сервер вернул флаг hasMore = true
.
Компонент диалогового окна выбора дела из справочника
Диалоговое окно выбора дела из Справочника номенклатуры дел представляет собой панель с тремя элементами управления:
-
Раскрывающийся список для выбора года,
-
Дерево разделов,
-
Дерево дел.
В приведённом далее коде пропущены участки с классом параметров и интерфейсом состояния диалогового окна. Полный исходный код доступен на GitHub.
RefCasesWebExtension\src\Controls\RefCases\RefCasesSelectDialog\RefCasesSelectDialog.tsx
export class RefCasesSelectDialog extends React.Component<IRefCasesSelectDialogProps, IRefCasesSelectDialogState> {
(1)
state: IRefCasesSelectDialogState;
constructor(props: IRefCasesSelectDialogProps) {
super(props);
this.state = {} as IRefCasesSelectDialogState;
this.state.requestHelper = new RequestHelper(() => this.forceUpdate());
this.collectYearsList = this.collectYearsList.bind(this);
this.loadSectionsTree = this.loadSectionsTree.bind(this);
this.loadCasesTree = this.loadCasesTree.bind(this);
this.onSectionNodeSelected = this.onSectionNodeSelected.bind(this);
this.onCaseNodeSelected = this.onCaseNodeSelected.bind(this);
this.onCaseNodeAccepted = this.onCaseNodeAccepted.bind(this); (2)
this.collectYearsList(); (3)
}
public get selectedCase() {
return this.state.selectedCaseNode;
}
protected collectYearsList() { (4)
this.props.services.refCasesService.getYears(this.props.rootSectionId).then((items) => { (5)
this.state.years = items.map(x => ({
id: x.id,
title: x.displayValue
} as IComboBoxElement));
this.setState({ showYearsList: true }); (6)
});
}
protected loadSectionsTree(): Promise<IDynamicTreeNodeData[]> { (7)
return new Promise<IDynamicTreeNodeData[]>((resolve, reject) => {
this.state.requestHelper.send(
() => this.props.services.refCasesService.getSections(this.state.selectedYearID, this.props.rootSectionId),
items => {
let nodes = RefCasesSectionTreeNode.Create(items);
resolve(nodes);
},
reject);
});
}
protected loadCasesTree(): Promise<IDynamicTreeNodeData[]> { (8)
return new Promise<IDynamicTreeNodeData[]>((resolve, reject) => {
this.state.requestHelper.send(
() => this.props.services.refCasesService.getCases(this.state.selectedSectionID),
items => {
let nodes = RefCasesCaseTreeNode.Create(items);
resolve(nodes);
},
reject);
});
}
protected onSectionNodeSelected(node: TreeNode) { (9)
this.state.selectedSectionID = node.uniqueId;
this.state.selectedCaseNode = null;
this.props.nodeSelected && this.props.nodeSelected(null);
this.setState({ showCasesTree: false }, () => this.setState({ showCasesTree: true }));
}
protected onCaseNodeSelected(node: TreeNode) { (10)
this.state.selectedCaseNode = node.data as RefCasesCaseTreeNode;
this.props.nodeSelected && this.props.nodeSelected(node.data as RefCasesCaseTreeNode);
}
protected onCaseNodeAccepted(node: TreeNode) { (11)
this.state.selectedCaseNode = node.data as RefCasesCaseTreeNode;
this.props.nodeAccepted && this.props.nodeAccepted(node.data as RefCasesCaseTreeNode);
}
render(): React.ReactNode { (12)
let yearsList = <div>{resources.RefCases_Years} (13)
<CommonComboBox elements={this.state.years} selectedID={this.state.selectedYearID}
onChange={(selectedElement: IComboBoxElement) => { (14)
this.setState({ selectedYearID: selectedElement.id });
this.setState({ showSectionsTree: false }, () => this.setState({ showSectionsTree: true })); (15)
this.setState({ showCasesTree: false });
this.forceUpdate();
}}
renderElementList={(elements, expanded) =>
<PopoverComboBoxBodyContent mode={PopoverMode.BottomDropdown} isOpen={expanded} className="combobox-helper">
{elements}
</PopoverComboBoxBodyContent>
} />
</div>;
let sectionsTree = <div className="ref-cases-dialog__tree"> (16)
<div className="tree-block">{resources.RefCases_Sections}
<DynamicTree loadNodes={this.loadSectionsTree} treeHeight={300}
nodeSelected={this.onSectionNodeSelected} > (17)
</DynamicTree>
</div>
</div>;
let casesTree = <div className="ref-cases-dialog__tree"> (18)
<div className="tree-block">{resources.RefCases_Cases}
<DynamicTree loadNodes={this.loadCasesTree} treeHeight={300}
nodeSelected={this.onCaseNodeSelected} nodeAccepted={this.onCaseNodeAccepted} > (19)
</DynamicTree>
</div>
</div>;
return (
<div>
{this.state.showYearsList && yearsList}
{this.state.showSectionsTree && sectionsTree}
{this.state.showCasesTree && casesTree}
</div>
);
}
}
1 | @internal |
2 | Связываем функции и обработчиков событий с контекстом. |
3 | Загружаем список лет из справочника. |
4 | Загружаем из Справочника номенклатуры дел список лет в state.years , который является источником данных для элемента управления CommonComboBox . |
5 | Если установлен раздел, из которого возможен выбор Дел — rootSectionId , будет возвращен только год с данным разделом. |
6 | Показываем элемент со списком лет. |
7 | Возвращает список разделов из Справочника номенклатуры дел. |
8 | Загружает список дел после выбора раздела. |
9 | Сохраняет Дело в selectedNode после его выбора в списке дел. |
10 | Сохраняет Дело в selectedNode после его выбора в списке дел и нажатия кнопки Выбрать. |
11 | Сохраняет Дело в selectedNode после его выбора в списке дел. |
12 | Инициализация интерфейса. |
13 | Список лет. |
14 | При выборе года из списка инициализируем дерево разделов. |
15 | Элемент для выбора Раздела перемонтируется, элемент для выбора Дела отмонтируется. |
16 | Дерево Разделов. |
17 | При выборе Раздела из дерева разделов вызываем метод, загружающий дерево Дел. |
18 | Список дел. |
19 | При выборе Дела, сохраняем значение узла, а при двойном щелчке вызываем обрабочик onCaseNodeAccepted . |