From 26023cac7a91cae5383cfffd26d44fba6a95fb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A1=E8=89=B2?= Date: Mon, 28 Aug 2023 14:56:37 +0800 Subject: [PATCH] feat(abc:cell): add `cell` component (#1530) --- _mock/user.ts | 4 + packages/abc/cell/cell-host.directive.ts | 33 ++ packages/abc/cell/cell.component.ts | 217 ++++++++++++ packages/abc/cell/cell.module.ts | 33 ++ packages/abc/cell/cell.service.ts | 172 ++++++++++ packages/abc/cell/cell.spec.ts | 372 +++++++++++++++++++++ packages/abc/cell/cell.types.ts | 307 +++++++++++++++++ packages/abc/cell/demo/simple.md | 217 ++++++++++++ packages/abc/cell/index.en-US.md | 122 +++++++ packages/abc/cell/index.ts | 5 + packages/abc/cell/index.zh-CN.md | 122 +++++++ packages/abc/cell/ng-package.json | 6 + packages/abc/cell/style/index.less | 63 ++++ packages/abc/index.less | 1 + packages/abc/st/demo/cell.md | 74 ++++ packages/abc/st/index.en-US.md | 3 +- packages/abc/st/index.zh-CN.md | 3 +- packages/abc/st/st-column-source.ts | 4 + packages/abc/st/st-data-source.ts | 7 +- packages/abc/st/st-td.component.html | 3 +- packages/abc/st/st.interfaces.ts | 10 + packages/abc/st/st.module.ts | 2 + packages/abc/st/st.types.ts | 2 + packages/abc/st/test/st.spec.ts | 19 ++ packages/abc/theme-default.less | 10 + packages/theme/src/pipes/date/date.pipe.ts | 11 +- packages/theme/src/pipes/yn/yn.pipe.ts | 44 ++- packages/util/config/abc/cell.type.ts | 126 +++++++ packages/util/config/abc/index.ts | 1 + packages/util/config/config.types.ts | 2 + packages/util/date-time/time.ts | 13 +- packages/util/format/currency.types.ts | 5 + src/app/app.module.ts | 2 + src/app/shared/cell-widget/module.ts | 19 ++ src/app/shared/cell-widget/test.ts | 24 ++ src/app/shared/shared-delon.module.ts | 2 + src/site.config.js | 2 +- 37 files changed, 2031 insertions(+), 31 deletions(-) create mode 100644 packages/abc/cell/cell-host.directive.ts create mode 100644 packages/abc/cell/cell.component.ts create mode 100644 packages/abc/cell/cell.module.ts create mode 100644 packages/abc/cell/cell.service.ts create mode 100644 packages/abc/cell/cell.spec.ts create mode 100644 packages/abc/cell/cell.types.ts create mode 100644 packages/abc/cell/demo/simple.md create mode 100644 packages/abc/cell/index.en-US.md create mode 100644 packages/abc/cell/index.ts create mode 100644 packages/abc/cell/index.zh-CN.md create mode 100644 packages/abc/cell/ng-package.json create mode 100644 packages/abc/cell/style/index.less create mode 100644 packages/abc/st/demo/cell.md create mode 100644 packages/util/config/abc/cell.type.ts create mode 100644 src/app/shared/cell-widget/module.ts create mode 100644 src/app/shared/cell-widget/test.ts diff --git a/_mock/user.ts b/_mock/user.ts index 35c350ee9..60f76717c 100644 --- a/_mock/user.ts +++ b/_mock/user.ts @@ -15,6 +15,7 @@ export const USERS = { for (let i = 0; i < +req.queryString.ps; i++) { res.list.push({ id: i + 1, + type: r(1, 3), picture: { thumbnail: `https://randomuser.me/api/portraits/thumb/${r(0, 1) === 0 ? 'men' : 'women'}/${r(1, 50)}.jpg` }, @@ -27,6 +28,9 @@ export const USERS = { email: `aaa${r(1, 10)}@qq.com`, phone: `phone-${r(1000, 100000)}`, price: r(10, 10000000), + total: r(10, 10000000), + website: `https://${r(10, 10000000)}.com/`, + disabled: r(1, 100) > 50, registered: new Date() }); } diff --git a/packages/abc/cell/cell-host.directive.ts b/packages/abc/cell/cell-host.directive.ts new file mode 100644 index 000000000..1b2069cc2 --- /dev/null +++ b/packages/abc/cell/cell-host.directive.ts @@ -0,0 +1,33 @@ +import { Directive, Input, OnInit, Type, ViewContainerRef } from '@angular/core'; + +import { warn } from '@delon/util/other'; + +import { CellService } from './cell.service'; +import { CellWidgetData } from './cell.types'; + +@Directive({ + selector: '[cell-widget-host]' +}) +export class CellHostDirective implements OnInit { + @Input() data!: CellWidgetData; + + constructor( + private srv: CellService, + private viewContainerRef: ViewContainerRef + ) {} + + ngOnInit(): void { + const widget = this.data.options!.widget!; + const componentType = this.srv.getWidget(widget.key!)?.ref as Type; + if (componentType == null) { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + warn(`cell: No widget for type "${widget.key}"`); + } + return; + } + + this.viewContainerRef.clear(); + const componentRef = this.viewContainerRef.createComponent(componentType); + (componentRef.instance as { data: CellWidgetData }).data = this.data; + } +} diff --git a/packages/abc/cell/cell.component.ts b/packages/abc/cell/cell.component.ts new file mode 100644 index 000000000..6f99e839f --- /dev/null +++ b/packages/abc/cell/cell.component.ts @@ -0,0 +1,217 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Inject, + Input, + OnChanges, + OnDestroy, + Output, + Renderer2, + SimpleChange, + ViewEncapsulation +} from '@angular/core'; +import type { SafeValue } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { updateHostClass } from '@delon/util/browser'; +import { BooleanInput, InputBoolean } from '@delon/util/decorator'; +import { WINDOW } from '@delon/util/token'; +import type { NzSafeAny } from 'ng-zorro-antd/core/types'; +import { NzImage, NzImageService } from 'ng-zorro-antd/image'; + +import { CellService } from './cell.service'; +import type { CellDefaultText, CellOptions, CellTextResult, CellValue, CellWidgetData } from './cell.types'; + +@Component({ + selector: 'cell, [cell]', + template: ` + + + + + + + + + + + + + + + + + {{ _unit }} + + + + + {{ safeOpt.default?.text }} + + + + + + + + `, + exportAs: 'cell', + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class CellComponent implements OnChanges, OnDestroy { + static ngAcceptInputType_loading: BooleanInput; + static ngAcceptInputType_disabled: BooleanInput; + + private destroy$?: Subscription; + + _text!: string | SafeValue | string[] | number; + _unit?: string; + res?: CellTextResult; + showDefault = false; + + @Input() value?: CellValue; + @Output() readonly valueChange = new EventEmitter(); + @Input() options?: CellOptions; + @Input() @InputBoolean() loading = false; + @Input() @InputBoolean() disabled = false; + + get safeOpt(): CellOptions { + return this.res?.options ?? {}; + } + + get isText(): boolean { + return this.res?.safeHtml === 'text'; + } + + get hostData(): CellWidgetData { + return { + value: this.value, + options: this.srv.fixOptions(this.options) + }; + } + + constructor( + private srv: CellService, + private router: Router, + private cdr: ChangeDetectorRef, + private el: ElementRef, + private renderer: Renderer2, + private imgSrv: NzImageService, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + @Inject(WINDOW) private win: any + ) {} + + private updateValue(): void { + this.destroy$?.unsubscribe(); + this.destroy$ = this.srv.get(this.value, this.options).subscribe(res => { + this.res = res; + this.showDefault = this.value == (this.safeOpt.default as CellDefaultText).condition; + this._text = res.result?.text ?? ''; + this._unit = res.result?.unit ?? this.safeOpt?.unit; + this.cdr.detectChanges(); + this.setClass(); + }); + } + + private setClass(): void { + const { el, renderer } = this; + const { renderType, size } = this.safeOpt; + updateHostClass(el.nativeElement, renderer, { + [`cell`]: true, + [`cell__${renderType}`]: renderType != null, + [`cell__${size}`]: size != null, + [`cell__has-unit`]: this._unit, + [`cell__has-default`]: this.showDefault, + [`cell__disabled`]: this.disabled + }); + el.nativeElement.dataset.type = this.safeOpt.type; + } + + ngOnChanges(changes: { [p in keyof CellComponent]?: SimpleChange }): void { + // Do not call updateValue when only updating loading, disabled + if (Object.keys(changes).every(k => ['loading', 'disabled'].includes(k))) { + this.setClass(); + } else { + this.updateValue(); + } + } + + change(value: NzSafeAny): void { + this.value = value; + this.valueChange.emit(value); + } + + _link(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + if (this.disabled) return; + + const link = this.safeOpt.link; + const url = link?.url; + if (url == null) return; + + if (/https?:\/\//g.test(url)) { + (this.win as Window).open(url, link?.target); + } else { + this.router.navigateByUrl(url); + } + } + + _showImg(img: string): void { + const config = this.safeOpt.img; + if (config == null || config.big == null) return; + + let idx = -1; + const list = (this._text as string[]).map((p, index) => { + if (idx === -1 && p === img) idx = index; + return typeof config.big === 'function' ? config.big(p) : p; + }); + this.imgSrv + .preview( + list.map(p => ({ src: p }) as NzImage), + config.previewOptions + ) + .switchTo(idx); + } + + ngOnDestroy(): void { + this.destroy$?.unsubscribe(); + } +} diff --git a/packages/abc/cell/cell.module.ts b/packages/abc/cell/cell.module.ts new file mode 100644 index 000000000..67f835083 --- /dev/null +++ b/packages/abc/cell/cell.module.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { NzBadgeModule } from 'ng-zorro-antd/badge'; +import { NzCheckboxModule } from 'ng-zorro-antd/checkbox'; +import { NzImageModule } from 'ng-zorro-antd/experimental/image'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzRadioModule } from 'ng-zorro-antd/radio'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; + +import { CellHostDirective } from './cell-host.directive'; +import { CellComponent } from './cell.component'; + +const COMPS = [CellComponent]; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + NzCheckboxModule, + NzRadioModule, + NzBadgeModule, + NzTagModule, + NzToolTipModule, + NzIconModule, + NzImageModule + ], + declarations: [...COMPS, CellHostDirective], + exports: COMPS +}) +export class CellModule {} diff --git a/packages/abc/cell/cell.service.ts b/packages/abc/cell/cell.service.ts new file mode 100644 index 000000000..aa3d534e2 --- /dev/null +++ b/packages/abc/cell/cell.service.ts @@ -0,0 +1,172 @@ +import { Injectable, Type } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { map, Observable, of } from 'rxjs'; + +import { yn } from '@delon/theme'; +import { AlainCellConfig, AlainConfigService } from '@delon/util/config'; +import { formatDate } from '@delon/util/date-time'; +import { CurrencyService, formatMask } from '@delon/util/format'; +import { deepMerge } from '@delon/util/other'; +import type { NzSafeAny } from 'ng-zorro-antd/core/types'; +import { NzI18nService } from 'ng-zorro-antd/i18n'; + +import type { + CellFuValue, + CellOptions, + CellTextResult, + CellTextUnit, + CellType, + CellWidget, + CellWidgetFn +} from './cell.types'; + +@Injectable({ providedIn: 'root' }) +export class CellService { + private globalOptions!: AlainCellConfig; + private widgets: { [key: string]: CellWidget } = { + date: { + type: 'fn', + ref: (value, opt) => { + return { text: formatDate(value as string, opt.date!.format!, this.nzI18n.getDateLocale()) }; + } + }, + mega: { + type: 'fn', + ref: (value, opt) => { + const res = this.currency.mega(value as number, opt.mega); + return { text: res.value, unit: res.unitI18n }; + } + }, + currency: { + type: 'fn', + ref: (value, opt) => { + return { text: this.currency.format(value as number, opt.currency) }; + } + }, + cny: { + type: 'fn', + ref: (value, opt) => { + return { text: this.currency.cny(value as number, opt.cny) }; + } + }, + boolean: { + type: 'fn', + ref: (value, opt) => { + return { text: this.dom.bypassSecurityTrustHtml(yn(value as boolean, opt.boolean)) }; + } + }, + img: { + type: 'fn', + ref: value => { + return { text: Array.isArray(value) ? value : [value] }; + } + } + }; + + constructor( + configSrv: AlainConfigService, + private nzI18n: NzI18nService, + private currency: CurrencyService, + private dom: DomSanitizer + ) { + this.globalOptions = configSrv.merge('cell', { + date: { format: 'yyyy-MM-dd HH:mm:ss' }, + img: { size: 32 }, + default: { text: '-' } + })!; + } + + registerWidget(key: string, widget: Type): void { + this.widgets[key] = { type: 'widget', ref: widget }; + } + + getWidget(key: string): CellWidget | undefined { + return this.widgets[key]; + } + + private genType(value: unknown, options: CellOptions): CellType { + if (options.type != null) return options.type; + + const typeOf = typeof value; + // When is timestamp + if (typeOf === 'number' && /^[0-9]{13}$/g.test(value as string)) return 'date'; + if (value instanceof Date || options.date != null) return 'date'; + + // Auto detection + if (options.widget != null) return 'widget'; + else if (options.mega != null) return 'mega'; + else if (options.currency != null) return 'currency'; + else if (options.cny != null) return 'cny'; + else if (options.img != null) return 'img'; + else if (options.link != null) return 'link'; + else if (options.html != null) return 'html'; + else if (options.badge != null) return 'badge'; + else if (options.tag != null) return 'tag'; + else if (options.checkbox != null) return 'checkbox'; + else if (options.radio != null) return 'radio'; + else if (options.enum != null) return 'enum'; + else if (typeOf === 'number') return 'number'; + else if (typeOf === 'boolean' || options.boolean != null) return 'boolean'; + else return 'string'; + } + + fixOptions(options?: CellOptions): CellOptions { + return deepMerge({}, this.globalOptions, options); + } + + get(value: unknown, options?: CellOptions): Observable { + const type = this.genType(value, { ...options }); + const opt = this.fixOptions(options); + opt.type = type; + const isSafeHtml = + typeof value === 'object' && + typeof (value as NzSafeAny)?.getTypeName === 'function' && + (value as NzSafeAny)?.getTypeName() != null; + + let res: CellTextResult = { + result: + typeof value === 'object' && !isSafeHtml + ? (value as CellTextUnit) + : { text: value == null ? '' : isSafeHtml ? value : `${value}` }, + options: opt + }; + + const widget = this.widgets[type]; + if (widget?.type === 'fn') { + res.result = (widget.ref as CellWidgetFn)(value, opt); + } + + return (typeof value === 'function' ? (value as CellFuValue)(value, opt) : of(res.result)).pipe( + map(text => { + res.result = text; + let dictData: { tooltip?: string } | undefined; + switch (type) { + case 'badge': + dictData = (opt.badge?.data ?? {})[value as string]; + res.result = { color: 'default', ...dictData }; + break; + case 'tag': + dictData = (opt.tag?.data ?? {})[value as string]; + res.result = dictData as CellTextUnit; + break; + case 'enum': + res.result = { text: (opt.enum ?? {})[value as string] }; + break; + case 'html': + res.safeHtml = opt.html?.safe; + break; + case 'string': + if (isSafeHtml) res.safeHtml = 'safeHtml'; + break; + } + if ((type === 'badge' || type === 'tag') && dictData?.tooltip != null) { + res.options.tooltip = dictData.tooltip; + } + if (opt.mask != null) { + res.result.text = formatMask(res.result.text as string, opt.mask); + } + return res; + }) + ); + } +} diff --git a/packages/abc/cell/cell.spec.ts b/packages/abc/cell/cell.spec.ts new file mode 100644 index 000000000..53aba8f15 --- /dev/null +++ b/packages/abc/cell/cell.spec.ts @@ -0,0 +1,372 @@ +import { Component, DebugElement, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserModule, By, DomSanitizer } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { cleanCdkOverlayHtml, createTestContext } from '@delon/testing'; +import { WINDOW } from '@delon/util/token'; +import { NzSafeAny } from 'ng-zorro-antd/core/types'; +import { NzImageService } from 'ng-zorro-antd/image'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; + +import { CellComponent } from './cell.component'; +import { CellModule } from './cell.module'; +import { CellService } from './cell.service'; +import { CellFuValue, CellOptions, CellWidgetData } from './cell.types'; + +const DATE = new Date(2022, 0, 1, 1, 2, 3); + +describe('abc: cell', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestComponent; + let page: PageObject; + + const moduleAction = (): void => { + TestBed.configureTestingModule({ + imports: [CellModule, NoopAnimationsModule, BrowserModule, RouterTestingModule.withRoutes([])], + declarations: [TestComponent, TestWidget] + }); + }; + + describe('', () => { + beforeEach(moduleAction); + afterEach(() => cleanCdkOverlayHtml()); + + describe('', () => { + beforeEach(() => { + ({ fixture, dl, context } = createTestContext(TestComponent)); + page = new PageObject(); + }); + + it('value support functionn', () => { + const fn: CellFuValue = () => of({ text: 'via fn' }); + page.update(fn).check('via fn'); + }); + + describe('#type', () => { + it('with string', () => { + page.update('1').check('1'); + }); + it('with string and mask', () => { + page.update('1234', { mask: '*99*' }).check('*23*'); + }); + it('with number', () => { + page.update(100).check('100'); + page.update(1000, { type: 'number' }).check('1000'); + }); + it('with boolean', () => { + page.update(false).count('.yn__no', 1).update(true).count('.yn__no', 0).count('.yn__yes', 1); + }); + it('with checkbox', () => { + page.update(false, { type: 'checkbox', checkbox: { label: 'A' } }).count('.ant-checkbox', 1); + }); + it('with radio', () => { + page.update(false, { type: 'radio', radio: { label: 'A' } }).count('.ant-radio', 1); + }); + it('with mega', () => { + page.update(1000000, { mega: {} }).check('1万'); + }); + it('with currency', () => { + page.update(1000000, { currency: {} }).check('1,000,000'); + }); + it('with cny', () => { + page.update(1000000, { cny: {} }).check('壹佰万元整'); + }); + it('with date', () => { + page.update(DATE, { date: {} }).check('2022-01-01 01:02:03'); + page.update(+DATE).check('2022-01-01 01:02:03'); + }); + it('with checkbox', () => { + page + .update(true, { checkbox: { label: 'a' } }) + .count('.ant-checkbox', 1) + .check('a'); + }); + it('with radio', () => { + page + .update(true, { radio: { label: 'a' } }) + .count('.ant-radio', 1) + .check('a'); + }); + it('with enum', () => { + page + .update(1, { enum: { 1: 'Success', 2: 'Error' } }) + .check('Success') + .update(2) + .check('Error') + .update(3) + .check('') + .update(3, { enum: undefined }) + .check('3'); + }); + describe('with img', () => { + it('should be working', () => { + page + .update('1.jpg', { img: { big: true } }) + .count('.img', 1) + .click('.img') + .count('.ant-image-preview', 1, true); + }); + it('when array string', () => { + page.update(['1.jpg', '2.jpg'], { img: {} }).count('.img', 2); + }); + it('should be preview array', () => { + const imgSrv = TestBed.inject(NzImageService); + spyOn(imgSrv, 'preview').and.returnValue({ + switchTo: () => {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + page + .update(['1.jpg', '2.jpg'], { img: { big: true } }) + .count('.img', 2) + .click('.img'); + expect(imgSrv.preview).toHaveBeenCalled(); + }); + it('should be disabled preview when big is false', () => { + const imgSrv = TestBed.inject(NzImageService); + spyOn(imgSrv, 'preview'); + page.update(['1.jpg', '2.jpg'], { img: {} }).count('.img', 2).click('.img'); + expect(imgSrv.preview).not.toHaveBeenCalled(); + }); + it('should be reset big image', () => { + page.update(['1.jpg', '2.jpg'], { img: { big: src => `new${src}` } }).click('.img'); + const el = document.querySelector('.ant-image-preview-img') as HTMLImageElement; + expect(el.src).toContain(`new1.jpg`); + }); + }); + it('with html', () => { + page + .update(`1`, { html: { safe: 'html' } }) + .check('1') + .update(`2`, { html: { safe: 'text' } }) + .check(`2`); + }); + it('with SafeHtml', () => { + const safeHtml = TestBed.inject(DomSanitizer).bypassSecurityTrustHtml('a'); + page.update(safeHtml).check('a'); + }); + describe('with link', () => { + it('navgation router', () => { + const router = TestBed.inject(Router); + spyOn(router, 'navigateByUrl'); + page.update('to', { link: { url: '/router' } }).click('a'); + expect(router.navigateByUrl).toHaveBeenCalled(); + }); + it('navgation window.open', () => { + const win = TestBed.inject(WINDOW); + spyOn(win, 'open'); + page.update('to', { link: { url: 'https://a.com' } }).click('a'); + expect(win.open).toHaveBeenCalled(); + }); + it('should be disabled', () => { + const router = TestBed.inject(Router); + spyOn(router, 'navigateByUrl'); + const win = TestBed.inject(WINDOW); + spyOn(win, 'open'); + page.update('to', { link: {} }).click('a'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(win.open).not.toHaveBeenCalled(); + }); + it('should be abort when url is null', () => { + const router = TestBed.inject(Router); + spyOn(router, 'navigateByUrl'); + page.update('to', { link: { url: undefined } }).click('a'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + describe('with badge', () => { + it('should be working', () => { + page + .update('1', { badge: { data: { '1': { text: 'A', tooltip: 'tips' } } } }) + .check('A') + .count('.ant-badge-status-default', 1); + const tooltips = dl.queryAll(By.directive(NzTooltipDirective)); + expect(tooltips.length).toBe(1); + page.update('2', {}).check('2'); + }); + it('should be empty text when is invalid key', () => { + page.update('2', { type: 'badge' }).check(''); + }); + }); + it('with tag', () => { + page + .update('1', { tag: { data: { '1': { text: 'A', color: '#f50', tooltip: 'tips' } } } }) + .check('A') + .count('.ant-tag-has-color', 1); + const tooltips = dl.queryAll(By.directive(NzTooltipDirective)); + expect(tooltips.length).toBe(1); + page.update('2', {}).check('2'); + }); + describe('with widget', () => { + it('shoule be working', () => { + const srv = TestBed.inject(CellService); + srv.registerWidget(TestWidget.KEY, TestWidget); + page.update('1', { widget: { key: TestWidget.KEY, data: 'new data' } }).check('1-new data'); + }); + it('when key is invalid', () => { + spyOn(console, 'warn'); + page.update('1', { widget: { key: 'invalid', data: 'new data' } }); + expect(console.warn).toHaveBeenCalled(); + }); + }); + }); + + it('#unit', () => { + page.update({ text: '1', unit: '%' }).check('1', '.cell span').check('%', '.unit'); + }); + }); + + describe('[property]', () => { + beforeEach(() => { + ({ fixture, dl, context } = createTestContext(TestComponent)); + page = new PageObject(); + }); + + it('#valueChange', () => { + spyOn(context, 'valueChange'); + context.value = true; + context.options = { type: 'checkbox' }; + fixture.detectChanges(); + expect(context.valueChange).not.toHaveBeenCalled(); + page.getEl('.ant-checkbox').click(); + fixture.detectChanges(); + expect(context.valueChange).toHaveBeenCalled(); + }); + + it('#type', () => { + context.options = { renderType: 'primary' }; + fixture.detectChanges(); + page.count('.cell__primary', 1); + context.options = { renderType: 'success' }; + fixture.detectChanges(); + page.count('.cell__success', 1); + }); + + it('#size', () => { + context.options = { size: 'large' }; + fixture.detectChanges(); + page.count('.cell__large', 1); + context.options = { size: 'small' }; + fixture.detectChanges(); + page.count('.cell__small', 1); + }); + + it('#loading', () => { + context.loading = true; + fixture.detectChanges(); + page.count('.anticon-loading', 1); + context.loading = false; + fixture.detectChanges(); + page.count('.anticon-loading', 0); + }); + + it('#disabled', () => { + context.disabled = true; + fixture.detectChanges(); + page.count('.cell__disabled', 1); + context.disabled = false; + fixture.detectChanges(); + page.count('.cell__disabled', 0); + }); + + it('#default', () => { + context.options = { default: { text: '*', condition: '1' } }; + context.value = '1'; + fixture.detectChanges(); + page.count('.cell__has-default', 1); + context.value = '2'; + fixture.detectChanges(); + page.count('.cell__has-default', 0); + }); + + it('#unit', () => { + context.options = { unit: '*' }; + context.value = 1; + fixture.detectChanges(); + page.check('*', '.unit'); + }); + }); + }); + + class PageObject { + update(value: unknown, options?: CellOptions): this { + context.value = value; + if (options != null) context.options = options; + fixture.detectChanges(); + return this; + } + checkType(type: string): this { + const el = this.getEl('.cell'); + expect(el != null).toBe(true); + expect(el.dataset.type).toBe(type); + return this; + } + check(text: string, cls?: string, contain = false): this { + const el = this.getEl(cls ?? '.cell'); + expect(el != null).toBe(true); + if (contain) { + expect(el.innerText.trim()).toContain(text); + } else { + expect(el.innerText.trim()).toBe(text); + } + return this; + } + click(cls: string): this { + const el = dl.query(By.css(cls)).nativeElement; + expect(el != null).toBe(true); + el.click(); + fixture.detectChanges(); + return this; + } + getEl(cls: string): HTMLElement { + return dl.query(By.css(cls)).nativeElement; + } + getEls(cls: string): DebugElement[] { + return dl.queryAll(By.css(cls)); + } + count(cls: string, count: number = 1, viaBody = false): this { + if (viaBody) { + expect(document.querySelectorAll(cls).length).toBe(count); + } else { + expect(this.getEls(cls).length).toBe(count); + } + return this; + } + } +}); + +@Component({ + template: `{{ data.value }}-{{ data.options.widget.data }}` +}) +class TestWidget { + static readonly KEY = 'test'; + + data!: CellWidgetData; +} + +@Component({ + template: ` + + ` +}) +class TestComponent { + @ViewChild('comp', { static: true }) + comp!: CellComponent; + + value?: unknown; + valueChange(_?: NzSafeAny): void {} + options?: CellOptions; + loading = false; + disabled = false; +} diff --git a/packages/abc/cell/cell.types.ts b/packages/abc/cell/cell.types.ts new file mode 100644 index 000000000..82ef96346 --- /dev/null +++ b/packages/abc/cell/cell.types.ts @@ -0,0 +1,307 @@ +import type { Type } from '@angular/core'; +import type { SafeValue } from '@angular/platform-browser'; +import type { Observable } from 'rxjs'; + +import type { + CurrencyCNYOptions, + CurrencyFormatOptions, + CurrencyMegaOptions, + FormatMaskOption +} from '@delon/util/format'; +import type { NzImagePreviewOptions } from 'ng-zorro-antd/image'; + +export type CellRenderType = 'primary' | 'success' | 'danger' | 'warning'; + +export type CellSize = 'large' | 'small'; + +export type CellBaseValue = string | number | boolean | Date | null | undefined | SafeValue; + +export interface CellTextUnit { + text?: string | SafeValue | string[] | number; + color?: string; + unit?: string; +} + +export interface CellTextResult { + result: CellTextUnit; + safeHtml?: 'text' | 'html' | 'safeHtml'; + options: CellOptions; +} + +export type CellValue = CellBaseValue | CellBaseValue[] | CellTextUnit | CellFuValue; + +export type CellFuValue = (value: unknown, options: CellOptions) => Observable; + +export type CellWidgetFn = (value: unknown, options: CellOptions) => CellTextUnit; + +export interface CellWidget { + type: 'fn' | 'widget'; + ref: Type | CellWidgetFn; +} + +export type CellType = + | 'string' + | 'number' + | 'mega' + | 'currency' + | 'cny' + | 'boolean' + | 'date' + | 'img' + | 'link' + | 'html' + | 'badge' + | 'tag' + | 'checkbox' + | 'radio' + | 'enum' + | 'widget'; + +export interface CellOptions { + /** + * 指定渲染类型,若不指定则根据 `value` 类型自动转换 + */ + type?: CellType; + + tooltip?: string; + + /** + * Render Type + * + * 渲染类型 + */ + renderType?: CellRenderType; + + /** + * Size + * + * 大小 + */ + size?: CellSize; + + /** + * Default Text + * + * 默认文本 + */ + default?: CellDefaultText; + + /** + * Unit + * + * 单位 + */ + unit?: string; + + /** + * Format mask, [Document](https://ng-alain.com/util/format/en#formatMask) + * + * 格式化掩码, 参考[文档](https://ng-alain.com/util/format/zh#formatMask) + */ + mask?: string | FormatMaskOption; + + widget?: { + key?: string; + data?: unknown; + }; + + /** + * Date config, supports `minutes ago` formatting + * + * 日期配置,支持 `几分钟前` 格式化 + */ + date?: { + /** + * 格式化字符,默认:`yyyy-MM-dd HH:mm:ss` + * - 若值为 `fn` 时,渲染为 `几分钟前` + */ + format?: string; + }; + + /** + * Large number format filter, [Document](https://ng-alain.com/util/format/en#mega) + * + * 大数据格式化,[文档](https://ng-alain.com/util/format/en#mega) + */ + mega?: CurrencyMegaOptions; + + /** + * 货币 + */ + currency?: CurrencyFormatOptions; + + /** + * Converted into RMB notation + * + * 转化成人民币表示法 + */ + cny?: CurrencyCNYOptions; + + /** + * 布尔 + */ + boolean?: { + yes?: string; + no?: string; + mode?: 'full' | 'icon' | 'text'; + }; + + /** + * Image config, support large image preview + * + * 图像配置,支持大图预览 + */ + img?: { + size?: number; + /** + * 点击查看大图,若 `true` 表示直接使用当前作为大图 + */ + big?: true | string | ((value: unknown) => string); + previewOptions?: NzImagePreviewOptions; + }; + + /** + * Link, if it starts with `/`, it means routing jump + * + * 链接,若指定URL是以 `/` 开头视为路由跳转 + */ + link?: { + /** + * Link, if it starts with `/`, it means routing jump + * + * 链接,若指定URL是以 `/` 开头视为路由跳转 + */ + url?: string; + /** + * Open type of the external link + * + * 外链的打开方式 + */ + target?: '_blank' | '_self' | '_parent' | '_top'; + }; + + /** + * HTML config + * + * HTML 配置 + */ + html?: { + safe?: 'text' | 'html' | 'safeHtml'; + }; + + /** + * Badge config + * + * 徽章配置 + */ + badge?: { + data?: CellBadge; + }; + + /** + * Tag config + * + * 标签配置 + */ + tag?: { + data?: CellTag; + }; + + /** + * Checkbox config + * + * 复选框配置 + */ + checkbox?: { + label?: string; + }; + + /** + * Radio config + * + * 单选框配置 + */ + radio?: { + label?: string; + }; + + enum?: { [key: string]: string; [key: number]: string }; +} + +/** + * 徽标信息 + */ +export interface CellBadge { + [key: number]: CellBadgeValue; + [key: string]: CellBadgeValue; +} + +export interface CellBadgeValue { + /** + * 文本 + */ + text?: string; + /** + * 徽标颜色值 + */ + color?: 'success' | 'processing' | 'default' | 'error' | 'warning'; + /** + * Text popup tip + * + * 文字提示 + */ + tooltip?: string; +} + +/** + * 标签信息 + */ +export interface CellTag { + [key: number]: CellTagValue; + [key: string]: CellTagValue; +} + +export interface CellTagValue { + /** + * 文本 + */ + text?: string; + /** + * 颜色值,支持预设和色值 + * - 预设:geekblue,blue,purple,success,red,volcano,orange,gold,lime,green,cyan + * - 色值:#f50,#ff0 + */ + color?: + | 'geekblue' + | 'blue' + | 'purple' + | 'success' + | 'red' + | 'volcano' + | 'orange' + | 'gold' + | 'lime' + | 'green' + | 'cyan' + | string; + /** + * Text popup tip + * + * 文字提示 + */ + tooltip?: string; +} + +export interface CellDefaultText { + text?: string; + condition?: unknown; +} + +export interface CellWidgetData { + value?: unknown; + options?: CellOptions; +} + +export interface CellWidgetInstance { + readonly data: CellWidgetData; +} diff --git a/packages/abc/cell/demo/simple.md b/packages/abc/cell/demo/simple.md new file mode 100644 index 000000000..c634c4dde --- /dev/null +++ b/packages/abc/cell/demo/simple.md @@ -0,0 +1,217 @@ +--- +title: + zh-CN: 基础样例 + en-US: Basic Usage +order: 0 +--- + +## zh-CN + +最简单的用法。 + +## en-US + +Simplest of usage. + +```ts +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { delay, finalize, of, take } from 'rxjs'; + +import { subDays } from 'date-fns'; + +import { CellBadge, CellFuValue, CellOptions, CellRenderType } from '@delon/abc/cell'; + +@Component({ + selector: 'app-demo', + template: ` +
+
{{ i | json }} =>
+
date-fn =>
+
mega =>
+
mask =>
+
currency =>
+
cny =>
+
+ yn => + Change Value +
+
+ img => + +
+
+ img preview => + +
+
+ img list => + +
+
+ link => + + Change Disabled +
+
+ html => + +
+
+ SafeHtml => + + updateSafeHtml +
+
+ badge => + +
+
+ tag => + +
+
+ checkbox => + + Change Disabled +
+
+ radio => + + Change Value + Change Disabled +
+
+ enum => + + Change Value(enum value: {{ enumValue }}) +
+
+ default => + +
+
+ {{ i }} => + +
+
+ size => + , , + +
+
+ tooltip => + +
+
+ loading => + + Change +
+
+ Async => + + Again +
+
Unit =>
+
Text Unit =>
+
+ custom widget => + +
+
+ `, + styles: [ + ` + :host ::ng-deep .ant-col { + margin-bottom: 8px; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DemoComponent implements OnInit { + value: unknown = 'string'; + checkbox = false; + radio = true; + disabled = false; + yn = true; + default: string = '-'; + defaultCondition: unknown = '*'; + options?: CellOptions; + baseList = ['string', true, false, 100, 1000000, new Date()]; + typeList: CellRenderType[] = ['primary', 'success', 'danger', 'warning']; + now = new Date(); + day3 = subDays(new Date(), 3); + HTML = `Strong`; + status: CellBadge = { + WAIT: { text: 'Wait', tooltip: 'Refers to waiting for the user to ship' }, + FINISHED: { text: 'Done', color: 'success' } + }; + loading = true; + asyncLoading = true; + async?: CellFuValue; + safeHtml = this.ds.bypassSecurityTrustHtml(`Strong Html`); + enum = { 1: 'Success', 2: 'Error' }; + enumValue = 1; + bigImg: CellOptions = { + img: { + size: 32, + big: true // 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png' + } + }; + + constructor( + private ds: DomSanitizer, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.again(); + } + + refresh(): void { + this.value = new Date(); + this.cdr.detectChanges(); + } + + again(): void { + this.asyncLoading = true; + this.async = (() => + of({ text: `${+new Date()}` }).pipe( + take(1), + delay(1000 * 1), + finalize(() => { + this.asyncLoading = false; + this.cdr.detectChanges(); + }) + )) as CellFuValue; + this.cdr.detectChanges(); + } + + updateSafeHtml(): void { + this.safeHtml = this.ds.bypassSecurityTrustHtml(`alert('a');`); + this.cdr.detectChanges(); + } +} +``` diff --git a/packages/abc/cell/index.en-US.md b/packages/abc/cell/index.en-US.md new file mode 100644 index 000000000..47063de66 --- /dev/null +++ b/packages/abc/cell/index.en-US.md @@ -0,0 +1,122 @@ +--- +type: CURD +title: cell +subtitle: Cell Data +cols: 1 +order: 4 +module: import { CellModule } from '@delon/abc/cell'; +--- + +Cell formatting is supported for multiple data types, and supports widget mode. + +## API + +### cell + +| Property | Description | Type | Default | +|----------|-------------|------|---------| +| `[value]` | Value of the cell | `unknown` | - | +| `[options]` | Option of the cell | `CellOptions` | - | +| `[loading]` | Whether loading | `boolean` | `false` | + +### CellOptions + +| Property | Description | Type | Default | +|----------|-------------|------|---------| +| `[type]` | Render type of the cell | - | - | +| `[tooltip]` | Text popup tip | `string` | - | +| `[renderType]` | Render type of the cell | `primary,success,danger,warning` | - | +| `[size]` | Size of the cell | `large,small` | - | +| `[unit]` | Unit, can also be specified by `value: {text: 100, unit: 'RMB'}` | `string` | `-` | +| `[default]` | Default text | `string | CellDefaultText` | - | +| `[mask]` | Format mask, [Document](https://ng-alain.com/util/format/en#formatMask) | `string, FormatMaskOption` | - | +| `[widget]` | Widget config | `{key?: string, data?: string}` | - | +| `[date]` | Date config, supports `minutes ago` formatting | `{format?: string}` | - | +| `[mega]` | Large number format filter, [Document](https://ng-alain.com/util/format/en#mega) | `CurrencyMegaOptions` | - | +| `[currency]` | Currency config | `CurrencyFormatOptions` | - | +| `[boolean]` | Boolean config | `YNOptions` | - | +| `[img]` | Image config, support large image preview | `{ size?: number; big?: boolean }` | - | +| `[link]` | Link config | `{ url?: string; target?: string }` | - | +| `[html]` | HTML config | `{ safe?: string }` | - | +| `[badge]` | Badge config | `{ data?: CellBadge }` | - | +| `[tag]` | Tag config | `{ data?: CellTag }` | - | +| `[checkbox]` | Checkbox config | `{ label?: string }` | - | +| `[radio]` | Radio config | `{ label?: string }` | - | + +**Type** + +- `string` String +- `number` Number +- `mega` Large number format filter, [Document](https://ng-alain.com/util/format/en#mega) +- `currency` Currency +- `cny` Converted into RMB notation +- `boolean` Boolean +- `date` Date +- `img` Image, support large image preview +- `link` Link +- `html` HTML +- `badge` Badge +- `tag` Tag +- `checkbox` Checkbox (Support `disabled`) +- `radio` Radio (Support `disabled`) +- `enum` Enum +- `widget` Custom widget + +**Custom widget** + +Just implement the `CellWidgetInstance` interface, for example: + +```ts +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import type { CellWidgetData, CellWidgetInstance } from '@delon/abc/cell'; +import { NzMessageService } from 'ng-zorro-antd/message'; + +@Component({ + selector: 'cell-widget-test', + template: ` `, + host: { + '(click)': 'show()' + }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CellTestWidget implements CellWidgetInstance { + static readonly KEY = 'test'; + + readonly data!: CellWidgetData; + + constructor(private msg: NzMessageService) {} + + show(): void { + this.msg.info(`click`); + } +} +``` + +`data` is a fixed parameter, including `value`, `options` configuration items. + +Secondly, you also need to call `CellService.registerWidget` to register the widget; usually a new module will be built separately, for example: + +```ts +import { NgModule } from '@angular/core'; + +import { CellService } from '@delon/abc/cell'; + +import { CellTestWidget } from './test'; +import { SharedModule } from '../shared.module'; + +export const CELL_WIDGET_COMPONENTS = [CellTestWidget]; + +@NgModule({ + declarations: CELL_WIDGET_COMPONENTS, + imports: [SharedModule], + exports: CELL_WIDGET_COMPONENTS +}) +export class CellWidgetModule { + constructor(srv: CellService) { + srv.registerWidget(CellTestWidget.KEY, CellTestWidget); + } +} +``` + +Finally, just register `CellWidgetModule` under the root module. diff --git a/packages/abc/cell/index.ts b/packages/abc/cell/index.ts new file mode 100644 index 000000000..397ff56e1 --- /dev/null +++ b/packages/abc/cell/index.ts @@ -0,0 +1,5 @@ +export * from './cell.component'; +export * from './cell-host.directive'; +export * from './cell.module'; +export * from './cell.service'; +export * from './cell.types'; diff --git a/packages/abc/cell/index.zh-CN.md b/packages/abc/cell/index.zh-CN.md new file mode 100644 index 000000000..576768624 --- /dev/null +++ b/packages/abc/cell/index.zh-CN.md @@ -0,0 +1,122 @@ +--- +type: CURD +title: cell +subtitle: 单元格数据 +cols: 1 +order: 4 +module: import { CellModule } from '@delon/abc/cell'; +--- + +内置支持十几种数据类型的格式化,且支持小部件自定义模式。 + +## API + +### cell + +| 成员 | 说明 | 类型 | 默认值 | +|----|----|----|-----| +| `[value]` | 值 | `unknown` | - | +| `[options]` | 选项 | `CellOptions` | - | +| `[loading]` | 是否加载中 | `boolean` | `false` | + +### CellOptions + +| 成员 | 说明 | 类型 | 默认值 | +|----|----|----|-----| +| `[type]` | 渲染类型 | - | - | +| `[tooltip]` | 文字提示 | `string` | - | +| `[renderType]` | 渲染类型 | `primary,success,danger,warning` | - | +| `[size]` | 大小 | `large,small` | - | +| `[unit]` | 单位,也可通过 `value: {text: 100, unit: '元'}` 来指定 | `string` | `-` | +| `[default]` | 默认文本 | `string | CellDefaultText` | - | +| `[mask]` | 格式化掩码, 参考[文档](https://ng-alain.com/util/format/zh#formatMask) | `string, FormatMaskOption` | - | +| `[widget]` | 小部件配置 | `{key?: string, data?: string}` | - | +| `[date]` | 日期配置,支持 `几分钟前` 格式化 | `{format?: string}` | - | +| `[mega]` | 大数据格式化配置 | `CurrencyMegaOptions` | - | +| `[currency]` | 货币配置 | `CurrencyFormatOptions` | - | +| `[boolean]` | 布尔配置 | `YNOptions` | - | +| `[img]` | 图像配置,支持大图预览 | `{ size?: number; big?: boolean }` | - | +| `[link]` | 链接配置 | `{ url?: string; target?: string }` | - | +| `[html]` | HTML 配置 | `{ safe?: string }` | - | +| `[badge]` | 徽章配置 | `{ data?: CellBadge }` | - | +| `[tag]` | 标签配置 | `{ data?: CellTag }` | - | +| `[checkbox]` | 复选框配置 | `{ label?: string }` | - | +| `[radio]` | 单选框配置 | `{ label?: string }` | - | + +**渲染类型** + +- `string` 字符串 +- `number` 数字 +- `mega` 大数据格式化 +- `currency` 货币 +- `cny` 转化成人民币表示法 +- `boolean` 布尔 +- `date` 日期 +- `img` 图像,支持大图预览 +- `link` 链接 +- `html` HTML +- `badge` 徽章 +- `tag` 标签 +- `checkbox` 复选框(支持 `disabled`) +- `radio` 单选框(支持 `disabled`) +- `enum` 枚举转换 +- `widget` 自定义小部件 + +**自定义小部件** + +实现 `CellWidgetInstance` 接口即可,例如: + +```ts +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import type { CellWidgetData, CellWidgetInstance } from '@delon/abc/cell'; +import { NzMessageService } from 'ng-zorro-antd/message'; + +@Component({ + selector: 'cell-widget-test', + template: ` `, + host: { + '(click)': 'show()' + }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CellTestWidget implements CellWidgetInstance { + static readonly KEY = 'test'; + + readonly data!: CellWidgetData; + + constructor(private msg: NzMessageService) {} + + show(): void { + this.msg.info(`click`); + } +} +``` + +其中 `data` 为固定参数,包含 `value`、`options` 配置项。 + +其次,还需要调用 `CellService.registerWidget` 注册小部件;通常会单独构建一个新的模块,例如: + +```ts +import { NgModule } from '@angular/core'; + +import { CellService } from '@delon/abc/cell'; + +import { CellTestWidget } from './test'; +import { SharedModule } from '../shared.module'; + +export const CELL_WIDGET_COMPONENTS = [CellTestWidget]; + +@NgModule({ + declarations: CELL_WIDGET_COMPONENTS, + imports: [SharedModule], + exports: CELL_WIDGET_COMPONENTS +}) +export class CellWidgetModule { + constructor(srv: CellService) { + srv.registerWidget(CellTestWidget.KEY, CellTestWidget); + } +} +``` + +最后,将 `CellWidgetModule` 注册到根模块下即可。 diff --git a/packages/abc/cell/ng-package.json b/packages/abc/cell/ng-package.json new file mode 100644 index 000000000..6cbdbfc2e --- /dev/null +++ b/packages/abc/cell/ng-package.json @@ -0,0 +1,6 @@ +{ + "lib": { + "flatModuleFile": "cell", + "entryFile": "index.ts" + } +} diff --git a/packages/abc/cell/style/index.less b/packages/abc/cell/style/index.less new file mode 100644 index 000000000..e807da80f --- /dev/null +++ b/packages/abc/cell/style/index.less @@ -0,0 +1,63 @@ +@import '../../../theme/theme-default.less'; +@cell-prefix: ~'.cell'; + +@{cell-prefix} { + &__has-unit { + align-items: baseline; + } + + &__has-default { + color: @cell-default-color; + } + + &__primary { + color: @primary-color; + } + + &__success { + color: @success-color; + } + + &__danger { + color: @error-color; + } + + &__warning { + color: @warning-color; + } + + &__large { + font-size: @cell-large; + } + + &__small { + font-size: @cell-small; + } + + &[data-type="img"] { + .img:not(:last-child) { + margin-right: @cell-img-space; + } + } + + .img { + vertical-align: middle; + border-radius: 4px; + } + + .unit { + margin-left: 2px; + color: @cell-unit-color; + font-size: @cell-unit-font-size; + } + + &__disabled { + &[data-type="link"] { + pointer-events: none; + + > a { + color: @cell-link-disabled-color; + } + } + } +} diff --git a/packages/abc/index.less b/packages/abc/index.less index d8311c2a9..801130704 100644 --- a/packages/abc/index.less +++ b/packages/abc/index.less @@ -18,3 +18,4 @@ @import './loading/style/index.less'; @import './onboarding/style/index.less'; @import './pdf/style/index.less'; +@import './cell/style/index.less'; diff --git a/packages/abc/st/demo/cell.md b/packages/abc/st/demo/cell.md new file mode 100644 index 000000000..a7b725771 --- /dev/null +++ b/packages/abc/st/demo/cell.md @@ -0,0 +1,74 @@ +--- +order: 3 +title: + zh-CN: 单元格 + en-US: Cell +--- + +## zh-CN + +使用 `cell` 组件更丰富的渲染。 + +## en-US + +Use the `cell` component for richer rendering. + +```ts +import { Component, ViewChild } from '@angular/core'; +import { STColumn, STComponent } from '@delon/abc/st'; +import type { CellOptions } from '@delon/abc/cell'; + +@Component({ + selector: 'app-demo', + template: ` + + + `, +}) +export class DemoComponent { + url = `/users?total=2&field=list`; + params = { a: 1, b: 2 }; + @ViewChild('st', { static: false }) private st!: STComponent; + columns: STColumn[] = [ + { title: '编号', index: 'id', width: 55 }, + { title: '类型', index: 'type', width: 60, cell: { enum: { 1: '普通', 2: '会员', 3: '管理' } } }, + { title: '头像', index: 'picture.thumbnail', width: 64, cell: { type: 'img' } }, + { title: '邮箱', index: 'email', width: 120 }, + { title: '电话', index: 'phone', cell: { mask: '999****9999' } }, + { + title: { text: '佣金', optionalHelp: '计算公式=订单金额 * 0.6%' }, + index: 'price', + cell: { + type: 'currency', + unit: '元' + } + }, + { + title: '人民币写法', + index: 'total', + cell: { + type: 'cny' + } + }, + { + title: 'Site', + index: 'website', + width: 100, + cell: record => { + return { + tooltip: record.website, + link: { + url: record.website + } + } as CellOptions; + } + }, + { title: '可用', index: 'disabled', width: 64, cell: { type: 'boolean' } }, + { title: '注册时间', index: 'registered', width: 180, cell: { type: 'date' } } + ]; + + setRow(): void { + this.st.setRow(0, { price: 100000000 }); + } +} +``` diff --git a/packages/abc/st/index.en-US.md b/packages/abc/st/index.en-US.md index dc1e12f18..d3134f9bf 100644 --- a/packages/abc/st/index.en-US.md +++ b/packages/abc/st/index.en-US.md @@ -254,7 +254,8 @@ class TestComponent { |----------|-------------|------|---------| | `[title]` | Name of this column | `string, STColumnTitle` | - | | `[i18n]` | I18n key of this column | `string` | - | -| `[type]` | `no` Rows number
`checkbox` selection
`radio` selection
`link` Link that triggers `click`
`img` Align to the center
`number` Align to the right
`currency` Align to the right
`date` Align to the center
`badge` [Nz-Badge](https://ng.ant.design/components/badge/en)
`tag` [Nz-Tag](https://ng.ant.design/components/tag/en)
`yn` Make boolean as [badge](/theme/yn)
`widget` Custom widgets to render columns | `string` | - | +| `[type]` | `no` Rows number
`checkbox` selection
`radio` selection
`link` Link that triggers `click`
`img` Align to the center
`number` Align to the right
`currency` Align to the right
`date` Align to the center
`badge` [Nz-Badge](https://ng.ant.design/components/badge/en)
`tag` [Nz-Tag](https://ng.ant.design/components/tag/en)
`yn` Make boolean as [badge](/theme/yn)
`cell` Rendered using the `cell` component, see [cell](/components/cell)
`widget` Custom widgets to render columns | `string` | - | +| `[cell]` | Rendered using the `cell` component, see [cell](/components/cell). | `CellOptions | ((record: T, column: STColumn) => CellOptions)` | - | | `[index]` | Display field of the data record, could be set like `a.b.c` | `string, string[]` | - | | `[render]` | Custom render template ID | `string, TemplateRef, TemplateRef<{ $implicit: STData; index: number }>` | - | | `[renderTitle]` | Title custom render template ID | `string, TemplateRef, TemplateRef<{ $implicit: STColumn; index: number }>` | - | diff --git a/packages/abc/st/index.zh-CN.md b/packages/abc/st/index.zh-CN.md index e650aed21..8e102c053 100644 --- a/packages/abc/st/index.zh-CN.md +++ b/packages/abc/st/index.zh-CN.md @@ -254,7 +254,8 @@ class TestComponent { |----|----|----|-----| | `[title]` | 列名 | `string, STColumnTitle` | - | | `[i18n]` | 列名i18n | `string` | - | -| `[type]` | `no` 行号
`checkbox` 多选
`radio` 单选
`link` 链接,可触发 `click`
`img` 图像且居中
`number` 数字且居右
`currency` 货币且居右
`date` 日期格式且居中
`badge` [徽标](https://ng.ant.design/components/badge/zh)
`tag` [标签](https://ng.ant.design/components/tag/zh)
`yn` 将`boolean`类型徽章化 [document](/theme/yn)
`widget` 自定义小部件来渲染列 | `string` | - | +| `[type]` | `no` 行号
`checkbox` 多选
`radio` 单选
`link` 链接,可触发 `click`
`img` 图像且居中
`number` 数字且居右
`currency` 货币且居右
`date` 日期格式且居中
`badge` [徽标](https://ng.ant.design/components/badge/zh)
`tag` [标签](https://ng.ant.design/components/tag/zh)
`yn` 将`boolean`类型徽章化 [document](/theme/yn)
使用 `cell` 组件渲染,见[cell](/components/cell)
`widget` 自定义小部件来渲染列 | `string` | - | +| `[cell]` | 使用 `cell` 组件渲染,见[cell](/components/cell)。 | `CellOptions | ((record: T, column: STColumn) => CellOptions)` | - | | `[index]` | 列数据在数据项中对应的 key,支持 `a.b.c` 的嵌套写法 | `string, string[]` | - | | `[render]` | 自定义渲染ID | `string, TemplateRef, TemplateRef<{ $implicit: STData; index: number }>` | - | | `[renderTitle]` | 标题自定义渲染ID | `string, TemplateRef, TemplateRef<{ $implicit: STColumn; index: number }>` | - | diff --git a/packages/abc/st/st-column-source.ts b/packages/abc/st/st-column-source.ts index b80310c69..734ba6d9d 100644 --- a/packages/abc/st/st-column-source.ts +++ b/packages/abc/st/st-column-source.ts @@ -459,6 +459,10 @@ export class STColumnSource { item.width = '50px'; } } + // cell + if (item.cell != null) { + item.type = 'cell'; + } // types if (item.type === 'yn') { item.yn = { truth: true, ...this.cog.yn, ...item.yn }; diff --git a/packages/abc/st/st-data-source.ts b/packages/abc/st/st-data-source.ts index b5961202f..439e3fea8 100644 --- a/packages/abc/st/st-data-source.ts +++ b/packages/abc/st/st-data-source.ts @@ -4,6 +4,7 @@ import { Host, Injectable } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { Observable, of, map } from 'rxjs'; +import type { CellOptions } from '@delon/abc/cell'; import { DatePipe, YNPipe, _HttpClient } from '@delon/theme'; import type { AlainSTConfig } from '@delon/util/config'; import { CurrencyService } from '@delon/util/format'; @@ -349,7 +350,11 @@ export class STDataSource { return { buttons: this.genButtons(c.buttons, result[i], c), _text: '', props }; } - return { ...this.get(result[i], c, i), props }; + let cell: CellOptions | undefined; + if (typeof c.cell === 'function') { + cell = c.cell(result[i], c); + } + return { ...this.get(result[i], c, i), props, cell }; }); result[i]._rowClassName = [rowClassName ? rowClassName(result[i], i) : null, result[i].className] .filter(w => !!w) diff --git a/packages/abc/st/st-td.component.html b/packages/abc/st/st-td.component.html index 78de46c72..f57ba9532 100644 --- a/packages/abc/st/st-td.component.html +++ b/packages/abc/st/st-td.component.html @@ -79,7 +79,8 @@ [nz-tooltip]="i._values[cIdx].tooltip" /> - + + { * - `currency` 货币且居右(若 `className` 存在则优先) * - `date` 日期格式且居中(若 `className` 存在则优先),使用 `dateFormat` 自定义格式 * - `yn` 将`boolean`类型徽章化 [document](https://ng-alain.com/docs/data-render#yn) + * - `cell` 使用 `cell` 组件渲染 [document](https://ng-alain.com/components/cell) * - `widget` 使用自定义小部件动态创建 */ type?: @@ -283,7 +285,15 @@ export interface STColumn { | 'date' | 'yn' | 'no' + | 'cell' | 'widget'; + + /** + * `cell` component options + * + * `cell` 组件配置项 + */ + cell?: CellOptions | ((record: T, column: STColumn) => CellOptions); /** * 链接回调,若返回一个字符串表示导航URL会自动触发 `router.navigateByUrl` */ diff --git a/packages/abc/st/st.module.ts b/packages/abc/st/st.module.ts index e372c23f5..f74d37cc4 100644 --- a/packages/abc/st/st.module.ts +++ b/packages/abc/st/st.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { CellModule } from '@delon/abc/cell'; import { LetModule } from '@delon/abc/let'; import { DelonACLModule } from '@delon/acl'; import { NzBadgeModule } from 'ng-zorro-antd/badge'; @@ -33,6 +34,7 @@ const COMPONENTS = [STComponent, STRowDirective, STWidgetHostDirective]; FormsModule, DelonACLModule, LetModule, + CellModule, NzPopconfirmModule, NzTableModule, NzIconModule, diff --git a/packages/abc/st/st.types.ts b/packages/abc/st/st.types.ts index 503ba6beb..5b53aa2e0 100644 --- a/packages/abc/st/st.types.ts +++ b/packages/abc/st/st.types.ts @@ -2,6 +2,7 @@ import { TemplateRef } from '@angular/core'; import { SafeHtml } from '@angular/platform-browser'; +import type { CellOptions } from '@delon/abc/cell'; import type { NgClassType } from 'ng-zorro-antd/core/types'; import type { @@ -91,4 +92,5 @@ export interface _STDataValue { safeType: STColumnSafeType; buttons?: _STColumnButton[]; props?: STOnCellResult | null; + cell?: CellOptions; } diff --git a/packages/abc/st/test/st.spec.ts b/packages/abc/st/test/st.spec.ts index e3444e4dd..96c669eb6 100644 --- a/packages/abc/st/test/st.spec.ts +++ b/packages/abc/st/test/st.spec.ts @@ -394,6 +394,25 @@ describe('abc: st', () => { page.asyncEnd(); })); }); + describe('with cell', () => { + it('should be working', fakeAsync(() => { + page + .updateColumn([{ index: 'id', cell: { type: 'checkbox' } }]) + .expectElCount('.cell', PS) + .expectElCount('.ant-checkbox', PS); + })); + it('should be support function', fakeAsync(() => { + page + .updateColumn([ + { + index: 'id', + cell: i => (i.id === 1 ? { type: 'checkbox' } : {}) + } + ]) + .expectElCount('.cell', PS) + .expectElCount('.ant-checkbox', 1); + })); + }); describe('[other]', () => { it('should custom render via format', fakeAsync(() => { page diff --git a/packages/abc/theme-default.less b/packages/abc/theme-default.less index 5495ee8f1..b042e37f4 100644 --- a/packages/abc/theme-default.less +++ b/packages/abc/theme-default.less @@ -103,3 +103,13 @@ // -- @tag-select-margin: 16px; @tag-select-item-margin-right: 24px; + +// cell +// -- +@cell-default-color: @text-color-secondary; +@cell-large: 18px; +@cell-small: 12px; +@cell-unit-color: @text-color-secondary; +@cell-unit-font-size: 12px; +@cell-img-space: 4px; +@cell-link-disabled-color: @text-color; diff --git a/packages/theme/src/pipes/date/date.pipe.ts b/packages/theme/src/pipes/date/date.pipe.ts index 43fecbcef..8d8b72d55 100644 --- a/packages/theme/src/pipes/date/date.pipe.ts +++ b/packages/theme/src/pipes/date/date.pipe.ts @@ -1,9 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { format, formatDistanceToNow } from 'date-fns'; - -import { toDate } from '@delon/util/date-time'; -import type { NzSafeAny } from 'ng-zorro-antd/core/types'; +import { formatDate } from '@delon/util/date-time'; import { NzI18nService } from 'ng-zorro-antd/i18n'; @Pipe({ name: '_date' }) @@ -11,10 +8,6 @@ export class DatePipe implements PipeTransform { constructor(private nzI18n: NzI18nService) {} transform(value: Date | string | number, formatString: string = 'yyyy-MM-dd HH:mm'): string { - value = toDate(value); - if (isNaN(value as NzSafeAny)) return ''; - - const langOpt = { locale: this.nzI18n.getDateLocale() }; - return formatString === 'fn' ? formatDistanceToNow(value, langOpt) : format(value, formatString, langOpt); + return formatDate(value, formatString, this.nzI18n.getDateLocale()); } } diff --git a/packages/theme/src/pipes/yn/yn.pipe.ts b/packages/theme/src/pipes/yn/yn.pipe.ts index 7622b795e..a64798c46 100644 --- a/packages/theme/src/pipes/yn/yn.pipe.ts +++ b/packages/theme/src/pipes/yn/yn.pipe.ts @@ -8,27 +8,39 @@ const ICON_NO = `${ICON_YES}${yes}` + : `${ICON_NO}${no}`; + break; + case 'text': + html = value ? `${yes}` : `${no}`; + break; + default: + html = value ? `${ICON_YES}` : `${ICON_NO}`; + break; + } + return html; +} + @Pipe({ name: 'yn' }) export class YNPipe implements PipeTransform { constructor(private dom: DomSanitizer) {} transform(value: boolean, yes?: string, no?: string, mode?: YNMode, isSafeHtml: boolean = true): SafeHtml { - let html = ''; - yes = yes || '是'; - no = no || '否'; - switch (mode) { - case 'full': - html = value - ? `${ICON_YES}${yes}` - : `${ICON_NO}${no}`; - break; - case 'text': - html = value ? `${yes}` : `${no}`; - break; - default: - html = value ? `${ICON_YES}` : `${ICON_NO}`; - break; - } + const html = yn(value, { yes, no, mode }); return isSafeHtml ? this.dom.bypassSecurityTrustHtml(html) : html; } } diff --git a/packages/util/config/abc/cell.type.ts b/packages/util/config/abc/cell.type.ts new file mode 100644 index 000000000..cbc34fa6d --- /dev/null +++ b/packages/util/config/abc/cell.type.ts @@ -0,0 +1,126 @@ +import type { NzImagePreviewOptions } from 'ng-zorro-antd/image'; + +export interface AlainCellConfig { + /** + * Size + * + * 大小 + */ + size?: 'large' | 'small'; + /** + * Default Text + * + * 默认文本 + */ + default?: { + text?: string; + condition?: unknown; + }; + /** + * 日期 + */ + date?: { + /** + * 格式化字符,默认:`yyyy-MM-dd HH:mm:ss` + */ + format?: string; + }; + + /** + * 货币 + */ + currency?: { + /** + * The starting unit of the value, `yuan` means 元, `cent` means 分, default: `yuan` + * + * 值的起始单位,`yuan` 元,`cent` 分,默认:`yuan` + */ + startingUnit?: 'yuan' | 'cent'; + /** + * Using `DEFAULT_CURRENCY_CODE` when value is `true + * + * 是否使用 `CurrencyPipe` 来替代 + */ + useAngular?: boolean; + /** + * 精度,默认:`2` + */ + precision?: number; + /** + * 是否忽略精度 `.0` 或 `.00` 结尾的字符,默认:`true` + */ + ingoreZeroPrecision?: boolean; + + /** + * Use anguar `currency` pipe parse when is set, pls refer to [document](https://angular.io/api/common/CurrencyPipe) + * + * 若指定则表示使用 Angular 自带的 `currency` 管道来解析,见[文档](https://angular.cn/api/common/CurrencyPipe) + */ + ngCurrency?: { + display: 'code' | 'symbol' | 'symbol-narrow'; + currencyCode?: string; + digitsInfo?: string; + locale?: string; + }; + }; + + /** + * Converted into RMB notation + * + * 转化成人民币表示法 + */ + cny?: { + /** + * The starting unit of the value, `yuan` means 元, `cent` means 分, default: `yuan` + * + * 值的起始单位,`yuan` 元,`cent` 分,默认:`yuan` + */ + startingUnit?: 'yuan' | 'cent'; + /** + * Whether to return to uppercase notation, default: `true` + * + * 是否返回大写表示法,默认:`true` + */ + inWords?: boolean; + /** + * Specify negative sign, default: `negative` + * + * 指定负数符号,默认:`负` + */ + minusSymbol?: string; + }; + + /** + * 布尔 + */ + boolean?: { + yes?: string; + no?: string; + mode?: 'full' | 'icon' | 'text'; + }; + + /** + * Image config, support large image preview + * + * 图像配置,支持大图预览 + */ + img?: { + /** + * 大小,默认:`32` + */ + size?: number; + /** + * 是否允许点击查看大图,默认:`true` + */ + big?: boolean; + + previewOptions?: NzImagePreviewOptions; + }; + + /** + * HTML 配置 + */ + html?: { + safe?: 'text' | 'html' | 'safeHtml'; + }; +} diff --git a/packages/util/config/abc/index.ts b/packages/util/config/abc/index.ts index 505f575cb..863cc374c 100644 --- a/packages/util/config/abc/index.ts +++ b/packages/util/config/abc/index.ts @@ -14,4 +14,5 @@ export * from './zip.type'; export * from './media.type'; export * from './pdf.type'; export * from './onboarding.type'; +export * from './cell.type'; export * from './exception.type'; diff --git a/packages/util/config/config.types.ts b/packages/util/config/config.types.ts index 30995f960..13796d086 100644 --- a/packages/util/config/config.types.ts +++ b/packages/util/config/config.types.ts @@ -1,6 +1,7 @@ import { InjectionToken } from '@angular/core'; import { + AlainCellConfig, AlainDateRangePickerConfig, AlainErrorCollectConfig, AlainExceptionType, @@ -44,6 +45,7 @@ export interface AlainConfig { sv?: AlainSVConfig; st?: AlainSTConfig; sf?: AlainSFConfig; + cell?: AlainCellConfig; xlsx?: AlainXlsxConfig; zip?: AlainZipConfig; pdf?: AlainPdfConfig; diff --git a/packages/util/date-time/time.ts b/packages/util/date-time/time.ts index 2cd0eaf49..74d8358af 100644 --- a/packages/util/date-time/time.ts +++ b/packages/util/date-time/time.ts @@ -12,10 +12,13 @@ import { startOfYear, subMonths, subWeeks, - subYears + subYears, + format, + formatDistanceToNow } from 'date-fns'; import type { NzSafeAny } from 'ng-zorro-antd/core/types'; +import type { DateLocale } from 'ng-zorro-antd/i18n'; /** * Get the time range, return `[ Date, Date]` for the start and end dates @@ -113,3 +116,11 @@ export function toDate(value?: Date | string | number | null, options?: ToDateOp return isNaN(tryDate as NzSafeAny) ? defaultValue : tryDate; } + +export function formatDate(value: Date | string | number, formatString: string, dateLocale?: DateLocale): string { + value = toDate(value); + if (isNaN(value as NzSafeAny)) return ''; + + const langOpt = { locale: dateLocale }; + return formatString === 'fn' ? formatDistanceToNow(value, langOpt) : format(value, formatString, langOpt); +} diff --git a/packages/util/format/currency.types.ts b/packages/util/format/currency.types.ts index d774b751f..3893f9d0b 100644 --- a/packages/util/format/currency.types.ts +++ b/packages/util/format/currency.types.ts @@ -38,6 +38,11 @@ export interface CurrencyFormatOptions extends CurrencyStartingUnitOptions { }; } +/** + * Large number format filter, [Document](https://ng-alain.com/util/format/en#mega) + * + * 大数据格式化,[文档](https://ng-alain.com/util/format/en#mega) + */ export interface CurrencyMegaOptions extends CurrencyStartingUnitOptions { /** * 精度,默认:`2` diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 633927b50..dd6133cfe 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { IconComponent } from './shared/components/icon/icon.component'; import { JsonSchemaModule } from './shared/json-schema/json-schema.module'; import { SharedModule } from './shared/shared.module'; import { STWidgetModule } from './shared/st-widget/st-widget.module'; +import { CellWidgetModule } from './shared/cell-widget/module'; export function StartupServiceFactory(startupService: StartupService): () => Promise { return () => startupService.load(); @@ -66,6 +67,7 @@ function registerElements(injector: Injector, platformId: {}): void { SharedModule, JsonSchemaModule, STWidgetModule, + CellWidgetModule, RoutesModule, ExampleModule, NgxTinymceModule.forRoot({ diff --git a/src/app/shared/cell-widget/module.ts b/src/app/shared/cell-widget/module.ts new file mode 100644 index 000000000..e5316bef8 --- /dev/null +++ b/src/app/shared/cell-widget/module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; + +import { CellService } from '@delon/abc/cell'; + +import { CellTestWidget } from './test'; +import { SharedModule } from '../shared.module'; + +export const CELL_WIDGET_COMPONENTS = [CellTestWidget]; + +@NgModule({ + declarations: CELL_WIDGET_COMPONENTS, + imports: [SharedModule], + exports: CELL_WIDGET_COMPONENTS +}) +export class CellWidgetModule { + constructor(srv: CellService) { + srv.registerWidget(CellTestWidget.KEY, CellTestWidget); + } +} diff --git a/src/app/shared/cell-widget/test.ts b/src/app/shared/cell-widget/test.ts new file mode 100644 index 000000000..22bb329ba --- /dev/null +++ b/src/app/shared/cell-widget/test.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import type { CellWidgetData, CellWidgetInstance } from '@delon/abc/cell'; +import { NzMessageService } from 'ng-zorro-antd/message'; + +@Component({ + selector: 'cell-widget-test', + template: ` `, + host: { + '(click)': 'show()' + }, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CellTestWidget implements CellWidgetInstance { + static readonly KEY = 'test'; + + readonly data!: CellWidgetData; + + constructor(private msg: NzMessageService) {} + + show(): void { + this.msg.info(`click`); + } +} diff --git a/src/app/shared/shared-delon.module.ts b/src/app/shared/shared-delon.module.ts index 95bfe542a..6860b3162 100644 --- a/src/app/shared/shared-delon.module.ts +++ b/src/app/shared/shared-delon.module.ts @@ -1,5 +1,6 @@ import { AutoFocusModule } from '@delon/abc/auto-focus'; import { AvatarListModule } from '@delon/abc/avatar-list'; +import { CellModule } from '@delon/abc/cell'; import { CountDownModule } from '@delon/abc/count-down'; import { DatePickerModule } from '@delon/abc/date-picker'; import { DownFileModule } from '@delon/abc/down-file'; @@ -58,6 +59,7 @@ export const SHARED_DELON_MODULES = [ SGModule, LoadingModule, QRModule, + CellModule, OnboardingModule, ErrorCollectModule, ExceptionModule, diff --git a/src/site.config.js b/src/site.config.js index ea98ccfeb..9c55c2a95 100644 --- a/src/site.config.js +++ b/src/site.config.js @@ -79,7 +79,7 @@ module.exports = { { name: 'form', route: '/form/getting-started', - order: 4, + order: 10, i18n: true, lib: true, meta: {