Browse Source

Refactor to use Angular signals and input() API

Refactors extensible form, table, and related components to use Angular's signal-based input() API instead of @Input and ngOnChanges. Updates internal state management to use signals and effects, improves type safety with ReadonlyPropData, and updates templates to use new signal-based accessors. Removes legacy OnChanges logic and adapts utility and directive code to the new reactive paradigm.
pull/24777/head
Fahri Gedik 8 months ago
parent
commit
cb1116a8e7
  1. 54
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html
  2. 108
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts
  3. 4
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.html
  4. 32
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts
  5. 18
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html
  6. 180
      npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts
  7. 5
      npm/ng-packs/packages/components/extensible/src/lib/components/grid-actions/grid-actions.component.ts
  8. 41
      npm/ng-packs/packages/components/extensible/src/lib/directives/prop-data.directive.ts
  9. 5
      npm/ng-packs/packages/components/extensible/src/lib/models/actions.ts
  10. 27
      npm/ng-packs/packages/components/extensible/src/lib/models/props.ts
  11. 10
      npm/ng-packs/packages/components/extensible/src/lib/utils/form-props.util.ts
  12. 45
      npm/ng-packs/packages/components/page/src/page-part.directive.ts

54
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html

@ -1,34 +1,34 @@
<ng-container *abpPermission="prop.permission; runChangeDetection: false">
@switch (getComponent(prop)) {
<ng-container *abpPermission="prop().permission; runChangeDetection: false">
@switch (getComponent(prop())) {
@case ('template') {
<ng-container *ngComponentOutlet="prop.template; injector: injectorForCustomComponent" />
<ng-container *ngComponentOutlet="prop().template; injector: injectorForCustomComponent" />
}
}
<div [class]="containerClassName" class="mb-2">
@switch (getComponent(prop)) {
@switch (getComponent(prop())) {
@case ('input') {
<ng-template [ngTemplateOutlet]="label" />
<input #field [id]="prop.id" [formControlName]="prop.name" [autocomplete]="prop.autocomplete" [type]="getType(prop)"
<input #field [id]="prop().id" [formControlName]="prop().name" [autocomplete]="prop().autocomplete" [type]="getType(prop())"
[abpDisabled]="disabled" [readonly]="readonly" class="form-control" />
}
@case ('hidden') {
<input [formControlName]="prop.name" type="hidden" />
<input [formControlName]="prop().name" type="hidden" />
}
@case ('checkbox') {
<div class="form-check" validationTarget>
<input #field [id]="prop.id" [formControlName]="prop.name" [abpDisabled]="disabled" type="checkbox"
<input #field [id]="prop().id" [formControlName]="prop().name" [abpDisabled]="disabled" type="checkbox"
class="form-check-input" />
<ng-template [ngTemplateOutlet]="label" [ngTemplateOutletContext]="{ $implicit: 'form-check-label' }" />
</div>
}
@case ('select') {
<ng-template [ngTemplateOutlet]="label" />
<select #field [id]="prop.id" [formControlName]="prop.name" [abpDisabled]="disabled"
<select #field [id]="prop().id" [formControlName]="prop().name" [abpDisabled]="disabled"
class="form-select form-control">
@for (option of options$ | async; track option.value) {
<option [ngValue]="option.value">
@if (prop.isExtra) {
@if (prop().isExtra) {
{{ '::' + option.key | abpLocalization }}
} @else {
{{ option.key }}
@ -39,42 +39,42 @@
}
@case ('multiselect') {
<ng-template [ngTemplateOutlet]="label"></ng-template>
<abp-extensible-form-multi-select [prop]="prop" [options]="options$ | async" [formControlName]="prop.name"
<abp-extensible-form-multi-select [prop]="prop()" [options]="options$ | async" [formControlName]="prop().name"
[abpDisabled]="disabled" />
}
@case ('typeahead') {
<ng-template [ngTemplateOutlet]="label" />
<div #typeahead class="position-relative" validationStyle validationTarget>
<input #field [id]="prop.id" [autocomplete]="prop.autocomplete" [abpDisabled]="disabled" [ngbTypeahead]="search"
<input #field [id]="prop().id" [autocomplete]="prop().autocomplete" [abpDisabled]="disabled" [ngbTypeahead]="search"
[editable]="false" [inputFormatter]="typeaheadFormatter" [resultFormatter]="typeaheadFormatter"
[ngModelOptions]="{ standalone: true }" [(ngModel)]="typeaheadModel"
(selectItem)="setTypeaheadValue($event.item)" (blur)="setTypeaheadValue(typeaheadModel)"
[class.is-invalid]="typeahead.classList.contains('is-invalid')" class="form-control" />
<input [formControlName]="prop.name" type="hidden" />
<input [formControlName]="prop().name" type="hidden" />
</div>
}
@case ('date') {
<ng-template [ngTemplateOutlet]="label" />
<input [id]="prop.id" [formControlName]="prop.name" (click)="datepicker.open()" (keyup.space)="datepicker.open()"
<input [id]="prop().id" [formControlName]="prop().name" (click)="datepicker.open()" (keyup.space)="datepicker.open()"
ngbDatepicker #datepicker="ngbDatepicker" type="text" class="form-control" />
}
@case ('time') {
<ng-template [ngTemplateOutlet]="label" />
<ngb-timepicker [formControlName]="prop.name" />
<ngb-timepicker [formControlName]="prop().name" />
}
@case ('dateTime') {
<ng-template [ngTemplateOutlet]="label" />
<abp-extensible-date-time-picker [prop]="prop" [meridian]="meridian$ | async" />
<abp-extensible-date-time-picker [prop]="prop()" [meridian]="meridian$ | async" />
}
@case ('textarea') {
<ng-template [ngTemplateOutlet]="label" />
<textarea #field [id]="prop.id" [formControlName]="prop.name" [abpDisabled]="disabled" [readonly]="readonly"
<textarea #field [id]="prop().id" [formControlName]="prop().name" [abpDisabled]="disabled" [readonly]="readonly"
class="form-control"></textarea>
}
@case ('passwordinputgroup') {
<ng-template [ngTemplateOutlet]="label" />
<div class="input-group form-group" validationTarget>
<input class="form-control" [id]="prop.id" [formControlName]="prop.name" [abpShowPassword]="showPassword" />
<input class="form-control" [id]="prop().id" [formControlName]="prop().name" [abpShowPassword]="showPassword" />
<button class="btn btn-secondary" type="button" (click)="showPassword = !showPassword">
<i class="fa" aria-hidden="true" [class]="{
'fa-eye-slash': !showPassword,
@ -85,27 +85,27 @@
}
}
@if (prop.formText) {
<small class="text-muted d-block">{{ prop.formText | abpLocalization }}</small>
@if (prop().formText) {
<small class="text-muted d-block">{{ prop().formText | abpLocalization }}</small>
}
</div>
</ng-container>
<ng-template #label let-classes>
<label [htmlFor]="prop.id" [class]="classes || 'form-label d-inline-block'">
<label [htmlFor]="prop().id" [class]="classes || 'form-label d-inline-block'">
<span class="d-inline-flex align-items-center gap-1 text-nowrap">
@if (prop.displayTextResolver) {
{{ prop.displayTextResolver(data()) | abpLocalization }}
@if (prop().displayTextResolver) {
{{ prop().displayTextResolver(data()) | abpLocalization }}
} @else {
@if (prop.isExtra) {
{{ '::' + prop.displayName | abpLocalization }}
@if (prop().isExtra) {
{{ '::' + prop().displayName | abpLocalization }}
} @else {
{{ prop.displayName | abpLocalization }}
{{ prop().displayName | abpLocalization }}
}
}
{{ asterisk }}
@if (prop.tooltip) {
<i [ngbTooltip]="prop.tooltip.text | abpLocalization" [placement]="prop.tooltip.placement || 'auto'"
@if (prop().tooltip) {
<i [ngbTooltip]="prop().tooltip.text | abpLocalization" [placement]="prop().tooltip.placement || 'auto'"
container="body" class="bi bi-info-circle"></i>
}
</span>

108
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts

@ -14,13 +14,11 @@ import {
ElementRef,
inject,
Injector,
Input,
OnChanges,
Optional,
SimpleChanges,
SkipSelf,
ViewChild,
signal,
effect,
input
} from '@angular/core';
@ -43,7 +41,7 @@ import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { DateAdapter, DisabledDirective, TimeAdapter } from '@abp/ng.theme.shared';
import { EXTRA_PROPERTIES_KEY } from '../../constants/extra-properties';
import { FormProp } from '../../models/form-props';
import { PropData } from '../../models/props';
import { PropData, ReadonlyPropData } from '../../models/props';
import { selfFactory } from '../../utils/factory.util';
import { addTypeaheadTextSuffix } from '../../utils/typeahead.util';
import { eExtensibleComponents } from '../../enums/components';
@ -74,7 +72,7 @@ import { ExtensibleFormMultiselectComponent } from '../multi-select/extensible-f
NgComponentOutlet,
NgTemplateOutlet,
FormsModule
],
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ExtensibleFormPropService],
viewProviders: [
@ -87,7 +85,7 @@ import { ExtensibleFormMultiselectComponent } from '../multi-select/extensible-f
{ provide: NgbTimeAdapter, useClass: TimeAdapter },
],
})
export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit {
export class ExtensibleFormPropComponent implements AfterViewInit {
protected service = inject(ExtensibleFormPropService);
public readonly cdRef = inject(ChangeDetectorRef);
public readonly track = inject(TrackByService);
@ -96,9 +94,9 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit {
private readonly form = this.#groupDirective.form;
readonly data = input.required<PropData>();
@Input() prop!: FormProp;
readonly first = input<boolean>(undefined);
readonly isFirstGroup = input<boolean>(undefined);
readonly prop = input.required<FormProp>();
readonly first = input<boolean | undefined>(undefined);
readonly isFirstGroup = input<boolean | undefined>(undefined);
@ViewChild('field') private fieldRef!: ElementRef<HTMLElement>;
injectorForCustomComponent?: Injector;
@ -111,10 +109,55 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit {
typeaheadModel: any;
passwordKey = eExtensibleComponents.PasswordComponent;
disabledFn = (data: PropData) => false;
disabledFn = (data: ReadonlyPropData) => false;
get disabled() {
return this.disabledFn(this.data());
return this.disabledFn(this.data().data);
}
constructor() {
// Watch prop changes and update state
effect(() => {
const currentProp = this.prop();
if (!currentProp) return;
const { options, readonly, disabled, validators, className, template } = currentProp;
if (template) {
this.injectorForCustomComponent = Injector.create({
providers: [
{
provide: EXTENSIONS_FORM_PROP,
useValue: currentProp,
},
{
provide: EXTENSIONS_FORM_PROP_DATA,
useValue: this.data()?.data?.record,
},
{ provide: ControlContainer, useExisting: FormGroupDirective },
],
parent: this.injector,
});
}
if (options) this.options$ = options(this.data().data);
if (readonly) this.readonly = readonly(this.data().data);
if (disabled) {
this.disabledFn = disabled;
}
if (validators) {
this.validators = validators(this.data().data);
this.setAsterisk();
}
if (className !== undefined) {
this.containerClassName = className;
}
const [keyControl, valueControl] = this.getTypeaheadControls();
if (keyControl && valueControl)
this.typeaheadModel = { key: keyControl.value, value: valueControl.value };
});
}
setTypeaheadValue(selectedOption: ABP.Option<string>) {
@ -131,7 +174,7 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit {
? text$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(text => this.prop?.options?.(this.data(), text) || of([])),
switchMap(text => this.prop()?.options?.(this.data().data, text) || of([])),
)
: of([]);
@ -140,12 +183,12 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit {
meridian$ = this.service.meridian$;
get isInvalid() {
const control = this.form.get(this.prop.name);
const control = this.form.get(this.prop().name);
return control?.touched && control.invalid;
}
private getTypeaheadControls() {
const { name } = this.prop;
const { name } = this.prop();
const extraPropName = `${EXTRA_PROPERTIES_KEY}.${name}`;
const keyControl =
this.form.get(addTypeaheadTextSuffix(extraPropName)) ||
@ -173,43 +216,4 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit {
getType(prop: FormProp): string {
return this.service.getType(prop);
}
ngOnChanges({ prop, data }: SimpleChanges) {
const currentProp = prop?.currentValue as FormProp;
const { options, readonly, disabled, validators, className, template } = currentProp || {};
if (template) {
this.injectorForCustomComponent = Injector.create({
providers: [
{
provide: EXTENSIONS_FORM_PROP,
useValue: currentProp,
},
{
provide: EXTENSIONS_FORM_PROP_DATA,
useValue: (data?.currentValue as PropData)?.record,
},
{ provide: ControlContainer, useExisting: FormGroupDirective },
],
parent: this.injector,
});
}
if (options) this.options$ = options(this.data());
if (readonly) this.readonly = readonly(this.data());
if (disabled) {
this.disabledFn = disabled;
}
if (validators) {
this.validators = validators(this.data());
this.setAsterisk();
}
if (className !== undefined) {
this.containerClassName = className;
}
const [keyControl, valueControl] = this.getTypeaheadControls();
if (keyControl && valueControl)
this.typeaheadModel = { key: keyControl.value, value: valueControl.value };
}
}

4
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.html

@ -1,6 +1,6 @@
@if (form) {
@for (groupedProp of groupedPropList.items; track i; let i = $index; let first = $first) {
<ng-container *abpPropData="let data; fromList: groupedProp.formPropList; withRecord: record">
@for (groupedProp of groupedPropList()?.items; track i; let i = $index; let first = $first) {
<ng-container *abpPropData="let data; fromList: groupedProp.formPropList; withRecord: record()">
@if (isAnyGroupMemberVisible(i, data) && groupedProp.group?.className) {
<div [class]="groupedProp.group?.className"
[attr.data-name]="groupedProp.group?.name || groupedProp.group?.className">

32
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts

@ -4,11 +4,13 @@ import {
ChangeDetectorRef,
Component,
inject,
Input,
Optional,
QueryList,
SkipSelf,
ViewChildren,
input,
signal,
effect
} from '@angular/core';
import { ControlContainer, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { EXTRA_PROPERTIES_KEY } from '../../constants/extra-properties';
@ -44,18 +46,22 @@ export class ExtensibleFormComponent<R = any> {
@ViewChildren(ExtensibleFormPropComponent)
formProps!: QueryList<ExtensibleFormPropComponent>;
@Input()
set selectedRecord(record: R) {
const type = !record || JSON.stringify(record) === '{}' ? 'create' : 'edit';
const propList = this.extensions[`${type}FormProps`].get(this.identifier).props;
this.groupedPropList = this.createGroupedList(propList);
this.record = record;
}
readonly selectedRecord = input<R | undefined>(undefined);
extraPropertiesKey = EXTRA_PROPERTIES_KEY;
groupedPropList!: GroupedFormPropList;
readonly groupedPropList = signal<GroupedFormPropList | undefined>(undefined);
groupedPropListOfArray: FormProp<any>[][];
record!: R;
readonly record = signal<R | undefined>(undefined);
constructor() {
effect(() => {
const recordValue = this.selectedRecord();
const type = !recordValue || JSON.stringify(recordValue) === '{}' ? 'create' : 'edit';
const propList = this.extensions[`${type}FormProps`].get(this.identifier).props;
this.groupedPropList.set(this.createGroupedList(propList));
this.record.set(recordValue);
});
}
get form(): UntypedFormGroup {
return (this.container ? this.container.control : { controls: {} }) as UntypedFormGroup;
@ -75,8 +81,10 @@ export class ExtensibleFormComponent<R = any> {
}
//TODO: Reactor this method
isAnyGroupMemberVisible(index: number, data) {
const { items } = this.groupedPropList;
isAnyGroupMemberVisible(index: number, data: any) {
const groupedPropListValue = this.groupedPropList();
if (!groupedPropListValue) return false;
const { items } = groupedPropListValue;
const formPropList = items[index].formPropList.toArray();
return formPropList.some(prop => prop.visible(data));
}

18
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html

@ -1,6 +1,6 @@
@if (isBrowser) {
<ngx-datatable #table default [rows]="data" [count]="recordsTotal()" [list]="list()"
[selectionType]="selectable() ? _selectionType : undefined" (activate)="tableActivate.emit($event)"
[selectionType]="selectable() ? selectionType() : undefined" (activate)="tableActivate.emit($event)"
(select)="onSelect($event)" [selected]="selected()" (scroll)="onScroll($event)" [scrollbarV]="infiniteScroll()"
[style.height]="getTableHeight()" [loadingIndicator]="infiniteScroll() && isLoading()"
[footerHeight]="infiniteScroll() ? false : 50">
@ -28,7 +28,7 @@
<ng-template ngx-datatable-header-template let-value="value" let-allRowsSelected="allRowsSelected"
let-selectFn="selectFn">
@if (_selectionType !== 'single') {
@if (selectionType() !== 'single') {
<div class="form-check">
<input class="form-check-input table-check" type="checkbox" [checked]="allRowsSelected"
(change)="selectFn(!allRowsSelected)" />
@ -38,12 +38,12 @@
<ng-template ngx-datatable-cell-template let-value="value" let-row="row" let-isSelected="isSelected"
let-onCheckboxChangeFn="onCheckboxChangeFn">
@if(_selectionType === 'single') {
@if(selectionType() === 'single') {
<div class="h-100 form-check form-check-sm form-check-custom form-check-solid">
<input class="form-check-input" type="radio" [checked]="isSelected" (change)="onCheckboxChangeFn($event)" />
</div>
}
@if (_selectionType !== 'single') {
@if (selectionType() !== 'single') {
<div class="h-100 form-check form-check-sm form-check-custom form-check-solid">
<input class="form-check-input" type="checkbox" [checked]="isSelected" (change)="onCheckboxChangeFn($event)" />
</div>
@ -52,12 +52,12 @@
</ngx-datatable-column>
}
@if (actionsTemplate || (actionList.length && hasAtLeastOnePermittedAction)) {
<ngx-datatable-column [name]="actionsText | abpLocalization" [maxWidth]="_actionsColumnWidth() ?? undefined"
@if (actionsTemplate() || (actionList.length && hasAtLeastOnePermittedAction)) {
<ngx-datatable-column [name]="actionsText() | abpLocalization" [maxWidth]="_actionsColumnWidth() ?? undefined"
[width]="_actionsColumnWidth() ?? 200" [canAutoResize]="!_actionsColumnWidth()" [sortable]="false">
<ng-template let-row="row" let-i="rowIndex" ngx-datatable-cell-template>
<ng-container
*ngTemplateOutlet="actionsTemplate || gridActions; context: { $implicit: row, index: i }"></ng-container>
*ngTemplateOutlet="actionsTemplate() || gridActions; context: { $implicit: row, index: i }"></ng-container>
<ng-template #gridActions>
@if (isVisibleActions(row)) {
<abp-grid-actions [index]="i" [record]="row" text="AbpUi::Actions"></abp-grid-actions>
@ -67,8 +67,8 @@
</ngx-datatable-column>
}
@for (prop of propList; track prop.name; let i = $index) {
<ngx-datatable-column *abpVisible="prop.columnVisible(getInjected)" [width]="columnWidths[i] ?? 200"
[canAutoResize]="!columnWidths[i]"
<ngx-datatable-column *abpVisible="prop.columnVisible(getInjected)" [width]="columnWidths()[i] ?? 200"
[canAutoResize]="!columnWidths()[i]"
[name]="(prop.isExtra ? '::' + prop.displayName : prop.displayName) | abpLocalization" [prop]="prop.name"
[sortable]="prop.sortable">
<ng-template ngx-datatable-header-template let-column="column" let-sortFn="sortFn">

180
npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts

@ -5,21 +5,18 @@ import {
Component,
computed,
ContentChild,
EventEmitter,
inject,
Injector,
Input,
LOCALE_ID,
OnChanges,
OnDestroy,
Output,
PLATFORM_ID,
signal,
SimpleChanges,
TemplateRef,
TrackByFunction,
ViewChild,
input
input,
effect,
output
} from '@angular/core';
import { AsyncPipe, isPlatformBrowser, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
@ -47,7 +44,7 @@ import {
import { ePropType } from '../../enums/props.enum';
import { EntityActionList } from '../../models/entity-actions';
import { EntityProp, EntityPropList } from '../../models/entity-props';
import { PropData } from '../../models/props';
import { ReadonlyPropData } from '../../models/props';
import { ExtensionsService } from '../../services/extensions.service';
import {
ENTITY_PROP_TYPE_CLASSES,
@ -87,7 +84,7 @@ const DEFAULT_ACTIONS_COLUMN_WIDTH = 150;
}
`],
})
export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewInit, OnDestroy {
export class ExtensibleTableComponent<R = any> implements AfterViewInit, OnDestroy {
readonly #injector = inject(Injector);
readonly getInjected = this.#injector.get.bind(this.#injector);
protected readonly cdr = inject(ChangeDetectorRef);
@ -99,54 +96,56 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
private platformId = inject(PLATFORM_ID);
protected isBrowser = isPlatformBrowser(this.platformId);
protected _actionsText!: string;
@Input()
set actionsText(value: string) {
this._actionsText = value;
}
get actionsText(): string {
return this._actionsText ?? (this.actionList.length >= 1 ? 'AbpUi::Actions' : '');
}
@Input() data!: R[];
// Input signals
readonly actionsTextInput = input<string | undefined>(undefined, { alias: 'actionsText' });
readonly dataInput = input<R[]>([], { alias: 'data' });
readonly list = input.required<ListService>();
readonly recordsTotal = input.required<number>();
@Input() set actionsColumnWidth(width: number) {
this._actionsColumnWidth.set(width ? Number(width) : undefined);
}
@Input() actionsTemplate?: TemplateRef<any>;
@Output() tableActivate = new EventEmitter();
readonly actionsColumnWidthInput = input<number | undefined>(undefined, { alias: 'actionsColumnWidth' });
readonly actionsTemplate = input<TemplateRef<any> | undefined>(undefined);
readonly selectable = input(false);
@Input() set selectionType(value: SelectionType | string) {
this._selectionType = typeof value === 'string' ? SelectionType[value] : value;
}
_selectionType: SelectionType = SelectionType.multiClick;
readonly selectionTypeInput = input<SelectionType | string>(SelectionType.multiClick, { alias: 'selectionType' });
readonly selected = input<any[]>([]);
@Output() selectionChange = new EventEmitter<any[]>();
// Infinite scroll configuration
readonly infiniteScroll = input(false);
readonly isLoading = input(false);
readonly scrollThreshold = input(10);
@Output() loadMore = new EventEmitter<void>();
readonly tableHeight = input<number>(undefined);
readonly rowDetailTemplate = input<TemplateRef<RowDetailContext<R>>>(undefined);
readonly tableHeight = input<number | undefined>(undefined);
readonly rowDetailTemplate = input<TemplateRef<RowDetailContext<R>> | undefined>(undefined);
readonly rowDetailHeight = input<string | number>('100%');
@Output() rowDetailToggle = new EventEmitter<R>();
// Output signals
readonly tableActivate = output<any>();
readonly selectionChange = output<any[]>();
readonly loadMore = output<void>();
readonly rowDetailToggle = output<R>();
// Internal signals
protected readonly _data = signal<R[]>([]);
private readonly _actionsColumnWidth = signal<number | undefined>(DEFAULT_ACTIONS_COLUMN_WIDTH);
@ContentChild(ExtensibleTableRowDetailComponent)
rowDetailComponent?: ExtensibleTableRowDetailComponent<R>;
@ViewChild('table', { static: false }) table!: DatatableComponent;
// Computed values
protected readonly actionsText = computed(() => {
return this.actionsTextInput() ?? (this.actionList.length >= 1 ? 'AbpUi::Actions' : '');
});
protected readonly selectionType = computed(() => {
const value = this.selectionTypeInput();
return typeof value === 'string' ? SelectionType[value as keyof typeof SelectionType] : value;
});
protected get data(): R[] {
return this._data();
}
protected set data(value: R[]) {
this._data.set(value);
}
protected get effectiveRowDetailTemplate(): TemplateRef<RowDetailContext<R>> | undefined {
return this.rowDetailComponent?.template() ?? this.rowDetailTemplate();
}
@ -163,9 +162,6 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
readonly trackByFn: TrackByFunction<EntityProp<R>> = (_, item) => item.name;
// Signal for actions column width
private readonly _actionsColumnWidth = signal<number | undefined>(DEFAULT_ACTIONS_COLUMN_WIDTH);
// Infinite scroll: debounced load more subject
private readonly loadMoreSubject = new Subject<void>();
private readonly loadMoreSubscription = this.loadMoreSubject
@ -187,6 +183,53 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
this.permissionService.filterItemsByPolicy(
this.actionList.toArray().map(action => ({ requiredPolicy: action.permission })),
).length > 0;
// Watch actionsColumnWidth input
effect(() => {
const width = this.actionsColumnWidthInput();
this._actionsColumnWidth.set(width ? Number(width) : undefined);
});
// Watch data input changes
effect(() => {
const dataValue = this.dataInput();
if (!dataValue) return;
if (dataValue.length < 1) {
this.list().totalCount = this.recordsTotal();
}
this._data.set(dataValue.map((record: any, index: number) => {
this.propList.forEach(prop => {
const propData = { getInjected: this.getInjected, record, index } as ReadonlyPropData;
const value = this.getContent(prop.value, propData);
const propKey = `_${prop.value.name}`;
record[propKey] = {
visible: prop.value.visible(propData),
value,
};
if (prop.value.component) {
record[propKey].injector = Injector.create({
providers: [
{
provide: PROP_DATA_STREAM,
useValue: value,
},
{
provide: ROW_RECORD,
useValue: record,
},
],
parent: this.#injector,
});
record[propKey].component = prop.value.component;
}
});
return record;
}));
});
}
private getIcon(value: boolean) {
@ -201,7 +244,7 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
return key;
}
getContent(prop: EntityProp<R>, data: PropData): Observable<string> {
getContent(prop: EntityProp<R>, data: ReadonlyPropData): Observable<string> {
return prop.valueResolver(data).pipe(
map(value => {
switch (prop.type) {
@ -217,45 +260,6 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
);
}
ngOnChanges({ data }: SimpleChanges) {
if (!data?.currentValue) return;
if (data.currentValue.length < 1) {
this.list().totalCount = this.recordsTotal();
}
this.data = data.currentValue.map((record: any, index: number) => {
this.propList.forEach(prop => {
const propData = { getInjected: this.getInjected, record, index } as any;
const value = this.getContent(prop.value, propData);
const propKey = `_${prop.value.name}`;
record[propKey] = {
visible: prop.value.visible(propData),
value,
};
if (prop.value.component) {
record[propKey].injector = Injector.create({
providers: [
{
provide: PROP_DATA_STREAM,
useValue: value,
},
{
provide: ROW_RECORD,
useValue: record,
},
],
parent: this.#injector,
});
record[propKey].component = prop.value.component;
}
});
return record;
});
}
isVisibleActions(rowData: any): boolean {
const actions = this.actionList.toArray();
const visibleActions = actions.filter(action => {
@ -278,9 +282,9 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
return visibleActions.length > 0;
}
onSelect({ selected }) {
onSelect({ selected }: { selected: any[] }) {
const selectedValue = this.selected();
this.selected().splice(0, selectedValue.length);
selectedValue.splice(0, selectedValue.length);
selectedValue.push(...selected);
this.selectionChange.emit(selected);
}
@ -330,7 +334,7 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
ngAfterViewInit(): void {
if (!this.infiniteScroll()) {
this.list()?.requestStatus$?.pipe(filter(status => status === 'loading')).subscribe(() => {
this.data = [];
this._data.set([]);
this.cdr.markForCheck();
});
}

5
npm/ng-packs/packages/components/extensible/src/lib/components/grid-actions/grid-actions.component.ts

@ -1,7 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
Input,
TrackByFunction,
input
} from '@angular/core';
@ -35,9 +34,7 @@ import { NgTemplateOutlet } from '@angular/common';
})
export class GridActionsComponent<R = any> extends AbstractActionsComponent<EntityActionList<R>> {
readonly icon = input('fa fa-cog');
@Input() readonly index?: number;
readonly index = input<number | undefined>(undefined);
readonly text = input('');
readonly trackByFn: TrackByFunction<EntityAction<R>> = (_, item) => item.text;

41
npm/ng-packs/packages/components/extensible/src/lib/directives/prop-data.directive.ts

@ -2,34 +2,33 @@
import {
Directive,
Injector,
Input,
OnChanges,
OnDestroy,
TemplateRef,
ViewContainerRef,
effect,
inject,
input
} from '@angular/core';
import { PropData, PropList } from '../models/props';
type InferredRecord<L> = L extends PropList<infer R> ? R : never;
@Directive({
exportAs: 'abpPropData',
selector: '[abpPropData]',
})
export class PropDataDirective<L extends PropList<any>>
extends PropData<InferredData<L>>
implements OnChanges, OnDestroy
extends PropData<InferredRecord<L>>
implements OnDestroy
{
private tempRef = inject<TemplateRef<any>>(TemplateRef);
private vcRef = inject(ViewContainerRef);
readonly propList = input<L>(undefined, { alias: "abpPropDataFromList" });
@Input('abpPropDataWithRecord') record!: InferredData<L>['record'];
readonly propList = input<L | undefined>(undefined, { alias: 'abpPropDataFromList' });
readonly record = input.required<InferredRecord<L>>({ alias: 'abpPropDataWithRecord' });
readonly index = input<number | undefined>(undefined, { alias: 'abpPropDataAtIndex' });
@Input('abpPropDataAtIndex') index?: number;
readonly getInjected: InferredData<L>['getInjected'];
readonly getInjected: PropData<InferredRecord<L>>['getInjected'];
constructor() {
const injector = inject(Injector);
@ -37,14 +36,19 @@ export class PropDataDirective<L extends PropList<any>>
super();
this.getInjected = injector.get.bind(injector);
}
ngOnChanges() {
this.vcRef.clear();
this.vcRef.createEmbeddedView(this.tempRef, {
$implicit: this.data,
index: 0,
// Watch for input changes and re-render
effect(() => {
// Read all inputs to track them
this.record();
this.index();
this.propList();
this.vcRef.clear();
this.vcRef.createEmbeddedView(this.tempRef, {
$implicit: this.data,
index: 0,
});
});
}
@ -52,6 +56,3 @@ export class PropDataDirective<L extends PropList<any>>
this.vcRef.clear();
}
}
type InferredData<L> = PropData<InferredRecord<L>>;
type InferredRecord<L> = L extends PropList<infer R> ? R : never;

5
npm/ng-packs/packages/components/extensible/src/lib/models/actions.ts

@ -9,18 +9,19 @@ export abstract class ActionData<R = any> {
notFoundValue?: T,
flags?: InjectOptions,
) => T;
index?: number;
index?: number | InputSignal<number | undefined>;
abstract record: R | InputSignal<R>;
get data(): ReadonlyActionData<R> {
return {
getInjected: this.getInjected,
index: this.index,
index: isSignal(this.index) ? this.index() : this.index,
record: isSignal(this.record) ? this.record() : this.record,
};
}
}
export type ReadonlyActionData<R = any> = Readonly<{
getInjected: <T>(
token: Type<T> | InjectionToken<T>,

27
npm/ng-packs/packages/components/extensible/src/lib/models/props.ts

@ -1,4 +1,4 @@
import { InjectionToken, InjectOptions, Type } from '@angular/core';
import { InjectionToken, InjectOptions, InputSignal, isSignal, Type } from '@angular/core';
import { LinkedList } from '@abp/utils';
import { ePropType } from '../enums/props.enum';
import { FormPropTooltip } from './form-props';
@ -11,19 +11,27 @@ export abstract class PropData<R = any> {
notFoundValue?: T,
options?: InjectOptions,
) => T;
index?: number;
abstract record: R;
index?: number | InputSignal<number | undefined>;
abstract record: R | InputSignal<R>;
get data(): ReadonlyPropData<R> {
return {
getInjected: this.getInjected,
index: this.index,
record: this.record,
index: isSignal(this.index) ? this.index() : this.index,
record: isSignal(this.record) ? this.record() : this.record,
};
}
}
export type ReadonlyPropData<R = any> = Readonly<Omit<PropData<R>, 'data'>>;
export type ReadonlyPropData<R = any> = Readonly<{
getInjected: <T>(
token: Type<T> | InjectionToken<T>,
notFoundValue?: T,
options?: InjectOptions,
) => T;
index?: number;
record: R;
}>;
export abstract class Prop<R = any> {
constructor(
@ -43,9 +51,10 @@ export abstract class Prop<R = any> {
}
}
export type PropCallback<T, R = any> = (data: Omit<PropData<T>, 'data'>, auxData?: any) => R;
export type PropPredicate<T> = (data?: Omit<PropData<T>, 'data'>, auxData?: any) => boolean;
export type PropDisplayTextResolver<T> = (data?: Omit<PropData<T>, 'data'>) => string;
export type PropCallback<T, R = any> = (data: ReadonlyPropData<T>, auxData?: any) => R;
export type PropPredicate<T> = (data?: ReadonlyPropData<T>, auxData?: any) => boolean;
export type PropDisplayTextResolver<T> = (data?: ReadonlyPropData<T>) => string;
export abstract class PropsFactory<C extends Props<any>> {
protected abstract _ctor: Type<C>;

10
npm/ng-packs/packages/components/extensible/src/lib/utils/form-props.util.ts

@ -4,11 +4,14 @@ import { DateTimeAdapter, DateAdapter, TimeAdapter } from '@abp/ng.theme.shared'
import { EXTRA_PROPERTIES_KEY } from '../constants/extra-properties';
import { ePropType } from '../enums/props.enum';
import { FormPropList } from '../models/form-props';
import { PropData } from '../models/props';
import { PropData, ReadonlyPropData } from '../models/props';
import { ExtensionsService } from '../services/extensions.service';
import { EXTENSIONS_IDENTIFIER } from '../tokens/extensions.token';
export function generateFormFromProps<R = any>(data: PropData<R>) {
export function generateFormFromProps<R = any>(propData: PropData<R>) {
// Use .data to get resolved values (handles InputSignal)
const data: ReadonlyPropData<R> = propData.data;
const extensions = data.getInjected(ExtensionsService<R>);
const identifier = data.getInjected(EXTENSIONS_IDENTIFIER);
@ -16,7 +19,7 @@ export function generateFormFromProps<R = any>(data: PropData<R>) {
const extraForm = new UntypedFormGroup({});
form.addControl(EXTRA_PROPERTIES_KEY, extraForm);
const record = data.record || {};
const record: any = data.record || {};
const type = JSON.stringify(record) === '{}' ? 'create' : 'edit';
const props: FormPropList<R> = extensions[`${type}FormProps`].get(identifier).props;
const extraProperties = record[EXTRA_PROPERTIES_KEY] || {};
@ -65,3 +68,4 @@ export function generateFormFromProps<R = any>(data: PropData<R>) {
return form;
}

45
npm/ng-packs/packages/components/page/src/page-part.directive.ts

@ -2,16 +2,13 @@ import {
Directive,
TemplateRef,
ViewContainerRef,
Input,
InjectionToken,
OnInit,
OnDestroy,
Injector,
OnChanges,
SimpleChanges,
SimpleChange,
inject,
input
input,
effect
} from '@angular/core';
import { Observable, Subscription, of } from 'rxjs';
@ -19,7 +16,7 @@ export interface PageRenderStrategy {
shouldRender(type?: string): boolean | Observable<boolean>;
onInit?(type?: string, injector?: Injector, context?: any): void;
onDestroy?(type?: string, injector?: Injector, context?: any): void;
onContextUpdate?(change?: SimpleChange): void;
onContextUpdate?(context?: any): void;
}
export const PAGE_RENDER_STRATEGY = new InjectionToken<PageRenderStrategy>('PAGE_RENDER_STRATEGY');
@ -27,20 +24,34 @@ export const PAGE_RENDER_STRATEGY = new InjectionToken<PageRenderStrategy>('PAGE
@Directive({
selector: '[abpPagePart]',
})
export class PagePartDirective implements OnInit, OnDestroy, OnChanges {
export class PagePartDirective implements OnInit, OnDestroy {
private templateRef = inject<TemplateRef<any>>(TemplateRef);
private viewContainer = inject(ViewContainerRef);
private renderLogic = inject<PageRenderStrategy>(PAGE_RENDER_STRATEGY, { optional: true })!;
private injector = inject(Injector);
hasRendered = false;
type!: string;
subscription!: Subscription;
readonly context = input<any>(undefined, { alias: "abpPagePartContext" });
@Input() set abpPagePart(type: string) {
this.type = type;
this.createRenderStream(type);
readonly context = input<any>(undefined, { alias: 'abpPagePartContext' });
readonly abpPagePart = input<string>('');
constructor() {
// Watch for type changes
effect(() => {
const type = this.abpPagePart();
if (type) {
this.createRenderStream(type);
}
});
// Watch for context changes
effect(() => {
const ctx = this.context();
if (this.renderLogic?.onContextUpdate) {
this.renderLogic.onContextUpdate(ctx);
}
});
}
render = (shouldRender: boolean) => {
@ -53,15 +64,9 @@ export class PagePartDirective implements OnInit, OnDestroy, OnChanges {
}
};
ngOnChanges({ context }: SimpleChanges): void {
if (this.renderLogic?.onContextUpdate) {
this.renderLogic.onContextUpdate(context);
}
}
ngOnInit() {
if (this.renderLogic?.onInit) {
this.renderLogic.onInit(this.type, this.injector, this.context());
this.renderLogic.onInit(this.abpPagePart(), this.injector, this.context());
}
}
@ -69,7 +74,7 @@ export class PagePartDirective implements OnInit, OnDestroy, OnChanges {
this.clearSubscription();
if (this.renderLogic?.onDestroy) {
this.renderLogic.onDestroy(this.type, this.injector, this.context());
this.renderLogic.onDestroy(this.abpPagePart(), this.injector, this.context());
}
}

Loading…
Cancel
Save