ohayolibs
Version:
Ohayo is a set of essential modules for ohayojp.
880 lines (801 loc) • 26 kB
text/typescript
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { DecimalPipe, DOCUMENT } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Inject,
Input,
OnChanges,
OnDestroy,
Optional,
Output,
SimpleChange,
SimpleChanges,
TemplateRef,
TrackByFunction,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { Router } from '@angular/router';
import {
OhayoI18NService,
OHAYO_I18N_TOKEN,
CNCurrencyPipe,
DatePipe,
OhayoLocaleService,
DrawerHelper,
LocaleData,
ModalHelper,
YNPipe,
} from '@ohayo/theme';
import {
OhayoConfigService,
OhayoSTConfig,
BooleanInput,
deepMergeKey,
InputBoolean,
InputNumber,
NumberInput,
toBoolean,
} from '@ohayo/util';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzResizeEvent } from 'ng-zorro-antd/resizable';
import { NzTableComponent, NzTableData } from 'ng-zorro-antd/table';
import { from, Observable, of, Subject, Subscription } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { STColumnSource } from './st-column-source';
import { STDataSource, STDataSourceOptions, STDataSourceResult } from './st-data-source';
import { STExport } from './st-export';
import { STRowSource } from './st-row.directive';
import { ST_DEFULAT_CONFIG } from './st.config';
import {
STChange,
STChangeType,
STColumn,
STColumnButton,
STColumnFilterMenu,
STColumnSelection,
STData,
STError,
STExportOptions,
STLoadOptions,
STMultiSort,
STPage,
STReq,
STRes,
STResetColumnsOption,
STResizable,
STRowClassName,
STSingleSort,
STStatisticalResults,
STWidthMode,
} from './st.interfaces';
import { _STColumn } from './st.types';
export class STComponent implements AfterViewInit, OnChanges, OnDestroy {
static ngAcceptInputType_ps: NumberInput;
static ngAcceptInputType_pi: NumberInput;
static ngAcceptInputType_total: NumberInput;
static ngAcceptInputType_loadingDelay: NumberInput;
static ngAcceptInputType_bordered: BooleanInput;
static ngAcceptInputType_expandRowByClick: BooleanInput;
static ngAcceptInputType_expandAccordion: BooleanInput;
static ngAcceptInputType_rowClickTime: NumberInput;
static ngAcceptInputType_responsive: BooleanInput;
static ngAcceptInputType_responsiveHideHeaderFooter: BooleanInput;
static ngAcceptInputType_virtualScroll: BooleanInput;
static ngAcceptInputType_virtualItemSize: NumberInput;
static ngAcceptInputType_virtualMaxBufferPx: NumberInput;
static ngAcceptInputType_virtualMinBufferPx: NumberInput;
private unsubscribe$ = new Subject<void>();
private data$: Subscription;
private totalTpl = ``;
private cog: OhayoSTConfig;
private rowClickCount = 0;
private _req: STReq;
private _res: STRes;
private _page: STPage;
private _widthMode: STWidthMode;
private customWidthConfig: boolean = false;
_widthConfig: string[] = [];
locale: LocaleData = {};
_loading = false;
_data: STData[] = [];
_statistical: STStatisticalResults = {};
_isPagination = true;
_allChecked = false;
_allCheckedDisabled = false;
_indeterminate = false;
_headers: STColumn[][] = [];
_columns: _STColumn[] = [];
readonly orgTable: NzTableComponent;
get req(): STReq {
return this._req;
}
set req(value: STReq) {
this._req = deepMergeKey({}, true, this.cog.req, value);
}
/** 返回体配置 */
get res(): STRes {
return this._res;
}
set res(value: STRes) {
const item = (this._res = deepMergeKey({}, true, this.cog.res, value));
const reName = item.reName!;
if (!Array.isArray(reName.list)) reName.list = reName.list!.split('.');
if (!Array.isArray(reName.total)) reName.total = reName.total!.split('.');
this._res = item;
}
get page(): STPage {
return this._page;
}
set page(value: STPage) {
this._page = { ...this.cog.page, ...value };
this.updateTotalTpl();
}
data: string | STData[] | Observable<STData[]>;
columns: STColumn[] = [];
ps = 10;
pi = 1;
total = 0;
loading: boolean | null = null;
loadingDelay = 0;
loadingIndicator: TemplateRef<void>;
bordered = false;
size: 'small' | 'middle' | 'default';
scroll: { y?: string; x?: string };
singleSort: STSingleSort;
private _multiSort?: STMultiSort;
get multiSort(): NzSafeAny {
return this._multiSort;
}
set multiSort(value: NzSafeAny) {
if ((typeof value === 'boolean' && !toBoolean(value)) || (typeof value === 'object' && Object.keys(value).length === 0)) {
this._multiSort = undefined;
return;
}
this._multiSort = {
...(typeof value === 'object' ? value : {}),
};
}
rowClassName: STRowClassName;
set widthMode(value: STWidthMode) {
this._widthMode = { ...this.cog.widthMode, ...value };
}
get widthMode(): STWidthMode {
return this._widthMode;
}
set widthConfig(val: string[]) {
this._widthConfig = val;
this.customWidthConfig = val && val.length > 0;
}
private _resizable: STResizable;
set resizable(val: STResizable | boolean) {
this._resizable = typeof val === 'object' ? val : { disabled: !toBoolean(val) };
}
header: string | TemplateRef<void>;
footer: string | TemplateRef<void>;
bodyHeader: TemplateRef<STStatisticalResults>;
body: TemplateRef<STStatisticalResults>;
expandRowByClick = false;
expandAccordion = false;
expand: TemplateRef<{ $implicit: {}; column: STColumn }>;
noResult: string | TemplateRef<void>;
rowClickTime = 200;
responsive: boolean = true;
responsiveHideHeaderFooter: boolean;
// tslint:disable-next-line:no-output-native
readonly error = new EventEmitter<STError>();
// tslint:disable-next-line:no-output-native
readonly change = new EventEmitter<STChange>();
virtualScroll = false;
virtualItemSize = 54;
virtualMaxBufferPx = 200;
virtualMinBufferPx = 100;
virtualForTrackBy: TrackByFunction<NzTableData> = index => index;
/**
* Get the number of the current page
*/
get count(): number {
return this._data.length;
}
/**
* Get the data of the current page
*/
get list(): STData[] {
return this._data;
}
private get routerState(): { pi: number; ps: number; total: number } {
const { pi, ps, total } = this;
return { pi, ps, total };
}
constructor(
i18nSrv: OhayoI18NService,
private cdr: ChangeDetectorRef,
private router: Router,
private el: ElementRef,
private exportSrv: STExport,
private modalHelper: ModalHelper,
private drawerHelper: DrawerHelper,
private doc: any,
private columnSource: STColumnSource,
private dataSource: STDataSource,
private ohayoI18n: OhayoLocaleService,
configSrv: OhayoConfigService,
) {
this.setCog(configSrv.merge('st', ST_DEFULAT_CONFIG)!);
this.ohayoI18n.change.pipe(takeUntil(this.unsubscribe$)).subscribe(() => {
this.locale = this.ohayoI18n.getData('st');
if (this._columns.length > 0) {
this.updateTotalTpl();
this.cd();
}
});
i18nSrv.change
.pipe(
takeUntil(this.unsubscribe$),
filter(() => this._columns.length > 0),
)
.subscribe(() => this.refreshColumns());
}
private setCog(cog: OhayoSTConfig): void {
const copyMultiSort = { ...cog.multiSort };
// Because multiSort.global will affect the result, it should be removed first, and multiSort will be operated again after processing.
delete cog.multiSort;
this.cog = cog;
Object.assign(this, cog);
if (copyMultiSort.global !== false) {
this.multiSort = copyMultiSort;
}
this.columnSource.setCog(cog);
}
cd(): this {
this.cdr.detectChanges();
return this;
}
renderTotal(total: string, range: string[]): string {
return this.totalTpl
? this.totalTpl.replace('{{total}}', total).replace('{{range[0]}}', range[0]).replace('{{range[1]}}', range[1])
: '';
}
private changeEmit(type: STChangeType, data?: any): void {
const res: STChange = {
type,
pi: this.pi,
ps: this.ps,
total: this.total,
};
if (data != null) {
res[type] = data;
}
this.change.emit(res);
}
// #region data
/**
* 获取过滤后所有数据
* - 本地数据:包含排序、过滤后不分页数据
* - 远程数据:不传递 `pi`、`ps` 两个参数
*/
get filteredData(): Promise<STData[]> {
return this.loadData({ paginator: false } as any).then(res => res.list);
}
private updateTotalTpl(): void {
const { total } = this.page;
if (typeof total === 'string' && total.length) {
this.totalTpl = total;
} else if (toBoolean(total)) {
this.totalTpl = this.locale.total;
} else {
this.totalTpl = '';
}
}
private setLoading(val: boolean): void {
if (this.loading == null) {
this._loading = val;
this.cdr.detectChanges();
}
}
private loadData(options?: STDataSourceOptions): Promise<STDataSourceResult> {
const { pi, ps, data, req, res, page, total, singleSort, multiSort, rowClassName } = this;
return new Promise((resolvePromise, rejectPromise) => {
if (this.data$) {
this.data$.unsubscribe();
}
this.data$ = this.dataSource
.process({
pi,
ps,
total,
data,
req,
res,
page,
columns: this._columns,
singleSort,
multiSort,
rowClassName,
paginator: true,
...options,
})
.pipe(takeUntil(this.unsubscribe$))
.subscribe(
result => resolvePromise(result),
error => {
console.warn('st.loadDate', error);
rejectPromise(error);
},
);
});
}
private async loadPageData(): Promise<this> {
this.setLoading(true);
try {
const result = await this.loadData();
this.setLoading(false);
if (typeof result.pi !== 'undefined') {
this.pi = result.pi;
}
if (typeof result.ps !== 'undefined') {
this.ps = result.ps;
}
if (typeof result.total !== 'undefined') {
this.total = result.total;
}
if (typeof result.pageShow !== 'undefined') {
this._isPagination = result.pageShow;
}
this._data = result.list as STData[];
this._statistical = result.statistical as STStatisticalResults;
this.changeEmit('loaded', result.list);
// Should be re-render in next tike when using virtual scroll
// https://github.com/ohayojp/ohayojp/issues/1836
if (this.cdkVirtualScrollViewport) {
Promise.resolve().then(() => this.cdkVirtualScrollViewport.checkViewportSize());
}
return this._refCheck();
} catch (error) {
this.setLoading(false);
if (!this.unsubscribe$.isStopped) {
this.cdr.detectChanges();
this.error.emit({ type: 'req', error });
}
return this;
}
}
/** 清空所有数据 */
clear(cleanStatus: boolean = true): this {
if (cleanStatus) {
this.clearStatus();
}
this._data = [];
return this.cd();
}
/** 清空所有状态 */
clearStatus(): this {
return this.clearCheck().clearRadio().clearFilter().clearSort();
}
/**
* 根据页码重新加载数据
*
* @param pi 指定当前页码,默认:`1`
* @param extraParams 重新指定 `extraParams` 值
* @param options 选项
*/
load(pi: number = 1, extraParams?: {}, options?: STLoadOptions): this {
if (pi !== -1) this.pi = pi;
if (typeof extraParams !== 'undefined') {
this.req.params = options && options.merge ? { ...this.req.params, ...extraParams } : extraParams;
}
this._change('pi', options);
return this;
}
/**
* 重新刷新当前页
* @param extraParams 重新指定 `extraParams` 值
*/
reload(extraParams?: {}, options?: STLoadOptions): this {
return this.load(-1, extraParams, options);
}
/**
* 重置且重新设置 `pi` 为 `1`,包含以下值:
* - `check` 数据
* - `radio` 数据
* - `sort` 数据
* - `fileter` 数据
*
* @param extraParams 重新指定 `extraParams` 值
*/
reset(extraParams?: {}, options?: STLoadOptions): this {
this.clearStatus().load(1, extraParams, options);
return this;
}
private _toTop(enforce?: boolean): void {
if (!(enforce == null ? this.page.toTop : enforce)) return;
const el = this.el.nativeElement as HTMLElement;
if (this.scroll) {
if (this.cdkVirtualScrollViewport) {
this.cdkVirtualScrollViewport.scrollTo({
top: 0,
left: 0,
});
} else {
el.querySelector('.ant-table-body')!.scrollTo(0, 0);
}
return;
}
el.scrollIntoView();
// fix header height
this.doc.documentElement.scrollTop -= this.page.toTopOffset!;
}
_change(type: 'pi' | 'ps', options?: STLoadOptions): void {
if (type === 'pi' || (type === 'ps' && this.pi <= Math.ceil(this.total / this.ps))) {
this.loadPageData().then(() => this._toTop(options?.toTop));
}
this.changeEmit(type);
}
_click(e: Event, item: STData, col: STColumn): boolean {
e.preventDefault();
e.stopPropagation();
const res = col.click!(item, this);
if (typeof res === 'string') {
this.router.navigateByUrl(res, { state: this.routerState });
}
return false;
}
private closeOtherExpand(item: STData): void {
if (this.expandAccordion === false) return;
this._data.filter(i => i !== item).forEach(i => (i.expand = false));
}
_rowClick(e: Event, item: STData, index: number): void {
if ((e.target as HTMLElement).nodeName === 'INPUT') return;
const { expand, expandRowByClick, rowClickTime } = this;
if (!!expand && item.showExpand !== false && expandRowByClick) {
item.expand = !item.expand;
this.closeOtherExpand(item);
this.changeEmit('expand', item);
return;
}
++this.rowClickCount;
if (this.rowClickCount !== 1) return;
setTimeout(() => {
const data = { e, item, index };
if (this.rowClickCount === 1) {
this.changeEmit('click', data);
} else {
this.changeEmit('dblClick', data);
}
this.rowClickCount = 0;
}, rowClickTime);
}
_expandChange(item: STData, expand: boolean): void {
if (this.expandRowByClick) {
return;
}
item.expand = expand;
this.closeOtherExpand(item);
this.changeEmit('expand', item);
}
/**
* Remove a row in the table, like this:
*
* ```
* this.st.removeRow(0)
* this.st.removeRow(stDataItem)
* ```
*/
removeRow(data: STData | STData[] | number): this {
if (typeof data === 'number') {
this._data.splice(data, 1);
} else {
if (!Array.isArray(data)) {
data = [data];
}
(data as STData[])
.map(item => this._data.indexOf(item))
.filter(pos => pos !== -1)
.forEach(pos => this._data.splice(pos, 1));
}
// recalculate no
this._columns
.filter(w => w.type === 'no')
.forEach(c => this._data.forEach((i, idx) => (i._values[c.__point!] = { _text: this.dataSource.getNoIndex(i, c, idx), org: idx })));
return this.cd();
}
/**
* Sets the row value for the `index` in the table, like this:
*
* - `optinos.refreshSchema` Whether to refresh of st schemas
* - `optinos.emitReload` Whether to trigger a reload http request when data is url
*
* ```
* this.st.setRow(0, { price: 100 })
* this.st.setRow(0, { price: 100, name: 'asdf' })
* ```
*/
setRow(index: number, item: STData, options?: { refreshSchema?: boolean; emitReload?: boolean }): this {
options = { refreshSchema: false, emitReload: false, ...options };
this._data[index] = deepMergeKey(this._data[index], false, item);
this.optimizeData();
if (options.refreshSchema) {
this.resetColumns({ emitReload: options.emitReload });
return this;
}
this.cdr.detectChanges();
return this;
}
// #endregion
// #region sort
sort(col: _STColumn, idx: number, value: any): void {
if (this.multiSort) {
col._sort!.default = value;
col._sort!.tick = this.dataSource.nextSortTick;
} else {
this._columns.forEach((item, index) => (item._sort!.default = index === idx ? value : null));
}
this.cdr.detectChanges();
this.loadPageData();
const res = {
value,
map: this.dataSource.getReqSortMap(this.singleSort, this.multiSort, this._columns),
column: col,
};
this.changeEmit('sort', res);
}
clearSort(): this {
this._columns.forEach(item => (item._sort!.default = null));
return this;
}
// #endregion
// #region filter
private handleFilter(col: STColumn): void {
// 过滤表示一种数据的变化应重置页码为 `1`
this.pi = 1;
this.columnSource.updateDefault(col.filter!);
this.loadPageData();
this.changeEmit('filter', col);
}
_filterConfirm(col: _STColumn): void {
this.handleFilter(col);
}
_filterRadio(col: _STColumn, item: STColumnFilterMenu, checked: boolean): void {
col.filter!.menus!.forEach(i => (i.checked = false));
item.checked = checked;
}
_filterClear(col: _STColumn): void {
this.columnSource.cleanFilter(col);
this.handleFilter(col);
}
clearFilter(): this {
this._columns.filter(w => w.filter && w.filter.default === true).forEach(col => this.columnSource.cleanFilter(col));
return this;
}
_filterClick($event: MouseEvent): void {
$event.stopPropagation();
}
// #endregion
// #region checkbox
/** 清除所有 `checkbox` */
clearCheck(): this {
return this._checkAll(false);
}
private _refCheck(): this {
const validData = this._data.filter(w => !w.disabled);
const checkedList = validData.filter(w => w.checked === true);
this._allChecked = checkedList.length > 0 && checkedList.length === validData.length;
const allUnChecked = validData.every(value => !value.checked);
this._indeterminate = !this._allChecked && !allUnChecked;
this._allCheckedDisabled = this._data.length === this._data.filter(w => w.disabled).length;
return this.cd();
}
_checkAll(checked?: boolean): this {
checked = typeof checked === 'undefined' ? this._allChecked : checked;
this._data.filter(w => !w.disabled).forEach(i => (i.checked = checked));
return this._refCheck()._checkNotify();
}
_checkSelection(i: STData, value: boolean): this {
i.checked = value;
return this._refCheck()._checkNotify();
}
_rowSelection(row: STColumnSelection): this {
row.select(this._data);
return this._refCheck()._checkNotify();
}
_checkNotify(): this {
const res = this._data.filter(w => !w.disabled && w.checked === true);
this.changeEmit('checkbox', res);
return this;
}
// #endregion
// #region radio
/** 清除所有 `radio` */
clearRadio(): this {
this._data.filter(w => w.checked).forEach(item => (item.checked = false));
this.changeEmit('radio', null);
return this;
}
_refRadio(checked: boolean, item: STData): this {
// if (item.disabled === true) return;
this._data.filter(w => !w.disabled).forEach(i => (i.checked = false));
item.checked = checked;
this.changeEmit('radio', item);
return this;
}
// #endregion
// #region buttons
_btnClick(record: STData, btn: STColumnButton, e?: Event): void {
// should be stop propagation when expandRowByClick is true
if (e && this.expandRowByClick === true) {
e.stopPropagation();
}
if (btn.type === 'modal' || btn.type === 'static') {
const { modal } = btn;
const obj = { [modal!.paramsName!]: record };
(this.modalHelper[btn.type === 'modal' ? 'create' : 'createStatic'] as any)(
modal!.component,
{ ...obj, ...(modal!.params && modal!.params!(record)) },
deepMergeKey({}, true, this.cog.modal, modal),
)
.pipe(filter(w => typeof w !== 'undefined'))
.subscribe((res: NzSafeAny) => this.btnCallback(record, btn, res));
return;
} else if (btn.type === 'drawer') {
const { drawer } = btn;
const obj = { [drawer!.paramsName!]: record };
this.drawerHelper
.create(
drawer!.title!,
drawer!.component,
{ ...obj, ...(drawer!.params && drawer!.params!(record)) },
deepMergeKey({}, true, this.cog.drawer, drawer),
)
.pipe(filter(w => typeof w !== 'undefined'))
.subscribe(res => this.btnCallback(record, btn, res));
return;
} else if (btn.type === 'link') {
const clickRes = this.btnCallback(record, btn);
if (typeof clickRes === 'string') {
this.router.navigateByUrl(clickRes, { state: this.routerState });
}
return;
}
this.btnCallback(record, btn);
}
private btnCallback(record: STData, btn: STColumnButton, modal?: any): any {
if (!btn.click) return;
if (typeof btn.click === 'string') {
switch (btn.click) {
case 'load':
this.load();
break;
case 'reload':
this.reload();
break;
}
} else {
return btn.click(record, modal, this);
}
}
_btnText(record: STData, btn: STColumnButton): string {
return typeof btn.text === 'function' ? btn.text(record, btn) : btn.text || '';
}
_validBtns(btns: STColumnButton[], item: STData, col: STColumn): STColumnButton[] {
return btns.filter(btn => {
const result = btn.iif!(item, btn, col);
const isRenderDisabled = btn.iifBehavior === 'disabled';
btn._result = result;
btn._disabled = !result && isRenderDisabled;
return result || isRenderDisabled;
});
}
// #endregion
// #region export
/**
* 导出当前页,确保已经注册 `XlsxModule`
* @param newData 重新指定数据;若为 `true` 表示使用 `filteredData` 数据
* @param opt 额外参数
*/
export(newData?: STData[] | true, opt?: STExportOptions): void {
(newData === true ? from(this.filteredData) : of(newData || this._data)).subscribe((res: STData[]) =>
this.exportSrv.export({
...opt,
data: res,
columens: this._columns,
}),
);
}
// #endregion
// #region resizable
colResize({ width }: NzResizeEvent, column: _STColumn): void {
column.width = `${width}px`;
this.changeEmit('resize', column);
}
// #endregion
get cdkVirtualScrollViewport(): CdkVirtualScrollViewport {
return this.orgTable.cdkVirtualScrollViewport!;
}
resetColumns(options?: STResetColumnsOption): Promise<this> {
options = { emitReload: true, preClearData: false, ...options };
if (typeof options.columns !== 'undefined') {
this.columns = options.columns;
}
if (typeof options.pi !== 'undefined') {
this.pi = options.pi;
}
if (typeof options.ps !== 'undefined') {
this.ps = options.ps;
}
if (options.emitReload) {
// Should clean data, Because of changing columns may cause inaccurate data
options.preClearData = true;
}
if (options.preClearData) {
this._data = [];
}
this.refreshColumns();
if (options.emitReload) {
return this.loadPageData();
} else {
this.cd();
return Promise.resolve(this);
}
}
private refreshColumns(): this {
const res = this.columnSource.process(this.columns as _STColumn[], { widthMode: this.widthMode, resizable: this._resizable });
this._columns = res.columns;
this._headers = res.headers;
if (this.customWidthConfig === false && res.headerWidths != null) {
this._widthConfig = res.headerWidths;
}
return this;
}
private optimizeData(): void {
this._data = this.dataSource.optimizeData({ columns: this._columns, result: this._data, rowClassName: this.rowClassName });
}
ngAfterViewInit(): void {
this.columnSource.restoreAllRender(this._columns);
}
ngOnChanges(changes: { [P in keyof this]?: SimpleChange } & SimpleChanges): void {
if (changes.columns) {
this.refreshColumns().optimizeData();
}
const changeData = changes.data;
if (changeData && changeData.currentValue && !(this.req.lazyLoad && changeData.firstChange)) {
this.loadPageData();
}
if (changes.loading) {
this._loading = changes.loading.currentValue;
}
}
ngOnDestroy(): void {
const { unsubscribe$ } = this;
unsubscribe$.next();
unsubscribe$.complete();
}
}