IMP-165, IMP-167: Add wallet manager and refactor nested table (#170)

This commit is contained in:
Rinat Arsaev 2024-02-13 22:01:14 +07:00 committed by GitHub
parent 5397e45f40
commit 1ef4111a6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 2300 additions and 4241 deletions

4573
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"build": "ng build && transloco-optimize dist/assets/i18n",
"test": "ng test",
"i18n:extract": "transloco-keys-manager extract && prettier src/assets/i18n/** --write",
"i18n:clean": "transloco-keys-manager extract --remove-extra-keys && prettier src/assets/i18n/* --write",
"i18n:clean": "transloco-keys-manager extract --remove-extra-keys && prettier src/assets/i18n/* --write && prettier src/assets/i18n/** --write",
"i18n:check": "transloco-keys-manager find --emit-error-on-extra-keys",
"coverage": "npx http-server -c-1 -o -p 9875 ./coverage",
"lint": "ng lint --max-warnings=0",
@ -25,27 +25,27 @@
"spell:fix": "cspell --no-progress --show-suggestions --show-context **"
},
"dependencies": {
"@angular/animations": "^17.0.8",
"@angular/cdk": "~17.0.4",
"@angular/common": "^17.0.8",
"@angular/compiler": "^17.0.8",
"@angular/core": "^17.0.8",
"@angular/forms": "^17.0.8",
"@angular/material": "~17.0.4",
"@angular/material-moment-adapter": "^17.0.4",
"@angular/platform-browser": "^17.0.8",
"@angular/platform-browser-dynamic": "^17.0.8",
"@angular/router": "^17.0.8",
"@angular/animations": "^17.1.3",
"@angular/cdk": "~17.1.2",
"@angular/common": "^17.1.3",
"@angular/compiler": "^17.1.3",
"@angular/core": "^17.1.3",
"@angular/forms": "^17.1.3",
"@angular/material": "~17.1.2",
"@angular/material-moment-adapter": "^17.1.2",
"@angular/platform-browser": "^17.1.3",
"@angular/platform-browser-dynamic": "^17.1.3",
"@angular/router": "^17.1.3",
"@ngneat/transloco": "^6.0.4",
"@ngneat/until-destroy": "^9.0.0",
"@sentry/angular-ivy": "^7.92.0",
"@sentry/integrations": "^7.92.0",
"@sentry/tracing": "^7.92.0",
"@vality/ng-core": "^17.0.0",
"@sentry/angular-ivy": "^7.100.1",
"@sentry/integrations": "^7.100.1",
"@sentry/tracing": "^7.100.1",
"@vality/ng-core": "^17.1.1-pr-57-4da820a.0",
"@vality/swag-anapi-v2": "2.0.1-32ed85f.0",
"@vality/swag-api-keys-v2": "0.1.2-f0ece04.0",
"@vality/swag-claim-management": "0.1.1-6b6711b.0",
"@vality/swag-organizations": "1.0.0",
"@vality/swag-organizations": "1.0.1-de0cd06.0",
"@vality/swag-payments": "0.1.3-77c86a5.0",
"@vality/swag-url-shortener": "0.1.0",
"@vality/swag-wallet": "0.1.3-e6d0c88.0",
@ -54,12 +54,12 @@
"css-element-queries": "1.2.3",
"humanize-duration": "^3.19.0",
"jwt-decode": "^3.1.2",
"keycloak-angular": "^15.0.0",
"keycloak-angular": "^15.1.0",
"keycloak-js": "^18.0.1",
"lodash-es": "^4.17.21",
"moment": "2.29.4",
"ng-apexcharts": "1.7.1",
"ng-flex-layout": "^17.0.1-beta.2",
"ng-flex-layout": "^17.1.3-beta.1",
"rxjs": "^7.8.1",
"short-uuid": "4.2.0",
"tslib": "^2.4.0",
@ -68,12 +68,12 @@
},
"devDependencies": {
"@angular-builders/custom-webpack": "^17.0.0",
"@angular-devkit/build-angular": "^17.0.9",
"@angular-devkit/build-angular": "^17.1.3",
"@angular-eslint/builder": "^17.2.0",
"@angular-eslint/schematics": "^17.2.0",
"@angular/cli": "^17.0.9",
"@angular/compiler-cli": "^17.0.8",
"@angular/language-service": "^17.0.8",
"@angular/cli": "^17.1.3",
"@angular/compiler-cli": "^17.1.3",
"@angular/language-service": "^17.1.3",
"@ngneat/transloco-keys-manager": "^3.8.0",
"@ngneat/transloco-optimize": "^5.0.3",
"@types/d3": "^5.7.0",

View File

@ -1,9 +1,12 @@
import { Injectable } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco';
import { ResourceScopeId, RoleId } from '@vality/swag-organizations';
import { ResourceScopeId } from '@vality/swag-organizations';
import { RoleId } from '../../auth/types/role-id';
import { DictionaryService } from '../utils';
export type ResourceScopeIdInternal = ResourceScopeId | 'Wallet';
@Injectable({
providedIn: 'root',
})
@ -14,6 +17,7 @@ export class OrganizationsDictionaryService {
Accountant: this.t.translate('organizations.roleId.Accountant', null, 'dictionary'),
Integrator: this.t.translate('organizations.roleId.Integrator', null, 'dictionary'),
Manager: this.t.translate('organizations.roleId.Manager', null, 'dictionary'),
WalletManager: this.t.translate('organizations.roleId.WalletManager', null, 'dictionary'),
/* eslint-enable @typescript-eslint/naming-convention */
}));
resourceScopeId$ = this.dictionaryService.create<ResourceScopeId>(() => ({
@ -21,6 +25,12 @@ export class OrganizationsDictionaryService {
Shop: this.t.translate('organizations.resourceScopeId.Shop', null, 'dictionary'),
/* eslint-enable @typescript-eslint/naming-convention */
}));
resourceScopeIdPlural$ = this.dictionaryService.create<ResourceScopeId>(() => ({
/* eslint-disable @typescript-eslint/naming-convention */
Shop: this.t.translate('organizations.resourceScopeIdPlural.Shop', null, 'dictionary'),
Wallet: this.t.translate('organizations.resourceScopeIdPlural.Wallet', null, 'dictionary'),
/* eslint-enable @typescript-eslint/naming-convention */
}));
constructor(
private t: TranslocoService,

View File

@ -1,7 +1,6 @@
import { RoleId } from '@vality/swag-organizations';
import { RoleAccessGroup } from './types/role-access';
import { RoleAccessName } from './types/role-access-name';
import { RoleId } from './types/role-id';
export const ROLE_ACCESS_GROUPS: RoleAccessGroup[] = [
{
@ -70,7 +69,7 @@ export const ROLE_ACCESS_GROUPS: RoleAccessGroup[] = [
},
{
name: RoleAccessName.Wallets,
availableRoles: [RoleId.Administrator, RoleId.Accountant, RoleId.Integrator],
availableRoles: [RoleId.WalletManager],
},
{
name: RoleAccessName.Claims,

View File

@ -1,7 +1,7 @@
import { RoleId } from '@vality/swag-organizations';
import { Overwrite } from 'utility-types';
import { RoleAccessName } from './role-access-name';
import { RoleId } from './role-id';
export interface RoleAccess {
name: RoleAccessName;

View File

@ -0,0 +1,10 @@
/**
* https://github.com/valitydev/bouncer-policies/blob/4bbeaef91653a40bf4583acc581bff1e5aba0ba6/policies/service/authz/roles/data.yaml#L15
*/
export enum RoleId {
Administrator = 'Administrator',
Manager = 'Manager',
Accountant = 'Accountant',
Integrator = 'Integrator',
WalletManager = 'WalletManager',
}

View File

@ -1,70 +1,56 @@
<dsh-nested-table
<div
*transloco="let t; scope: 'organization-section'; read: 'organizationSection.changeRolesTable'"
class="wrapper"
>
<dsh-nested-table-row>
<dsh-nested-table-col class="dsh-body-2"></dsh-nested-table-col>
<dsh-nested-table-col
*ngFor="let roleId of roleIds"
class="dsh-body-2"
fxLayoutAlign="center center"
><button dsh-button (click)="show(roleId)">
{{ (roleIdDict$ | async)?.[roleId] }}
</button></dsh-nested-table-col
>
<dsh-nested-table-col *ngIf="isAllowAdd">
<span
class="dsh-text-color-secondary dsh-body-1"
style="text-decoration: underline; cursor: pointer"
(click)="show()"
>{{ t('info') }}</span
>
<dsh-nested-table
[cellsTemplates]="cellsTemplates"
[columns]="columns$ | async"
[data]="data$ | async"
[footerTemplates]="footerTemplates"
[headersTemplates]="{ add: headerTpl }"
>
<ng-template #headerTpl let-column="column">
<button color="accent" dsh-button (click)="add()">{{ t('add') }}</button>
</dsh-nested-table-col>
</dsh-nested-table-row>
<ng-container *ngIf="shops$ | async" [dshNestedTableCollapse]="true">
<dsh-nested-table-row>
<dsh-nested-table-col>
<dsh-nested-table-collapse-button
>{{ t('shops') }} ({{
(shops$ | async).length
}})</dsh-nested-table-collapse-button
>
</dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let roleId of roleIds">
<mat-checkbox
[checked]="checkedAll(roleId) | async"
[disabled]="disabledAll(roleId)"
[indeterminate]="isIntermediate(roleId) | async"
[ngClass]="{ disabled: disabledAll(roleId) }"
(change)="toggleAll(roleId)"
></mat-checkbox>
</dsh-nested-table-col>
</dsh-nested-table-row>
<dsh-nested-table-group *dshNestedTableCollapseBody [displayedCount]="10">
<dsh-nested-table-row *ngFor="let shop of shops$ | async">
<dsh-nested-table-col>{{ shop.details.name }}</dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let roleId of roleIds">
<mat-checkbox
[checked]="checked(roleId, shop.id) | async"
[disabled]="disabled(roleId, shop.id) | async"
[ngClass]="{ disabled: (disabled(roleId, shop.id) | async) }"
(change)="toggle(roleId, shop.id, $event.checked)"
></mat-checkbox>
</dsh-nested-table-col>
</dsh-nested-table-row>
</dsh-nested-table-group>
</ng-container>
<dsh-nested-table-row>
<dsh-nested-table-col class="dsh-body-2"></dsh-nested-table-col>
<dsh-nested-table-col
*ngFor="let roleId of roleIds"
class="dsh-body-2"
fxLayoutAlign="center center"
>
</ng-template>
<ng-template #roleCellTpl let-column="column" let-value="value">
<mat-checkbox
*ngIf="
value?.scope !== 'Wallet' && column.field !== 'WalletManager';
else selectedTpl
"
[checked]="checked(column.field, value?.shop?.id, value?.scope) | async"
[disabled]="
(disabled(column.field, value?.shop?.id, value?.scope) | async) || inProgress
"
[indeterminate]="
value?.shop || value?.scope === 'Wallet'
? false
: (isIntermediate(column.field) | async)
"
(change)="toggle(column.field, value?.shop?.id, $event.checked)"
></mat-checkbox>
<ng-template #selectedTpl>
<dsh-selection
[selected]="checked(column.field, value?.shop?.id, value?.scope) | async"
class="selection"
></dsh-selection>
</ng-template>
</ng-template>
<ng-template #footerCellTpl let-column="column" let-value="value">
<button
[disabled]="!(isAllowRemoves$ | async)"
[disabled]="!(isAllowRemove(column?.field) | async) || inProgress"
color="warn"
dsh-button
(click)="remove(roleId)"
(click)="remove(column.field)"
>
{{ t('remove') }}
</button>
</dsh-nested-table-col>
</dsh-nested-table-row>
</dsh-nested-table>
</ng-template>
</dsh-nested-table>
</div>

View File

@ -2,6 +2,11 @@
display: none;
}
.disabled {
pointer-events: none;
.wrapper {
overflow: auto;
.selection {
display: flex;
justify-content: center;
}
}

View File

@ -1,46 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { ApiShopsService } from '@dsh/app/api';
import { DialogConfig, DIALOG_CONFIG } from '@dsh/app/sections/tokens';
import { provideMockService, provideMockToken } from '@dsh/app/shared/tests';
import { ChangeRolesTableComponent } from './change-roles-table.component';
@Component({
selector: 'dsh-host',
template: `<dsh-change-roles-table></dsh-change-roles-table>`,
})
class HostComponent {}
describe('ChangeRolesTableComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let debugElement: DebugElement;
let component: ChangeRolesTableComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommonModule, ReactiveFormsModule],
declarations: [HostComponent, ChangeRolesTableComponent],
providers: [
provideMockService(ApiShopsService),
provideMockService(MatDialog),
provideMockToken(DIALOG_CONFIG, {} as DialogConfig),
],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
debugElement = fixture.debugElement.query(By.directive(ChangeRolesTableComponent));
component = debugElement.componentInstance;
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,35 +1,50 @@
import {
ChangeDetectorRef,
Component,
EventEmitter,
Inject,
Input,
OnInit,
Output,
OnChanges,
booleanAttribute,
ViewChild,
TemplateRef,
signal,
computed,
Injector,
DestroyRef,
} from '@angular/core';
import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ComponentChanges } from '@vality/ng-core';
import { ResourceScopeId, RoleId, Organization } from '@vality/swag-organizations';
import {
ComponentChanges,
getEnumValues,
DialogService,
DialogResponseStatus,
} from '@vality/ng-core';
import { ResourceScopeId, Organization } from '@vality/swag-organizations';
import { Shop } from '@vality/swag-payments';
import { uniqBy } from 'lodash-es';
import isNil from 'lodash-es/isNil';
import { BehaviorSubject, combineLatest, EMPTY, Observable, of, ReplaySubject } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable, of, ReplaySubject, filter, defer } from 'rxjs';
import { first, map, switchMap, tap, shareReplay } from 'rxjs/operators';
import { OrganizationsDictionaryService, MemberRoleOptionalId } from '@dsh/app/api/organizations';
import { ShopsService } from '@dsh/app/api/payments';
import { DialogConfig, DIALOG_CONFIG } from '@dsh/app/sections/tokens';
import {
OrganizationsDictionaryService,
MemberRoleOptionalId,
ResourceScopeIdInternal,
} from '@dsh/app/api/organizations';
import { ShopsService, toLiveShops } from '@dsh/app/api/payments';
import { RoleId } from '@dsh/app/auth/types/role-id';
import { sortRoleIds } from '@dsh/app/shared/components/organization-roles/utils/sort-role-ids';
import { NestedTableColumn, NestedTableNode } from '@dsh/components/nested-table';
import { addDialogsClass } from '@dsh/utils/add-dialogs-class';
import { equalRoles } from '../members/components/edit-roles-dialog/utils/equal-roles';
import { SelectRoleDialogComponent } from './components/select-role-dialog/select-role-dialog.component';
import { SelectRoleDialogResult } from './components/select-role-dialog/types/select-role-dialog-result';
import { SelectRoleDialogData } from './components/select-role-dialog/types/selected-role-dialog-data';
@UntilDestroy()
type DataItem = { shop?: Pick<Shop, 'id' | 'details'>; scope?: ResourceScopeIdInternal };
@Component({
selector: 'dsh-change-roles-table',
templateUrl: 'change-roles-table.component.html',
@ -38,64 +53,105 @@ import { SelectRoleDialogData } from './components/select-role-dialog/types/sele
export class ChangeRolesTableComponent implements OnInit, OnChanges {
@Input() set roles(roles: MemberRoleOptionalId[]) {
if (!isNil(roles)) {
this.addRoleIds(roles.map(({ roleId }) => roleId as RoleId));
this.roles$.next(roles);
this.addRoleIds(roles.map(({ roleId }) => roleId));
}
}
get roles(): MemberRoleOptionalId[] {
return this.roles$.value;
}
@Input() organization: Organization;
/**
* Edit mode:
* - no batch changes
* - there must be at least one role
*/
@Input({ transform: booleanAttribute }) editMode: boolean;
@Input({ transform: booleanAttribute }) hasAtLeastOneRole: boolean;
@Input({ transform: booleanAttribute }) controlled: boolean;
@Input() inProgress = false;
@Output() selectedRoles = new EventEmitter<MemberRoleOptionalId[]>();
@Output() addedRoles = new EventEmitter<MemberRoleOptionalId[]>();
@Output() removedRoles = new EventEmitter<MemberRoleOptionalId[]>();
@ViewChild('roleCellTpl') cellTemplate: TemplateRef<unknown>;
@ViewChild('footerCellTpl') footerTemplate: TemplateRef<unknown>;
organization$ = new ReplaySubject<Organization>(1);
roleIds: RoleId[] = [];
roleIds = signal<Set<RoleId>>(new Set());
shops$ = this.organization$.pipe(
switchMap((organization) =>
this.shopsService.getShopsForParty({ partyID: organization.party }),
),
map((shops) => toLiveShops(shops)),
shareReplay({ bufferSize: 1, refCount: true }),
);
roleIdDict$ = this.organizationsDictionaryService.roleId$;
get availableRoles(): RoleId[] {
return Object.values(RoleId).filter((r) => !this.roleIds.includes(r));
}
get isAllowAdd(): boolean {
return !!this.availableRoles.length && !this.hasAdminRole;
}
availableRoles = computed(() => Object.values(RoleId).filter((r) => !this.roleIds().has(r)));
roles$ = new BehaviorSubject<MemberRoleOptionalId[]>([]);
isAllowRemoves$ = this.roles$.pipe(
map(
(roles) =>
!this.editMode || roles.some((r) => roles.some((b) => b.roleId !== r.roleId)),
),
columns$ = combineLatest([
toObservable(this.roleIds),
this.organizationsDictionaryService.roleId$,
defer(() => toObservable(this.isAllowAdd, { injector: this.injector })),
this.organizationsDictionaryService.resourceScopeIdPlural$,
]).pipe(
map(([roleIds, rolesDict, isAllowAdd, scopesDict]): NestedTableColumn<DataItem>[] => [
{
field: 'name',
header: '',
formatter: (d) => (d.scope ? scopesDict[d.scope] : d.shop.details.name),
style: { 'min-width': '130px' },
},
...Array.from(roleIds).map((r) => ({
field: r,
header: rolesDict?.[r] || r,
style: { 'text-align': 'center' },
})),
...(isAllowAdd
? [
{
field: 'add',
header: '',
},
]
: []),
]),
);
data$: Observable<NestedTableNode<DataItem>[]> = combineLatest([this.shops$, this.roles$]).pipe(
map(([shops, roles]) => [
{
value: { scope: ResourceScopeId.Shop },
children: [
...shops,
...roles
.filter((r) => r.scope?.id === ResourceScopeId.Shop)
.map((r) => r.scope.resourceId)
.filter((id) => !!id && shops.every((s) => s.id !== id))
.map((id) => ({ id, details: { name: `${id}` } })),
].map((shop) => ({ value: { shop } })),
expanded: true,
},
{
value: { scope: 'Wallet' },
},
]),
);
private get hasAdminRole() {
return !!this.roles.find((r) => r.id === RoleId.Administrator);
get cellsTemplates() {
return Object.fromEntries(getEnumValues(RoleId).map((r) => [r, this.cellTemplate]));
}
get footerTemplates() {
return Object.fromEntries(getEnumValues(RoleId).map((r) => [r, this.footerTemplate]));
}
private isAllowAdd = computed(
() =>
!!this.availableRoles().length &&
!this.roles.some((r) => r.id === RoleId.Administrator),
);
constructor(
private shopsService: ShopsService,
private dialog: MatDialog,
@Inject(DIALOG_CONFIG) private dialogConfig: DialogConfig,
private cdr: ChangeDetectorRef,
private dialogService: DialogService,
private organizationsDictionaryService: OrganizationsDictionaryService,
private injector: Injector,
private destroyRef: DestroyRef,
) {}
ngOnChanges({ organization }: ComponentChanges<ChangeRolesTableComponent>) {
@ -105,48 +161,41 @@ export class ChangeRolesTableComponent implements OnInit, OnChanges {
}
ngOnInit(): void {
this.roles$.pipe(untilDestroyed(this)).subscribe((roles) => this.selectedRoles.emit(roles));
this.roles$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((roles) => this.selectedRoles.emit(roles));
}
add(): void {
const removeDialogsClass = addDialogsClass(this.dialog.openDialogs, 'dsh-hidden');
this.dialog
.open<SelectRoleDialogComponent, SelectRoleDialogData, SelectRoleDialogResult>(
SelectRoleDialogComponent,
{
...this.dialogConfig.large,
data: { availableRoles: this.availableRoles },
},
)
this.dialogService
.open(SelectRoleDialogComponent, { availableRoles: this.availableRoles() })
.afterClosed()
.pipe(
tap(() => removeDialogsClass()),
switchMap((result) =>
typeof result === 'object' ? of(result.selectedRoleId) : EMPTY,
),
untilDestroyed(this),
filter((result) => result.status === DialogResponseStatus.Success),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((roleId) => {
this.addRoleIds([roleId]);
if (roleId === RoleId.Administrator) {
this.addRoles([{ roleId: RoleId.Administrator }]);
.subscribe(({ data: { selectedRoleId } }) => {
this.addRoleIds([selectedRoleId]);
if (
selectedRoleId === RoleId.Administrator ||
selectedRoleId === RoleId.WalletManager
) {
this.addRoles([{ roleId: selectedRoleId }]);
}
this.cdr.detectChanges();
});
}
show(roleId: RoleId) {
show(roleId?: RoleId) {
const removeDialogsClass = addDialogsClass(this.dialog.openDialogs, 'dsh-hidden');
this.dialog
.open<SelectRoleDialogComponent, SelectRoleDialogData, SelectRoleDialogResult>(
SelectRoleDialogComponent,
{
...this.dialogConfig.large,
data: { availableRoles: [roleId], isShow: true },
},
)
this.dialogService
.open(SelectRoleDialogComponent, {
availableRoles: roleId ? [roleId] : getEnumValues(RoleId),
isShow: true,
})
.afterClosed()
.pipe(untilDestroyed(this))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
complete: () => {
removeDialogsClass();
@ -159,7 +208,11 @@ export class ChangeRolesTableComponent implements OnInit, OnChanges {
this.removeRoles(this.roles.filter((r) => r.roleId === roleId));
}
toggle(roleId: RoleId, resourceId: string): void {
toggle(roleId: RoleId, resourceId?: string): void {
if (!resourceId) {
this.toggleAll(roleId);
return;
}
const role: MemberRoleOptionalId = {
roleId,
scope: { id: ResourceScopeId.Shop, resourceId },
@ -172,65 +225,51 @@ export class ChangeRolesTableComponent implements OnInit, OnChanges {
}
}
toggleAll(roleId: RoleId): void {
const roles = this.roles.filter((r) => r.roleId === roleId);
combineLatest([this.shops$, this.checkedAll(roleId)])
.pipe(first(), untilDestroyed(this))
.subscribe(([shops, isCheckedAll]) => {
if (isCheckedAll) {
this.removeRoles(roles);
} else {
const newRoles = shops
.filter((s) => !roles.find((r) => r.scope?.resourceId === s.id))
.map(({ id: resourceId }) => ({
roleId,
scope: { id: ResourceScopeId.Shop, resourceId },
}));
this.addRoles(newRoles);
}
});
}
disabled(roleId: RoleId, resourceId: string): Observable<boolean> {
if (roleId === RoleId.Administrator) {
disabled(
roleId: RoleId,
resourceId?: string,
scopeId?: ResourceScopeIdInternal,
): Observable<boolean> {
if ([RoleId.Administrator, RoleId.WalletManager].includes(roleId) || scopeId === 'Wallet') {
return of(true);
}
if (!this.editMode) {
if (!this.hasAtLeastOneRole) {
return of(false);
}
return combineLatest([this.roles$, this.checked(roleId, resourceId)]).pipe(
map(([roles, isChecked]) => roles.length <= 1 && isChecked),
);
}
disabledAll(roleId: RoleId): boolean {
return roleId === RoleId.Administrator || this.editMode;
}
checked(roleId: RoleId, resourceId: string): Observable<boolean> {
return this.roles$.pipe(
map(
(roles) =>
roleId === RoleId.Administrator ||
!!roles.find((r) =>
equalRoles(r, { roleId, scope: { id: ResourceScopeId.Shop, resourceId } }),
),
([roles, isChecked]) =>
isChecked &&
(roles.length <= 1 ||
(!resourceId && uniqBy(roles, (r) => r.roleId).length <= 1)),
),
);
}
checkedAll(roleId: RoleId): Observable<boolean> {
return combineLatest([this.shops$, this.roles$]).pipe(
map(([shops, roles]) => {
const shopIds = shops.map(({ id }) => id);
return (
roleId === RoleId.Administrator ||
shops.length <=
roles.filter(
(r) => r.roleId === roleId && shopIds.includes(r.scope?.resourceId),
).length
);
}),
checked(
roleId: RoleId,
resourceId?: string,
scopeId?: ResourceScopeIdInternal,
): Observable<boolean> {
if (scopeId === 'Wallet') {
return of(roleId === RoleId.WalletManager);
}
if (roleId === RoleId.Administrator) {
return of(true);
}
return combineLatest([
resourceId
? of([resourceId])
: this.shops$.pipe(map((shops) => shops.map(({ id }) => id))),
this.roles$,
]).pipe(
map(([shopIds, roles]) =>
shopIds.every((resourceId) =>
roles.find((r) =>
equalRoles(r, { roleId, scope: { id: ResourceScopeId.Shop, resourceId } }),
),
),
),
);
}
@ -249,12 +288,41 @@ export class ChangeRolesTableComponent implements OnInit, OnChanges {
);
}
isAllowRemove(roleId: RoleId) {
return this.roles$.pipe(
map((roles) => !this.hasAtLeastOneRole || roles.some((r) => r.roleId !== roleId)),
);
}
private toggleAll(roleId: RoleId): void {
const roles = this.roles.filter((r) => r.roleId === roleId);
combineLatest([this.shops$, this.checked(roleId)])
.pipe(first(), takeUntilDestroyed(this.destroyRef))
.subscribe(([shops, isCheckedAll]) => {
if (isCheckedAll) {
this.removeRoles(roles);
} else {
const newRoles = shops
.filter((s) => !roles.find((r) => r.scope?.resourceId === s.id))
.map(({ id: resourceId }) => ({
roleId,
scope: { id: ResourceScopeId.Shop, resourceId },
}));
this.addRoles(newRoles);
}
});
}
private addRoleIds(roleIds: RoleId[]) {
this.roleIds = Array.from(new Set([...this.roleIds, ...roleIds])).sort(sortRoleIds);
const newRoleIds = new Set(roleIds.sort(sortRoleIds));
if (Array.from(newRoleIds).every((r) => this.roleIds().has(r))) {
return;
}
this.roleIds.update((v) => (newRoleIds.forEach((r) => v.add(r)), new Set(v)));
}
private removeRoleIds(roleIds: RoleId[]) {
this.roleIds = this.roleIds.filter((r) => !roleIds.includes(r));
this.roleIds.update((v) => (roleIds.forEach((r) => v.delete(r)), new Set(v)));
}
private addRoles(roles: MemberRoleOptionalId[]) {

View File

@ -1,14 +1,17 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { MatTableModule } from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslocoModule } from '@ngneat/transloco';
import { FlexModule } from 'ng-flex-layout';
import { BaseDialogModule } from '@dsh/app/shared/components/dialog/base-dialog';
import { ButtonModule } from '@dsh/components/buttons';
import { BootstrapIconModule } from '@dsh/components/indicators';
import { SelectionModule } from '@dsh/components/indicators/selection';
import { NestedTableModule } from '@dsh/components/nested-table';
@ -28,6 +31,10 @@ import { SelectRoleDialogComponent } from './components/select-role-dialog/selec
ReactiveFormsModule,
SelectionModule,
MatButtonModule,
MatTableModule,
BootstrapIconModule,
FormsModule,
MatTooltip,
],
declarations: [ChangeRolesTableComponent, SelectRoleDialogComponent],
exports: [ChangeRolesTableComponent],

View File

@ -1,40 +1,35 @@
<dsh-base-dialog
*transloco="let t; scope: 'organization-section'; read: 'organizationSection.selectRoleDialog'"
[noActions]="dialogData.isShow"
[title]="t('title')"
(cancel)="cancel()"
>
<mat-radio-group [formControl]="roleControl">
<dsh-nested-table [rowsGridTemplateColumns]="rowsGridTemplateColumns">
<dsh-nested-table-row *ngIf="!data.isShow">
<dsh-nested-table-col></dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let role of roles" fxLayoutAlign="center center">
<span class="dsh-body-2 header">{{ (roleIdDict$ | async)?.[role] }}</span>
</dsh-nested-table-col>
</dsh-nested-table-row>
<dsh-nested-table-row *ngIf="!data.isShow">
<dsh-nested-table-col></dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let role of roles">
<mat-radio-button [value]="role"></mat-radio-button>
</dsh-nested-table-col>
</dsh-nested-table-row>
<dsh-nested-table-row *ngFor="let access of accesses">
<dsh-nested-table-col>
<span [class.dsh-body-2]="access.isHeader">{{
(roleAccessDict$ | async)?.[access.name]
}}</span>
</dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let role of roles" fxLayoutAlign="center center">
<dsh-selection
*ngIf="access.availableRoles !== undefined"
[selected]="access.availableRoles.includes(role)"
></dsh-selection>
</dsh-nested-table-col>
</dsh-nested-table-row>
<div class="content">
<dsh-nested-table
[cellsTemplates]="cellsTemplates"
[columns]="columns$ | async"
[data]="data"
>
<ng-template #accessCellTpl let-column="column" let-index="index" let-value="value">
<dsh-selection
*ngIf="value && value?.availableRoles !== undefined"
[selected]="value?.availableRoles?.includes?.(column.field)"
class="selection"
></dsh-selection>
<div *ngIf="!value && index === 0" class="selection">
<mat-radio-group
[disabled]="!dialogData.availableRoles.includes(column?.field)"
[ngModel]="selectedRole$ | async"
(ngModelChange)="selectedRole$.next(column.field)"
>
<mat-radio-button [value]="column.field"></mat-radio-button>
</mat-radio-group>
</div>
</ng-template>
</dsh-nested-table>
</mat-radio-group>
<ng-container *ngIf="!data.isShow" dshBaseDialogActions>
<button [disabled]="roleControl.invalid" color="accent" dsh-button (click)="select()">
</div>
<ng-container *ngIf="!dialogData.isShow" dshBaseDialogActions>
<button [disabled]="!(selectedRole$ | async)" color="accent" dsh-button (click)="select()">
{{ t('select') }}
</button>
</ng-container>

View File

@ -2,3 +2,18 @@
word-break: break-all;
text-align: center;
}
::ng-deep mat-radio-button * {
padding: 0 !important;
margin: 0 !important;
}
.content {
overflow: auto;
height: 100%;
.selection {
display: flex;
justify-content: center;
}
}

View File

@ -1,43 +0,0 @@
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { RoleId } from '@vality/swag-organizations';
import { provideMockService, provideMockToken } from '@dsh/app/shared/tests';
import { SelectRoleDialogComponent } from './select-role-dialog.component';
@Component({
selector: 'dsh-host',
template: `<dsh-select-role-dialog></dsh-select-role-dialog>`,
})
class HostComponent {}
describe('SelectRoleDialogComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let debugElement: DebugElement;
let component: SelectRoleDialogComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [HostComponent, SelectRoleDialogComponent],
providers: [
provideMockToken(MAT_DIALOG_DATA, { availableRoles: Object.values(RoleId) }),
provideMockService(MatDialogRef),
],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
debugElement = fixture.debugElement.query(By.directive(SelectRoleDialogComponent));
component = debugElement.componentInstance;
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,58 +1,86 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { Validators, FormBuilder } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { RoleId } from '@vality/swag-organizations';
import { Component, ViewChild, TemplateRef, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DialogSuperclass, DEFAULT_DIALOG_CONFIG, getEnumValues } from '@vality/ng-core';
import { combineLatest, Observable, ReplaySubject } from 'rxjs';
import { map, first } from 'rxjs/operators';
import { OrganizationsDictionaryService } from '@dsh/app/api/organizations';
import { RoleAccess, ROLE_ACCESS_GROUPS } from '@dsh/app/auth';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
import { ROLE_PRIORITY_DESC } from '@dsh/app/shared/components/organization-roles/utils/sort-role-ids';
import { ROLE_ACCESS_GROUPS, RoleAccessGroup } from '@dsh/app/auth';
import { RoleId } from '@dsh/app/auth/types/role-id';
import {
ROLE_PRIORITY_DESC,
sortRoleIds,
} from '@dsh/app/shared/components/organization-roles/utils/sort-role-ids';
import { NestedTableColumn, NestedTableNode } from '@dsh/components/nested-table';
import { RoleAccessesDictionaryService } from './services/role-accesses-dictionary.service';
import { SelectRoleDialogResult } from './types/select-role-dialog-result';
import { SelectRoleDialogData } from './types/selected-role-dialog-data';
interface FlatRoleAccess extends RoleAccess {
isHeader: boolean;
}
@Component({
selector: 'dsh-select-role-dialog',
templateUrl: 'select-role-dialog.component.html',
styleUrls: ['select-role-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SelectRoleDialogComponent {
roleControl = this.fb.control<RoleId>(null, Validators.required);
accesses: FlatRoleAccess[] = ROLE_ACCESS_GROUPS.map((r) => ({ ...r, isHeader: true })).flatMap(
(r) => [r, ...(r.children || [])] as FlatRoleAccess[],
);
export class SelectRoleDialogComponent extends DialogSuperclass<
SelectRoleDialogComponent,
{ availableRoles: RoleId[]; isShow?: boolean },
{ selectedRoleId: RoleId }
> {
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
selectedRole$ = new ReplaySubject<RoleId>(1);
roleIdDict$ = this.organizationsDictionaryService.roleId$;
roleAccessDict$ = this.roleAccessesDictionaryService.roleAccessDict$;
get rowsGridTemplateColumns() {
return `2fr ${'1fr '.repeat(this.data.availableRoles.length)}`;
columns$: Observable<NestedTableColumn<RoleAccessGroup>[]> = combineLatest([
this.roleIdDict$,
this.roleAccessDict$,
]).pipe(
map(([roleIdDict, roleAccessDict]) => [
{
field: 'name',
header: '',
formatter: (d) => (d ? roleAccessDict[d.name] : ''),
},
...this.roles.sort(sortRoleIds).map((r) => ({ field: r, header: roleIdDict[r] })),
]),
);
data: NestedTableNode<RoleAccessGroup>[] = [
...(this.dialogData.isShow ? [] : [{ value: null }]),
...ROLE_ACCESS_GROUPS.map((g) => ({
value: g,
children: g.children?.map?.((a) => ({ value: a })),
expanded: true,
})),
];
@ViewChild('accessCellTpl') accessCellTpl: TemplateRef<unknown>;
get cellsTemplates() {
return Object.fromEntries(getEnumValues(RoleId).map((r) => [r, this.accessCellTpl]));
}
get roles() {
return this.data.availableRoles.sort(
return (this.dialogData?.availableRoles || []).sort(
(a, b) => ROLE_PRIORITY_DESC[a] - ROLE_PRIORITY_DESC[b],
);
}
constructor(
@Inject(MAT_DIALOG_DATA) private data: SelectRoleDialogData,
private dialogRef: MatDialogRef<SelectRoleDialogComponent, SelectRoleDialogResult>,
private fb: FormBuilder,
private destroyRef: DestroyRef,
private organizationsDictionaryService: OrganizationsDictionaryService,
private roleAccessesDictionaryService: RoleAccessesDictionaryService,
) {}
) {
super();
}
cancel() {
this.dialogRef.close(BaseDialogResponseStatus.Error);
this.closeWithError();
}
select() {
this.dialogRef.close({
selectedRoleId: this.roleControl.value,
});
this.selectedRole$
.pipe(first(), takeUntilDestroyed(this.destroyRef))
.subscribe((selectedRoleId) => {
this.closeWithSuccess({ selectedRoleId });
});
}
}

View File

@ -1,5 +0,0 @@
import { RoleId } from '@vality/swag-organizations';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
export type SelectRoleDialogResult = BaseDialogResponseStatus | { selectedRoleId: RoleId };

View File

@ -1,6 +0,0 @@
import { RoleId } from '@vality/swag-organizations';
export interface SelectRoleDialogData {
availableRoles: RoleId[];
isShow?: boolean;
}

View File

@ -1,4 +1,4 @@
import { RoleId } from '@vality/swag-organizations';
import { RoleId } from '@dsh/app/auth/types/role-id';
export interface ShopsRole {
id: RoleId;

View File

@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { DialogResponseStatus, DialogResponse } from '@vality/ng-core';
import { Invitation, Organization, RevokeInvitationRequest } from '@vality/swag-organizations';
import { filter, switchMap } from 'rxjs/operators';
import { InvitationsService } from '@dsh/app/api/organizations';
import { ErrorService, NotificationService } from '@dsh/app/shared';
import { ConfirmActionDialogComponent, ConfirmActionDialogResult } from '@dsh/components/popups';
import { ConfirmActionDialogComponent } from '@dsh/components/popups';
import { ignoreBeforeCompletion } from '@dsh/utils';
@UntilDestroy()
@ -31,12 +32,10 @@ export class InvitationComponent {
@ignoreBeforeCompletion
cancel() {
return this.dialog
.open<ConfirmActionDialogComponent, void, ConfirmActionDialogResult>(
ConfirmActionDialogComponent,
)
.open<ConfirmActionDialogComponent, void, DialogResponse>(ConfirmActionDialogComponent)
.afterClosed()
.pipe(
filter((r) => r === 'confirm'),
filter((r) => r.status === DialogResponseStatus.Success),
switchMap(() =>
this.invitationsService.revokeInvitation({
orgId: this.orgId,

View File

@ -5,10 +5,11 @@
(cancel)="cancel()"
>
<dsh-change-roles-table
[organization]="data.organization"
[inProgress]="!!(progress$ | async)"
[organization]="dialogData.organization"
[roles]="roles$ | async"
controlled
editMode
hasAtLeastOneRole
(addedRoles)="addRoles($event)"
(removedRoles)="removeRoles($event)"
></dsh-change-roles-table>

View File

@ -1,13 +1,12 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { DialogSuperclass, DEFAULT_DIALOG_CONFIG, progressTo } from '@vality/ng-core';
import { MemberRole } from '@vality/swag-organizations';
import { BehaviorSubject, defer, forkJoin, of, Subscription } from 'rxjs';
import { catchError, shareReplay, switchMap, map } from 'rxjs/operators';
import { MembersService } from '@dsh/app/api/organizations';
import { ErrorService } from '@dsh/app/shared';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
import { EditRolesDialogData } from './types/edit-roles-dialog-data';
@ -17,41 +16,51 @@ import { EditRolesDialogData } from './types/edit-roles-dialog-data';
templateUrl: 'edit-roles-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EditRolesDialogComponent {
export class EditRolesDialogComponent extends DialogSuperclass<
EditRolesDialogData,
EditRolesDialogData
> {
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
roles$ = defer(() => this.updateRoles$).pipe(
switchMap(() =>
this.membersService
.getOrgMember({ orgId: this.data.organization.id, userId: this.data.userId })
.getOrgMember({
orgId: this.dialogData.organization.id,
userId: this.dialogData.userId,
})
.pipe(map((r) => r.roles)),
),
untilDestroyed(this),
shareReplay(1),
);
progress$ = new BehaviorSubject(0);
private updateRoles$ = new BehaviorSubject<void>(null);
constructor(
private dialogRef: MatDialogRef<EditRolesDialogComponent, BaseDialogResponseStatus>,
@Inject(MAT_DIALOG_DATA) private data: EditRolesDialogData,
private membersService: MembersService,
private errorService: ErrorService,
) {}
) {
super();
}
cancel(): void {
this.dialogRef.close(BaseDialogResponseStatus.Cancelled);
this.closeWithCancellation();
}
addRoles(roles: MemberRole[]): Subscription {
return forkJoin(
roles.map((memberRole) =>
this.membersService.assignMemberRole({
orgId: this.data.organization.id,
userId: this.data.userId,
orgId: this.dialogData.organization.id,
userId: this.dialogData.userId,
memberRole,
}),
),
)
.pipe(
progressTo(this.progress$),
catchError((err) => {
this.errorService.error(err);
return of(undefined);
@ -65,11 +74,12 @@ export class EditRolesDialogComponent {
roles.map((role) =>
this.membersService
.removeMemberRole({
orgId: this.data.organization.id,
userId: this.data.userId,
orgId: this.dialogData.organization.id,
userId: this.dialogData.userId,
memberRoleId: role.id,
})
.pipe(
progressTo(this.progress$),
catchError((err) => {
this.errorService.error(err);
return of(undefined);

View File

@ -1,10 +1,15 @@
import { MemberRoleOptionalId } from '@dsh/app/api/organizations';
export function equalRoles(a: MemberRoleOptionalId, b: MemberRoleOptionalId) {
export function equalRoles(a: MemberRoleOptionalId, b: MemberRoleOptionalId): boolean {
if (typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (a.id && b.id) {
return a.id === b.id;
}
return (
(a.id && b.id && a.id === b.id) ||
(a.roleId === b.roleId &&
((!a.scope && !b.scope) ||
(a.scope.id === b.scope.id && a.scope.resourceId === b.scope.resourceId)))
a.roleId === b.roleId &&
a.scope?.id === b.scope?.id &&
a.scope?.resourceId === b.scope?.resourceId
);
}

View File

@ -2,26 +2,22 @@ import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Inject,
Input,
OnChanges,
Output,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ComponentChanges } from '@vality/ng-core';
import { ComponentChanges, DialogService, DialogResponseStatus } from '@vality/ng-core';
import { Member, Organization } from '@vality/swag-organizations';
import { filter, switchMap } from 'rxjs/operators';
import { MembersService } from '@dsh/app/api/organizations';
import { DialogConfig, DIALOG_CONFIG } from '@dsh/app/sections/tokens';
import { ErrorService, NotificationService } from '@dsh/app/shared';
import { OrganizationManagementService } from '@dsh/app/shared/services/organization-management/organization-management.service';
import { ConfirmActionDialogComponent, ConfirmActionDialogResult } from '@dsh/components/popups';
import { ConfirmActionDialogComponent } from '@dsh/components/popups';
import { ignoreBeforeCompletion } from '@dsh/utils';
import { EditRolesDialogComponent } from '../edit-roles-dialog/edit-roles-dialog.component';
import { EditRolesDialogData } from '../edit-roles-dialog/types/edit-roles-dialog-data';
@UntilDestroy()
@Component({
@ -36,8 +32,7 @@ export class MemberComponent implements OnChanges {
@Output() changed = new EventEmitter<void>();
constructor(
private dialog: MatDialog,
@Inject(DIALOG_CONFIG) private dialogConfig: DialogConfig,
private dialogService: DialogService,
private organizationManagementService: OrganizationManagementService,
private membersService: MembersService,
private notificationService: NotificationService,
@ -52,13 +47,11 @@ export class MemberComponent implements OnChanges {
@ignoreBeforeCompletion
removeFromOrganization() {
return this.dialog
.open<ConfirmActionDialogComponent, void, ConfirmActionDialogResult>(
ConfirmActionDialogComponent,
)
return this.dialogService
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(
filter((r) => r === 'confirm'),
filter((r) => r.status === DialogResponseStatus.Success),
switchMap(() =>
this.membersService.expelOrgMember({
orgId: this.organization.id,
@ -78,13 +71,10 @@ export class MemberComponent implements OnChanges {
@ignoreBeforeCompletion
editRoles() {
return this.dialog
.open<EditRolesDialogComponent, EditRolesDialogData>(EditRolesDialogComponent, {
...this.dialogConfig.large,
data: {
organization: this.organization,
userId: this.member.id,
},
return this.dialogService
.open(EditRolesDialogComponent, {
organization: this.organization,
userId: this.member.id,
})
.afterClosed()
.pipe(untilDestroyed(this))

View File

@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ComponentChanges } from '@vality/ng-core';
import { ComponentChanges, DialogResponseStatus } from '@vality/ng-core';
import { Organization } from '@vality/swag-organizations';
import isNil from 'lodash-es/isNil';
import { filter, pluck, switchMap } from 'rxjs/operators';
@ -22,7 +22,7 @@ import {
} from '@dsh/app/shared/services';
import { FetchOrganizationsService } from '@dsh/app/shared/services/fetch-organizations';
import { OrganizationManagementService } from '@dsh/app/shared/services/organization-management/organization-management.service';
import { ConfirmActionDialogComponent, ConfirmActionDialogResult } from '@dsh/components/popups';
import { ConfirmActionDialogComponent } from '@dsh/components/popups';
import { ignoreBeforeCompletion } from '@dsh/utils';
import { RenameOrganizationDialogComponent } from '../rename-organization-dialog/rename-organization-dialog.component';
@ -64,12 +64,10 @@ export class OrganizationComponent implements OnChanges {
@ignoreBeforeCompletion
leave() {
return this.dialog
.open<ConfirmActionDialogComponent, void, ConfirmActionDialogResult>(
ConfirmActionDialogComponent,
)
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(
filter((r) => r === 'confirm'),
filter((r) => r.status === DialogResponseStatus.Success),
switchMap(() =>
this.organizationsService.cancelOrgMembership({ orgId: this.organization.id }),
),

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { UntypedFormArray, UntypedFormBuilder } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { toMinor } from '@vality/ng-core';
import { toMinor, DialogResponseStatus } from '@vality/ng-core';
import {
InvoiceLineTaxMode,
InvoiceLineTaxVAT,
@ -129,7 +129,7 @@ export class CreateInvoiceTemplateService {
this.dialog
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(filter((r) => r === 'confirm'))
.pipe(filter((r) => r.status === DialogResponseStatus.Success))
.subscribe(() => {
this.cartForm.clear();
this.addProduct();

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslocoService } from '@ngneat/transloco';
import { NotifyLogService } from '@vality/ng-core';
import { NotifyLogService, DialogResponseStatus } from '@vality/ng-core';
import { combineLatest, Observable, of, Subject } from 'rxjs';
import { catchError, filter, switchMap, takeUntil } from 'rxjs/operators';
@ -38,7 +38,7 @@ export class DeleteWebhookService {
this.dialog
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(filter((r) => r === 'confirm')),
.pipe(filter((r) => r.status === DialogResponseStatus.Success)),
]),
),
switchMap(([webhookID]) =>

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslocoService } from '@ngneat/transloco';
import { DialogResponseStatus } from '@vality/ng-core';
import { combineLatest, Observable, of, Subject } from 'rxjs';
import { catchError, filter, switchMap, takeUntil, tap, map, first } from 'rxjs/operators';
@ -38,7 +39,7 @@ export class CancelReportService {
this.dialog
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(filter((r) => r === 'confirm')),
.pipe(filter((r) => r.status === DialogResponseStatus.Success)),
]),
),
switchMap(([reportID]) =>

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslocoService } from '@ngneat/transloco';
import { NotifyLogService } from '@vality/ng-core';
import { NotifyLogService, DialogResponseStatus } from '@vality/ng-core';
import { Observable, of } from 'rxjs';
import { catchError, filter, map, switchMap } from 'rxjs/operators';
@ -24,7 +24,7 @@ export class ShopActionsService {
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(
filter((r) => r === 'confirm'),
filter((r) => r.status === DialogResponseStatus.Success),
switchMap(() => this.shopsService.suspendShopForParty({ shopID })),
map(() => {
this.log.success(
@ -55,7 +55,7 @@ export class ShopActionsService {
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(
filter((r) => r === 'confirm'),
filter((r) => r.status === DialogResponseStatus.Success),
switchMap(() => this.shopsService.activateShopForParty({ shopID })),
map(() => {
this.log.success(

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslocoService } from '@ngneat/transloco';
import { NotifyLogService } from '@vality/ng-core';
import { NotifyLogService, DialogResponseStatus } from '@vality/ng-core';
import { combineLatest, Observable, of, Subject } from 'rxjs';
import { catchError, filter, switchMap, takeUntil } from 'rxjs/operators';
@ -40,7 +40,7 @@ export class DeleteWebhookService {
this.dialog
.open(ConfirmActionDialogComponent)
.afterClosed()
.pipe(filter((r) => r === 'confirm')),
.pipe(filter((r) => r.status === DialogResponseStatus.Success)),
]),
),
switchMap(([{ webhookID, identityID }]) =>

View File

@ -1,5 +1,3 @@
export enum BaseDialogResponseStatus {
Success = 'success',
Error = 'error',
Cancelled = 'canceled',
}
import { DialogResponseStatus as BaseDialogResponseStatus } from '@vality/ng-core';
export { BaseDialogResponseStatus };

View File

@ -1,4 +1,6 @@
import { ResourceScopeId, RoleId } from '@vality/swag-organizations';
import { ResourceScopeId } from '@vality/swag-organizations';
import { RoleId } from '@dsh/app/auth/types/role-id';
export type ResourceId = string;

View File

@ -1,5 +1,7 @@
import { MemberRole } from '@vality/swag-organizations';
import { RoleId } from '@dsh/app/auth/types/role-id';
import { RoleGroup, RoleGroupScope } from '../types/role-group';
import { sortRoleIds } from './sort-role-ids';
@ -9,7 +11,7 @@ export function groupRoles(roles: MemberRole[]): RoleGroup[] {
.reduce<RoleGroup[]>((groups, role) => {
let group: RoleGroup = groups.find((g) => g.id === role.roleId);
if (!group) {
group = { id: role.roleId, scopes: [] };
group = { id: role.roleId as RoleId, scopes: [] };
groups.push(group);
}
let scope: RoleGroupScope = group.scopes.find((s) => s.id === role.scope.id);

View File

@ -1,11 +1,12 @@
import { RoleId } from '@vality/swag-organizations';
import { RoleId } from '@dsh/app/auth/types/role-id';
export const ROLE_PRIORITY_DESC: Record<RoleId, number> = {
/* eslint-disable @typescript-eslint/naming-convention */
Administrator: 0,
Manager: 1,
Accountant: 2,
Integrator: 3,
[RoleId.Administrator]: 0,
[RoleId.Manager]: 1,
[RoleId.Accountant]: 2,
[RoleId.Integrator]: 3,
[RoleId.WalletManager]: 3,
/* eslint-enable @typescript-eslint/naming-convention */
};

View File

@ -1,7 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Organization, Member, RoleId } from '@vality/swag-organizations';
import { Organization, Member } from '@vality/swag-organizations';
import isNil from 'lodash-es/isNil';
import {
Observable,
@ -16,6 +16,7 @@ import {
import { switchMap, shareReplay, catchError, map, tap, filter } from 'rxjs/operators';
import { OrgsService, MembersService, DEFAULT_ORGANIZATION_NAME } from '@dsh/app/api/organizations';
import { RoleId } from '@dsh/app/auth/types/role-id';
import { KeycloakTokenInfoService } from '@dsh/app/shared/services/keycloak-token-info';
import { ErrorService } from '../error';

View File

@ -1,9 +1,10 @@
import { Injectable } from '@angular/core';
import { Member, Organization, RoleId } from '@vality/swag-organizations';
import { Member, Organization } from '@vality/swag-organizations';
import { combineLatest, defer, Observable, ReplaySubject } from 'rxjs';
import { map, pluck, shareReplay, switchMap } from 'rxjs/operators';
import { MembersService } from '@dsh/app/api/organizations';
import { RoleId } from '@dsh/app/auth/types/role-id';
import { SHARE_REPLAY_CONF } from '@dsh/app/custom-operators';
import { KeycloakTokenInfoService } from '@dsh/app/shared';

View File

@ -103,11 +103,16 @@
"resourceScopeId": {
"Shop": "Shops"
},
"resourceScopeIdPlural": {
"Shop": "Shops",
"Wallet": "Wallets"
},
"roleId": {
"Accountant": "Accountant",
"Administrator": "Administrator",
"Integrator": "Integrator",
"Manager": "Manager"
"Manager": "Manager",
"WalletManager": "Wallets Manager"
}
},
"payments": {

View File

@ -103,11 +103,16 @@
"resourceScopeId": {
"Shop": "Магазины"
},
"resourceScopeIdPlural": {
"Shop": "Магазины",
"Wallet": "Кошельки"
},
"roleId": {
"Accountant": "Бухгалтер",
"Administrator": "Администратор",
"Integrator": "Интегратор",
"Manager": "Менеджер"
"Manager": "Менеджер",
"WalletManager": "Менеджер по кошелькам"
}
},
"payments": {

View File

@ -15,8 +15,8 @@
},
"changeRolesTable": {
"add": "Add",
"remove": "Delete",
"shops": "Shops"
"info": "Roles accesses",
"remove": "Delete"
},
"createInvitationDialog": {
"form": {
@ -123,7 +123,7 @@
"wallets": "Wallets"
},
"selectRoleDialog": {
"select": "Select",
"title": "Select role"
"select": "Add",
"title": "Roles"
}
}

View File

@ -15,8 +15,8 @@
},
"changeRolesTable": {
"add": "Добавить",
"remove": "Удалить",
"shops": "Магазины"
"info": "Права доступа ролей",
"remove": "Удалить"
},
"createInvitationDialog": {
"form": {
@ -123,7 +123,7 @@
"wallets": "Кошельки"
},
"selectRoleDialog": {
"select": "Выбрать",
"title": "Выберите роль"
"select": "Добавить",
"title": "Роли"
}
}

View File

@ -1,19 +1,4 @@
@use '@angular/material' as mat;
@mixin dsh-nested-table-theme($theme) {
$foreground: map-get($theme, foreground);
$primary: map-get($theme, primary);
.dsh-nested-table {
&-group {
&-show-more {
color: mat.get-color-from-palette($primary, 400);
}
}
&-item,
&-row-item {
border-color: map-get($foreground, dividers) !important;
}
}
}

View File

@ -1 +0,0 @@
export const ROW_ITEM_CLASS = 'class.dsh-nested-table-row-item';

View File

@ -1 +0,0 @@
export const TABLE_ITEM_CLASS = 'class.dsh-nested-table-item';

View File

@ -1,23 +0,0 @@
:host {
display: flex;
align-items: center;
min-height: 32px;
padding: 8px;
& > ::ng-deep button,
& > ::ng-deep mat-checkbox,
& > ::ng-deep mat-radio-button {
margin: auto;
.mdc-radio {
padding: 0;
}
.mdc-label {
display: none;
}
}
& > ::ng-deep button {
width: 100%;
}
}

View File

@ -1,34 +0,0 @@
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NestedTableColComponent } from './nested-table-col.component';
@Component({
selector: 'dsh-host',
template: `<dsh-nested-table-col></dsh-nested-table-col>`,
})
class HostComponent {}
describe('NestedTableColComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let debugElement: DebugElement;
let component: NestedTableColComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [],
declarations: [HostComponent, NestedTableColComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
debugElement = fixture.debugElement.query(By.directive(NestedTableColComponent));
component = debugElement.componentInstance;
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,14 +0,0 @@
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
import { ROW_ITEM_CLASS } from '@dsh/components/nested-table/classes/row-item-class';
@Component({
selector: 'dsh-nested-table-col',
templateUrl: 'nested-table-col.component.html',
styleUrls: ['nested-table-col.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NestedTableColComponent {
@HostBinding(ROW_ITEM_CLASS) readonly rowItemClass = true;
@HostBinding('class.dsh-body-1') readonly body1Class = true;
}

View File

@ -1,9 +0,0 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
export const ANIMATION_TIMING = '225ms cubic-bezier(0.4,0.0,0.2,1)';
export const EXPANSION = trigger('expansion', [
state('void', style({ height: '0px', padding: '0', opacity: 0.5 })),
state('*', style({ height: '*', padding: '*', opacity: 1 })),
transition('* <=> *', animate(ANIMATION_TIMING)),
]);

View File

@ -1,12 +0,0 @@
<ng-content></ng-content>
<dsh-nested-table-row *ngIf="showMoreDisplayed$ | async">
<dsh-nested-table-col>
<span
*transloco="let t; scope: 'components'; read: 'components.shared'"
class="dsh-nested-table-group-show-more dsh-body-1"
(click)="showAll()"
>
{{ t('showMore') }}
</span>
</dsh-nested-table-col>
</dsh-nested-table-row>

View File

@ -1,10 +0,0 @@
:host {
overflow: hidden;
.dsh-nested-table-group {
&-show-more {
cursor: pointer;
text-decoration: underline;
}
}
}

View File

@ -1,94 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { cold } from 'jasmine-marbles';
import { NestedTableColComponent } from '@dsh/components/nested-table/components/nested-table-col/nested-table-col.component';
import { NestedTableRowComponent } from '@dsh/components/nested-table/components/nested-table-row/nested-table-row.component';
import { LayoutManagementService } from '@dsh/components/nested-table/services/layout-management/layout-management.service';
import { NestedTableGroupComponent } from './nested-table-group.component';
@Component({
selector: 'dsh-host',
template: `
<dsh-nested-table-group [displayedCount]="displayedCount">
<dsh-nested-table-row *ngFor="let i of rows"></dsh-nested-table-row>
</dsh-nested-table-group>
`,
})
class HostComponent {
displayedCount: number;
rows = new Array(10).fill(null);
}
describe('NestedTableLimitedRowsComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let debugElement: DebugElement;
let component: NestedTableGroupComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, CommonModule],
declarations: [
HostComponent,
NestedTableGroupComponent,
NestedTableRowComponent,
NestedTableColComponent,
],
providers: [LayoutManagementService],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
debugElement = fixture.debugElement.query(By.directive(NestedTableGroupComponent));
component = debugElement.componentInstance;
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
it('should be init', () => {
expect(component.showMoreDisplayed$).toBeObservable(cold('(a)', { a: false }));
fixture.componentInstance.displayedCount = 2;
fixture.detectChanges();
expect(component.showMoreDisplayed$).toBeObservable(cold('(a)', { a: true }));
});
describe('showAll', () => {
it('should showMoreDisplayed', () => {
fixture.componentInstance.displayedCount = 2;
component.showAll();
fixture.detectChanges();
expect(component.showMoreDisplayed$).toBeObservable(cold('(a)', { a: false }));
});
});
describe('template rows', () => {
it('should display all', () => {
const rows = fixture.debugElement.queryAll(By.directive(NestedTableRowComponent));
expect(rows.length).toBe(10);
for (const row of rows) {
expect((row.componentInstance as NestedTableRowComponent).display).toBe('grid');
}
});
it('should display by limit', () => {
fixture.componentInstance.displayedCount = 2;
fixture.detectChanges();
const rows = fixture.debugElement.queryAll(By.directive(NestedTableRowComponent));
expect(rows.length).toBe(11);
for (const row of rows.slice(0, 1)) {
expect((row.componentInstance as NestedTableRowComponent).display).toBe('grid');
}
for (const row of rows.slice(2, 9)) {
expect((row.componentInstance as NestedTableRowComponent).display).toBe('none');
}
expect((rows[10].componentInstance as NestedTableRowComponent).display).toBe('grid');
});
});
});

View File

@ -1,81 +0,0 @@
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ContentChildren,
HostBinding,
Input,
OnChanges,
QueryList,
} from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ComponentChanges } from '@vality/ng-core';
import { BehaviorSubject, combineLatest, defer } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { TABLE_ITEM_CLASS } from '@dsh/components/nested-table/classes/table-item-class';
import { NestedTableRowComponent } from '@dsh/components/nested-table/components/nested-table-row/nested-table-row.component';
import { queryListStartedArrayChanges } from '@dsh/utils';
import { EXPANSION } from './expansion';
@UntilDestroy()
@Component({
selector: 'dsh-nested-table-group',
templateUrl: 'nested-table-group.component.html',
styleUrls: ['nested-table-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [EXPANSION],
})
export class NestedTableGroupComponent implements AfterContentInit, OnChanges {
@Input() displayedCount: number;
showMoreDisplayed$ = defer(() =>
combineLatest([
queryListStartedArrayChanges(this.rowChildren),
this.displayedAll$,
this.displayedCount$,
]),
).pipe(
// displayedCount + 1 - to show the last element instead of the "show all" link when the number of elements is only 1 more
map(
([rows, displayedAll, displayedCount]) =>
!displayedAll && rows.length > displayedCount + 1,
),
untilDestroyed(this),
shareReplay(1),
);
@HostBinding(TABLE_ITEM_CLASS) readonly tableItemClass = true;
@HostBinding('@expansion') readonly expansion;
@ContentChildren(NestedTableRowComponent) private rowChildren =
new QueryList<NestedTableRowComponent>();
private displayedAll$ = new BehaviorSubject<boolean>(false);
private displayedCount$ = new BehaviorSubject(Infinity);
ngOnChanges({ displayedCount }: ComponentChanges<NestedTableGroupComponent>) {
if (displayedCount) {
this.displayedCount$.next(displayedCount.currentValue);
}
}
ngAfterContentInit() {
this.listenShowAll();
}
showAll() {
this.displayedAll$.next(true);
}
private listenShowAll() {
combineLatest([queryListStartedArrayChanges(this.rowChildren), this.showMoreDisplayed$])
.pipe(untilDestroyed(this))
.subscribe(([rows, showMoreDisplayed]) => {
if (showMoreDisplayed) {
rows.forEach((row, idx) => row.setHidden(idx >= this.displayedCount));
} else {
rows.forEach((row) => row.setHidden(false));
}
});
}
}

View File

@ -1,2 +0,0 @@
<ng-content></ng-content>
<dsh-nested-table-col *ngFor="let col of fillCols">{{ col }}</dsh-nested-table-col>

View File

@ -1,9 +0,0 @@
:host {
& > ::ng-deep .dsh-nested-table-row-item {
border-right: 1px solid;
&:last-child {
border-right: none;
}
}
}

View File

@ -1,52 +0,0 @@
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { cold } from 'jasmine-marbles';
import { NestedTableColComponent } from '@dsh/components/nested-table/components/nested-table-col/nested-table-col.component';
import { LayoutManagementService } from '@dsh/components/nested-table/services/layout-management/layout-management.service';
import { NestedTableRowComponent } from './nested-table-row.component';
@Component({
selector: 'dsh-host',
template: `
<dsh-nested-table-row>
<dsh-nested-table-col></dsh-nested-table-col>
<dsh-nested-table-col></dsh-nested-table-col>
<dsh-nested-table-col></dsh-nested-table-col>
</dsh-nested-table-row>
`,
})
class HostComponent {}
describe('NestedTableRowComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let debugElement: DebugElement;
let component: NestedTableRowComponent;
let layoutManagementService: LayoutManagementService;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HostComponent, NestedTableRowComponent, NestedTableColComponent],
providers: [LayoutManagementService],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
debugElement = fixture.debugElement.query(By.directive(NestedTableRowComponent));
component = debugElement.componentInstance;
layoutManagementService = TestBed.inject(LayoutManagementService);
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
it('should be init', () => {
expect(component.colsCount$).toBeObservable(cold('(a)', { a: 3 }));
layoutManagementService.setLayoutColsCount(4);
expect(component.fillCols).toEqual(['']);
});
});

View File

@ -1,79 +0,0 @@
import {
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
ElementRef,
HostBinding,
OnInit,
QueryList,
Renderer2,
} from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ReplaySubject } from 'rxjs';
import { pluck } from 'rxjs/operators';
import { NestedTableColComponent } from '@dsh/components/nested-table/components/nested-table-col/nested-table-col.component';
import { queryListStartedArrayChanges } from '@dsh/utils';
import { TABLE_ITEM_CLASS } from '../../classes/table-item-class';
import { LayoutManagementService } from '../../services/layout-management/layout-management.service';
@UntilDestroy()
@Component({
selector: 'dsh-nested-table-row',
templateUrl: 'nested-table-row.component.html',
styleUrls: ['nested-table-row.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NestedTableRowComponent implements AfterContentInit, OnInit {
@HostBinding(TABLE_ITEM_CLASS) readonly tableItemClass = true;
@HostBinding('style.grid-template-columns') gridTemplateColumns: string;
@HostBinding('style.display') display = 'grid';
colsCount$ = new ReplaySubject<number>(1);
fillCols: string[];
@ContentChildren(NestedTableColComponent)
private nestedTableColComponentChildren: QueryList<NestedTableColComponent>;
constructor(
private layoutManagementService: LayoutManagementService,
private el: ElementRef,
private renderer: Renderer2,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
this.layoutManagementService.gridTemplateColumns$
.pipe(untilDestroyed(this))
.subscribe((gridTemplateColumns) => {
this.gridTemplateColumns = gridTemplateColumns;
this.renderer.setStyle(
this.el.nativeElement,
'grid-template-columns',
gridTemplateColumns,
);
});
this.layoutManagementService.getFillCols(this.colsCount$).subscribe((fillCols) => {
this.fillCols = fillCols;
this.cdr.detectChanges();
});
}
ngAfterContentInit() {
this.listenColsCount();
}
setHidden(hidden: boolean) {
this.display = hidden ? 'none' : 'grid';
this.renderer.setStyle(this.el.nativeElement, 'display', this.display);
}
private listenColsCount() {
queryListStartedArrayChanges(this.nestedTableColComponentChildren)
.pipe(pluck('length'), untilDestroyed(this))
.subscribe((colsCount) => this.colsCount$.next(colsCount));
}
}

View File

@ -1 +1,2 @@
export * from './nested-table.module';
export * from './nested-table.component';

View File

@ -1,14 +0,0 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import { IndicatorRotateState } from './types/indicator-rotate';
export const ANIMATION_TIMING = '225ms cubic-bezier(0.4,0.0,0.2,1)';
export const INDICATOR_ROTATE = trigger('indicatorRotate', [
state(
[IndicatorRotateState.Collapsed, 'void'].join(','),
style({ transform: 'rotate(90deg)' }),
),
state(IndicatorRotateState.Expanded, style({ transform: 'rotate(180deg)' })),
transition('expanded <=> collapsed, void => collapsed', animate(ANIMATION_TIMING)),
]);

View File

@ -1,2 +0,0 @@
<div class="dsh-body-2"><ng-content></ng-content></div>
<dsh-bi [@indicatorRotate]="animationState$ | async" icon="chevron-up"></dsh-bi>

View File

@ -1,6 +0,0 @@
:host {
display: flex;
place-content: stretch space-between;
cursor: pointer;
width: 100%;
}

View File

@ -1,38 +0,0 @@
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ExpansionService } from '@dsh/components/nested-table/nested-table-collapse/services/expansion/expansion.service';
import { NestedTableCollapseButtonComponent } from './nested-table-collapse-button.component';
@Component({
selector: 'dsh-host',
template: `<dsh-nested-table-collapse-button></dsh-nested-table-collapse-button>`,
})
class HostComponent {}
describe('NestedTableCollapseButtonComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let debugElement: DebugElement;
let component: NestedTableCollapseButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [HostComponent, NestedTableCollapseButtonComponent],
providers: [ExpansionService],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
debugElement = fixture.debugElement.query(By.directive(NestedTableCollapseButtonComponent));
component = debugElement.componentInstance;
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,32 +0,0 @@
import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { map, shareReplay } from 'rxjs/operators';
import { ExpansionService } from '../../services/expansion/expansion.service';
import { INDICATOR_ROTATE } from './indicator-rotate';
import { IndicatorRotateState } from './types/indicator-rotate';
@UntilDestroy()
@Component({
selector: 'dsh-nested-table-collapse-button',
templateUrl: 'nested-table-collapse-button.component.html',
styleUrls: ['nested-table-collapse-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [INDICATOR_ROTATE],
})
export class NestedTableCollapseButtonComponent {
animationState$ = this.expansionService.expanded$.pipe(
map((expanded) =>
expanded ? IndicatorRotateState.Expanded : IndicatorRotateState.Collapsed,
),
untilDestroyed(this),
shareReplay(1),
);
constructor(private expansionService: ExpansionService) {}
@HostListener('click') onClick() {
this.expansionService.toggle();
}
}

View File

@ -1,4 +0,0 @@
export enum IndicatorRotateState {
Collapsed = 'Collapsed',
Expanded = 'Expanded',
}

View File

@ -1,30 +0,0 @@
import { Directive, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ExpansionService } from '../../services/expansion/expansion.service';
@UntilDestroy()
@Directive({
selector: '[dshNestedTableCollapseBody]',
})
export class NestedTableCollapseBodyDirective implements OnInit {
constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef,
private expansionService: ExpansionService,
) {}
ngOnInit() {
this.expansionService.expanded$
.pipe(untilDestroyed(this))
.subscribe((expanded) => this.expand(expanded));
}
private expand(expanded: boolean) {
if (expanded) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
}

View File

@ -1 +0,0 @@
export * from './nested-table-collapse.module';

View File

@ -1,15 +0,0 @@
import { Directive, Input } from '@angular/core';
import { ExpansionService } from './services/expansion/expansion.service';
@Directive({
selector: '[dshNestedTableCollapse]',
providers: [ExpansionService],
})
export class NestedTableCollapseDirective {
@Input() set dshNestedTableCollapse(expanded: boolean) {
this.expansionService.setExpanded(expanded);
}
constructor(private expansionService: ExpansionService) {}
}

View File

@ -1,22 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from 'ng-flex-layout';
import { BootstrapIconModule } from '@dsh/components/indicators';
import { NestedTableCollapseButtonComponent } from './components/nested-table-collapse-button/nested-table-collapse-button.component';
import { NestedTableCollapseBodyDirective } from './directives/nested-table-collapse-body/nested-table-collapse-body.directive';
import { NestedTableCollapseDirective } from './nested-table-collapse.directive';
const SHARED_COMPONENTS = [
NestedTableCollapseDirective,
NestedTableCollapseButtonComponent,
NestedTableCollapseBodyDirective,
];
@NgModule({
imports: [CommonModule, FlexLayoutModule, BootstrapIconModule],
declarations: SHARED_COMPONENTS,
exports: SHARED_COMPONENTS,
})
export class NestedTableCollapseModule {}

View File

@ -1,45 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { cold } from 'jasmine-marbles';
import { ExpansionService } from './expansion.service';
describe('ExpansionService', () => {
let service: ExpansionService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [ExpansionService],
});
service = TestBed.inject(ExpansionService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should be init', () => {
expect(service.expanded$).toBeObservable(cold('(a)', { a: false }));
});
describe('setExpanded', () => {
it('should be emit', () => {
service.setExpanded(true);
expect(service.expanded$).toBeObservable(cold('(a)', { a: true }));
service.setExpanded(false);
expect(service.expanded$).toBeObservable(cold('(a)', { a: false }));
service.setExpanded(false);
expect(service.expanded$).toBeObservable(cold('(a)', { a: false }));
});
});
describe('toggle', () => {
it('should be toggle', () => {
service.toggle();
expect(service.expanded$).toBeObservable(cold('(a)', { a: true }));
service.toggle();
expect(service.expanded$).toBeObservable(cold('(a)', { a: false }));
});
});
});

View File

@ -1,17 +0,0 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, defer } from 'rxjs';
@Injectable()
export class ExpansionService {
expanded$ = defer(() => this._expanded$.asObservable());
private _expanded$ = new BehaviorSubject(false);
toggle() {
this.setExpanded(!this._expanded$.value);
}
setExpanded(expanded: boolean) {
this._expanded$.next(expanded);
}
}

View File

@ -1 +1,72 @@
<ng-content></ng-content>
<table [dataSource]="data" mat-table>
<ng-container
*ngFor="let column of columns; let columnIndex = index"
[matColumnDef]="column.field"
[sticky]="columnIndex === 0"
>
<th *matHeaderCellDef mat-header-cell style="text-align: center">
<ng-container *ngIf="headersTemplates[column.field]; else defaultHeaderTpl">
<ng-container
*ngTemplateOutlet="headersTemplates[column.field]; context: { column: column }"
></ng-container>
</ng-container>
<ng-template #defaultHeaderTpl>
{{ column.header }}
</ng-template>
</th>
<td
*matCellDef="let item; let itemIndex = index"
[style.width.px]="0"
[style]="column.style"
class="dsh-body-1"
mat-cell
>
<div
*ngIf="item.expandable && columnIndex === 0; else cellTpl"
[style.margin-left.px]="item.level * 32"
style="
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
gap: 8px;
"
(click)="toggle(item)"
>
<ng-container *ngTemplateOutlet="cellTpl"></ng-container>
<dsh-bi
[icon]="isExpanded(item) ? 'chevron-up' : 'chevron-right'"
size="sm"
></dsh-bi>
</div>
<ng-template #cellTpl>
<div [class.dsh-body-2]="item.level === 0">
<ng-container *ngIf="cellsTemplates[column.field]; else defaultCellTpl">
<ng-container
*ngTemplateOutlet="
cellsTemplates[column.field];
context: { value: item.value, index: itemIndex, column: column }
"
></ng-container>
</ng-container>
<ng-template #defaultCellTpl>
{{ column.formatter ? column.formatter(item.value) : '' }}
</ng-template>
</div>
</ng-template>
</td>
<ng-container *ngIf="footerTemplates">
<td *matFooterCellDef mat-footer-cell style="text-align: center">
<ng-container
*ngTemplateOutlet="footerTemplates[column.field]; context: { column: column }"
></ng-container>
</td>
</ng-container>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<ng-container *ngIf="footerTemplates">
<tr *matFooterRowDef="displayedColumns; sticky: true" mat-footer-row></tr>
</ng-container>
</table>

View File

@ -1,13 +0,0 @@
:host {
display: flex;
flex-direction: column;
}
:host > ::ng-deep .dsh-nested-table-item,
::ng-deep .dsh-nested-table-item > ::ng-deep .dsh-nested-table-item {
border-bottom: 1px solid;
&:last-child {
border-bottom: none;
}
}

View File

@ -1,52 +1,90 @@
import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ContentChildren,
Input,
OnChanges,
QueryList,
} from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ComponentChanges } from '@vality/ng-core';
import { combineLatest } from 'rxjs';
import { distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { FlatTreeControl } from '@angular/cdk/tree';
import { Component, Input, TemplateRef } from '@angular/core';
import { MatTreeFlattener, MatTreeFlatDataSource } from '@angular/material/tree';
import { of } from 'rxjs';
import { first } from 'rxjs/operators';
import { NestedTableRowComponent } from '@dsh/components/nested-table/components/nested-table-row/nested-table-row.component';
import { LayoutManagementService } from '@dsh/components/nested-table/services/layout-management/layout-management.service';
import { queryListStartedArrayChanges } from '@dsh/utils';
export type NestedTableNode<T = unknown> = {
value: T;
children?: NestedTableNode<T>[];
expanded?: boolean;
};
export type NestedTableFlatNode<T = unknown> = {
value: T;
expandable: boolean;
level: number;
initExpanded: boolean;
};
export interface NestedTableColumn<T = unknown> {
field: string;
header: string;
formatter?: (d: T) => string;
style?: Record<string, unknown>;
}
function flatten<T>(node: NestedTableNode<T>, level: number): NestedTableFlatNode<T> {
return {
value: node.value,
expandable: node.children?.length > 0,
level: level,
initExpanded: node.expanded,
};
}
const TREE_CONTROL = new FlatTreeControl<NestedTableFlatNode>(
(node) => node.level,
(node) => node.expandable,
);
const TREE_FLATTENER = new MatTreeFlattener<NestedTableNode, NestedTableFlatNode>(
flatten,
(node) => node.level,
(node) => node.expandable,
(node) => node.children,
);
@UntilDestroy()
@Component({
selector: 'dsh-nested-table',
templateUrl: 'nested-table.component.html',
styleUrls: ['nested-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [LayoutManagementService],
})
export class NestedTableComponent implements AfterContentInit, OnChanges {
@Input() rowsGridTemplateColumns: string;
@ContentChildren(NestedTableRowComponent)
nestedTableRowComponentChildren: QueryList<NestedTableRowComponent>;
export class NestedTableComponent {
@Input({
transform: (data: NestedTableNode[]) => {
const dataSource = new MatTreeFlatDataSource(TREE_CONTROL, TREE_FLATTENER);
dataSource.data = data || [];
dataSource
.connect({ viewChange: of() })
.pipe(first())
.subscribe((flatten) => {
for (const d of flatten) {
if (d.initExpanded) {
TREE_CONTROL.expand(d);
}
}
});
return dataSource;
},
})
data!: MatTreeFlatDataSource<NestedTableNode, NestedTableFlatNode>;
@Input() columns: NestedTableColumn[] = [];
@Input() cellsTemplates: Record<string, TemplateRef<unknown>> = {};
@Input() headersTemplates: Record<string, TemplateRef<unknown>> = {};
@Input() footerTemplates: Record<string, TemplateRef<unknown>> = {};
constructor(private layoutManagementService: LayoutManagementService) {}
get displayedColumns() {
return (this.columns || []).map((c) => c.field);
}
ngOnChanges({ rowsGridTemplateColumns }: ComponentChanges<NestedTableComponent>) {
if (rowsGridTemplateColumns) {
this.layoutManagementService.setRowsGridTemplateColumns(
rowsGridTemplateColumns.currentValue,
);
toggle(data: NestedTableFlatNode) {
if (data.expandable) {
TREE_CONTROL.toggle(data);
}
}
ngAfterContentInit() {
queryListStartedArrayChanges(this.nestedTableRowComponentChildren)
.pipe(
switchMap((rows) => combineLatest(rows.map(({ colsCount$ }) => colsCount$))),
map((rowsColsCounts) => Math.max(...rowsColsCounts)),
distinctUntilChanged(),
untilDestroyed(this),
)
.subscribe((colsCount) => this.layoutManagementService.setLayoutColsCount(colsCount));
isExpanded(data: NestedTableFlatNode) {
return TREE_CONTROL.isExpanded(data);
}
}

View File

@ -1,24 +1,24 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { TranslocoModule } from '@ngneat/transloco';
import { FlexLayoutModule } from 'ng-flex-layout';
import { NestedTableColComponent } from './components/nested-table-col/nested-table-col.component';
import { NestedTableGroupComponent } from './components/nested-table-group/nested-table-group.component';
import { NestedTableRowComponent } from './components/nested-table-row/nested-table-row.component';
import { NestedTableCollapseModule } from './nested-table-collapse';
import { ButtonModule } from '@dsh/components/buttons';
import { BootstrapIconModule } from '@dsh/components/indicators';
import { NestedTableComponent } from './nested-table.component';
const SHARED_COMPONENTS = [
NestedTableComponent,
NestedTableColComponent,
NestedTableRowComponent,
NestedTableGroupComponent,
];
@NgModule({
imports: [CommonModule, FlexLayoutModule, TranslocoModule],
declarations: SHARED_COMPONENTS,
exports: [...SHARED_COMPONENTS, NestedTableCollapseModule],
imports: [
CommonModule,
TranslocoModule,
MatButtonModule,
MatTableModule,
ButtonModule,
BootstrapIconModule,
],
declarations: [NestedTableComponent],
exports: [NestedTableComponent],
})
export class NestedTableModule {}

View File

@ -1,52 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { cold } from 'jasmine-marbles';
import { of } from 'rxjs';
import { LayoutManagementService } from './layout-management.service';
describe('LayoutManagementService', () => {
let service: LayoutManagementService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [LayoutManagementService],
});
service = TestBed.inject(LayoutManagementService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getFillCols', () => {
it('should be return fill cols', () => {
service.setLayoutColsCount(10);
expect(service.getFillCols(of(6))).toBeObservable(
cold('(a)', { a: new Array(4).fill('') }),
);
});
});
describe('layoutColsCount$', () => {
it('should be return layoutColsCount$', () => {
service.setLayoutColsCount(10);
expect(service.layoutColsCount$).toBeObservable(cold('(a)', { a: 10 }));
});
});
describe('gridTemplateColumns$', () => {
it('should be return default', () => {
service.setLayoutColsCount(5);
expect(service.gridTemplateColumns$).toBeObservable(
cold('(a)', { a: '1fr 1fr 1fr 1fr 1fr' }),
);
});
it('should be return by set', () => {
service.setRowsGridTemplateColumns('1fr 100%');
service.setLayoutColsCount(5);
expect(service.gridTemplateColumns$).toBeObservable(cold('(a)', { a: '1fr 100%' }));
});
});
});

View File

@ -1,45 +0,0 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, combineLatest, defer, Observable, of, ReplaySubject } from 'rxjs';
import { distinctUntilChanged, map, shareReplay, switchMap } from 'rxjs/operators';
import { SHARE_REPLAY_CONF } from '@dsh/app/custom-operators';
@Injectable()
export class LayoutManagementService {
layoutColsCount$ = defer(() => this._layoutColsCount$.asObservable());
gridTemplateColumns$ = defer(() => this.rowsGridTemplateColumns$).pipe(
switchMap((gridTemplateColumns) =>
gridTemplateColumns
? of(gridTemplateColumns)
: this.layoutColsCount$.pipe(
map((colsCount) =>
LayoutManagementService.getDefaultGridTemplateColumns(colsCount),
),
),
),
shareReplay(SHARE_REPLAY_CONF),
);
private _layoutColsCount$ = new ReplaySubject<number>(1);
private rowsGridTemplateColumns$ = new BehaviorSubject<string>(null);
getFillCols(colsCount$: Observable<number>): Observable<string[]> {
return combineLatest([this.layoutColsCount$, colsCount$]).pipe(
map(([baseCount, count]) => Math.max(baseCount - count, 0)),
distinctUntilChanged(),
map((count) => new Array(count).fill('')),
);
}
setRowsGridTemplateColumns(rowsGridTemplateColumns: string) {
this.rowsGridTemplateColumns$.next(rowsGridTemplateColumns);
}
setLayoutColsCount(colsCount: number) {
this._layoutColsCount$.next(colsCount);
}
private static getDefaultGridTemplateColumns(colsCount: number) {
return new Array(colsCount).fill('1fr').join(' ');
}
}

View File

@ -1,23 +1,16 @@
import { Component } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
// TODO: replace with BaseDialogResponseStatus
export type ConfirmActionDialogResult = 'cancel' | 'confirm';
import { DialogSuperclass } from '@vality/ng-core';
@Component({
templateUrl: 'confirm-action-dialog.component.html',
styleUrls: ['confirm-action-dialog.component.scss'],
})
export class ConfirmActionDialogComponent {
constructor(
public dialogRef: MatDialogRef<ConfirmActionDialogComponent, ConfirmActionDialogResult>,
) {}
export class ConfirmActionDialogComponent extends DialogSuperclass<ConfirmActionDialogComponent> {
cancel() {
this.dialogRef.close('cancel');
this.closeWithCancellation();
}
confirm() {
this.dialogRef.close('confirm');
this.closeWithSuccess();
}
}