Диалоги журналов - QualitySolution/QSProjects GitHub Wiki
Библиотека QS.Project имеет стандартные механизм для создания в приложениях различных справочников и журналов документов. То есть если вам нужен диалог который отображает таблицу чего бы то ни было, и как правило имеет возможность добавлять новые элементы, изменять их через диалог редактирования, или удалять, вы нашли то что надо.
Модуль реализован в стиле MVVM:
- Model - узел журнала специальный класс созданный для хранения информации о строке журнала, обычно он имеет либо часть полей сущности отображаемой в журнале, и иногда дополнительные колонки связанные с отображаемой журналом сущностью. На пример для журнала заказов модель вероятно будет иметь поля: Номер заказа, Дата, Сумма, поля непосредственно заказа и собранные запросом данные из других таблиц: Имя контрагента, Автор заказа.
- View - класс в библиотеке одинаков для всех журналов. В версии с GTK для каждого журнала необходимо зарегистрировать набор отображаемых в таблице колонок, через класс TreeViewColumnsConfigFactory.
- ViewModel - модель самого журнала, наследованная от базового класса для упрощения создания собственных журналов.
Создайте новый класс ViewModel журнала на основании JournalViewModelBase. Если вы создадите свой журнал на основе JournalViewModelBase вы получите максимальную гибкость журнала, так как он имеет только базовый возможности и максимально универсален. Для журналов просто отображающих список сущностей реализующих стандартные действия: выбрать, добавить, изменить, удалить, больше подойдет готовое решение на базе класса EntityJournalViewModelBase.
Предупреждение: Если у вас при работе журнала проиходят какие-то странные ошибки, так или иначе связанные с многопоточность. Убедитесь что ваши функции создания запроса используют переданный ей в качестве аргумента uow потока, а не UoW основного окна.
// Типы классов: ↓Сущность↓ ↓Модель диалога↓ ↓Модель узла журнала↓
public class OrganizationJournalViewModel : EntityJournalViewModelBase<Organization, OrganizationViewModel, OrganizationJournalNode>
{
public OrganizationJournalViewModel(IUnitOfWorkFactory unitOfWorkFactory, IInteractiveService interactiveService, INavigationManager navigationManager, IDeleteEntityService deleteEntityService, ICurrentPermissionService currentPermissionService = null) : base(unitOfWorkFactory, interactiveService, navigationManager, deleteEntityService, currentPermissionService)
{
UseSlider = true; //Разрешаем использовать слайдер для открытия диалога сущности
}
//Функция должна сформировать запрос QueryOver к базе данных
protected override IQueryOver<Organization> ItemsQuery(IUnitOfWork uow)
{
OrganizationJournalNode resultAlias = null;
return uow.Session.QueryOver<Organization>()
.Where(GetSearchCriterion<Organization>(
x => x.Id, //Указываем по каким полям должен работать поиск
x => x.Name,
))
.SelectList((list) => list
.Select(x => x.Id).WithAlias(() => resultAlias.Id)
.Select(x => x.Name).WithAlias(() => resultAlias.Name)
).TransformUsing(Transformers.AliasToBean<OrganizationJournalNode>());
}
}
//Класс узла журнала, описывающий поля, который в последствии могут быть отображены в колонках журнала.
public class OrganizationJournalNode
{
public int Id { get; set; }
public string Name { get; set; }
}
В GTK версии для работы View журнала, необходимо зарегистрировать набор колонок для каждого журнала. Сделать это можно через настройку класса фабрики TreeViewColumnsConfigFactory.
Пример простой таблички с двумя колонками. "Код" и "Название", для журнала объекта
TreeViewColumnsConfigFactory.Register<ObjectJournalViewModel>(
() => FluentColumnsConfig<ObjectJournalNode>.Create()
.AddColumn("Код").AddTextRenderer(node => node.Id.ToString()).SearchHighlight()
.AddColumn("Название").AddTextRenderer(node => node.Name).SearchHighlight()
.Finish()
);
Выполнение кода настройки колонок происходит в момент создания журнала, а не в момент регистрации, уже после создания ViewModel журнала, поэтому лямбда может обращаять к ViewModel за какими либо настройками, например как в более сложном пример ниже, со скрытием одной из колонок в зависимости от значения свойства ViewModel журнала.
TreeViewColumnsConfigFactory.Register<ObjectJournalViewModel>(
(jvm) => FluentColumnsConfig<ObjectJournalNode>.Create()
.AddColumn("Код").AddTextRenderer(node => node.Id.ToString()).SearchHighlight()
.AddColumn("Скрываемая колонка").Visible(jvm.ColumnVisible).AddTextRenderer(node => node.AnotherText)
.AddColumn("Название").AddTextRenderer(node => node.Name).SearchHighlight()
.Finish()
);
- [Основное пространство проекта].Journal.Filter.ViewModels.[Подпространство бизнес группы] - ViewModel фильтров журналов
- [Основное пространство проекта].Journal.Filter.Views.[Подпространство бизнес группы] - View фильтров журналов
- [Основное пространство проекта].Journal.ViewModels.[Подпространство бизнес группы] - ViewModel журналов
- [Основное пространство проекта].Journal.JournalsColumnsConfigs - Класс с конфигурациями колонок всех журналов.
Для получения данных из базы или откуда нибудь еще, например через API из стороннего сервиса, ViewModel журнала обращается к специальному загрузчику данных реализующему интерфейс IDataLoader
. На текущий момент имеются следующие реализации загрузчиков:
- ThreadDataLoader - повсеместно используемый загрузчик, для работы использует Nhibernate, может загружать данных постранично с динамической подгрузкой, умеет объединяеть данные из нескольких запросов к базе, например при запросе в одну таблицу разных типов документов, несколько запросов к базе выполняет параллельно, в разных потоках.
- AnyDataLoader - Простой загрузчик позволяет программисту самому подготовить данные журнала вызовом функции, например запросив их через API стороннего сервиса. Пока поддерживается только однопопоточный режим.
- OracleSQLDataLoader - Узко направленый загрузчик, для получения данных из базы Oracle с использванием библиотеки Dapper и чистого SQL. Чтобы не тянуть зависимости к Oracle, в основные библиотеки размещен в проекте https://github.com/QualitySolution/Workwear/blob/release/nlmk/Workwear/Tools/Oracle/OracleSQLDataLoader.cs . Но его можно взять за образец, при необходимости реализации загрузчика запрашивающего данные через чистый SQL.
Загрузчик журнала должен быть размещен с свойстве журнала DataLoader.
Пример создания загрузчика для двух запросов:
// В конструкторе журнала
...
var dataLoader = new ThreadDataLoader<StockDocumentsJournalNode>(unitOfWorkFactory);
dataLoader.AddQuery(QueryIncomeDoc);
dataLoader.AddQuery(QueryExpenseDoc);
dataLoader.MergeInOrderBy(x => x.Date, true); //Здесь указываем сортировку по которой будут объединятся данные из разных запросов.
DataLoader = dataLoader;
...
//Метод запроса
//Функция создания запроса вызывается каждый раз при запросе и получает на вход используемый сейчас uow
protected IQueryOver<Income> QueryIncomeDoc(IUnitOfWork uow)
{
if(Filter.StokDocumentType != StokDocumentType.IncomeDoc)
return null; //Обратите внимание, если метод возвращает null запрос выполнятся не будет.
Income incomeAlias = null;
var incomeQuery = uow.Session.QueryOver<Income>(() => incomeAlias);
if(Filter.StartDate.HasValue) //Пример использования фильта в запросе
incomeQuery.Where(o => o.Date >= Filter.StartDate.Value);
return incomeQuery
.JoinQueryOver(() => incomeAlias.EmployeeCard, () => employeeAlias, NHibernate.SqlCommand.JoinType.LeftOuterJoin)
.SelectList(list => list
.Select(() => incomeAlias.Id).WithAlias(() => resultAlias.Id)
.Select(() => incomeAlias.Date).WithAlias(() => resultAlias.Date)
)
.OrderBy(() => incomeAlias.Date).Desc
.TransformUsing(Transformers.AliasToBean<StockDocumentsJournalNode>());
}
Обратите внимание что текущая реализация для ускорения слияния разных запросов, не пересортировывает данные запроса и поэтому рассчитывает на то что порядок следования данных в каждом из запросов соответствует порядку сортировки при объединение указанном в MergeInOrderBy.
Для подстчета общего количества строк используется теже самые запросы, но из них убирается сортировка, список колонок и группировка, при запросе скалярного резутата на количество. Из-за такого поведения Nh, запросы в которых количество результирующих строк сокращалось засчет используемой группировки, будет подсчитываться неравильно. В этом случае есть возможность вручную модифицировать запрос, для корректного подсчета строк, используете следующий вид метода формирования запроса.
private IQueryOver<Income> QueryIncomeDoc(IUnitOfWork uow, bool isCounting)
{
...
}
Иногда после загрузки с полученными данными хочется выполнить дополнительные операции. Например заполнить какие то поля их других источников. Для таких случаев в ThreadDataLoader есть свойство PostLoadProcessingFunc, туда можно передать функцию которая будет вызываться каждый раз после загрузке данных, для их обработки перед отображением в журнале.
Поиск в журнале работает как набор сложный условий, составленный из двух списков. Набора колонок для поиска и набора значений, введенных пользователем. Например мы хотим искать и в Имени и в Фамилии, то есть используем 2 колонки. Если пользователь ввел только одно значение например "Анд", то поиск будет искать с условием (Фамилия LIKE '%Анд%' OR Имя LIKE '%Анд%')
. Но если мы ищем несколько значений например "Андрей" и "Ган", то поиск составит уже более сложное условие (Фамилия LIKE '%Андрей%' OR Имя LIKE '%Андрей%') AND (Фамилия LIKE '%Ган%' OR Имя LIKE '%Ган%')
. То есть мы всегда хотим найти все значения в любом из полей.
В журнале имеется помощник для создания таких условий поиска. Вызывается он через MakeSearchCriterion
. Полный пример использования:
employees
.Where( MakeSearchCriterion<Employee>.By(
() => employeeAlias.Id,
() => employeeAlias.LastName,
() => employeeAlias.FirstName,
() => employeeAlias.Patronymic,
() => postAlias.Name,
() => subdivisionAlias.Name,
() => employeeAlias.Comment
)
.WithLikeMode(MatchMode.Exact)
.By(() => employeeAlias.CardNumber)
.By(x => x.PersonnelNumber)
.ByPrepareValue(s => phoneFormatter.FormatString(s), () => employeeAlias.PhoneNumber)
.Finish()
)
если при вызове MakeSearchCriterion указать тип корневой сущности, как в примере выше. То при указании колонок поиска не обязательно использовать псевдонимы, можно использовать лямбду вида x => x.Property.
По умолчанию поиск по текстовым колонка осуществляется через Like %значение%, то есть ищем вхождения в любом месте строка. Но для части колонок или всех режим поиска можно изменить, вызвав .WithLikeMode(MatchMode.Exact), все колонки добавленные после этой строки уже будут использовать другой режим сопоставления.
Поиск по всем цифровым колонкам содержащим значения int, uint или decimal осуществляется только при полном соответствии значению. То есть введенное пользователем "5" будет соответствовать только значению "5" и не покажет значения с "55" или "505". В отличие от поиска по тексту.
Так же при необходимости можно для некоторых полей предварительно обработать поисковое значение, использовав метод ByPrepareValue
. Он позволяет задать функцию которая обработает значение введенное пользователем. В примере выше при поиске по колонке с номером телефона, программа сначала обрабатывает введенное пользователем значение приводя к единому формату в котором данные хранятся в базе. В этом случае пользователь может ввести "89872783157", а в базу данных будет отправлено "+7-987-278-31-57".
Искать в журнале можно не только по целым колонкам, можно создать IProjection и кинуть его в поиск. Например, поиск по ФИО в сотрудниках:
Employee employeeAlias = null;
var query = uow.Session.QueryOver(() => employeeAlias);
// Создаем projection для поиска по сотрудника ФИО
var employeeProjection = Projections.SqlFunction(
new SQLFunctionTemplate(NHibernateUtil.String, "CONCAT_WS(' ', ?1, ?2, ?3)"),
NHibernateUtil.String,
Projections.Property(() => employeeAlias.LastName),
Projections.Property(() => employeeAlias.Name),
Projections.Property(() => employeeAlias.Patronymic)
);
query.Where(GetSearchCriterion(
() => employeeProjection // указываем, что поиск должен идти по ФИО
));
Очень удобно когда пользователю визуально почему так или иная строка попала под условия текстового поиска, журналы поддерживают выделение строки поиска найденного в тексте колонки жирным шрифтом. Для этого при создании колонки добавьте настройку SearchHighlight
.
...
.AddColumn("Номер").AddTextRenderer(node => node.Id.ToString()).SearchHighlight()
...
Журналы поддерживают возможность добавлять в себя отдельную панель с фильтром. То есть панель на которой размещены какие-то контролы, которые как-то влияют отбор записей в журнале.
Чтобы добавить фильтр в журнал необходимо создать класс ViewModel фильтра реализующий интерфейс IJournalFilterViewModel лучше наследоваться от базового класса JournalFilterViewModelBase и реализовать создание или получение ViewModel фильтра в классе журнала с установкой его в свойство JournalFilter вьюмодели журнала.
Просто пример реализации журнала не претендующий на идиал:
public class NomenclatureJournalViewModel : EntityJournalViewModelBase<Nomenclature, NomenclatureViewModel, NomenclatureJournalNode>
{
public NomenclatureFilterViewModel Filter { get; private set; }
public NomenclatureJournalViewModel(IUnitOfWorkFactory unitOfWorkFactory, IInteractiveService interactiveService, INavigationManager navigationManager, ILifetimeScope autofacScope, IDeleteEntityService deleteEntityService = null, ICurrentPermissionService currentPermissionService = null) : base(unitOfWorkFactory, interactiveService, navigationManager, deleteEntityService, currentPermissionService)
{
JournalFilter = Filter = autofacScope.Resolve<NomenclatureFilterViewModel>(new TypedParameter(typeof(JournalViewModelBase), this));
}
...
}
У ViewModel фильтра есть одно свойство IsShow, базовый класс его реализует. Оно отвечает за отображение фильтра в журнале. Иногда хочется чтобы фильтр был спрятан, а пользователь по своему желанию смог его развернуть.
Любые операции со строками журнала могут быть реализованы с помощью добавления в список действий NodeActionsList базового класса журнала так называемых Действий журнала (JournalAction). В базовом классе журнала уже существует реализация типовых действий со строками журнала. Это действия, связанные с выбором, добавлением, редактированием и удалением записей. Добавление типовых действий в свой журнал возможно посредством вызова соответствующих методов: CreateDefaultSelectAction(), CreateDefaultAddActions(), CreateDefaultEditAction() и CreateDefaultDeleteAction().
Для создания нового действия используется конструктор
JournalAction(string title, Func<object[], bool> sensitiveFunc, Func<object[], bool> visibleFunc, Action<object[]> executeAction = null, string hotKeys = null)
где:
- title - Название действия; *sensitiveFunc - Функция проверки sensetive(отклика кнопки на нажатие) при выделенных Node-ах;
- visibleFunc - Функция проверки Visible(видно ли действие, к примеру, как объект выпадающего меню) при выделенных Node-ах;
- executeAction - Выполняемая функция, при активировании с выделенными Node-ами;
- hotKeys - горячие клавиши (через запятую), по которым запускается действие (можно использовать названия из enum Gdk.Key)
Пример создания типового действия для удаления записи журнала и добавления созданного действия к списку действий журнала:
var deleteAction = new JournalAction("Удалить",
(selected) => canDelete && selected.Any(),
(selected) => VisibleDeleteAction,
(selected) => DeleteEntities(selected.Cast<TNode>().ToArray()),
"Delete"
);
NodeActionsList.Add(deleteAction);
Журналами поддерживаются действия(кнопки) которые сами не выполняют ни каких действий, а открывают меню с дочерними действиями.
Такие действия создаются так же как и обычные, но им необходимо заполнить список дочерних действий ChildActionsList
. Ниже приведен пример создания такой кнопки:
var addAction = new JournalAction("Добавить",
(selected) => true,
(selected) => true,
null,
"Insert"
);
NodeActionsList.Add(addAction);
foreach(StokDocumentType docType in Enum.GetValues(typeof(StokDocumentType))) {
var insertDocAction = new JournalAction(
docType.GetEnumTitle(),
(selected) => true,
(selected) => true,
(selected) => CreateEntityDialog(docType)
);
addAction.ChildActionsList.Add(insertDocAction);
}
containerBuilder.RegisterType<GtkViewFactory>().As<IGtkViewFactory>();
builder.Register(cc => new ClassNamesBaseGtkViewResolver(cc.Resolve<IGtkViewFactory>(),
typeof(ClientView),
typeof(DeletionView),
typeof(NewVersionView)
)).As<IGtkViewResolver>();
builder.RegisterDecorator<IGtkViewResolver>((c, p, i) =>
new RegisteredGtkViewResolver(c.Resolve<IGtkViewFactory>(), i)
.RegisterView<JournalViewModelBase, JournalView>()
.RegisterView<SearchViewModel, OneEntrySearchView>());