Создание клиентского расширения с реализацией элемента управления

Непосредственно элемент управления для 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.

Компонент диалогового окна выбора дела из справочника

Диалоговое окно выбора дела из Справочника номенклатуры дел представляет собой панель с тремя элементами управления:

  • Раскрывающийся список для выбора года,

  • Дерево разделов,

  • Дерево дел.

В модуле Делопроизводство 5 секция дел является древовидной, функция вложенных дел не используется. Тем не менее в данном примере для совместимости используется элемент, поддерживающий древовидную структуру данных.

С целью уменьшения количества исходного кода в данном примере умышленно не используется функция выбора текущего значения элемента управления в окне выбора Дела. Данная возможность может быть реализована самостоятельно.

В приведённом далее коде пропущены участки с классом параметров и интерфейсом состояния диалогового окна. Полный исходный код доступен на 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.

Пример стиля для дерева дел и дерева разделов

Файл RefCasesWebExtension/src/Controls/RefCases/RefCasesSelectDialog/RefCasesSelectDialog.scss
.ref-cases-dialog__tree {
    border-bottom: 1px solid lightgray;
    padding-top: 10px;
    display: inline-block;
    width: 50%;
}