add tenant and user endpoints, services, models and guards

This commit is contained in:
Gagan7900
2026-07-18 16:38:41 +05:30
parent be86ab3564
commit 7c4e34e478
50 changed files with 3064 additions and 451 deletions
@@ -0,0 +1,32 @@
import { buildApiUrl } from '../../config/api-url.util';
export const TENANT_CURRENCIES_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/tenant-currencies/datatable'
),
create: buildApiUrl(
'masterAdmin',
'/v1/tenant-currencies'
),
getById: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/tenant-currencies/${encodeURIComponent(id)}`
),
autocomplete: buildApiUrl(
'masterAdmin',
'/v1/tenant-currencies/autocomplete'
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/tenant-currencies/${encodeURIComponent(id)}`
)
} as const;
@@ -0,0 +1,32 @@
import { buildApiUrl } from '../../config/api-url.util';
export const TENANT_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/tenants/datatable'
),
create: buildApiUrl(
'masterAdmin',
'/v1/tenants'
),
getById: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/tenants/${encodeURIComponent(id)}`
),
autocomplete: buildApiUrl(
'masterAdmin',
'/v1/tenants/autocomplete'
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/tenants/${encodeURIComponent(id)}`
)
} as const;
@@ -0,0 +1,10 @@
import { buildApiUrl } from '../../config/api-url.util';
export const USER_ENDPOINTS = {
dataTable: buildApiUrl('identity', '/v1/users/datatable'),
create: buildApiUrl('identity', '/v1/users'),
getById: (id: string) => buildApiUrl('identity', `/v1/users/${encodeURIComponent(id)}`),
update: (id: string) => buildApiUrl('identity', `/v1/users/${encodeURIComponent(id)}`),
autocomplete: buildApiUrl('identity', '/v1/users/autocomplete')
} as const;
@@ -0,0 +1,11 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../../services/auth/auth.service';
export const superAdminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return (auth.currentUser?.roles ?? []).includes('super_admin')
? true
: router.createUrlTree(['/dashboards/crm']);
};
+26 -1
View File
@@ -4,6 +4,31 @@ import { ToastrService } from 'ngx-toastr';
import { catchError, throwError } from 'rxjs'; import { catchError, throwError } from 'rxjs';
import { API_CONFIG } from '../config/api.config'; import { API_CONFIG } from '../config/api.config';
const backendErrorMessage = (body: unknown): string | null => {
if (typeof body === 'string') return body.trim() || null;
if (typeof body !== 'object' || body === null) return null;
const record = body as Record<string, unknown>;
for (const key of ['detail', 'message', 'title']) {
const value = record[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
};
const httpErrorMessage = (error: HttpErrorResponse): string => {
const backendMessage = backendErrorMessage(error.error);
if (backendMessage) return backendMessage;
if (error.status > 0) {
const statusText = error.statusText.trim();
const meaningfulStatusText = statusText && statusText.toUpperCase() !== 'OK';
return `HTTP ${error.status}${meaningfulStatusText ? ` ${statusText}` : ''}`;
}
return error.message || 'Request failed';
};
export const errorInterceptor: HttpInterceptorFn = (req, next) => { export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const toastr = inject(ToastrService); const toastr = inject(ToastrService);
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`; const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
@@ -13,7 +38,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe( return next(req).pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) { if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed'; const message = httpErrorMessage(error);
toastr.error(message, 'Request failed'); toastr.error(message, 'Request failed');
} }
@@ -1,6 +1,7 @@
export interface CurrencyDto { export interface CurrencyDto {
id: string; id: string;
code: string; code: string;
iso2: string;
name: string; name: string;
symbol: string; symbol: string;
numericCode: number; numericCode: number;
@@ -0,0 +1,53 @@
import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
export interface TenantCurrencyDto {
id: string;
tenantId: string;
tenantName: string;
currencyId: string;
currencyName: string;
isBaseCurrency: boolean;
isReporting: boolean;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface TenantCurrencyLookupDto {
id: string;
tenantId: string;
currencyId: string;
isBaseCurrency: boolean;
isReporting: boolean;
}
export interface CreateTenantCurrencyRequest {
tenantId: string;
currencyId: string;
isBaseCurrency: boolean;
isReporting: boolean;
}
export interface UpdateTenantCurrencyRequest {
isBaseCurrency: boolean;
isReporting: boolean;
isActive: boolean;
}
export interface TenantCurrencyTableRow extends DataTableRecord {
readonly id: string;
readonly tenantId: string;
readonly tenantName: string;
readonly currencyId: string;
readonly currencyName: string;
readonly isBaseCurrency: boolean;
readonly isReporting: boolean;
readonly isActive: boolean;
readonly serialNumber: number;
readonly createdOn?: string;
readonly modifiedOn?: string | null;
}
export type TenantCurrencyModalMode = 'create' | 'edit';
@@ -0,0 +1,68 @@
import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
export enum TenantStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 }
export interface TenantDto {
id: string;
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string;
defaultLanguageName: string | null;
defaultDbConnectionId: string | null;
defaultDbConnectionName: string | null;
defaultCurrencyId: string;
defaultCurrencyName: string | null;
defaultTimezoneId: string;
defaultTimezoneName: string | null;
dataRegion: string;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface TenantLookupDto {
id: string;
name: string;
code: string;
}
export interface CreateTenantRequest {
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string;
defaultCurrencyId: string;
defaultTimezoneId: string;
dataRegion: string;
}
export interface UpdateTenantRequest {
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string;
defaultCurrencyId: string;
defaultTimezoneId: string;
defaultDbConnectionId: string | null;
dataRegion: string;
isActive: boolean;
}
export interface TenantTableRow extends DataTableRecord {
readonly id: string;
readonly code: string;
readonly name: string;
readonly status: TenantStatus;
readonly dataRegion: string;
readonly isActive: boolean;
readonly serialNumber: number;
readonly createdOn?: string;
readonly modifiedOn?: string | null;
readonly defaultLanguageName: string | null;
readonly defaultCurrencyName: string | null;
readonly defaultTimezoneName: string | null;
}
export type TenantModalMode = 'create' | 'edit';
+29
View File
@@ -0,0 +1,29 @@
export enum UserStatus { Pending = 0, Active = 1, Suspended = 2, Locked = 3, Disabled = 4 }
export interface CreateUserRequest {
email: string;
password: string;
roleCodes: string[];
}
export interface UserDto {
id: string;
email: string;
status: UserStatus;
roles: string[];
isActive: boolean;
createdOn: string;
updatedOn?: string | null;
lastLoginOn: string | null;
}
export interface UserLookupDto {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
}
export interface UpdateUserRequest extends CreateUserRequest {
readonly isActive: boolean;
}
export type UserModalMode = 'create' | 'edit';
+12 -2
View File
@@ -24,8 +24,6 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
{ {
title: 'Global Master', title: 'Global Master',
type: 'sub', type: 'sub',
@@ -41,6 +39,18 @@ export const SAAS_MENU_DATA: MenuContext = {
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
], ],
}, },
{
title: 'Tenant Master',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
{ path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
],
},
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
{ {
title: 'Configuration', title: 'Configuration',
type: 'sub', type: 'sub',
@@ -0,0 +1,74 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { TokenStorageService } from '../auth/token-storage.service';
import { MenuService } from './menu.service';
describe('MenuService dependency and authorization', () => {
let menuService: MenuService;
let tokenStorage: TokenStorageService;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideRouter([])],
});
menuService = TestBed.inject(MenuService);
tokenStorage = TestBed.inject(TokenStorageService);
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('constructs MenuService and AuthService without a circular dependency', () => {
expect(menuService).toBeTruthy();
expect(TestBed.inject(AuthService)).toBeTruthy();
});
it('hides the Users menu when the stored user is not a super administrator', async () => {
tokenStorage.saveAuth(
{ accessToken: 'token', user: { id: '1', email: 'user@example.com', roles: ['admin'] } },
false,
);
const context = await firstValueFrom(menuService.loadMenu());
expect(hasPath(context.items, '/users')).toBe(false);
});
it('shows the Users menu when the stored user is a super administrator', async () => {
tokenStorage.saveAuth(
{
accessToken: 'token',
user: { id: '1', email: 'super@example.com', roles: ['super_admin'] },
},
false,
);
const context = await firstValueFrom(menuService.loadMenu());
expect(hasPath(context.items, '/users')).toBe(true);
});
it('clears menu state during context cleanup', async () => {
await firstValueFrom(menuService.loadMenu());
menuService.clear();
expect(menuService.menuContext()).toBeNull();
});
});
function hasPath(items: ReturnType<MenuService['getNavigationMenu']>, path: string): boolean {
return items.some(
(item) =>
item.path === path ||
(item.children ? hasPath(item.children, path) : false) ||
(item.children2 ? hasPath(item.children2, path) : false),
);
}
+16 -2
View File
@@ -1,15 +1,18 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, tap } from 'rxjs'; import { Observable, of, tap } from 'rxjs';
import { MenuContext } from '../../models/context/context.model'; import { MenuContext } from '../../models/context/context.model';
import { SAAS_MENU_DATA } from './menu.data'; import { SAAS_MENU_DATA } from './menu.data';
import { Menu } from '../../../core/services/common/nav.service'; import { Menu } from '../../../core/services/common/nav.service';
import { TokenStorageService } from '../auth/token-storage.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class MenuService { export class MenuService {
private readonly tokenStorage = inject(TokenStorageService);
readonly menuContext = signal<MenuContext | null>(null); readonly menuContext = signal<MenuContext | null>(null);
loadMenu(): Observable<MenuContext> { loadMenu(): Observable<MenuContext> {
const context = this.cloneMenuContext(SAAS_MENU_DATA); const context = this.cloneMenuContext(SAAS_MENU_DATA);
context.items = this.filterAuthorizedItems(context.items);
return of(context).pipe( return of(context).pipe(
tap((menuContext) => { tap((menuContext) => {
this.menuContext.set(menuContext); this.menuContext.set(menuContext);
@@ -39,4 +42,15 @@ export class MenuService {
private cloneMenuItems(items: Menu[]): Menu[] { private cloneMenuItems(items: Menu[]): Menu[] {
return JSON.parse(JSON.stringify(items)) as Menu[]; return JSON.parse(JSON.stringify(items)) as Menu[];
} }
}
private filterAuthorizedItems(items: Menu[]): Menu[] {
const isSuperAdmin = (this.tokenStorage.getUser()?.roles ?? []).includes('super_admin');
return items
.filter(item => isSuperAdmin || item.path !== '/users')
.map(item => ({
...item,
children: item.children ? this.filterAuthorizedItems(item.children) : item.children,
children2: item.children2 ? this.filterAuthorizedItems(item.children2) : item.children2
}));
}
}
@@ -0,0 +1,47 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { TENANT_CURRENCIES_ENDPOINTS } from '../../end-points/tenant/tenant-currencies.endpoints';
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
import {
CreateTenantCurrencyRequest,
TenantCurrencyDto,
TenantCurrencyDto as TenantCurrencyDtoAlias,
TenantCurrencyLookupDto,
UpdateTenantCurrencyRequest
} from '../../models/tenant/tenant-currencies.model';
@Injectable({
providedIn: 'root'
})
export class TenantCurrenciesService {
private readonly http = inject(HttpClient);
getTenantDataTable(query: DataTableQuery): Observable<DataTableResult<TenantCurrencyDto>> {
return this.http.post<DataTableResult<TenantCurrencyDto>>(TENANT_CURRENCIES_ENDPOINTS.dataTable, query);
}
createTenant(request: CreateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
return this.http.post<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.create, request);
}
updateTenant(id: string, request: UpdateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
return this.http.put<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.update(id), request);
}
getTenantById(id: string): Observable<TenantCurrencyDto> {
return this.http.get<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.getById(id));
}
autocomplete(term?: string, limit = 10): Observable<readonly TenantCurrencyLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly TenantCurrencyLookupDto[]>(TENANT_CURRENCIES_ENDPOINTS.autocomplete, { params });
}
}
@@ -0,0 +1,46 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { TENANT_ENDPOINTS } from '../../end-points/tenant/tenant.endpoints';
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
import {
CreateTenantRequest,
TenantDto,
TenantLookupDto,
UpdateTenantRequest
} from '../../models/tenant/tenant.model';
@Injectable({
providedIn: 'root'
})
export class TenantService {
private readonly http = inject(HttpClient);
getTenantDataTable(query: DataTableQuery): Observable<DataTableResult<TenantDto>> {
return this.http.post<DataTableResult<TenantDto>>(TENANT_ENDPOINTS.dataTable, query);
}
createTenant(request: CreateTenantRequest): Observable<TenantDto> {
return this.http.post<TenantDto>(TENANT_ENDPOINTS.create, request);
}
updateTenant(id: string, request: UpdateTenantRequest): Observable<TenantDto> {
return this.http.put<TenantDto>(TENANT_ENDPOINTS.update(id), request);
}
getTenantById(id: string): Observable<TenantDto> {
return this.http.get<TenantDto>(TENANT_ENDPOINTS.getById(id));
}
autocomplete(term?: string, limit = 10): Observable<readonly TenantLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly TenantLookupDto[]>(TENANT_ENDPOINTS.autocomplete, { params });
}
}
@@ -0,0 +1,40 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { CreateUserRequest, UpdateUserRequest, UserDto, UserLookupDto } from '../../models/user/user.model';
import { USER_ENDPOINTS } from '../../end-points/user/user.endpoints';
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
@Injectable({
providedIn: 'root'
})
export class UserService {
private readonly http = inject(HttpClient);
createUser(request: CreateUserRequest): Observable<UserDto> {
return this.http.post<UserDto>(USER_ENDPOINTS.create, request);
}
getById(id: string): Observable<UserDto> {
return this.http.get<UserDto>(USER_ENDPOINTS.getById(id));
}
getDataTable(query: DataTableQuery): Observable<DataTableResult<UserDto>> {
return this.http.post<DataTableResult<UserDto>>(USER_ENDPOINTS.dataTable, query);
}
update(id: string, request: UpdateUserRequest): Observable<UserDto> {
return this.http.put<UserDto>(USER_ENDPOINTS.update(id), request);
}
autocomplete(term: string | null, limit = 10): Observable<readonly UserLookupDto[]> {
const normalizedTerm = term?.trim() || null;
let params = new HttpParams().set('limit', limit);
if (normalizedTerm !== null) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly UserLookupDto[]>(USER_ENDPOINTS.autocomplete, { params });
}
}
@@ -1,12 +1,8 @@
<!-- Start::row-1 --> <!-- Start::row-1 -->
<div class="grid grid-cols-12 gap-6"> <div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12"> <div class="xl:col-span-12 col-span-12">
<div class="box custom-box"> <app-filter-card title="Location Selection" bodyClass="p-4">
<div class="box-body p-4">
<div class="flex items-center justify-between flex-wrap gap-4"> <div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex flex-wrap gap-1 newproject">
<div class="box-title mb-0">Location Selection</div>
</div>
<form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4"> <form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3"> <div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
@@ -55,8 +51,7 @@
</div> </div>
</form> </form>
</div> </div>
</div> </app-filter-card>
</div>
</div> </div>
</div> </div>
<!-- End::row-1 --> <!-- End::row-1 -->
@@ -64,7 +59,7 @@
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()" <app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true" [pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
[showAddButton]="canAddCity()" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()" [showAddButton]="true" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)" searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)" toolTip="Add City" /> (actionClicked)="onActionClick($event)" toolTip="Add City" />
@@ -80,15 +75,16 @@
formControlName="countryId" formControlName="countryId"
inputId="city-country" inputId="city-country"
label="Country" label="Country"
placeholder="Search country" placeholder="Search"
[searchFn]="searchCountries" [searchFn]="searchCountries"
[displayWith]="displayCountry" [displayWith]="displayCountry"
[valueWith]="countryValue" [valueWith]="countryValue"
[resolveValueFn]="resolveCountry"
[selectedItem]="selectedFormCountry()" [selectedItem]="selectedFormCountry()"
[minSearchLength]="1" [minSearchLength]="1"
[debounceTime]="300" [debounceTime]="300"
[limit]="50" [limit]="50"
[clearable]="false" [clearable]="true"
[required]="true" [required]="true"
[readonly]="modalMode() !== 'create'" [readonly]="modalMode() !== 'create'"
[validationMessages]="{ required: 'Country is required.' }" [validationMessages]="{ required: 'Country is required.' }"
@@ -105,14 +101,16 @@
inputId="city-state" inputId="city-state"
label="State" label="State"
[placeholder]="formStatePlaceholder()" [placeholder]="formStatePlaceholder()"
[help]="'Select a country first'"
[searchFn]="searchFormStates" [searchFn]="searchFormStates"
[displayWith]="displayState" [displayWith]="displayState"
[valueWith]="stateValue" [valueWith]="stateValue"
[resolveValueFn]="resolveState"
[selectedItem]="selectedFormState()" [selectedItem]="selectedFormState()"
[minSearchLength]="1" [minSearchLength]="1"
[debounceTime]="300" [debounceTime]="300"
[limit]="50" [limit]="50"
[clearable]="false" [clearable]="true"
[required]="true" [required]="true"
[disabled]="!cityForm.controls.countryId.value" [disabled]="!cityForm.controls.countryId.value"
[readonly]="modalMode() !== 'create'" [readonly]="modalMode() !== 'create'"
@@ -126,7 +124,7 @@
<div class="col-span-12 md:col-span-6"> <div class="col-span-12 md:col-span-6">
<app-form-input formControlName="name" inputId="city-name" label="City Name" <app-form-input formControlName="name" inputId="city-name" label="City Name"
placeholder="Enter city name" autocomplete="off" [required]="true" placeholder="Name" autocomplete="off" [required]="true"
[maxLength]="150" [validationMessages]="{ [maxLength]="150" [validationMessages]="{
required: 'City Name is required.', required: 'City Name is required.',
maxlength: 'City Name cannot exceed 150 characters.' maxlength: 'City Name cannot exceed 150 characters.'
@@ -135,7 +133,7 @@
<div class="col-span-12 md:col-span-6"> <div class="col-span-12 md:col-span-6">
<app-form-input formControlName="code" inputId="city-code" label="City Code" <app-form-input formControlName="code" inputId="city-code" label="City Code"
placeholder="Enter city code" autocomplete="off" [required]="true" placeholder="Code" autocomplete="off" [required]="true"
[maxLength]="16" [validationMessages]="{ [maxLength]="16" [validationMessages]="{
required: 'City Code is required.', required: 'City Code is required.',
maxlength: 'City Code cannot exceed 16 characters.', maxlength: 'City Code cannot exceed 16 characters.',
@@ -148,7 +146,7 @@
formControlName="timezoneId" formControlName="timezoneId"
inputId="city-timezone" inputId="city-timezone"
label="Timezone" label="Timezone"
placeholder="Search timezone" placeholder="Search"
[searchFn]="searchTimezones" [searchFn]="searchTimezones"
[displayWith]="displayTimezone" [displayWith]="displayTimezone"
[valueWith]="timezoneValue" [valueWith]="timezoneValue"
@@ -47,6 +47,7 @@ import {
AutocompleteValueFn AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; } from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Modal } from '../../../../../shared/components/modal/modal'; import { Modal } from '../../../../../shared/components/modal/modal';
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
interface CityTableRow extends DataTableRecord { interface CityTableRow extends DataTableRecord {
id: string; id: string;
@@ -65,7 +66,7 @@ interface CityTableRow extends DataTableRecord {
@Component({ @Component({
selector: 'city-list', selector: 'city-list',
standalone: true, standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete], imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
templateUrl: './city-list.html', templateUrl: './city-list.html',
styleUrl: './city-list.scss' styleUrl: './city-list.scss'
}) })
@@ -148,8 +149,16 @@ export class CityList {
}; };
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name; readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id; readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
value => this.countryApi.getCountryById(value).pipe(
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
);
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name; readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id; readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
readonly resolveState: AutocompleteResolveValueFn<StateLookupDto, string> =
value => this.stateApi.getStateById(value).pipe(
map(state => ({ id: state.id, name: state.name, code: state.code ?? '' }))
);
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> = readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
timezone => `${timezone.ianaId}${timezone.displayName}`; timezone => `${timezone.ianaId}${timezone.displayName}`;
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
@@ -157,12 +166,11 @@ export class CityList {
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone))); value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone)));
readonly filterStatePlaceholder = computed(() => readonly filterStatePlaceholder = computed(() =>
this.selectedCountryId() ? 'Search state' : 'Select a country first' this.selectedCountryId() ? 'Search' : 'Select a country first'
); );
readonly formStatePlaceholder = computed(() => readonly formStatePlaceholder = computed(() =>
this.cityForm.controls.countryId.value ? 'Search state' : 'Select a country first' this.cityForm.controls.countryId.value ? 'Search' : 'Search'
); );
readonly canAddCity = computed(() => !!this.selectedStateId());
readonly emptyMessage = computed(() => readonly emptyMessage = computed(() =>
this.selectedCountryId() && this.selectedStateId() this.selectedCountryId() && this.selectedStateId()
? 'No cities found' ? 'No cities found'
@@ -179,10 +187,10 @@ export class CityList {
return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City'; return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City';
}); });
readonly submitLabel = computed(() => readonly submitLabel = computed(() =>
this.modalMode() === 'create' ? 'Save City' : 'Update City' this.modalMode() === 'create' ? 'Save' : 'Update'
); );
readonly loadingLabel = computed(() => readonly loadingLabel = computed(() =>
this.modalMode() === 'create' ? 'Saving City...' : 'Updating City...' this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
); );
readonly columns = signal<DataTableColumn<CityTableRow>[]>([ readonly columns = signal<DataTableColumn<CityTableRow>[]>([
@@ -288,11 +296,7 @@ export class CityList {
const city = this.toCityDto(event.row); const city = this.toCityDto(event.row);
switch (event.action.type) { switch (event.action.type) {
case 'edit': case 'edit':
this.openExistingCity(city, 'edit', { this.openExistingCity(city, 'edit');
id: event.row.stateId,
name: event.row.stateName,
code: ''
});
break; break;
case 'activate': case 'activate':
this.updateCityStatus(city, true); this.updateCityStatus(city, true);
@@ -304,21 +308,14 @@ export class CityList {
} }
onAddCity(): void { onAddCity(): void {
const countryId = this.selectedCountryId();
const stateId = this.selectedStateId();
if (!countryId || !stateId) {
this.toastr.error('Select a country and state before adding a city.');
return;
}
this.modalMode.set('create'); this.modalMode.set('create');
this.selectedCity.set(null); this.selectedCity.set(null);
this.submitAttempted.set(false); this.submitAttempted.set(false);
this.selectedFormCountry.set(this.selectedCountry()); this.selectedFormCountry.set(null);
this.selectedFormState.set(this.selectedFilterState()); this.selectedFormState.set(null);
this.cityForm.enable({ emitEvent: false }); this.cityForm.enable({ emitEvent: false });
this.cityForm.reset({ countryId, stateId, name: '', code: '', timezoneId: null }, { emitEvent: false }); this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false });
this.cityForm.controls.stateId.enable({ emitEvent: false }); this.cityForm.controls.stateId.disable({ emitEvent: false });
this.resetFormState(); this.resetFormState();
this.showCityModal.set(true); this.showCityModal.set(true);
} }
@@ -456,21 +453,21 @@ export class CityList {
}); });
} }
private openExistingCity(city: CityDto, mode: 'edit', stateSeed?: StateLookupDto): void { private openExistingCity(city: CityDto, mode: 'edit'): void {
this.cityApi.getCityById(city.id).pipe( this.cityApi.getCityById(city.id).pipe(
switchMap(details => this.stateApi.getStateById(details.stateId).pipe(
map(stateDetails => ({ details, countryId: stateDetails.countryId }))
)),
take(1) take(1)
).subscribe(details => { ).subscribe(({ details, countryId }) => {
this.selectedCity.set(details); this.selectedCity.set(details);
this.modalMode.set(mode); this.modalMode.set(mode);
this.submitAttempted.set(false); this.submitAttempted.set(false);
this.selectedFormCountry.set(this.selectedCountry()); this.selectedFormCountry.set(null);
this.selectedFormState.set( this.selectedFormState.set(null);
stateSeed
?? (this.selectedFilterState()?.id === details.stateId ? this.selectedFilterState() : null)
);
this.cityForm.enable({ emitEvent: false }); this.cityForm.enable({ emitEvent: false });
this.cityForm.reset({ this.cityForm.reset({
countryId: this.selectedCountryId() ?? '', countryId,
stateId: details.stateId, stateId: details.stateId,
name: details.name ?? '', name: details.name ?? '',
code: details.code ?? '', code: details.code ?? '',
@@ -135,14 +135,14 @@ export class CountryList {
readonly countrySubmitLabel = computed(() => readonly countrySubmitLabel = computed(() =>
this.countryModalMode() === 'create' this.countryModalMode() === 'create'
? 'Save Country' ? 'Save'
: 'Update Country' : 'Update'
); );
readonly countryLoadingLabel = computed(() => readonly countryLoadingLabel = computed(() =>
this.countryModalMode() === 'create' this.countryModalMode() === 'create'
? 'Saving Country...' ? 'Saving...'
: 'Updating Country...' : 'Updating...'
); );
readonly countrySubmitAction = computed<'save' | 'update'>(() => readonly countrySubmitAction = computed<'save' | 'update'>(() =>
@@ -157,6 +157,7 @@ export class CountryList {
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true }, { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true }, { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true }, { key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
{ key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' },
{ {
key: 'isActive', key: 'isActive',
label: 'Status', label: 'Status',
@@ -20,18 +20,17 @@
(actionClicked)="onActionClick($event)" (actionClicked)="onActionClick($event)"
> >
<ng-template appDataTableCell="name" let-row let-value="value"> <ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span @if (getFlagUrl(row.iso2); as flagUrl) {
class="inline-flex min-w-8 justify-center rounded-sm bg-light px-2 py-1 text-[0.75rem] font-semibold text-primary dark:bg-black/20" <img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
> (error)="onFlagError($event)" />
{{ row.symbol }} }
</span>
<span class="font-semibold"> <span class="font-semibold">
{{ value }} {{ value }}
</span> </span>
</div> </div>
</ng-template> </ng-template>
<ng-template appDataTableCell="code" let-value="value"> <ng-template appDataTableCell="code" let-value="value">
<span class="badge bg-primary/10 text-primary"> <span class="badge bg-primary/10 text-primary">
@@ -75,6 +74,7 @@
required: 'Currency Name is required.', required: 'Currency Name is required.',
maxlength: 'Currency Name cannot exceed 100 characters.' maxlength: 'Currency Name cannot exceed 100 characters.'
}" }"
[submitAttempted]="currencySubmitAttempted()"
/> />
</div> </div>
@@ -84,7 +84,6 @@
inputId="currency-code" inputId="currency-code"
label="Currency Code" label="Currency Code"
placeholder="e.g.: AED" placeholder="e.g.: AED"
inputClass="uppercase"
autocomplete="off" autocomplete="off"
[required]="true" [required]="true"
[minLength]="3" [minLength]="3"
@@ -95,6 +94,7 @@
maxlength: 'Currency Code must contain exactly 3 letters.', maxlength: 'Currency Code must contain exactly 3 letters.',
pattern: 'Currency Code can contain letters only.' pattern: 'Currency Code can contain letters only.'
}" }"
[submitAttempted]="currencySubmitAttempted()"
/> />
</div> </div>
@@ -111,6 +111,7 @@
required: 'Currency Symbol is required.', required: 'Currency Symbol is required.',
maxlength: 'Currency Symbol cannot exceed 8 characters.' maxlength: 'Currency Symbol cannot exceed 8 characters.'
}" }"
[submitAttempted]="currencySubmitAttempted()"
/> />
</div> </div>
@@ -123,7 +124,7 @@
inputMode="numeric" inputMode="numeric"
placeholder="e.g.: 784" placeholder="e.g.: 784"
autocomplete="off" autocomplete="off"
[required]="true" [required]="false"
[min]="1" [min]="1"
[max]="999" [max]="999"
[step]="1" [step]="1"
@@ -132,6 +133,7 @@
min: 'Numeric Code must be at least 1.', min: 'Numeric Code must be at least 1.',
max: 'Numeric Code cannot exceed 999.' max: 'Numeric Code cannot exceed 999.'
}" }"
[submitAttempted]="currencySubmitAttempted()"
/> />
</div> </div>
@@ -153,6 +155,7 @@
min: 'Decimal Digits cannot be less than 0.', min: 'Decimal Digits cannot be less than 0.',
max: 'Decimal Digits cannot exceed 4.' max: 'Decimal Digits cannot exceed 4.'
}" }"
[submitAttempted]="currencySubmitAttempted()"
/> />
</div> </div>
</div> </div>
@@ -34,6 +34,7 @@ import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/c
interface CurrencyTableRow extends DataTableRecord { interface CurrencyTableRow extends DataTableRecord {
id: string; id: string;
code: string; code: string;
iso2: string;
name: string; name: string;
symbol: string; symbol: string;
numericCode: number; numericCode: number;
@@ -124,14 +125,14 @@ export class CurrencyList {
readonly currencySubmitLabel = computed(() => readonly currencySubmitLabel = computed(() =>
this.currencyModalMode() === 'create' this.currencyModalMode() === 'create'
? 'Save Currency' ? 'Save'
: 'Update Currency' : 'Update'
); );
readonly currencyLoadingLabel = computed(() => readonly currencyLoadingLabel = computed(() =>
this.currencyModalMode() === 'create' this.currencyModalMode() === 'create'
? 'Saving Currency...' ? 'Saving...'
: 'Updating Currency...' : 'Updating...'
); );
readonly currencySubmitAction = computed<'save' | 'update'>(() => readonly currencySubmitAction = computed<'save' | 'update'>(() =>
@@ -143,6 +144,7 @@ export class CurrencyList {
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([ readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
{ key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true },
{ key: 'code', label: 'Code', header: 'Code', sortable: true }, { key: 'code', label: 'Code', header: 'Code', sortable: true },
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true }, { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true }, { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
@@ -495,6 +497,7 @@ export class CurrencyList {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
iso2: row.iso2?.toLowerCase() ?? '',
name: row.name, name: row.name,
symbol: row.symbol, symbol: row.symbol,
numericCode: row.numericCode, numericCode: row.numericCode,
@@ -504,4 +507,16 @@ export class CurrencyList {
modifiedOn: row.modifiedOn modifiedOn: row.modifiedOn
}; };
} }
getFlagUrl(iso2: string | null | undefined): string {
const code = iso2?.trim().toLowerCase();
return code && /^[a-z]{2}$/.test(code)
? `https://flagcdn.com/24x18/${code}.png`
: '';
}
onFlagError(event: Event): void {
const image = event.target as HTMLImageElement;
image.style.display = 'none';
}
} }
@@ -84,10 +84,10 @@ export class LanguageList {
this.modalMode() === 'create' ? 'Add Language' : 'Edit Language' this.modalMode() === 'create' ? 'Add Language' : 'Edit Language'
); );
readonly submitLabel = computed(() => readonly submitLabel = computed(() =>
this.modalMode() === 'create' ? 'Save Language' : 'Update Language' this.modalMode() === 'create' ? 'Save' : 'Update'
); );
readonly loadingLabel = computed(() => readonly loadingLabel = computed(() =>
this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...' this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
); );
readonly submitAction = computed<'save' | 'update'>(() => readonly submitAction = computed<'save' | 'update'>(() =>
this.modalMode() === 'create' ? 'save' : 'update' this.modalMode() === 'create' ? 'save' : 'update'
@@ -1,20 +1,14 @@
<!-- Start::row-1 -->
<div class="grid grid-cols-12 gap-6"> <div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12"> <div class="xl:col-span-12 col-span-12">
<div class="box custom-box"> <app-filter-card title="Country Selection" bodyClass="p-4">
<div class="box-body p-4">
<div class="flex items-center justify-between flex-wrap gap-4"> <div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex flex-wrap gap-1 newproject">
<div class="box-title mb-0">Country Selection</div>
</div>
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4"> <form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3"> <div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
<app-autocomplete <app-autocomplete
formControlName="countryId" formControlName="countryId"
inputId="state-country-filter" inputId="state-country-filter"
label="Country" label="Country"
placeholder="Search country" placeholder="Search"
[searchFn]="searchCountries" [searchFn]="searchCountries"
[displayWith]="displayCountry" [displayWith]="displayCountry"
[valueWith]="countryValue" [valueWith]="countryValue"
@@ -31,16 +25,14 @@
</div> </div>
</form> </form>
</div> </div>
</div> </app-filter-card>
</div>
</div> </div>
</div> </div>
<!-- End::row-1 -->
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()" <app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true" [pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true"
[showAddButton]="showStateAddButton()" searchPlaceholder="Search states..." [searchDebounceTime]="300" [showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" /> (sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
@@ -59,6 +51,32 @@
(closed)="closeStateModal()" (submitted)="saveState()"> (closed)="closeStateModal()" (submitted)="saveState()">
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off"> <form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5"> <div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="countryId"
inputId="state-country"
label="Country"
placeholder="Search"
[searchFn]="searchCountries"
[displayWith]="displayCountry"
[valueWith]="countryValue"
[resolveValueFn]="resolveCountry"
[selectedItem]="selectedFormCountry()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="stateModalMode() === 'create'"
[required]="true"
[clearable]="true"
[readonly]="stateModalMode() !== 'create'"
[validationMessages]="{ required: 'Country is required.' }"
[submitAttempted]="stateSubmitAttempted()"
wrapperClass="w-full"
(itemSelected)="onFormCountrySelected($event)"
(cleared)="onFormCountryCleared()"
/>
</div>
<div class="col-span-12 md:col-span-6"> <div class="col-span-12 md:col-span-6">
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name" <app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name"
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{ autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
@@ -6,7 +6,7 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { Subject, catchError, distinctUntilChanged, finalize, of, switchMap } from 'rxjs'; import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap } from 'rxjs';
import { import {
CountryLookupDto CountryLookupDto
@@ -33,12 +33,14 @@ import { DataTable } from '../../../../../shared/components/data-table/data-tabl
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import { import {
AutocompleteDisplayFn, AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn, AutocompleteSearchFn,
AutocompleteValueFn AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; } from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { Modal } from '../../../../../shared/components/modal/modal'; import { Modal } from '../../../../../shared/components/modal/modal';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
interface StateTableRow extends DataTableRecord { interface StateTableRow extends DataTableRecord {
id: string; id: string;
@@ -54,12 +56,12 @@ interface StateTableRow extends DataTableRecord {
@Component({ @Component({
selector: 'state-list', selector: 'state-list',
standalone: true, standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog], imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
templateUrl: './state-list.html', templateUrl: './state-list.html',
styleUrl: './state-list.scss', styleUrl: './state-list.scss',
}) })
export class StateList { export class StateList {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly stateApi = inject(StateService); private readonly stateApi = inject(StateService);
private readonly countryApi = inject(CountryService); private readonly countryApi = inject(CountryService);
@@ -73,6 +75,7 @@ export class StateList {
readonly states = signal<StateTableRow[]>([]); readonly states = signal<StateTableRow[]>([]);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null); readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
readonly selectedCountryId = signal<string | null>(null); readonly selectedCountryId = signal<string | null>(null);
readonly totalRecords = signal(0); readonly totalRecords = signal(0);
readonly filteredRecords = signal(0); readonly filteredRecords = signal(0);
@@ -94,6 +97,10 @@ export class StateList {
(term, limit) => this.countryApi.autocomplete(term, limit); (term, limit) => this.countryApi.autocomplete(term, limit);
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name; readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id; readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
value => this.countryApi.getCountryById(value).pipe(
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
);
readonly stateForm = this.formBuilder.nonNullable.group({ readonly stateForm = this.formBuilder.nonNullable.group({
countryId: [ countryId: [
@@ -119,10 +126,6 @@ export class StateList {
] ]
}); });
readonly showStateAddButton = computed(() =>
!!this.selectedCountryId()
);
readonly emptyMessage = computed(() => readonly emptyMessage = computed(() =>
this.selectedCountryId() this.selectedCountryId()
? 'No states found' ? 'No states found'
@@ -143,14 +146,14 @@ export class StateList {
readonly stateSubmitLabel = computed(() => readonly stateSubmitLabel = computed(() =>
this.stateModalMode() === 'create' this.stateModalMode() === 'create'
? 'Save State' ? 'Save'
: 'Update State' : 'Update'
); );
readonly stateLoadingLabel = computed(() => readonly stateLoadingLabel = computed(() =>
this.stateModalMode() === 'create' this.stateModalMode() === 'create'
? 'Saving State...' ? 'Saving...'
: 'Updating State...' : 'Updating...'
); );
readonly stateSubmitAction = computed<'save' | 'update'>(() => readonly stateSubmitAction = computed<'save' | 'update'>(() =>
@@ -161,7 +164,7 @@ export class StateList {
readonly columns = signal<DataTableColumn<StateTableRow>[]>([ readonly columns = signal<DataTableColumn<StateTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
{ key: 'name', header: 'Name', label: 'Name', sortable: true ,align: 'left'}, { key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' },
{ key: 'code', header: 'Code', label: 'Code', sortable: true }, { key: 'code', header: 'Code', label: 'Code', sortable: true },
{ {
key: 'isActive', key: 'isActive',
@@ -269,6 +272,14 @@ export class StateList {
this.selectedCountryLookup.set(country); this.selectedCountryLookup.set(country);
} }
onFormCountrySelected(country: CountryLookupDto): void {
this.selectedFormCountry.set(country);
}
onFormCountryCleared(): void {
this.selectedFormCountry.set(null);
}
onSearch(value: string): void { onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim()); const query = this.queryState.setSearch(value.trim());
this.loadStates(query); this.loadStates(query);
@@ -336,20 +347,14 @@ export class StateList {
} }
onAddState(): void { onAddState(): void {
const countryId = this.selectedCountryId();
if (!countryId) {
this.toastr.error('Select a country before adding a state.');
return;
}
this.stateModalMode.set('create'); this.stateModalMode.set('create');
this.selectedStateId.set(null); this.selectedStateId.set(null);
this.selectedState.set(null); this.selectedState.set(null);
this.stateSubmitAttempted.set(false); this.stateSubmitAttempted.set(false);
this.selectedFormCountry.set(null);
this.stateForm.reset({ this.stateForm.reset({
countryId, countryId: '',
name: '', name: '',
code: '' code: ''
}); });
@@ -366,6 +371,7 @@ export class StateList {
this.showStateModal.set(false); this.showStateModal.set(false);
this.selectedStateId.set(null); this.selectedStateId.set(null);
this.selectedState.set(null); this.selectedState.set(null);
this.selectedFormCountry.set(null);
this.stateSubmitAttempted.set(false); this.stateSubmitAttempted.set(false);
} }
@@ -486,8 +492,9 @@ export class StateList {
.subscribe({ .subscribe({
next: stateDetails => { next: stateDetails => {
this.selectedState.set(stateDetails); this.selectedState.set(stateDetails);
this.selectedFormCountry.set(null);
this.stateForm.reset({ this.stateForm.reset({
countryId: stateDetails.countryId ?? this.selectedCountryId() ?? '', countryId: stateDetails.countryId ?? '',
name: stateDetails.name ?? '', name: stateDetails.name ?? '',
code: stateDetails.code ?? '' code: stateDetails.code ?? ''
}); });
@@ -566,6 +573,7 @@ export class StateList {
this.showStateModal.set(false); this.showStateModal.set(false);
this.selectedStateId.set(null); this.selectedStateId.set(null);
this.selectedState.set(null); this.selectedState.set(null);
this.selectedFormCountry.set(null);
this.stateSubmitAttempted.set(false); this.stateSubmitAttempted.set(false);
this.loadStates(this.queryState.getQuery()); this.loadStates(this.queryState.getQuery());
} }
@@ -62,7 +62,8 @@
formControlName="ianaId" formControlName="ianaId"
inputId="timezone-iana-id" inputId="timezone-iana-id"
label="IANA Timezone ID" label="IANA Timezone ID"
placeholder="e.g.: Asia/Kolkata" placeholder="Id"
help="e.g.: Asia/Kolkata"
autocomplete="off" autocomplete="off"
[required]="true" [required]="true"
[readonly]="isViewMode()" [readonly]="isViewMode()"
@@ -80,7 +81,8 @@
formControlName="displayName" formControlName="displayName"
inputId="timezone-display-name" inputId="timezone-display-name"
label="Display Name" label="Display Name"
placeholder="e.g.: India Standard Time" placeholder="Name"
help="e.g.: India Standard Time"
autocomplete="off" autocomplete="off"
[required]="true" [required]="true"
[readonly]="isViewMode()" [readonly]="isViewMode()"
@@ -100,13 +102,13 @@
label="UTC Offset (minutes)" label="UTC Offset (minutes)"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
placeholder="For example: 330" placeholder="Minutes"
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
[required]="true" [required]="true"
[readonly]="isViewMode()" [readonly]="isViewMode()"
[min]="-720" [min]="-720"
[max]="840" [max]="840"
[step]="1" [step]="1"
hint="e.g.: Enter an offset from -720 (-12:00) to 840 (+14:00)."
[submitAttempted]="submitAttempted()" [submitAttempted]="submitAttempted()"
[validationMessages]="{ [validationMessages]="{
required: 'UTC offset is required.', required: 'UTC offset is required.',
@@ -86,8 +86,8 @@ export class TimezoneList implements OnInit {
case 'view': return 'View Timezone'; case 'view': return 'View Timezone';
} }
}); });
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save Timezone' : 'Update Timezone'); readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update');
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving Timezone...' : 'Updating Timezone...'); readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...');
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update'); readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([ readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
@@ -0,0 +1,55 @@
<div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12">
<app-filter-card title="Tenant Selection" bodyClass="p-4">
<div class="flex items-center justify-between flex-wrap gap-4">
<form [formGroup]="tenantCurrencyFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
<app-autocomplete
formControlName="tenantId"
inputId="tenant-filter"
label="Tenant"
placeholder="Search"
[searchFn]="searchTenants"
[displayWith]="displayTenant"
[valueWith]="tenantValue"
[selectedItem]="selectedTenantLookup()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="true"
[hideLabel]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onTenantLookupSelected($event)"
/>
</div>
</form>
</div>
</app-filter-card>
</div>
</div>
<app-data-table
[columns]="columns()"
[rows]="tenantCurrencies()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Tenant Management"
buttonTitle="Add Tenant"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search tenants..."
[searchDebounceTime]="300"
actionHeaderClass="!text-center"
actionCellClass="!text-center"
emptyMessage="No tenants found"
emptyDescription="There is currently no data to display."
(addClicked)="onAddTenantCurrency()"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)"
/>
@@ -0,0 +1,410 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
import { TenantCurrenciesService } from '../../../../core/services/tenant/tenant-currencies.service';
import { LanguageService } from '../../../../core/services/language/language.service';
import { CurrencyService } from '../../../../core/services/currency/currency.service';
import { TimezoneService } from '../../../../core/services/timezone/timezone.service';
import { DataTable } from '../../../../shared/components/data-table/data-table';
import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
import { CreateTenantCurrencyRequest, TenantCurrencyDto, TenantCurrencyModalMode, TenantCurrencyTableRow, UpdateTenantCurrencyRequest } from '../../../../core/models/tenant/tenant-currencies.model';
import { LanguageLookupDto } from '../../../../core/models/language/language.model';
import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types';
import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model';
import { TenantLookupDto } from '../../../../core/models/tenant/tenant.model';
import { TenantService } from '../../../../core/services/tenant/tenant.service';
import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete';
import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
import { FormInput } from '../../../../shared/components/form/form-input/form-input';
import { Modal } from '../../../../shared/components/modal/modal';
import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog';
import { FilterCard } from '../../../../shared/components/filter-card/filter-card';
@Component({
selector: 'tenant-currencies',
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete, ConfirmDialog, FilterCard],
templateUrl: './tenant-currencies.html',
styleUrl: './tenant-currencies.scss',
})
export class TenantCurrencies {
private readonly destroyRef = inject(DestroyRef);
private readonly tenantCurrencyApi = inject(TenantCurrenciesService);
private readonly tenantApi = inject(TenantService);
private readonly languageApi = inject(LanguageService);
private readonly currencyApi = inject(CurrencyService);
private readonly timezoneApi = inject(TimezoneService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly queryRequests$ = new Subject<{ tenantId: string; query: DataTableQuery; }>();
readonly queryState = new DataTableQueryState();
readonly tenantCurrencies = signal<TenantCurrencyTableRow[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly saving = signal(false);
readonly showTenantCurrencyModal = signal(false);
readonly tenantCurrencyModalMode = signal<TenantCurrencyModalMode>('create');
readonly selectedTenantId = signal<string | null>(null);
readonly selectedTenantLookup = signal<TenantLookupDto | null>(null);
readonly tenantSubmitAttempted = signal(false);
readonly tenantCurrencyForm = this.formBuilder.group({
tenantId: this.formBuilder.control<string | null>(null, [Validators.required]),
currencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
isActive: this.formBuilder.control<number | null>(1, [Validators.required]),
isBaseCurrency: this.formBuilder.control<boolean>(false, [Validators.required]),
isReporting: this.formBuilder.control<boolean>(false, [Validators.required]),
});
readonly tenantCurrencyFilterForm = this.formBuilder.nonNullable.group({
tenantId: ['']
});
readonly searchTenants:
AutocompleteSearchFn<TenantLookupDto> =
(term, limit) =>
this.tenantApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error(
'Unable to load tenants.'
);
return of<
readonly TenantLookupDto[]
>([]);
})
);
readonly displayTenant:
AutocompleteDisplayFn<TenantLookupDto> =
tenant =>
[tenant.code, tenant.name]
.filter(Boolean)
.join(' - ');
readonly tenantValue:
AutocompleteValueFn<
TenantLookupDto,
string
> =
tenant => tenant.id;
readonly resolveTenant:
AutocompleteResolveValueFn<
TenantLookupDto,
string
> =
tenantId =>
this.tenantApi
.getTenantById(tenantId)
.pipe(
map(tenant => ({
id: tenant.id,
code: tenant.code,
name: tenant.name
}))
);
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
this.currencyApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load currencies.');
return of<readonly CurrencyLookupDto[]>([]);
})
);
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : '']
.filter(Boolean)
.join(' ');
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
this.currencyApi.getCurrencyById(value).pipe(
map(currency => ({
id: currency.id,
code: currency.code,
name: currency.name,
symbol: currency.symbol
}))
);
readonly modalTitle = computed(() =>
this.tenantCurrencyModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant'
);
readonly submitLabel = computed(() =>
this.tenantCurrencyModalMode() === 'create' ? 'Save' : 'Update'
);
readonly loadingLabel = computed(() =>
this.tenantCurrencyModalMode() === 'create' ? 'Saving...' : 'Updating...'
);
readonly submitAction = computed<'save' | 'update'>(() =>
this.tenantCurrencyModalMode() === 'create' ? 'save' : 'update'
);
readonly columns = signal<DataTableColumn<TenantCurrencyTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' },
{ key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' },
{
key: 'isBaseCurrency',
label: 'Base Currency',
header: 'Base Currency',
sortable: true,
badge: true,
formatter: value => value === true ? 'Yes' : 'No'
},
{
key: 'isReporting',
label: 'Reporting Currency',
header: 'Reporting Currency',
sortable: true,
badge: true,
formatter: value => value === true ? 'Yes' : 'No'
},
{
key: 'isActive',
label: 'Active',
header: 'Active',
sortable: true,
badge: true,
badgeClass: value => value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: value => value === true ? 'Active' : 'Inactive'
},
{
key: 'createdOn',
label: 'Created On',
header: 'Created On',
sortable: true,
formatter: value => this.formatDateTime(value)
}
]);
readonly actions = signal<DataTableAction<TenantCurrencyTableRow>[]>([
{
type: 'edit',
label: 'Edit',
icon: 'ti ti-edit',
className: 'text-primary'
},
{
type: 'delete',
label: 'Delete',
icon: 'ti ti-trash',
className: 'text-danger',
visible: row => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: row => !row.isActive
}
]);
private formatDateTime(value: unknown): string {
if (typeof value !== 'string' || value.trim().length === 0) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
constructor() {
this.queryRequests$
.pipe(
switchMap(({ tenantId, query }) =>
this.tenantCurrencyApi
.getTenantDataTable(this.buildTenantCurrencyQuery(query))
.pipe(
map(response => ({
response,
tenantId,
query
})),
catchError(() => {
this.toastr.error(
'Unable to load tenant currencies.'
);
this.clearTenantCurrencyGrid();
return of(null);
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(result => {
if (!result) {
return;
}
const {
response,
tenantId,
query
} = result;
if (this.selectedTenantId() !== tenantId) {
return;
}
if (response.draw !== query.draw) {
return;
}
const rows: TenantCurrencyTableRow[] =
response.rows.map(
(tenantCurrency, index) => ({
...tenantCurrency,
serialNumber:
(query.page - 1) *
query.pageSize +
index +
1
})
);
this.tenantCurrencies.set(rows);
this.totalRecords.set(response.total);
this.filteredRecords.set(
response.filtered
);
});
}
ngOnInit(): void {
this.clearTenantCurrencyGrid();
}
loadTenantCurrencies(query: DataTableQuery): void {
const tenantId = this.selectedTenantId();
if (!tenantId) {
this.clearTenantCurrencyGrid();
return;
}
this.queryRequests$.next({
tenantId,
query
});
}
onSearch(value: string): void {
this.loadTenantCurrencies(this.queryState.setSearch(value.trim()));
}
onPageChange(event: DataTablePageEvent): void {
this.loadTenantCurrencies(this.queryState.setPage(event));
}
onSortChange(event: DataTableSortEvent): void {
this.loadTenantCurrencies(this.queryState.setSort(event));
}
onActionClick(event: DataTableActionEvent<TenantCurrencyTableRow>): void {
if (event.action.type === 'edit') {
this.openEditTenantCurrency(event.row.id);
}
}
private buildTenantCurrencyQuery(query: DataTableQuery): DataTableQuery {
return {
...query,
sortBy: this.resolveSortField(query.sortBy ?? null)
};
}
private resolveSortField(sortBy: string | null | undefined): string | null {
if (!sortBy) {
return null;
}
switch (sortBy) {
case 'code':
case 'name':
case 'status':
case 'dataRegion':
case 'isActive':
case 'createdOn':
return sortBy;
default:
return null;
}
}
onTenantLookupSelected(tenant: TenantLookupDto | null): void {
this.selectedTenantLookup.set(tenant);
const tenantId = tenant?.id ?? null;
this.tenantCurrencyFilterForm.controls.tenantId
.setValue(tenantId ?? '');
this.onTenantSelected(tenantId);
}
onAddTenantCurrency(): void {
this.tenantCurrencyModalMode.set('create');
this.showTenantCurrencyModal.set(true);
}
closeTenantCurrencyModal(): void {
if (this.saving()) {
return;
}
this.showTenantCurrencyModal.set(false);
}
private openEditTenantCurrency(id: string): void {
this.tenantCurrencyModalMode.set('edit');
}
private onTenantSelected(tenantId: string | null): void {
this.selectedTenantId.set(tenantId);
this.clearTenantCurrencyGrid();
const query = this.queryState.reset();
if (!tenantId) {
return;
}
this.loadTenantCurrencies(query);
}
private clearTenantCurrencyGrid(): void {
this.tenantCurrencies.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
}
@@ -1,62 +1,185 @@
<app-data-table <app-data-table
title="Tenant List" [columns]="columns()"
[rows]="tenants()"
[columns]="columns" [actions]="actions()"
[rows]="tenants" [totalRecords]="totalRecords()"
[actions]="tableActions" [pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[loading]="loading" [pageSizeOptions]="[5, 10, 20, 50]"
emptyMessage="No tenants found" tableTitle="Tenant Management"
buttonTitle="Add Tenant"
[showSearch]="true" [showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search tenants..." searchPlaceholder="Search tenants..."
[searchDebounceTime]="300" [searchDebounceTime]="300"
actionHeaderClass="!text-center"
[totalRecords]="totalRecords" actionCellClass="!text-center"
[pageIndex]="pageIndex" emptyMessage="No tenants found"
[pageSize]="pageSize" emptyDescription="There is currently no data to display."
[pageSizeOptions]="[5, 10, 20, 50]" (addClicked)="onAddTenant()"
[showPaginator]="true"
[allowedPermissions]="allowedPermissions"
tableHeadClass=""
tableBodyClass=""
trHeadClass="border-b border-defaultborder"
trBodyClass="border-b border-defaultborder hover:bg-light cursor-pointer"
defaultThClass="text-start"
defaultTdClass=""
actionHeaderClass="text-center"
actionCellClass="text-center"
(searchChanged)="onSearch($event)" (searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)" (pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onTableAction($event)" (actionClicked)="onActionClick($event)"
(rowClicked)="onRowClick($event)" />
<modal
[open]="showTenantModal()"
[title]="modalTitle()"
size="lg"
[submitAction]="submitAction()"
[submitLabel]="submitLabel()"
[loadingLabel]="loadingLabel()"
[loading]="saving()"
(closed)="closeTenantModal()"
(submitted)="saveTenant()"
> >
<form [formGroup]="tenantForm" (ngSubmit)="saveTenant()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="code"
inputId="tenant-code"
label="Tenant Code"
placeholder="Code"
autocomplete="off"
[required]="true"
[maxLength]="50"
[validationMessages]="{
required: 'Tenant Code is required.',
maxlength: 'Tenant Code cannot exceed 50 characters.'
}"
[submitAttempted]="tenantSubmitAttempted()"
/>
</div>
<ng-template appDataTableCell="tenantName" let-row let-value="value"> <div class="col-span-12 md:col-span-6">
<div class="flex items-center"> <app-form-input
<span class="avatar avatar-xs me-2 avatar-rounded"> formControlName="name"
<img [src]="row.logo" [alt]="value"> inputId="tenant-name"
</span> label="Tenant Name"
placeholder="Name"
autocomplete="off"
[required]="true"
[maxLength]="150"
[validationMessages]="{
required: 'Tenant Name is required.',
maxlength: 'Tenant Name cannot exceed 150 characters.'
}"
[submitAttempted]="tenantSubmitAttempted()"
/>
</div>
<span class="font-semibold"> <div class="col-span-12 md:col-span-6">
{{ value }} <app-form-select
</span> formControlName="status"
</div> inputId="tenant-status"
</ng-template> label="Tenant Status"
placeholder="Select"
[options]="tenantStatusOptions()"
[required]="true"
[clearable]="false"
[searchable]="false"
[submitAttempted]="tenantSubmitAttempted()"
[validationMessages]="{
required: 'Tenant Status is required.'
}"
/>
</div>
</app-data-table> <div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="defaultLanguageId"
inputId="tenant-default-language-id"
label="Default Language"
placeholder="Search"
[searchFn]="searchLanguages"
[displayWith]="displayLanguage"
[valueWith]="languageValue"
[resolveValueFn]="resolveLanguage"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No languages found"
typeToSearchText="Type to search languages"
[required]="true"
[submitAttempted]="tenantSubmitAttempted()"
wrapperClass="w-full"
[validationMessages]="{
required: 'Default Language is required.'
}"
/>
</div>
<app-confirm-dialog <div class="col-span-12 md:col-span-6">
title="Delete Tenant" <app-autocomplete
text="Do you really want to delete this tenant?" formControlName="defaultCurrencyId"
confirmButtonText="Delete" inputId="tenant-default-currency-id"
cancelButtonText="Cancel" label="Default Currency"
(confirmed)="onDeleteConfirmed()" placeholder="Search"
(cancelled)="onDeleteCancelled()" [searchFn]="searchCurrencies"
></app-confirm-dialog> [displayWith]="displayCurrency"
[valueWith]="currencyValue"
[resolveValueFn]="resolveCurrency"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No currencies found"
typeToSearchText="Type to search currencies"
[required]="true"
[submitAttempted]="tenantSubmitAttempted()"
wrapperClass="w-full"
[validationMessages]="{
required: 'Default Currency is required.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="defaultTimezoneId"
inputId="tenant-default-timezone-id"
label="Default Timezone"
placeholder="Search"
[searchFn]="searchTimezones"
[displayWith]="displayTimezone"
[valueWith]="timezoneValue"
[resolveValueFn]="resolveTimezone"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No timezones found"
typeToSearchText="Type to search timezones"
[required]="true"
[submitAttempted]="tenantSubmitAttempted()"
wrapperClass="w-full"
[validationMessages]="{
required: 'Default Timezone is required.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="dataRegion"
inputId="tenant-data-region"
label="Data Region"
placeholder="Region"
help="e.g.: IN"
autocomplete="off"
[required]="true"
[maxLength]="100"
[validationMessages]="{
required: 'Data Region is required.',
maxlength: 'Data Region cannot exceed 100 characters.'
}"
[submitAttempted]="tenantSubmitAttempted()"
/>
</div>
</div>
</form>
</modal>
@@ -0,0 +1 @@
/* Intentionally empty: tenant screen reuses the shared Ynex layout classes. */
@@ -1,195 +1,579 @@
import { Component, signal, viewChild } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http';
import { CommonModule } from '@angular/common'; import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
import { CreateTenantRequest, TenantDto, TenantModalMode, TenantStatus, TenantTableRow, UpdateTenantRequest } from '../../../../core/models/tenant/tenant.model';
import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model';
import { LanguageLookupDto } from '../../../../core/models/language/language.model';
import { TimezoneLookupDto } from '../../../../core/models/timezone/timezone.model';
import { CurrencyService } from '../../../../core/services/currency/currency.service';
import { LanguageService } from '../../../../core/services/language/language.service';
import { TenantService } from '../../../../core/services/tenant/tenant.service';
import { TimezoneService } from '../../../../core/services/timezone/timezone.service';
import { DataTable } from '../../../../shared/components/data-table/data-table'; import { DataTable } from '../../../../shared/components/data-table/data-table';
import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types';
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableRecord, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive'; import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete';
import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types';
import { FormInput } from '../../../../shared/components/form/form-input/form-input';
import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models';
import { Modal } from '../../../../shared/components/modal/modal';
@Component({ @Component({
selector: 'tenant-list', selector: 'tenant-list',
imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog], standalone: true,
templateUrl: './tenant-list.html', imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete],
styleUrl: './tenant-list.scss', templateUrl: './tenant-list.html',
styleUrl: './tenant-list.scss'
}) })
export class TenantList { export class TenantList {
private readonly destroyRef = inject(DestroyRef);
private readonly tenantApi = inject(TenantService);
private readonly languageApi = inject(LanguageService);
private readonly currencyApi = inject(CurrencyService);
private readonly timezoneApi = inject(TimezoneService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly queryRequests$ = new Subject<DataTableQuery>();
tableQuery = new DataTableQueryState(); readonly queryState = new DataTableQueryState();
readonly tenants = signal<TenantTableRow[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly saving = signal(false);
readonly showTenantModal = signal(false);
readonly tenantModalMode = signal<TenantModalMode>('create');
readonly selectedTenantId = signal<string | null>(null);
readonly selectedTenant = signal<TenantDto | null>(null);
readonly tenantSubmitAttempted = signal(false);
loading = false; readonly tenantForm = this.formBuilder.group({
pageIndex = 1; code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
pageSize = 10; name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
totalRecords = 3; status: this.formBuilder.control<TenantStatus | null>(TenantStatus.Trial, [Validators.required]),
searchText = ''; defaultLanguageId: this.formBuilder.control<string | null>(null, [Validators.required]),
readonly pendingDeleteTenant = signal<{ id: number } | null>(null); defaultCurrencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
readonly deleteConfirmDialog = viewChild(ConfirmDialog); defaultTimezoneId: this.formBuilder.control<string | null>(null, [Validators.required]),
dataRegion: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(100)]),
isActive: this.formBuilder.control<number | null>(1, [Validators.required])
});
allowedPermissions: string[] = [ readonly tenantStatusOptions = signal<FormSelectOption<TenantStatus>[]>([
'tenant.view', { value: TenantStatus.Trial, label: 'Trial' },
'tenant.edit', { value: TenantStatus.Active, label: 'Active' },
'tenant.delete' { value: TenantStatus.Suspended, label: 'Suspended' },
]; { value: TenantStatus.Cancelled, label: 'Cancelled' }
]);
columns: DataTableColumn[] = [ readonly activeStatusOptions = signal<FormSelectOption<number>[]>([
{ key: 'id', header: 'Id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' }, { value: 1, label: 'Active' },
{ key: 'tenantName', header: 'Tenant Name', label: 'Tenant Name', sortable: true }, { value: 0, label: 'Inactive' }
{ key: 'companyCode', header: 'Company Code', label: 'Company Code', sortable: true }, ]);
{ key: 'email', header: 'Email', label: 'Email' },
{ key: 'country', header: 'Country', label: 'Country', sortable: true },
{
key: 'status',
header: 'Status',
label: 'Status',
sortable: true,
cellClass: 'text-center',
badge: true,
badgeClass: value =>
value === 'Active'
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger'
}
];
tenants = [ readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
{ this.languageApi.autocomplete(term, limit).pipe(
id: 1, catchError(() => {
tenantName: 'Syscom Corporation', this.toastr.error('Unable to load languages.');
companyCode: 'SYSCOM', return of<readonly LanguageLookupDto[]>([]);
email: 'admin@syscom.com', })
country: 'UAE', );
status: 'Active',
logo: 'assets/images/brand-logos/erp-logo-icon.png'
},
{
id: 2,
tenantName: 'Biz360 Demo',
companyCode: 'BIZ360',
email: 'demo@biz360.com',
country: 'India',
status: 'Active',
logo:'assets/images/brand-logos/erp-logo-icon.png'
},
{
id: 3,
tenantName: 'Test Tenant',
companyCode: 'TEST',
email: 'test@test.com',
country: 'India',
status: 'Inactive'
}
];
tenants1 = [ readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
{ this.currencyApi.autocomplete(term, limit).pipe(
id: 1, catchError(() => {
tenantName: 'Syscom Corporation', this.toastr.error('Unable to load currencies.');
companyCode: 'SYSCOM', return of<readonly CurrencyLookupDto[]>([]);
email: 'admin@syscom.com', })
country: 'UAE', );
status: 'Active'
},
{
id: 2,
tenantName: 'Biz360 Demo',
companyCode: 'BIZ360',
email: 'demo@biz360.com',
country: 'India',
status: 'Active'
},
{
id: 3,
tenantName: 'Test Tenant',
companyCode: 'TEST',
email: 'test@test.com',
country: 'India',
status: 'Inactive'
}
];
tableActions: DataTableAction[] = [ readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
{ this.timezoneApi.autocomplete(term, limit).pipe(
type: 'download', catchError(() => {
label: 'Download', this.toastr.error('Unable to load timezones.');
icon: 'ri-download-2-line !mb-0', return of<readonly TimezoneLookupDto[]>([]);
className: 'ti-btn ti-btn-sm ti-btn-success !rounded-full', })
permission: 'tenant.view' );
},
{
type: 'edit',
label: 'Edit',
icon: 'ri-edit-line !mb-0',
className: 'ti-btn ti-btn-sm ti-btn-info !rounded-full',
permission: 'tenant.edit'
},
{
type: 'delete',
label: 'Delete',
icon: 'ri-delete-bin-line',
className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full',
permission: 'tenant.delete',
//visible: row => row.status !== 'Active'
}
];
readonly displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = language => [language.code, language.name].filter(Boolean).join(' - ');
onSearch(searchText: string): void { readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = language => language.id;
this.searchText = searchText;
this.pageIndex = 1;
if (searchText.trim() === '') { readonly resolveLanguage: AutocompleteResolveValueFn<LanguageLookupDto, string> = value =>
this.tenants = [...this.tenants1]; this.languageApi.getById(value).pipe(
} else { map(language => ({
this.tenants = this.tenants.filter(tenant => id: language.id,
tenant.tenantName.toLowerCase().includes(searchText.toLowerCase()) || code: language.code,
tenant.companyCode.toLowerCase().includes(searchText.toLowerCase()) || name: language.name,
tenant.email.toLowerCase().includes(searchText.toLowerCase()) || nativeName: language.nativeName,
tenant.country.toLowerCase().includes(searchText.toLowerCase()) || isRightToLeft: language.isRightToLeft
tenant.status.toLowerCase().includes(searchText.toLowerCase()) }))
); );
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : '']
.filter(Boolean)
.join(' ');
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
this.currencyApi.getCurrencyById(value).pipe(
map(currency => ({
id: currency.id,
code: currency.code,
name: currency.name,
symbol: currency.symbol
}))
);
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> = timezone => [timezone.ianaId, timezone.displayName].filter(Boolean).join(' — ');
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = timezone => timezone.id;
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = value =>
this.timezoneApi.getById(value).pipe(
map(timezone => ({
id: timezone.id,
ianaId: timezone.ianaId,
displayName: timezone.displayName
}))
);
readonly modalTitle = computed(() =>
this.tenantModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant'
);
readonly submitLabel = computed(() =>
this.tenantModalMode() === 'create' ? 'Save' : 'Update'
);
readonly loadingLabel = computed(() =>
this.tenantModalMode() === 'create' ? 'Saving...' : 'Updating...'
);
readonly submitAction = computed<'save' | 'update'>(() =>
this.tenantModalMode() === 'create' ? 'save' : 'update'
);
readonly columns = signal<DataTableColumn<TenantTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' },
{ key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' },
{
key: 'status',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: value => this.getTenantStatusBadgeClass(value as TenantStatus),
formatter: value => this.formatTenantStatus(value as TenantStatus)
},
{ key: 'dataRegion', label: 'Data Region', header: 'Data Region', sortable: true, align: 'left' },
{
key: 'isActive',
label: 'Active',
header: 'Active',
sortable: true,
badge: true,
badgeClass: value => value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: value => value === true ? 'Active' : 'Inactive'
},
{
key: 'createdOn',
label: 'Created On',
header: 'Created On',
sortable: true,
formatter: value => this.formatDateTime(value)
}
]);
readonly actions = signal<DataTableAction<TenantTableRow>[]>([
{
type: 'edit',
label: 'Edit',
icon: 'ti ti-edit',
className: 'text-primary'
},
{
type: 'delete',
label: 'Delete',
icon: 'ti ti-trash',
className: 'text-danger',
visible: row => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: row => !row.isActive
}
]);
constructor() {
this.queryRequests$
.pipe(
switchMap(query =>
this.tenantApi.getTenantDataTable(this.buildTenantQuery(query)).pipe(
catchError(() => {
this.toastr.error('Unable to load tenants.');
this.clearTenantGrid();
return of(null);
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(response => {
if (!response) {
return;
}
const query = this.queryState.getQuery();
if (response.draw !== query.draw) {
return;
}
const tenantsWithSerialNumbers: TenantTableRow[] = response.rows.map((tenant, index) => ({
...tenant,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.tenants.set(tenantsWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
});
} }
} ngOnInit(): void {
this.loadTenants(this.queryState.getQuery());
onPageChange(event: DataTablePageEvent): void {
const query = this.tableQuery.setPage(event);
//this.loadTenants(query);
}
onSortChange(event: DataTableSortEvent): void {
const query = this.tableQuery.setSort(event);
//this.loadTenants(query);
}
onTableAction(event: any): void {
if (event?.action?.type === 'delete') {
this.pendingDeleteTenant.set(event.row as { id: number });
this.deleteConfirmDialog()?.open();
return;
} }
console.log('Action:', event.action.type, event.row); loadTenants(query: DataTableQuery): void {
} this.queryRequests$.next(query);
onDeleteConfirmed(): void {
const tenant = this.pendingDeleteTenant();
if (!tenant) {
return;
} }
this.pendingDeleteTenant.set(null); onSearch(value: string): void {
this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id); this.loadTenants(this.queryState.setSearch(value.trim()));
this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id); }
this.totalRecords = this.tenants.length;
console.log('Deleted tenant:', tenant);
}
onDeleteCancelled(): void { onPageChange(event: DataTablePageEvent): void {
this.pendingDeleteTenant.set(null); this.loadTenants(this.queryState.setPage(event));
} }
onRowClick(row: any): void { onSortChange(event: DataTableSortEvent): void {
console.log('Row clicked:', row); this.loadTenants(this.queryState.setSort(event));
} }
onActionClick(event: DataTableActionEvent<TenantTableRow>): void {
if (event.action.type === 'edit') {
this.openEditTenant(event.row.id);
}
}
onAddTenant(): void {
this.tenantModalMode.set('create');
this.selectedTenantId.set(null);
this.selectedTenant.set(null);
this.tenantSubmitAttempted.set(false);
this.resetTenantForm({
code: '',
name: '',
status: TenantStatus.Trial,
defaultLanguageId: null,
defaultCurrencyId: null,
defaultTimezoneId: null,
dataRegion: '',
isActive: 1
});
this.showTenantModal.set(true);
}
closeTenantModal(): void {
if (this.saving()) {
return;
}
this.showTenantModal.set(false);
this.selectedTenantId.set(null);
this.selectedTenant.set(null);
this.tenantSubmitAttempted.set(false);
this.resetTenantForm({
code: '',
name: '',
status: TenantStatus.Trial,
defaultLanguageId: null,
defaultCurrencyId: null,
defaultTimezoneId: null,
dataRegion: '',
isActive: 1
});
}
saveTenant(): void {
if (this.tenantForm.invalid) {
this.tenantSubmitAttempted.set(true);
this.tenantForm.markAllAsTouched();
this.focusFirstInvalidControl();
return;
}
if (this.saving()) {
return;
}
this.saving.set(true);
if (this.tenantModalMode() === 'create') {
this.tenantApi
.createTenant(this.buildCreateTenantRequest())
.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {
this.toastr.success('Tenant saved successfully.');
this.finishTenantSave();
},
error: (error: HttpErrorResponse) => this.handleSaveError(error)
});
return;
}
const tenantId = this.selectedTenantId();
if (!tenantId) {
this.saving.set(false);
return;
}
this.tenantApi
.updateTenant(tenantId, this.buildUpdateTenantRequest())
.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {
this.toastr.success('Tenant updated successfully.');
this.finishTenantSave();
},
error: (error: HttpErrorResponse) => this.handleSaveError(error)
});
}
openEditTenant(id: string): void {
this.tenantModalMode.set('edit');
this.selectedTenantId.set(id);
this.selectedTenant.set(null);
this.tenantSubmitAttempted.set(false);
this.resetTenantForm({
code: '',
name: '',
status: TenantStatus.Trial,
defaultLanguageId: null,
defaultCurrencyId: null,
defaultTimezoneId: null,
dataRegion: '',
isActive: 1
});
this.tenantApi
.getTenantById(id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: tenant => {
if (this.selectedTenantId() !== tenant.id) {
return;
}
this.selectedTenant.set(tenant);
this.resetTenantForm({
code: tenant.code ?? '',
name: tenant.name ?? '',
status: tenant.status ?? TenantStatus.Trial,
defaultLanguageId: tenant.defaultLanguageId ?? null,
defaultCurrencyId: tenant.defaultCurrencyId ?? null,
defaultTimezoneId: tenant.defaultTimezoneId ?? null,
dataRegion: tenant.dataRegion ?? '',
isActive: tenant.isActive ? 1 : 0
});
this.showTenantModal.set(true);
},
error: (error: HttpErrorResponse) => {
if (error.status === 404) {
this.toastr.error('The tenant is no longer available.');
}
this.selectedTenantId.set(null);
}
});
}
private buildTenantQuery(query: DataTableQuery): DataTableQuery {
return {
...query,
sortBy: this.resolveSortField(query.sortBy ?? null)
};
}
private resolveSortField(sortBy: string | null | undefined): string | null {
if (!sortBy) {
return null;
}
switch (sortBy) {
case 'code':
case 'name':
case 'status':
case 'dataRegion':
case 'isActive':
case 'createdOn':
return sortBy;
default:
return null;
}
}
private buildCreateTenantRequest(): CreateTenantRequest {
const value = this.tenantForm.getRawValue();
return {
code: value.code.trim(),
name: value.name.trim(),
status: value.status ?? TenantStatus.Trial,
defaultLanguageId: value.defaultLanguageId ?? '',
defaultCurrencyId: value.defaultCurrencyId ?? '',
defaultTimezoneId: value.defaultTimezoneId ?? '',
dataRegion: value.dataRegion.trim()
};
}
private buildUpdateTenantRequest(): UpdateTenantRequest {
const value = this.tenantForm.getRawValue();
return {
code: value.code.trim(),
name: value.name.trim(),
status: value.status ?? TenantStatus.Trial,
defaultLanguageId: value.defaultLanguageId ?? '',
defaultCurrencyId: value.defaultCurrencyId ?? '',
defaultTimezoneId: value.defaultTimezoneId ?? '',
defaultDbConnectionId: this.selectedTenant()?.defaultDbConnectionId ?? null,
dataRegion: value.dataRegion.trim(),
isActive: value.isActive === 1
};
}
private finishTenantSave(): void {
this.showTenantModal.set(false);
this.selectedTenantId.set(null);
this.selectedTenant.set(null);
this.tenantSubmitAttempted.set(false);
this.resetTenantForm({
code: '',
name: '',
status: TenantStatus.Trial,
defaultLanguageId: null,
defaultCurrencyId: null,
defaultTimezoneId: null,
dataRegion: '',
isActive: 1
});
this.loadTenants(this.queryState.getQuery());
}
private resetTenantForm(value: {
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string | null;
defaultCurrencyId: string | null;
defaultTimezoneId: string | null;
dataRegion: string;
isActive: number | null;
}): void {
this.tenantForm.reset(value);
this.tenantForm.markAsPristine();
this.tenantForm.markAsUntouched();
this.tenantForm.updateValueAndValidity();
}
private formatTenantStatus(status: TenantStatus): string {
switch (status) {
case TenantStatus.Trial:
return 'Trial';
case TenantStatus.Active:
return 'Active';
case TenantStatus.Suspended:
return 'Suspended';
case TenantStatus.Cancelled:
return 'Cancelled';
default:
return 'Unknown';
}
}
private getTenantStatusBadgeClass(status: TenantStatus): string {
switch (status) {
case TenantStatus.Trial:
return 'badge bg-warning/10 text-warning';
case TenantStatus.Active:
return 'badge bg-success/10 text-success';
case TenantStatus.Suspended:
return 'badge bg-info/10 text-info';
case TenantStatus.Cancelled:
return 'badge bg-danger/10 text-danger';
default:
return 'badge bg-secondary/10 text-secondary';
}
}
private formatDateTime(value: unknown): string {
if (typeof value !== 'string' || value.trim().length === 0) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
private handleSaveError(error: HttpErrorResponse): void {
if (error.status === 409) {
this.toastr.error('A tenant with this code already exists.', 'Duplicate tenant code');
}
}
private focusFirstInvalidControl(): void {
queueMicrotask(() => {
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
'modal .form-control.is-invalid, modal .ti-form-select.is-invalid, modal [aria-invalid="true"]'
);
control?.focus();
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
private clearTenantGrid(): void {
this.tenants.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
} }
+6 -1
View File
@@ -4,6 +4,11 @@ export const tenantsRoutes: Routes = [
{ {
path: '', path: '',
loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList), loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList),
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' }, data: { childTitle: 'Tenant Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
},
{
path: 'tenant-currencies',
loadComponent: () => import('./pages/tenant-currencies/tenant-currencies').then((m) => m.TenantCurrencies),
data: { childTitle: 'Tenant Currencies', parentTitle: 'Platform', subParentTitle: 'Configuration' },
}, },
]; ];
@@ -1,4 +1,19 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg"> <app-data-table [columns]="columns()" [rows]="users()" [actions]="actions()"
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform users</h3> (addClicked)="onAddUser()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Users, roles, assignment, deactivation, and password reset will be implemented here.</p> [pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Users" buttonTitle="Add"
</div> [showSearch]="true" [showAddButton]="true" searchPlaceholder="Search users..." [searchDebounceTime]="300"
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)" toolTip="Add User">
<!-- <ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
@if (getFlagUrl(row.iso2); as flagUrl) {
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
(error)="onFlagError($event)" />
}
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template> -->
</app-data-table>
@@ -1,11 +1,521 @@
import { Component } from '@angular/core'; import {
import { CommonModule } from '@angular/common'; ChangeDetectionStrategy,
Component,
DestroyRef,
OnInit,
inject,
signal
} from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
catchError,
finalize,
of,
Subject,
switchMap
} from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import {
CreateUserRequest,
UserModalMode,
UserStatus
} from '../../../../core/models/user/user.model';
import { UserService } from '../../../../core/services/user/user.service';
import { DataTable } from '../../../../shared/components/data-table/data-table';
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
import {
DataTableAction,
DataTableActionEvent,
DataTableColumn,
DataTablePageEvent,
DataTableQuery,
DataTableRecord,
DataTableSortEvent
} from '../../../../shared/components/data-table/data-table.types';
import { Modal } from '../../../../shared/components/modal/modal';
interface UserTableRow extends DataTableRecord {
id: string;
email: string;
status: UserStatus;
roles: string[];
isActive: boolean;
serialNumber: number;
createdOn: string;
lastLoginOn: string | null;
}
@Component({ @Component({
selector: 'app-users-list', selector: 'app-users-list',
standalone: true, standalone: true,
imports: [CommonModule], imports: [
ReactiveFormsModule,
RouterLink,
DataTable,
Modal
],
templateUrl: './users-list.html', templateUrl: './users-list.html',
styleUrl: './users-list.scss', styleUrl: './users-list.scss',
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class UsersList {} export class UsersList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly usersApi = inject(UserService);
private readonly toastr = inject(ToastrService);
private readonly usersQueryRequests$ =
new Subject<DataTableQuery>();
readonly users = signal<UserTableRow[]>([]);
readonly queryState = new DataTableQueryState();
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly userModalMode = signal<UserModalMode>('create');
readonly showUserModal = signal(false);
readonly saving = signal(false);
readonly userSubmitAttempted = signal(false);
readonly userForm = this.formBuilder.nonNullable.group({
email: [
'',
[
Validators.required,
Validators.email,
Validators.maxLength(256)
]
],
password: [
'',
[
Validators.required,
Validators.minLength(8),
Validators.maxLength(128)
]
],
roleCodes: this.formBuilder.nonNullable.control<string[]>(
[],
[
Validators.required,
control =>
control.value.length > 0
? null
: { required: true }
]
)
});
readonly columns = signal<DataTableColumn<UserTableRow>[]>([
{
key: 'serialNumber',
label: 'Sr. No.',
header: 'Sr. No.',
sortable: false,
width: '100px'
},
{
key: 'email',
label: 'Email',
header: 'Email',
sortable: true,
align: 'left'
},
{
key: 'status',
label: 'User Status',
header: 'User Status',
sortable: true,
formatter: value =>
this.formatUserStatus(value as UserStatus)
},
{
key: 'roles',
label: 'Roles',
header: 'Roles',
sortable: false,
align: 'left',
formatter: value =>
this.formatRoles(
Array.isArray(value)
? value.filter(
(item): item is string =>
typeof item === 'string'
)
: []
)
},
{
key: 'createdOn',
label: 'Created On',
header: 'Created On',
sortable: true
},
{
key: 'lastLoginOn',
label: 'Last Login On',
header: 'Last Login On',
sortable: true,
formatter: value =>
typeof value === 'string' && value.trim().length > 0
? value
: 'Never'
},
{
key: 'isActive',
label: 'Active',
header: 'Active',
sortable: true,
badge: true,
badgeClass: value =>
value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: value =>
value === true ? 'Active' : 'Inactive'
}
]);
onActionClick(event: DataTableActionEvent<UserTableRow>): void {
// const user = this.toUserDto(event.row);
// switch (event.action.type) {
// case 'view':
// this.viewUser(user);
// break;
// case 'edit':
// this.openEditUser(user);
// break;
// case 'delete':
// this.requestDeleteUser(user);
// break;
// case 'activate':
// this.activateUser(user);
// break;
// }
}
readonly actions = signal<DataTableAction<UserTableRow>[]>([]);
constructor() {
this.initializeUserQueryStream();
}
ngOnInit(): void {
this.loadUsers(this.queryState.getQuery());
}
loadUsers(query: DataTableQuery): void {
this.usersQueryRequests$.next(query);
}
onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim());
this.loadUsers(query);
}
onPageChange(event: DataTablePageEvent): void {
const query = this.queryState.setPage(event);
this.loadUsers(query);
}
onSortChange(event: DataTableSortEvent): void {
const query = this.queryState.setSort(event);
this.loadUsers(query);
}
onRefresh(): void {
const query = this.queryState.reset();
this.loadUsers(query);
}
onAddUser(): void {
this.userModalMode.set('create');
this.resetUserForm();
this.showUserModal.set(true);
}
closeUserModal(): void {
if (this.saving()) {
return;
}
this.showUserModal.set(false);
this.resetUserForm();
}
saveUser(): void {
this.userSubmitAttempted.set(true);
if (this.userForm.invalid || this.saving()) {
return;
}
const request = this.buildCreateUserRequest();
if (request.roleCodes.length === 0) {
this.userForm.controls.roleCodes.setErrors({
required: true
});
return;
}
this.saving.set(true);
this.usersApi
.createUser(request)
.pipe(
finalize(() => {
this.saving.set(false);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {
this.toastr.success('User created successfully.');
this.showUserModal.set(false);
this.resetUserForm();
this.refreshUsersAfterSave();
},
error: (error: HttpErrorResponse) => {
this.handleCreateUserError(error);
}
});
}
isControlInvalid(
controlName: keyof typeof this.userForm.controls
): boolean {
const control = this.userForm.controls[controlName];
return this.userSubmitAttempted() && control.invalid;
}
hasControlError(
controlName: keyof typeof this.userForm.controls,
errorName: string
): boolean {
const control = this.userForm.controls[controlName];
return (
this.userSubmitAttempted() &&
control.hasError(errorName)
);
}
private initializeUserQueryStream(): void {
this.usersQueryRequests$
.pipe(
switchMap(requestedQuery =>
this.usersApi.getDataTable(requestedQuery).pipe(
catchError(() => {
this.clearUsersGrid();
this.toastr.error('Unable to load users.');
return of(null);
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(response => {
if (response === null) {
return;
}
const currentQuery = this.queryState.getQuery();
if (response.draw !== currentQuery.draw) {
return;
}
const usersWithSerialNumbers: UserTableRow[] =
response.rows.map((user, index) => ({
id: user.id,
email: user.email,
status: user.status,
roles: [...user.roles],
isActive: user.isActive,
createdOn: user.createdOn,
lastLoginOn: user.lastLoginOn,
serialNumber:
(currentQuery.page - 1) *
currentQuery.pageSize +
index +
1
}));
this.users.set(usersWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
});
}
private buildCreateUserRequest(): CreateUserRequest {
const value = this.userForm.getRawValue();
const normalizedRoleCodes = Array.from(
new Set(
value.roleCodes
.map(roleCode => roleCode.trim())
.filter(roleCode => roleCode.length > 0)
)
);
return {
email: value.email.trim(),
password: value.password,
roleCodes: normalizedRoleCodes
};
}
private refreshUsersAfterSave(): void {
const query = this.queryState.reset();
this.loadUsers(query);
}
private resetUserForm(): void {
this.userForm.reset({
email: '',
password: '',
roleCodes: []
});
this.userSubmitAttempted.set(false);
this.userForm.markAsPristine();
this.userForm.markAsUntouched();
this.userForm.updateValueAndValidity({
emitEvent: false
});
}
private clearUsersGrid(): void {
this.users.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
private handleCreateUserError(
error: HttpErrorResponse
): void {
if (error.status === 409) {
this.toastr.error(
'A user with this email address already exists.'
);
return;
}
if (error.status === 400) {
this.toastr.error(
this.extractApiErrorMessage(error) ??
'The user information is invalid.'
);
return;
}
/*
* Authentication/session errors should normally be handled
* by the global authentication interceptor.
*/
if (error.status === 401 || error.status === 403) {
return;
}
this.toastr.error(
'Unable to create the user. Please try again.'
);
}
private extractApiErrorMessage(
error: HttpErrorResponse
): string | null {
const responseBody: unknown = error.error;
if (
typeof responseBody !== 'object' ||
responseBody === null
) {
return null;
}
const apiError = responseBody as {
detail?: unknown;
message?: unknown;
title?: unknown;
};
if (typeof apiError.detail === 'string') {
return apiError.detail;
}
if (typeof apiError.message === 'string') {
return apiError.message;
}
if (typeof apiError.title === 'string') {
return apiError.title;
}
return null;
}
private formatRoles(roles: readonly string[]): string {
if (roles.length === 0) {
return '—';
}
return roles
.map(role => this.formatRoleCode(role))
.join(', ');
}
private formatRoleCode(roleCode: string): string {
return roleCode
.split('_')
.filter(part => part.length > 0)
.map(
part =>
`${part.charAt(0).toUpperCase()}${part
.slice(1)
.toLowerCase()}`
)
.join(' ');
}
private formatUserStatus(status: UserStatus): string {
switch (status) {
case UserStatus.Pending:
return 'Pending';
case UserStatus.Active:
return 'Active';
case UserStatus.Suspended:
return 'Suspended';
case UserStatus.Locked:
return 'Locked';
case UserStatus.Disabled:
return 'Disabled';
default:
return 'Unknown';
}
}
}
+3 -1
View File
@@ -1,9 +1,11 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
export const usersRoutes: Routes = [ export const usersRoutes: Routes = [
{ {
path: '', path: '',
canActivate: [superAdminGuard],
loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList), loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList),
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' }, data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' },
}, }
]; ];
@@ -0,0 +1,41 @@
<div class="box custom-box" [class]="'box custom-box ' + cardClass()">
<div class="box-header justify-between" [class]="'box-header justify-between ' + headerClass()">
<div class="box-title">{{ title() }}</div>
<div class="flex items-center gap-2">
<ng-content select="[filterCardActions]" />
<button
type="button"
class="ti-btn ti-btn-sm ti-btn-light"
[class]="'ti-btn ti-btn-sm ti-btn-light ' + toggleButtonClass()"
[class.hidden]="!showToggleButton()"
[disabled]="!collapsible()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-controls]="resolvedContentId()"
[attr.aria-label]="currentTooltip()"
[appTooltip]="currentTooltip()"
(click)="toggleCollapse()"
>
<i [class]="currentIcon()" aria-hidden="true"></i>
</button>
</div>
</div>
<div
class="filter-card-body-grid"
[class.is-collapsed]="collapsed()"
[attr.aria-hidden]="collapsed()"
[attr.inert]="collapsed() ? '' : null"
>
<div class="filter-card-body-content">
<div
class="box-body"
[class]="'box-body ' + bodyClass()"
[id]="resolvedContentId()"
>
<ng-content />
</div>
</div>
</div>
</div>
@@ -0,0 +1,142 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterCard } from './filter-card';
@Component({
standalone: true,
imports: [FilterCard],
template: `
<app-filter-card
title="Country Selection"
[defaultCollapsed]="defaultCollapsed()"
[collapsible]="collapsible()"
[collapsedIcon]="collapsedIcon"
[expandedIcon]="expandedIcon"
[contentId]="contentId()"
(collapseChanged)="changes.push($event)"
>
<button filterCardActions type="button">Reset</button>
<input data-testid="projected-input" value="preserved" />
</app-filter-card>
`
})
class HostComponent {
readonly defaultCollapsed = signal(false);
readonly collapsible = signal(true);
readonly contentId = signal('');
readonly changes: boolean[] = [];
collapsedIcon = 'custom-collapsed';
expandedIcon = 'custom-expanded';
}
describe('FilterCard', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
let overlayContainer: OverlayContainer;
const card = (): FilterCard => fixture.debugElement.children[0].componentInstance;
const toggle = (): HTMLButtonElement => fixture.nativeElement.querySelector('button[aria-controls]');
const icon = (): HTMLElement => toggle().querySelector('i') as HTMLElement;
const bodyGrid = (): HTMLElement => fixture.nativeElement.querySelector('.filter-card-body-grid');
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
overlayContainer = TestBed.inject(OverlayContainer);
fixture.detectChanges();
});
afterEach(() => {
vi.useRealTimers();
overlayContainer.ngOnDestroy();
});
it('starts expanded by default', () => {
expect(card().collapsed()).toBe(false);
expect(bodyGrid().classList.contains('is-collapsed')).toBe(false);
});
it('reacts to a collapsed default state', () => {
host.defaultCollapsed.set(true);
fixture.detectChanges();
expect(card().collapsed()).toBe(true);
expect(bodyGrid().classList.contains('is-collapsed')).toBe(true);
});
it('switches icons and tooltips when toggled', () => {
expect(icon().className).toBe('custom-expanded');
expect(toggle().getAttribute('aria-label')).toBe('Hide Filters');
toggle().click();
fixture.detectChanges();
expect(icon().className).toBe('custom-collapsed');
expect(toggle().getAttribute('aria-label')).toBe('Show Filters');
});
it('updates an open tooltip when toggled without requiring another hover', () => {
vi.useFakeTimers();
toggle().dispatchEvent(new MouseEvent('mouseenter'));
vi.advanceTimersByTime(200);
fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('Hide Filters');
toggle().click();
fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('Show Filters');
expect(overlayContainer.getContainerElement().textContent).not.toContain('Hide Filters');
});
it('emits the new collapsed state', () => {
toggle().click();
toggle().click();
expect(host.changes).toEqual([true, false]);
});
it('sets accessible expanded and controls attributes', () => {
const id = card().resolvedContentId();
expect(toggle().getAttribute('aria-expanded')).toBe('true');
expect(toggle().getAttribute('aria-controls')).toBe(id);
expect(fixture.nativeElement.querySelector(`#${id}`)).not.toBeNull();
});
it('keeps projected content mounted while collapsed', () => {
const input = fixture.nativeElement.querySelector('[data-testid="projected-input"]');
toggle().click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="projected-input"]')).toBe(input);
expect(fixture.nativeElement.textContent).toContain('Reset');
});
it('uses custom icons', () => {
expect(icon().classList.contains('custom-expanded')).toBe(true);
toggle().click();
fixture.detectChanges();
expect(icon().classList.contains('custom-collapsed')).toBe(true);
});
it('uses a custom content id', () => {
host.contentId.set('state-filter-content');
fixture.detectChanges();
expect(toggle().getAttribute('aria-controls')).toBe('state-filter-content');
expect(fixture.nativeElement.querySelector('#state-filter-content')).not.toBeNull();
});
it('generates a stable content id', () => {
const generatedId = card().resolvedContentId();
fixture.detectChanges();
expect(generatedId).toMatch(/^filter-card-content-\d+$/);
expect(card().resolvedContentId()).toBe(generatedId);
});
it('does not collapse or emit when non-collapsible', () => {
host.collapsible.set(false);
fixture.detectChanges();
toggle().click();
expect(card().collapsed()).toBe(false);
expect(host.changes).toEqual([]);
expect(toggle().disabled).toBe(true);
});
});
@@ -0,0 +1,71 @@
import { Component, computed, effect, input, output, signal } from '@angular/core';
import { TooltipDirective } from '../../directives/tooltip/tooltip.directive';
let nextFilterCardId = 0;
@Component({
selector: 'app-filter-card',
standalone: true,
imports: [TooltipDirective],
templateUrl: './filter-card.html',
styles: `
:host { display: block; }
.filter-card-body-grid {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 200ms ease, visibility 200ms ease;
visibility: visible;
}
.filter-card-body-grid.is-collapsed {
grid-template-rows: 0fr;
visibility: hidden;
}
.filter-card-body-content { min-height: 0; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.filter-card-body-grid { transition: none; }
}
`
})
export class FilterCard {
readonly title = input<string>('Filters');
readonly defaultCollapsed = input<boolean>(false);
readonly collapsible = input<boolean>(true);
readonly showToggleButton = input<boolean>(true);
readonly collapsedIcon = input<string>('ri-filter-3-line');
readonly expandedIcon = input<string>('ri-arrow-up-s-line');
readonly collapsedTooltip = input<string>('Show Filters');
readonly expandedTooltip = input<string>('Hide Filters');
readonly cardClass = input<string>('');
readonly headerClass = input<string>('');
readonly bodyClass = input<string>('');
readonly toggleButtonClass = input<string>('');
readonly contentId = input<string>('');
readonly collapseChanged = output<boolean>();
readonly collapsed = signal(false);
private readonly generatedContentId = `filter-card-content-${++nextFilterCardId}`;
readonly resolvedContentId = computed(() => this.contentId().trim() || this.generatedContentId);
readonly currentIcon = computed(() =>
this.collapsed() ? this.collapsedIcon() : this.expandedIcon()
);
readonly currentTooltip = computed(() =>
this.collapsed() ? this.collapsedTooltip() : this.expandedTooltip()
);
constructor() {
effect(() => this.collapsed.set(this.defaultCollapsed()));
}
toggleCollapse(): void {
if (!this.collapsible()) {
return;
}
const collapsed = !this.collapsed();
this.collapsed.set(collapsed);
this.collapseChanged.emit(collapsed);
}
}
@@ -6,6 +6,7 @@
[disabled]="isDisabled()" [disabled]="isDisabled()"
[description]="description()" [description]="description()"
[hint]="hint()" [hint]="hint()"
[help]="help()"
[labelPosition]="labelPosition()" [labelPosition]="labelPosition()"
[hideLabel]="hideLabel()" [hideLabel]="hideLabel()"
[hideValidation]="hideValidation()" [hideValidation]="hideValidation()"
@@ -35,18 +36,19 @@
[attr.aria-readonly]="readonly()" [attr.aria-readonly]="readonly()"
(input)="onInput($event)" (input)="onInput($event)"
(focus)="onFocus()" (focus)="onFocus()"
(click)="onClick()"
(blur)="onBlur()" (blur)="onBlur()"
(keydown)="onKeydown($event)" (keydown)="onKeydown($event)"
/> />
@if (loading()) { @if (loading()) {
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2" aria-hidden="true"> <span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-primary" aria-hidden="true">
<span class="ti-spinner h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></span> <span class="ti-spinner block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
</span> </span>
} @else if (clearable() && (hasSelectedItem() || searchText())) { } @else if (clearable() && (hasSelectedItem() || searchText())) {
<button <button
type="button" type="button"
class="absolute end-3 top-1/2 -translate-y-1/2 text-textmuted hover:text-danger" class="absolute end-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center rounded-sm text-textmuted transition-colors hover:bg-light hover:text-danger focus:outline-none focus:ring-1 focus:ring-primary dark:hover:bg-black/20"
aria-label="Clear selection" aria-label="Clear selection"
[disabled]="isDisabled() || readonly()" [disabled]="isDisabled() || readonly()"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
@@ -75,11 +77,11 @@
<div <div
[id]="panelId()" [id]="panelId()"
role="listbox" role="listbox"
class="max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70" class="w-full max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70"
[class]="panelClass()" [class]="panelClass()"
> >
@if (message()) { @if (message()) {
<div class="px-3 py-2 text-[0.8125rem] text-textmuted" [class.text-danger]="error()"> <div class="bg-white px-3 py-2 text-[0.8125rem] text-textmuted dark:bg-bodybg dark:text-white/50" [class.text-danger]="error()">
{{ message() }} {{ message() }}
</div> </div>
} @else { } @else {
@@ -88,10 +90,8 @@
type="button" type="button"
role="option" role="option"
[id]="optionId(index)" [id]="optionId(index)"
[class]="activeIndex() === index [class]="optionClass(item, index)"
? 'block w-full px-3 py-2 text-start text-[0.8125rem] bg-light text-primary dark:bg-black/20 dark:text-white/70' [attr.aria-selected]="isOptionSelected(item)"
: 'block w-full px-3 py-2 text-start text-[0.8125rem] hover:bg-light dark:hover:bg-black/20'"
[attr.aria-selected]="activeIndex() === index"
(mousedown)="$event.preventDefault()" (mousedown)="$event.preventDefault()"
(mouseenter)="activeIndex.set(index)" (mouseenter)="activeIndex.set(index)"
(click)="select(item)" (click)="select(item)"
@@ -99,6 +99,15 @@
{{ displayWith()(item) }} {{ displayWith()(item) }}
</button> </button>
} }
@if (showingPreview()) {
<div
role="option"
aria-disabled="true"
class="cursor-default border-t border-defaultborder bg-light/50 px-3 py-2 text-[0.8125rem] italic text-textmuted dark:border-defaultborder/10 dark:bg-black/10 dark:text-white/50"
>
{{ previewText() }}
</div>
}
} }
</div> </div>
</ng-template> </ng-template>
@@ -1,11 +1,11 @@
import { OverlayContainer } from '@angular/cdk/overlay'; import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core'; import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable, Subject, of, throwError } from 'rxjs'; import { Observable, Subject, of, throwError } from 'rxjs';
import { Autocomplete } from './autocomplete'; import { Autocomplete } from './autocomplete';
import { AutocompleteSearchFn } from './autocomplete.types'; import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';
interface LookupItem { interface LookupItem {
readonly code: string; readonly code: string;
@@ -26,24 +26,29 @@ const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
[searchFn]="searchFn" [searchFn]="searchFn"
[displayWith]="displayWith" [displayWith]="displayWith"
[valueWith]="valueWith" [valueWith]="valueWith"
placeholder="e.g.: USD"
[selectedItem]="selectedItem()" [selectedItem]="selectedItem()"
[resolveValueFn]="resolveValueFn()"
[minSearchLength]="minLength" [minSearchLength]="minLength"
[debounceTime]="delay()" [debounceTime]="delay()"
[readonly]="readonly()" [readonly]="readonly()"
[showDropdownOnFocus]="openOnFocus" [showDropdownOnFocus]="openOnFocus()"
[submitAttempted]="submitAttempted()"
/> />
` `
}) })
class HostComponent { class HostComponent {
readonly control = new FormControl<string | null>(null); readonly control = new FormControl<string | null>(null);
readonly selectedItem = signal<LookupItem | null>(null); readonly selectedItem = signal<LookupItem | null>(null);
readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | null>(null);
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]); searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
readonly displayWith = (item: LookupItem): string => item.title; readonly displayWith = (item: LookupItem): string => item.title;
readonly valueWith = (item: LookupItem): string => item.code; readonly valueWith = (item: LookupItem): string => item.code;
minLength = 2; minLength = 2;
readonly delay = signal(300); readonly delay = signal(300);
readonly readonly = signal(false); readonly readonly = signal(false);
openOnFocus = false; readonly openOnFocus = signal(false);
readonly submitAttempted = signal(false);
} }
describe('Autocomplete', () => { describe('Autocomplete', () => {
@@ -73,6 +78,11 @@ describe('Autocomplete', () => {
it('initializes with an empty selection', () => { it('initializes with an empty selection', () => {
expect(component.searchText()).toBe(''); expect(component.searchText()).toBe('');
expect(component.options()).toEqual([]); expect(component.options()).toEqual([]);
expect(input().value).toBe('');
expect(input().placeholder).toBe('e.g.: USD');
expect(input().className).not.toContain('ti-form-control');
expect(input().className).toContain('placeholder:text-textmuted');
expect(input().className).toContain('dark:placeholder:text-white/50');
}); });
it('integrates with a reactive form control', () => { it('integrates with a reactive form control', () => {
@@ -80,6 +90,14 @@ describe('Autocomplete', () => {
expect(host.control.value).toBe('IN'); expect(host.control.value).toBe('IN');
}); });
it('treats edited selected text as search text rather than a selected item', () => {
component.select(INDIA);
expect(component.activeItem()).toEqual(INDIA);
type('Ind');
expect(component.searchText()).toBe('Ind');
expect(component.activeItem()).toBeNull();
});
it('does not emit onChange from writeValue', () => { it('does not emit onChange from writeValue', () => {
const change = vi.fn(); const change = vi.fn();
component.registerOnChange(change); component.registerOnChange(change);
@@ -87,6 +105,28 @@ describe('Autocomplete', () => {
expect(change).not.toHaveBeenCalled(); expect(change).not.toHaveBeenCalled();
}); });
it('does not show invalid styling after touch before submit', () => {
host.control.setValidators(Validators.required);
host.control.updateValueAndValidity();
host.control.markAsTouched();
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(false);
});
it('shows invalid styling after submit and removes it when valid', () => {
host.control.setValidators(Validators.required);
host.control.updateValueAndValidity();
host.submitAttempted.set(true);
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Country is required.');
host.control.setValue('IN');
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(false);
expect(fixture.nativeElement.textContent).not.toContain('Country is required.');
});
it('applies a disabled form state', () => { it('applies a disabled form state', () => {
host.control.disable(); host.control.disable();
fixture.detectChanges(); fixture.detectChanges();
@@ -117,6 +157,40 @@ describe('Autocomplete', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('loads five preview records on focus before the user types', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA, INDONESIA]));
host.openOnFocus.set(true);
host.searchFn = search;
fixture.detectChanges();
input().focus();
vi.advanceTimersByTime(0);
fixture.detectChanges();
expect(search).toHaveBeenCalledWith('', 5);
expect(component.options()).toEqual([INDIA, INDONESIA]);
expect(component.showingPreview()).toBe(true);
expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...');
vi.useRealTimers();
});
it('replaces preview records with typed search results', () => {
vi.useFakeTimers();
host.openOnFocus.set(true);
host.delay.set(0);
host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]);
fixture.detectChanges();
input().focus();
vi.advanceTimersByTime(0);
expect(component.options()).toEqual([INDIA]);
type('in');
vi.advanceTimersByTime(0);
fixture.detectChanges();
expect(component.options()).toEqual([INDONESIA]);
expect(component.showingPreview()).toBe(false);
expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...');
vi.useRealTimers();
});
it('cancels stale search requests', () => { it('cancels stale search requests', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const first = new Subject<readonly LookupItem[]>(); const first = new Subject<readonly LookupItem[]>();
@@ -227,6 +301,36 @@ describe('Autocomplete', () => {
expect(component.searchText()).toBe('India'); expect(component.searchText()).toBe('India');
}); });
it('does not resolve an empty string form value', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
fixture.detectChanges();
host.control.setValue('');
fixture.detectChanges();
expect(resolve).not.toHaveBeenCalled();
});
it('uses the matching selected item without resolving it again', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
host.selectedItem.set(INDIA);
fixture.detectChanges();
host.control.setValue('IN');
fixture.detectChanges();
expect(component.searchText()).toBe('India');
expect(resolve).not.toHaveBeenCalled();
});
it('resolves a non-empty value when no matching selected item is supplied', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
fixture.detectChanges();
host.control.setValue('IN');
fixture.detectChanges();
expect(resolve).toHaveBeenCalledWith('IN');
expect(component.searchText()).toBe('India');
});
it('does not retain a stale label when edit values change', () => { it('does not retain a stale label when edit values change', () => {
host.selectedItem.set(INDIA); host.selectedItem.set(INDIA);
host.control.setValue('IN'); host.control.setValue('IN');
@@ -40,6 +40,12 @@ interface SearchResult<TItem> {
readonly term: string; readonly term: string;
readonly options: readonly TItem[]; readonly options: readonly TItem[];
readonly failed: boolean; readonly failed: boolean;
readonly preview: boolean;
}
interface SearchRequest {
readonly term: string;
readonly preview: boolean;
} }
@Component({ @Component({
@@ -59,9 +65,10 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`; private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
private readonly inputTerms$ = new Subject<string>(); private readonly searchRequests$ = new Subject<SearchRequest>();
private readonly valuesToResolve$ = new Subject<TValue>(); private readonly valuesToResolve$ = new Subject<TValue>();
private formValue: TValue | null = null; private formValue: TValue | null = null;
private readonly controlStateVersion = signal(0);
private labelEdited = false; private labelEdited = false;
private onChange: (value: TValue | null) => void = () => {}; private onChange: (value: TValue | null) => void = () => {};
private onTouched: () => void = () => {}; private onTouched: () => void = () => {};
@@ -81,6 +88,8 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly minSearchLength = input(1); readonly minSearchLength = input(1);
readonly debounceTime = input(300); readonly debounceTime = input(300);
readonly limit = input(10); readonly limit = input(10);
readonly previewLimit = input(5);
readonly previewText = input('Type to search more...');
readonly disabled = input(false); readonly disabled = input(false);
readonly readonly = input(false); readonly readonly = input(false);
readonly clearable = input(true); readonly clearable = input(true);
@@ -89,7 +98,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly emptyText = input('No results found'); readonly emptyText = input('No results found');
readonly typeToSearchText = input('Type to search'); readonly typeToSearchText = input('Type to search');
readonly errorText = input('Unable to load results'); readonly errorText = input('Unable to load results');
readonly showDropdownOnFocus = input(false); readonly showDropdownOnFocus = input(true);
readonly closeOnSelect = input(true); readonly closeOnSelect = input(true);
readonly autocomplete = input('off'); readonly autocomplete = input('off');
readonly ariaLabel = input<string | null>(null); readonly ariaLabel = input<string | null>(null);
@@ -102,6 +111,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly validationMessages = input<ValidationMessageMap>({}); readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null); readonly description = input<string | null>(null);
readonly hint = input<string | null>(null); readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top'); readonly labelPosition = input<FormLabelPosition>('top');
readonly itemSelected = output<TItem>(); readonly itemSelected = output<TItem>();
@@ -118,6 +128,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly activeItem = signal<TItem | null>(null); readonly activeItem = signal<TItem | null>(null);
readonly searchText = signal(''); readonly searchText = signal('');
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly showingPreview = signal(false);
readonly formDisabled = signal(false); readonly formDisabled = signal(false);
readonly panelWidth = signal(0); readonly panelWidth = signal(0);
@@ -136,21 +147,24 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly message = computed(() => { readonly message = computed(() => {
if (this.loading()) return this.loadingText(); if (this.loading()) return this.loadingText();
if (this.error()) return this.errorText(); if (this.error()) return this.errorText();
if (this.showingPreview()) return '';
if (this.searchText().trim().length < this.minSearchLength()) { if (this.searchText().trim().length < this.minSearchLength()) {
return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`; return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`;
} }
return this.options().length ? '' : this.emptyText(); return this.options().length ? '' : this.emptyText();
}); });
readonly resolvedInputClass = computed(() => { readonly resolvedInputClass = computed(() => {
this.controlStateVersion();
const control = this.control(); const control = this.control();
const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted())); const invalid = !!(control?.invalid && this.submitAttempted());
return [ return [
'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor', 'ti-form-select w-full rounded-sm border border-defaultborder bg-white text-defaulttextcolor',
'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70', 'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70',
'focus:border-primary focus:ring-1 focus:ring-primary', 'placeholder:text-textmuted placeholder:opacity-100 dark:placeholder:text-white/50',
'pe-16', 'focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none',
'pe-10 transition-colors',
invalid ? 'is-invalid border-danger' : '', invalid ? 'is-invalid border-danger' : '',
this.isDisabled() ? 'cursor-not-allowed opacity-60' : '', this.isDisabled() ? 'cursor-not-allowed bg-light opacity-60 dark:bg-black/20' : '',
this.inputClass() this.inputClass()
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
}); });
@@ -161,24 +175,49 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
]; ];
constructor() { constructor() {
this.inputTerms$.pipe( effect((onCleanup) => {
map(term => term.trim()), const control = this.control();
debounce(() => timer(Math.max(0, this.debounceTime()))), if (!control) return;
distinctUntilChanged(),
switchMap(term => { const statusSubscription = control.statusChanges.subscribe(() => {
if (term.length < this.minSearchLength()) { this.controlStateVersion.update(value => value + 1);
return of<SearchResult<TItem>>({ term, options: [], failed: false }); });
const valueSubscription = control.valueChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const eventsSubscription = control.events?.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
onCleanup(() => {
statusSubscription.unsubscribe();
valueSubscription.unsubscribe();
eventsSubscription?.unsubscribe();
});
});
this.searchRequests$.pipe(
map(request => ({ ...request, term: request.term.trim() })),
debounce(request => timer(request.preview ? 0 : Math.max(0, this.debounceTime()))),
distinctUntilChanged((previous, current) =>
previous.term === current.term && previous.preview === current.preview
),
switchMap(request => {
if (!request.preview && request.term.length < this.minSearchLength()) {
return of<SearchResult<TItem>>({ ...request, options: [], failed: false });
} }
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);
return this.searchFn()(term, this.limit()).pipe( const limit = request.preview ? this.previewLimit() : this.limit();
map(options => ({ term, options, failed: false })), return this.searchFn()(request.term, limit).pipe(
catchError(() => of<SearchResult<TItem>>({ term, options: [], failed: true })) map(options => ({ ...request, options, failed: false })),
catchError(() => of<SearchResult<TItem>>({ ...request, options: [], failed: true }))
); );
}), }),
takeUntilDestroyed() takeUntilDestroyed()
).subscribe(result => { ).subscribe(result => {
this.loading.set(false); this.loading.set(false);
this.showingPreview.set(result.preview && !result.failed);
this.options.set(result.options); this.options.set(result.options);
this.activeIndex.set(-1); this.activeIndex.set(-1);
this.error.set(result.failed ? this.errorText() : null); this.error.set(result.failed ? this.errorText() : null);
@@ -203,7 +242,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
writeValue(value: TValue | null): void { writeValue(value: TValue | null): void {
this.formValue = value ?? null; this.formValue = value ?? null;
this.labelEdited = false; this.labelEdited = false;
if (this.formValue === null) { if (!this.hasResolvableValue(this.formValue)) {
this.applyResolvedItem(null); this.applyResolvedItem(null);
return; return;
} }
@@ -228,17 +267,21 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
const text = event.target.value; const text = event.target.value;
const previousText = this.searchText(); const previousText = this.searchText();
this.searchText.set(text); this.searchText.set(text);
this.activeItem.set(null);
this.showingPreview.set(false);
this.options.set([]);
this.labelEdited = this.formValue !== null && text !== previousText; this.labelEdited = this.formValue !== null && text !== previousText;
this.searchChanged.emit(text.trim()); this.searchChanged.emit(text.trim());
this.open(); this.open();
this.inputTerms$.next(text); this.searchRequests$.next({ term: text, preview: false });
} }
onFocus(): void { onFocus(): void {
if (this.showDropdownOnFocus()) { if (this.showDropdownOnFocus()) this.openPreview();
this.open(); }
this.inputTerms$.next(this.searchText());
} onClick(): void {
if (this.showDropdownOnFocus()) this.openPreview();
} }
onBlur(): void { onBlur(): void {
@@ -305,6 +348,19 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; } optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; } optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; }
isOptionSelected(item: TItem): boolean {
return this.formValue !== null && this.valuesEqual(this.valueWith()(item), this.formValue);
}
optionClass(item: TItem, index: number): string {
const highlighted = this.activeIndex() === index;
const selected = this.isOptionSelected(item);
return [
'block w-full px-3 py-2 text-start text-[0.8125rem] transition-colors',
highlighted || selected
? 'bg-primary text-white'
: 'bg-white text-defaulttextcolor hover:bg-primary hover:text-white dark:bg-bodybg dark:text-white/70 dark:hover:bg-primary dark:hover:text-white'
].join(' ');
}
private setActive(index: number): void { private setActive(index: number): void {
if (index < 0 || index >= this.options().length) return; if (index < 0 || index >= this.options().length) return;
@@ -319,6 +375,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
this.options.set([]); this.options.set([]);
this.error.set(null); this.error.set(null);
this.loading.set(false); this.loading.set(false);
this.showingPreview.set(false);
this.labelEdited = false; this.labelEdited = false;
this.onChange(null); this.onChange(null);
this.onTouched(); this.onTouched();
@@ -335,4 +392,16 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
} }
private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); } private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); }
private hasResolvableValue(value: TValue | null): value is TValue {
return value !== null && (typeof value !== 'string' || value.trim().length > 0);
}
private openPreview(): void {
if (this.isDisabled() || this.readonly() || this.isOpen()) return;
this.open();
if (!this.searchText().trim()) {
this.searchRequests$.next({ term: '', preview: true });
}
}
} }
@@ -1,45 +1,49 @@
<div [class]="resolvedWrapperClass()"> <div [class]="resolvedWrapperClass()">
@if (showLabel()) { @if (showLabel()) {
<label [for]="inputId()" [class]="resolvedLabelClass()" > <label [for]="inputId()" [class]="resolvedLabelClass()">
{{ label() }} {{ label() }}
@if (required()) { @if (required()) {
<span [class]="requiredClass()" aria-hidden="true" > <span [class]="requiredClass()" aria-hidden="true">
* *
</span> </span>
<span class="sr-only"> <span class="sr-only">
Required Required
</span> </span>
} }
</label>
@if (showHelpIcon()) {
<button type="button" class="text-muted hover:text-primary" [attr.aria-label]="help()" tooltipVariant="info" [appTooltip]="resolvedHelp()"
aria-label="Field help">
<i class="ti ti-info-circle"></i>
</button>
}
</label>
} }
<div [class]="resolvedContentClass()"> <div [class]="resolvedContentClass()">
@if (description()) { @if (description()) {
<p [id]="descriptionId()" [class]="descriptionClass()" > <p [id]="descriptionId()" [class]="descriptionClass()">
{{ description() }} {{ description() }}
</p> </p>
} }
<ng-content /> <ng-content />
@if (showHint()) { @if (showHint()) {
<p <p [id]="hintId()" [class]="hintClass()">
[id]="hintId()" {{ hint() }}
[class]="hintClass()" </p>
>
{{ hint() }}
</p>
} }
@if (!hideValidation()) { @if (!hideValidation()) {
<app-form-validation-message <app-form-validation-message [id]="validationId()" [fieldName]="label()" [control]="control()"
[id]="validationId()" [messages]="validationMessages()" [showWhenDirty]="showValidationWhenDirty()"
[fieldName]="label()" [submitAttempted]="submitAttempted()" />
[control]="control()"
[messages]="validationMessages()"
[showWhenDirty]="showValidationWhenDirty()"
/>
} }
</div> </div>
</div> </div>
@@ -12,13 +12,14 @@ import {
FormValidationMessage, FormValidationMessage,
ValidationMessageMap ValidationMessageMap
} from '../form-validation-message/form-validation-message'; } from '../form-validation-message/form-validation-message';
import { TooltipDirective } from '../../../directives/tooltip/tooltip.directive';
export type FormLabelPosition = 'top' | 'left' | 'hidden'; export type FormLabelPosition = 'top' | 'left' | 'hidden';
@Component({ @Component({
selector: 'app-form-field', selector: 'app-form-field',
standalone: true, standalone: true,
imports: [FormValidationMessage], imports: [FormValidationMessage, TooltipDirective],
templateUrl: './form-field.html', templateUrl: './form-field.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -36,6 +37,15 @@ export class FormField {
readonly description = input<string | null>(null); readonly description = input<string | null>(null);
readonly hint = input<string | null>(null); readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly showHelpIcon = computed(() => {
return !!this.help()?.trim();
});
readonly resolvedHelp = computed(() => {
return this.help()?.trim() ?? '';
});
readonly labelPosition = input<FormLabelPosition>('top'); readonly labelPosition = input<FormLabelPosition>('top');
@@ -120,9 +130,8 @@ export class FormField {
} }
return ( return (
control.touched || this.submitAttempted() ||
control.dirty || (this.showValidationWhenDirty() && control.dirty)
this.submitAttempted()
); );
}); });
@@ -6,6 +6,7 @@
[disabled]="isDisabled()" [disabled]="isDisabled()"
[description]="description()" [description]="description()"
[hint]="hint()" [hint]="hint()"
[help]="help()"
[hideValidation]="hideValidation()" [hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()" [showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()" [submitAttempted]="submitAttempted()"
@@ -7,7 +7,7 @@ import { ValidationMessageMap } from '../form-validation-message/form-validation
export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search'; export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search';
export type FormInputMode = | 'none'| 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; export type FormInputMode = | 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
export type FormInputIconPosition = 'left' | 'right'; export type FormInputIconPosition = 'left' | 'right';
@@ -28,7 +28,7 @@ export type FormInputIconPosition = 'left' | 'right';
export class FormInput implements ControlValueAccessor { export class FormInput implements ControlValueAccessor {
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
readonly inputId = input.required<string>(); readonly inputId = input.required<string>();
readonly label = input.required<string>(); readonly label = input.required<string>();
@@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor {
readonly pattern = input<string | null>(null); readonly pattern = input<string | null>(null);
readonly min = input<number | null>(null); readonly min = input<number | null>(null);
readonly max = input<number | null>(null); readonly max = input<number | null>(null);
readonly step = input<number | string | null>(null); readonly step = input<number | string | null>(null);
readonly description = input<string | null>(null); readonly description = input<string | null>(null);
readonly hint = input<string | null>(null); readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly hideValidation = input(false); readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false); readonly showValidationWhenDirty = input(false);
@@ -78,25 +79,25 @@ export class FormInput implements ControlValueAccessor {
readonly showPasswordToggle = input(true); readonly showPasswordToggle = input(true);
readonly loading = input(false); readonly loading = input(false);
readonly wrapperClass = input(''); readonly wrapperClass = input('');
readonly fieldContentClass = input(''); readonly fieldContentClass = input('');
readonly labelClass = input(''); readonly labelClass = input('');
readonly inputClass = input(''); readonly inputClass = input('');
readonly ariaLabel = input<string | null>(null); readonly ariaLabel = input<string | null>(null);
readonly ariaDescription = input<string | null>(null); readonly ariaDescription = input<string | null>(null);
readonly value = signal<string | number | null>(null); readonly value = signal<string | number | null>(null);
readonly formDisabled = signal(false); readonly formDisabled = signal(false);
readonly passwordVisible = signal(false); readonly passwordVisible = signal(false);
private readonly controlStateVersion = signal(0); private readonly controlStateVersion = signal(0);
private onChange: (value: string | number | null) => void = () => {}; private onChange: (value: string | number | null) => void = () => { };
private onTouched: () => void = () => {}; private onTouched: () => void = () => { };
constructor() { constructor() {
effect(() => { effect(() => {
@@ -192,9 +193,8 @@ export class FormInput implements ControlValueAccessor {
const showInvalidState = !!( const showInvalidState = !!(
control?.invalid && control?.invalid &&
( (
control.touched || this.submitAttempted() ||
control.dirty || (this.showValidationWhenDirty() && control.dirty)
this.submitAttempted()
) )
); );
@@ -228,9 +228,8 @@ export class FormInput implements ControlValueAccessor {
if ( if (
control?.invalid && control?.invalid &&
( (
control.touched || this.submitAttempted() ||
control.dirty || (this.showValidationWhenDirty() && control.dirty)
this.submitAttempted()
) )
) { ) {
ids.push(`${this.inputId()}-validation`); ids.push(`${this.inputId()}-validation`);
@@ -367,9 +367,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
if ( if (
control?.invalid && control?.invalid &&
( (
control.touched || this.submitAttempted() ||
control.dirty || (this.showValidationWhenDirty() && control.dirty)
this.submitAttempted()
) )
) { ) {
ids.push(`${this.resolvedInputId()}-validation`); ids.push(`${this.resolvedInputId()}-validation`);
@@ -406,9 +405,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
const showInvalidState = !!( const showInvalidState = !!(
control?.invalid && control?.invalid &&
( (
control.touched || this.submitAttempted() ||
control.dirty || (this.showValidationWhenDirty() && control.dirty)
this.submitAttempted()
) )
); );
@@ -71,9 +71,8 @@ export class FormValidationMessage {
} }
return ( return (
control.touched || this.submitAttempted() ||
control.dirty || (this.showWhenDirty() && control.dirty)
this.submitAttempted()
); );
}); });
@@ -1,10 +1,24 @@
import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core'; import {
ComponentRef,
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; Directive,
ElementRef,
HostListener,
OnDestroy,
effect,
inject,
input
} from '@angular/core';
import {
ConnectedPosition,
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentPortal } from '@angular/cdk/portal';
import { Tooltip } from './tooltip/tooltip'; import {
Tooltip
} from './tooltip/tooltip';
import { TooltipVariant } from './tooltip/tooltip';
export type TooltipPosition = export type TooltipPosition =
| 'top' | 'top'
@@ -27,20 +41,42 @@ export class TooltipDirective implements OnDestroy {
readonly tooltipPosition = readonly tooltipPosition =
input<TooltipPosition>('top'); input<TooltipPosition>('top');
readonly tooltipVariant =
input<TooltipVariant>('default');
readonly tooltipDisabled = input(false); readonly tooltipDisabled = input(false);
readonly tooltipDelay = input(200); readonly tooltipDelay = input(200);
private overlayRef: OverlayRef | null = null; private overlayRef: OverlayRef | null = null;
private showTimeout: ReturnType<typeof setTimeout> | null = null; private tooltipComponentRef:
ComponentRef<Tooltip> | null = null;
private showTimeout:
ReturnType<typeof setTimeout> | null = null;
constructor() {
effect(() => {
const text = this.appTooltip();
const variant = this.tooltipVariant();
this.tooltipComponentRef?.setInput('text', text);
this.tooltipComponentRef?.setInput(
'variant',
variant
);
});
}
@HostListener('mouseenter') @HostListener('mouseenter')
@HostListener('focusin') @HostListener('focusin')
show(): void { show(): void {
const tooltipText = this.appTooltip().trim();
if ( if (
this.tooltipDisabled() || this.tooltipDisabled() ||
!this.appTooltip() tooltipText.length === 0
) { ) {
return; return;
} }
@@ -48,8 +84,9 @@ export class TooltipDirective implements OnDestroy {
this.clearTimeout(); this.clearTimeout();
this.showTimeout = setTimeout(() => { this.showTimeout = setTimeout(() => {
this.showTimeout = null;
this.openTooltip(); this.openTooltip();
}, this.tooltipDelay()); }, Math.max(0, this.tooltipDelay()));
} }
@HostListener('mouseleave') @HostListener('mouseleave')
@@ -60,37 +97,49 @@ export class TooltipDirective implements OnDestroy {
} }
private openTooltip(): void { private openTooltip(): void {
if (this.overlayRef) { if (this.overlayRef?.hasAttached()) {
return; return;
} }
const positionStrategy = this.overlay const positionStrategy = this.overlay
.position() .position()
.flexibleConnectedTo(this.elementRef) .flexibleConnectedTo(
.withPositions(this.getPositions()); this.elementRef.nativeElement
)
.withPositions(this.getPositions())
.withPush(true);
this.overlayRef = this.overlay.create({ this.overlayRef = this.overlay.create({
positionStrategy, positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition() scrollStrategy:
this.overlay.scrollStrategies.reposition()
}); });
const portal = new ComponentPortal(Tooltip); const portal = new ComponentPortal(Tooltip);
const componentRef = this.overlayRef.attach(portal); this.tooltipComponentRef =
this.overlayRef.attach(portal);
componentRef.setInput( this.tooltipComponentRef.setInput(
'text', 'text',
this.appTooltip() this.appTooltip().trim()
);
this.tooltipComponentRef.setInput(
'variant',
this.tooltipVariant()
); );
} }
private closeTooltip(): void { private closeTooltip(): void {
this.overlayRef?.dispose(); this.overlayRef?.dispose();
this.overlayRef = null; this.overlayRef = null;
this.tooltipComponentRef = null;
} }
private clearTimeout(): void { private clearTimeout(): void {
if (!this.showTimeout) { if (this.showTimeout === null) {
return; return;
} }
@@ -110,6 +159,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'center', overlayX: 'center',
overlayY: 'bottom', overlayY: 'bottom',
offsetY: -8 offsetY: -8
},
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: 8
} }
], ],
@@ -120,6 +176,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'center', overlayX: 'center',
overlayY: 'top', overlayY: 'top',
offsetY: 8 offsetY: 8
},
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -8
} }
], ],
@@ -130,6 +193,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'end', overlayX: 'end',
overlayY: 'center', overlayY: 'center',
offsetX: -8 offsetX: -8
},
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: 8
} }
], ],
@@ -140,6 +210,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'start', overlayX: 'start',
overlayY: 'center', overlayY: 'center',
offsetX: 8 offsetX: 8
},
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -8
} }
] ]
}; };
@@ -1,7 +1,7 @@
<div <!-- <div
class=" class="
pointer-events-none pointer-events-none
max-w-[250px] max-w-xs
whitespace-normal whitespace-normal
rounded-sm rounded-sm
bg-primary bg-primary
@@ -16,4 +16,11 @@
role="tooltip" role="tooltip"
> >
{{ text() }} {{ text() }}
</div> -->
<div
[class]="resolvedTooltipClass()"
role="tooltip"
>
{{ text() }}
</div> </div>
@@ -1,9 +1,15 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
input input,
computed
} from '@angular/core'; } from '@angular/core';
export type TooltipVariant =
| 'default'
| 'info';
@Component({ @Component({
selector: 'app-tooltip', selector: 'app-tooltip',
standalone: true, standalone: true,
@@ -12,4 +18,42 @@ import {
}) })
export class Tooltip { export class Tooltip {
readonly text = input.required<string>(); readonly text = input.required<string>();
readonly variant =
input<TooltipVariant>('default');
readonly resolvedTooltipClass = computed(() => {
const baseClasses = [
'pointer-events-none',
'max-w-xs',
'whitespace-normal',
'rounded-sm',
'px-2',
'py-1',
'text-xs',
'font-medium',
'leading-4'
];
const variantClasses =
this.variant() === 'info'
? [
'border',
'border-defaultborder',
'bg-white',
'text-defaulttextcolor',
'shadow-lg',
'dark:bg-bodybg'
]
: [
'bg-primary',
'text-white',
'shadow-sm'
];
return [
...baseClasses,
...variantClasses
].join(' ');
});
} }