add endpoint configuration for city, currency, language, and timezone

This commit is contained in:
Gagan7900
2026-07-15 20:19:46 +05:30
parent 095aed204c
commit be86ab3564
72 changed files with 7874 additions and 471 deletions
+7
View File
@@ -45,3 +45,10 @@ __screenshots__/
# System files
.DS_Store
Thumbs.db
# Ignore local ESLint config
eslint.config.js
# Ignore preview folder
preview/
+27 -6
View File
@@ -3,7 +3,10 @@
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "0b3da18f-5d81-4a09-9b2f-b0ce36040773"
"analytics": "0b3da18f-5d81-4a09-9b2f-b0ce36040773",
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
@@ -23,7 +26,7 @@
"build": {
"builder": "@angular/build:application",
"options": {
"allowedCommonJsDependencies": [
"allowedCommonJsDependencies": [
"sweetalert2",
"inputmask",
"filepond",
@@ -53,13 +56,22 @@
"src/.htaccess"
],
"styles": [
"node_modules/@ng-select/ng-select/themes/default.theme.css",
"src/styles.scss"
],
"scripts": ["node_modules/preline/dist/preline.js"]
"scripts": [
"node_modules/preline/dist/preline.js"
]
},
"configurations": {
"production": {
"baseHref": "/angular/ynex-tailwind/preview/",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"baseHref": "/",
"budgets": [
{
"type": "initial",
@@ -80,7 +92,7 @@
"sourceMap": true
}
},
"defaultConfiguration": "production"
"defaultConfiguration": "development"
},
"serve": {
"builder": "@angular/build:dev-server",
@@ -96,8 +108,17 @@
},
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
}
}
}
+1789
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -9,7 +9,8 @@
"test": "ng test",
"sass": "sass ./public/assets/scss:./public/assets/css/",
"sass-min": "sass ./public/assets/scss:./public/assets/css/ --style compressed",
"postcss": "sass ./public/assets/scss:./public/assets/css && postcss ./public/assets/css/*.css --dir ./public/assets/css"
"postcss": "sass ./public/assets/scss:./public/assets/css && postcss ./public/assets/css/*.css --dir ./public/assets/css",
"lint": "ng lint"
},
"prettier": {
"printWidth": 100,
@@ -38,6 +39,7 @@
"@angular/platform-browser": "^21.2.10",
"@angular/platform-browser-dynamic": "^21.2.10",
"@angular/router": "^21.2.10",
"@ng-select/ng-select": "^21.8.2",
"@tailwindcss/forms": "^0.5.11",
"@tsparticles/angular": "^3.0.0",
"apexcharts": "^5.10.5",
@@ -51,6 +53,7 @@
"preline": "^4.1.2",
"rxjs": "~7.8.0",
"simplebar-angular": "^3.3.2",
"sweetalert2": "^11.26.25",
"tslib": "^2.3.0",
"tsparticles": "^3.9.1"
},
@@ -58,13 +61,17 @@
"@angular/build": "^21.0.5",
"@angular/cli": "^21.0.5",
"@angular/compiler-cli": "^21.2.10",
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.2.2",
"angular-eslint": "21.4.0",
"autoprefixer": "^10.4.27",
"eslint": "^10.3.0",
"jsdom": "^27.1.0",
"postcss": "^8.5.8",
"postcss-cli": "^11.0.1",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.2",
"typescript-eslint": "8.59.2",
"vitest": "^4.0.8"
}
}
}
@@ -0,0 +1,11 @@
import { buildApiUrl } from '../../config/api-url.util';
export const CITY_ENDPOINTS = {
dataTable: buildApiUrl('masterAdmin', '/v1/cities/datatable'),
create: buildApiUrl('masterAdmin', '/v1/cities'),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
update: (id: string) =>
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
autocomplete: buildApiUrl('masterAdmin', '/v1/cities/autocomplete')
} as const;
@@ -18,6 +18,11 @@ export const COUNTRY_ENDPOINTS = {
`/v1/countries/${encodeURIComponent(id)}`
),
autocomplete: buildApiUrl(
'masterAdmin',
'/v1/countries/autocomplete'
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
@@ -35,4 +40,4 @@ export const COUNTRY_ENDPOINTS = {
'masterAdmin',
`/v1/countries/${encodeURIComponent(id)}/status`
),
} as const;
} as const;
@@ -0,0 +1,44 @@
import { buildApiUrl } from '../../config/api-url.util';
export const CURRENCY_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/currencies/datatable'
),
create: buildApiUrl(
'masterAdmin',
'/v1/currencies'
),
getById: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/currencies/${encodeURIComponent(id)}`
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/currencies/${encodeURIComponent(id)}`
),
delete: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/currencies/${encodeURIComponent(id)}`
),
changeStatus: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/currencies/${encodeURIComponent(id)}/status`
),
autocomplete:
buildApiUrl(
'masterAdmin',
'/v1/currencies/autocomplete'
),
} as const;
@@ -0,0 +1,11 @@
import { buildApiUrl } from '../../config/api-url.util';
export const LANGUAGE_ENDPOINTS = {
dataTable: buildApiUrl('masterAdmin', '/v1/languages/datatable'),
create: buildApiUrl('masterAdmin', '/v1/languages'),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
update: (id: string) =>
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete')
} as const;
@@ -18,6 +18,11 @@ export const STATE_ENDPOINTS = {
`/v1/states/${encodeURIComponent(id)}`
),
autocomplete: buildApiUrl(
'masterAdmin',
'/v1/states/autocomplete'
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
@@ -35,4 +40,4 @@ export const STATE_ENDPOINTS = {
'masterAdmin',
`/v1/states/${encodeURIComponent(id)}/status`
),
} as const;
} as const;
@@ -0,0 +1,11 @@
import { buildApiUrl } from '../../config/api-url.util';
export const TIMEZONE_ENDPOINTS = {
dataTable: buildApiUrl('masterAdmin', '/v1/timezones/datatable'),
create: buildApiUrl('masterAdmin', '/v1/timezones'),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
update: (id: string) =>
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
autocomplete: buildApiUrl('masterAdmin', '/v1/timezones/autocomplete')
} as const;
+18 -4
View File
@@ -4,18 +4,32 @@ import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth/auth.service';
import { API_CONFIG } from '../config/api.config';
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
const RETRY_HEADER = 'X-Auth-Retry';
function isEndpointRequest(requestUrl: string, endpointUrl: string): boolean {
return (
requestUrl === endpointUrl ||
requestUrl.startsWith(`${endpointUrl}?`)
);
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
const isAuthRequest = req.url.includes(authBaseUrl);
const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`);
const isLoginRequest = isEndpointRequest(
req.url,
AUTH_ENDPOINTS.login
);
if (isAuthRequest) {
const isRefreshRequest = isEndpointRequest(
req.url,
AUTH_ENDPOINTS.refresh
);
if (isLoginRequest || isRefreshRequest) {
return next(req);
}
+26
View File
@@ -0,0 +1,26 @@
export interface CityDto {
id: string;
stateId: string;
name: string;
code: string | null;
timezoneId: string | null;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CreateCityRequest {
stateId: string;
name: string;
code: string;
timezoneId: string | null;
}
export interface UpdateCityRequest {
name: string;
code: string;
timezoneId: string | null;
isActive: boolean;
}
export type CityModalMode = 'create' | 'edit' ;
+22 -2
View File
@@ -5,7 +5,27 @@ export interface CountryDto {
name: string;
phoneCode: string | null;
defaultCurrencyId: string | null;
isActive?: boolean;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export type CountryModalMode = 'create' | 'edit';
export interface CountryLookupDto {
id: string;
iso2: string;
name: string;
}
export interface CreateCountryRequest {
iso2: string;
iso3: string;
name: string;
phoneCode: string | null;
defaultCurrencyId: string | null;
}
export interface UpdateCountryRequest extends CreateCountryRequest {
isActive: boolean;
}
export type CountryModalMode = 'create' | 'edit';
@@ -0,0 +1,33 @@
export interface CurrencyDto {
id: string;
code: string;
name: string;
symbol: string;
numericCode: number;
decimalDigits: number;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CurrencyLookupDto {
readonly id: string;
readonly code: string;
readonly name: string;
readonly symbol: string;
}
export interface CreateCurrencyRequest {
code: string;
name: string;
symbol: string;
numericCode: number;
decimalDigits: number;
}
export interface UpdateCurrencyRequest extends CreateCurrencyRequest {
isActive: boolean;
}
export type CurrencyModalMode = 'create' | 'edit';
@@ -0,0 +1,31 @@
export interface LanguageDto {
id: string;
code: string;
name: string;
nativeName: string;
isRightToLeft: boolean;
isActive: boolean;
createdOn: string;
modifiedOn: string | null;
}
export interface LanguageLookupDto {
readonly id: string;
readonly code: string;
readonly name: string;
readonly nativeName: string;
readonly isRightToLeft: boolean;
}
export interface CreateLanguageRequest {
code: string;
name: string;
nativeName: string;
isRightToLeft: boolean;
}
export interface UpdateLanguageRequest extends CreateLanguageRequest {
isActive: boolean;
}
export type LanguageModalMode = 'create' | 'edit';
+30
View File
@@ -0,0 +1,30 @@
export interface StateDto {
id: string;
countryId: string;
name: string;
code: string | null;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface StateLookupDto {
id: string;
name: string;
code: string;
}
export interface CreateStateRequest {
countryId: string;
name: string;
code: string;
}
export interface UpdateStateRequest {
countryId: null;
name: string;
code: string;
isActive: boolean;
}
export type StateModalMode = 'create' | 'edit';
@@ -0,0 +1,27 @@
export interface TimezoneDto {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
readonly utcOffsetMinutes: number;
readonly isActive: boolean;
readonly createdOn: string;
readonly modifiedOn: string | null;
}
export interface TimezoneLookupDto {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
}
export interface CreateTimezoneRequest {
readonly ianaId: string;
readonly displayName: string;
readonly utcOffsetMinutes: number;
}
export interface UpdateTimezoneRequest extends CreateTimezoneRequest {
readonly isActive: boolean;
}
export type TimezoneModalMode = 'create' | 'edit' | 'view';
@@ -121,7 +121,6 @@ export class TokenStorageService {
}
private isExpired(expiresOn: string | null): boolean {
debugger;
if (!expiresOn) {
return true;
}
@@ -0,0 +1,42 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { CITY_ENDPOINTS } from '../../end-points/city/city.endpoints';
import {
CityDto,
CreateCityRequest,
UpdateCityRequest
} from '../../models/city/city.model';
import {
DataTableQuery,
DataTableResult
} from '../../../shared/components/data-table/data-table.types';
@Injectable({ providedIn: 'root' })
export class CityService {
private readonly http = inject(HttpClient);
getCityDataTable(
query: DataTableQuery,
stateId: string
): Observable<DataTableResult<CityDto>> {
return this.http.post<DataTableResult<CityDto>>(
CITY_ENDPOINTS.dataTable,
query,
{ params: new HttpParams().set('stateId', stateId) }
);
}
createCity(request: CreateCityRequest): Observable<CityDto> {
return this.http.post<CityDto>(CITY_ENDPOINTS.create, request);
}
updateCity(id: string, request: UpdateCityRequest): Observable<CityDto> {
return this.http.put<CityDto>(CITY_ENDPOINTS.update(id), request);
}
getCityById(id: string): Observable<CityDto> {
return this.http.get<CityDto>(CITY_ENDPOINTS.getById(id));
}
}
+4 -1
View File
@@ -33,6 +33,9 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false,
dirchange: false,
children: [
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false },
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false },
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false },
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false },
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false },
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
@@ -75,4 +78,4 @@ export const SAAS_MENU_DATA: MenuContext = {
],
},
],
};
};
@@ -1,8 +1,14 @@
import { HttpClient } from "@angular/common/http";
import { HttpClient, HttpParams } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { COUNTRY_ENDPOINTS } from "../../../core/end-points/country/country.endpoints";
import { Observable } from "rxjs";
import { DataTableQuery, DataTableResult } from "../../../shared/components/data-table/data-table.types";
import {
CountryDto,
CountryLookupDto,
CreateCountryRequest,
UpdateCountryRequest
} from "../../models/country/country.model";
@Injectable({
providedIn: 'root'
@@ -11,7 +17,27 @@ export class CountryService {
private readonly http = inject(HttpClient);
getCountryDataTable(query: DataTableQuery): Observable<DataTableResult<any>> {
return this.http.post<DataTableResult<any>>(`${COUNTRY_ENDPOINTS.dataTable}`, query);
getCountryDataTable(query: DataTableQuery): Observable<DataTableResult<CountryDto>> {
return this.http.post<DataTableResult<CountryDto>>(`${COUNTRY_ENDPOINTS.dataTable}`, query);
}
}
createCountry(request: CreateCountryRequest): Observable<CountryDto> {
return this.http.post<CountryDto>(COUNTRY_ENDPOINTS.create, request);
}
updateCountry(id: string, request: UpdateCountryRequest): Observable<CountryDto> {
return this.http.put<CountryDto>(COUNTRY_ENDPOINTS.update(id), request);
}
getCountryById(id: string): Observable<CountryDto> {
return this.http.get<CountryDto>(COUNTRY_ENDPOINTS.getById(id));
}
autocomplete(term = '', limit = 50): Observable<CountryLookupDto[]> {
return this.http.get<CountryLookupDto[]>(COUNTRY_ENDPOINTS.autocomplete, {
params: new HttpParams()
.set('term', term)
.set('limit', limit)
});
}
}
@@ -0,0 +1,46 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { CURRENCY_ENDPOINTS } from '../../../core/end-points/currency/currency.endpoints';
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
import {
CreateCurrencyRequest,
CurrencyDto,
CurrencyLookupDto,
UpdateCurrencyRequest
} from '../../models/currency/currency.model';
@Injectable({
providedIn: 'root'
})
export class CurrencyService {
private readonly http = inject(HttpClient);
getCurrencyDataTable(query: DataTableQuery): Observable<DataTableResult<CurrencyDto>> {
return this.http.post<DataTableResult<CurrencyDto>>(CURRENCY_ENDPOINTS.dataTable, query);
}
createCurrency(request: CreateCurrencyRequest): Observable<CurrencyDto> {
return this.http.post<CurrencyDto>(CURRENCY_ENDPOINTS.create, request);
}
updateCurrency(id: string, request: UpdateCurrencyRequest): Observable<CurrencyDto> {
return this.http.put<CurrencyDto>(CURRENCY_ENDPOINTS.update(id), request);
}
getCurrencyById(id: string): Observable<CurrencyDto> {
return this.http.get<CurrencyDto>(CURRENCY_ENDPOINTS.getById(id));
}
autocomplete(term: string | null, limit = 10): Observable<readonly CurrencyLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly CurrencyLookupDto[]>(CURRENCY_ENDPOINTS.autocomplete, { params });
}
}
@@ -0,0 +1,48 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { LANGUAGE_ENDPOINTS } from '../../end-points/language/language.endpoints';
import {
CreateLanguageRequest,
LanguageDto,
LanguageLookupDto,
UpdateLanguageRequest
} from '../../models/language/language.model';
import {
DataTableQuery,
DataTableResult
} from '../../../shared/components/data-table/data-table.types';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private readonly http = inject(HttpClient);
getDataTable(query: DataTableQuery): Observable<DataTableResult<LanguageDto>> {
return this.http.post<DataTableResult<LanguageDto>>(LANGUAGE_ENDPOINTS.dataTable, query);
}
getById(id: string): Observable<LanguageDto> {
return this.http.get<LanguageDto>(LANGUAGE_ENDPOINTS.getById(id));
}
create(request: CreateLanguageRequest): Observable<LanguageDto> {
return this.http.post<LanguageDto>(LANGUAGE_ENDPOINTS.create, request);
}
update(id: string, request: UpdateLanguageRequest): Observable<LanguageDto> {
return this.http.put<LanguageDto>(LANGUAGE_ENDPOINTS.update(id), request);
}
autocomplete(
term: string | null,
limit = 10
): Observable<readonly LanguageLookupDto[]> {
let params = new HttpParams().set('limit', limit);
if (term !== null) {
params = params.set('term', term);
}
return this.http.get<readonly LanguageLookupDto[]>(LANGUAGE_ENDPOINTS.autocomplete, { params });
}
}
+33 -4
View File
@@ -1,8 +1,14 @@
import { HttpClient } from "@angular/common/http";
import { HttpClient, HttpParams } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { STATE_ENDPOINTS } from "../../end-points/state/state.endpoints"
import { Observable } from "rxjs";
import { DataTableQuery, DataTableResult } from "../../../shared/components/data-table/data-table.types";
import {
CreateStateRequest,
StateDto,
StateLookupDto,
UpdateStateRequest
} from "../../models/state/state.model";
@Injectable({
providedIn: 'root'
@@ -11,7 +17,30 @@ export class StateService {
private readonly http = inject(HttpClient);
getStateDataTable(query: DataTableQuery, countryId: string): Observable<DataTableResult<any>> {
return this.http.post<DataTableResult<any>>(`${STATE_ENDPOINTS.dataTable}`, { ...query, countryId });
getStateDataTable(query: DataTableQuery, countryId: string): Observable<DataTableResult<StateDto>> {
return this.http.post<DataTableResult<StateDto>>(`${STATE_ENDPOINTS.dataTable}`, query, {
params: new HttpParams().set('countryId', countryId)
});
}
}
createState(request: CreateStateRequest): Observable<StateDto> {
return this.http.post<StateDto>(STATE_ENDPOINTS.create, request);
}
updateState(id: string, request: UpdateStateRequest): Observable<StateDto> {
return this.http.put<StateDto>(STATE_ENDPOINTS.update(id), request);
}
getStateById(id: string): Observable<StateDto> {
return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id));
}
autocomplete(countryId: string, term = '', limit = 50): Observable<StateLookupDto[]> {
return this.http.get<StateLookupDto[]>(STATE_ENDPOINTS.autocomplete, {
params: new HttpParams()
.set('countryId', countryId)
.set('term', term)
.set('limit', limit)
});
}
}
@@ -0,0 +1,45 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { TIMEZONE_ENDPOINTS } from '../../end-points/timezone/timezone.endpoints';
import {
CreateTimezoneRequest,
TimezoneDto,
TimezoneLookupDto,
UpdateTimezoneRequest
} from '../../models/timezone/timezone.model';
import {
DataTableQuery,
DataTableResult
} from '../../../shared/components/data-table/data-table.types';
@Injectable({ providedIn: 'root' })
export class TimezoneService {
private readonly http = inject(HttpClient);
getDataTable(query: DataTableQuery): Observable<DataTableResult<TimezoneDto>> {
return this.http.post<DataTableResult<TimezoneDto>>(TIMEZONE_ENDPOINTS.dataTable, query);
}
getById(id: string): Observable<TimezoneDto> {
return this.http.get<TimezoneDto>(TIMEZONE_ENDPOINTS.getById(id));
}
create(request: CreateTimezoneRequest): Observable<TimezoneDto> {
return this.http.post<TimezoneDto>(TIMEZONE_ENDPOINTS.create, request);
}
update(id: string, request: UpdateTimezoneRequest): Observable<TimezoneDto> {
return this.http.put<TimezoneDto>(TIMEZONE_ENDPOINTS.update(id), request);
}
autocomplete(term: string | null, limit = 10): Observable<readonly TimezoneLookupDto[]> {
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 TimezoneLookupDto[]>(TIMEZONE_ENDPOINTS.autocomplete, { params });
}
}
@@ -1 +1,165 @@
<p>city-list works!</p>
<!-- Start::row-1 -->
<div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12">
<div class="box custom-box">
<div class="box-body p-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">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
<app-autocomplete
formControlName="countryId"
inputId="city-country-filter"
label="Country"
placeholder="Search country"
[searchFn]="searchCountries"
[displayWith]="displayCountry"
[valueWith]="countryValue"
[selectedItem]="selectedCountry()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="true"
[hideLabel]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterCountrySelected($event)"
(cleared)="onFilterCountryCleared()"
/>
</div>
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
<app-autocomplete
formControlName="stateId"
inputId="city-state-filter"
label="State"
[placeholder]="filterStatePlaceholder()"
[searchFn]="searchFilterStates"
[displayWith]="displayState"
[valueWith]="stateValue"
[selectedItem]="selectedFilterState()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="true"
[disabled]="!selectedCountryId()"
[hideLabel]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterStateSelected($event)"
(cleared)="onFilterStateCleared()"
/>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- End::row-1 -->
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
[showAddButton]="canAddCity()" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)" toolTip="Add City" />
<modal [open]="showCityModal()" [title]="modalTitle()" size="lg"
[submitAction]="modalMode() === 'create' ? 'save' : 'update'" [submitLabel]="submitLabel()"
[loadingLabel]="loadingLabel()" [loading]="saving()"
(closed)="closeCityModal()" (submitted)="saveCity()">
<form [formGroup]="cityForm" (ngSubmit)="saveCity()" autocomplete="off">
<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="city-country"
label="Country"
placeholder="Search country"
[searchFn]="searchCountries"
[displayWith]="displayCountry"
[valueWith]="countryValue"
[selectedItem]="selectedFormCountry()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="false"
[required]="true"
[readonly]="modalMode() !== 'create'"
[validationMessages]="{ required: 'Country is required.' }"
[submitAttempted]="submitAttempted()"
wrapperClass="w-full"
(itemSelected)="onFormCountrySelected($event)"
(cleared)="onFormCountryCleared()"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="stateId"
inputId="city-state"
label="State"
[placeholder]="formStatePlaceholder()"
[searchFn]="searchFormStates"
[displayWith]="displayState"
[valueWith]="stateValue"
[selectedItem]="selectedFormState()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="false"
[required]="true"
[disabled]="!cityForm.controls.countryId.value"
[readonly]="modalMode() !== 'create'"
[validationMessages]="{ required: 'State is required.' }"
[submitAttempted]="submitAttempted()"
wrapperClass="w-full"
(itemSelected)="onFormStateSelected($event)"
(cleared)="onFormStateCleared()"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="name" inputId="city-name" label="City Name"
placeholder="Enter city name" autocomplete="off" [required]="true"
[maxLength]="150" [validationMessages]="{
required: 'City Name is required.',
maxlength: 'City Name cannot exceed 150 characters.'
}" [submitAttempted]="submitAttempted()" />
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="code" inputId="city-code" label="City Code"
placeholder="Enter city code" autocomplete="off" [required]="true"
[maxLength]="16" [validationMessages]="{
required: 'City Code is required.',
maxlength: 'City Code cannot exceed 16 characters.',
pattern: 'City Code can contain letters, numbers, hyphens, and underscores only.'
}" [submitAttempted]="submitAttempted()" />
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="timezoneId"
inputId="city-timezone"
label="Timezone"
placeholder="Search timezone"
[searchFn]="searchTimezones"
[displayWith]="displayTimezone"
[valueWith]="timezoneValue"
[resolveValueFn]="resolveTimezone"
[minSearchLength]="2"
[debounceTime]="300"
[limit]="10"
emptyText="No timezones found"
[submitAttempted]="submitAttempted()"
/>
</div>
</div>
</form>
</modal>
@@ -1,11 +1,561 @@
import { Component } from '@angular/core';
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,
debounceTime,
distinctUntilChanged,
finalize,
map,
of,
switchMap,
take
} from 'rxjs';
import {
CityDto,
CityModalMode,
CreateCityRequest,
UpdateCityRequest
} from '../../../../../core/models/city/city.model';
import { CountryLookupDto } from '../../../../../core/models/country/country.model';
import { StateLookupDto } from '../../../../../core/models/state/state.model';
import { TimezoneDto, TimezoneLookupDto } from '../../../../../core/models/timezone/timezone.model';
import { CityService } from '../../../../../core/services/city/city.service';
import { CountryService } from '../../../../../core/services/country/country.service';
import { StateService } from '../../../../../core/services/state/state.service';
import { TimezoneService } from '../../../../../core/services/timezone/timezone.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 { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Modal } from '../../../../../shared/components/modal/modal';
interface CityTableRow extends DataTableRecord {
id: string;
stateId: string;
name: string;
code: string | null;
timezoneId: string | null;
isActive: boolean;
serialNumber: number;
stateName: string;
countryName: string;
createdOn?: string;
modifiedOn?: string | null;
}
@Component({
selector: 'city-list',
imports: [],
standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete],
templateUrl: './city-list.html',
styleUrl: './city-list.scss',
styleUrl: './city-list.scss'
})
export class CityList {
private readonly destroyRef = inject(DestroyRef);
private readonly cityApi = inject(CityService);
private readonly countryApi = inject(CountryService);
private readonly stateApi = inject(StateService);
private readonly timezoneApi = inject(TimezoneService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly cityQueryRequests$ = new Subject<DataTableQuery>();
readonly queryState = new DataTableQueryState();
readonly cities = signal<CityTableRow[]>([]);
readonly selectedCountryId = signal<string | null>(null);
readonly selectedStateId = signal<string | null>(null);
readonly selectedCountry = signal<CountryLookupDto | null>(null);
readonly selectedFilterState = signal<StateLookupDto | null>(null);
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
readonly selectedFormState = signal<StateLookupDto | null>(null);
readonly totalRecords = signal(0);
readonly saving = signal(false);
readonly showCityModal = signal(false);
readonly modalMode = signal<CityModalMode>('create');
readonly selectedCity = signal<CityDto | null>(null);
readonly submitAttempted = signal(false);
readonly filterForm = this.formBuilder.nonNullable.group({
countryId: [''],
stateId: [{ value: '', disabled: true }]
});
readonly cityForm = this.formBuilder.nonNullable.group({
countryId: ['', Validators.required],
stateId: [{ value: '', disabled: true }, Validators.required],
name: ['', [Validators.required, Validators.maxLength(150)]],
code: [
'',
[
Validators.required,
Validators.maxLength(16),
Validators.pattern(/^[A-Za-z0-9_-]+$/)
]
],
timezoneId: this.formBuilder.control<string | null>(null)
});
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> =
(term, limit) => this.timezoneApi.autocomplete(term, limit);
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
(term, limit) => this.countryApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load countries.');
return of<CountryLookupDto[]>([]);
})
);
readonly searchFilterStates: AutocompleteSearchFn<StateLookupDto> =
(term, limit) => {
const countryId = this.selectedCountryId();
if (!countryId) return of<StateLookupDto[]>([]);
return this.stateApi.autocomplete(countryId, term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load states.');
return of<StateLookupDto[]>([]);
})
);
};
readonly searchFormStates: AutocompleteSearchFn<StateLookupDto> =
(term, limit) => {
const countryId = this.cityForm.controls.countryId.value;
if (!countryId) return of<StateLookupDto[]>([]);
return this.stateApi.autocomplete(countryId, term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load states.');
return of<StateLookupDto[]>([]);
})
);
};
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
timezone => `${timezone.ianaId}${timezone.displayName}`;
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
timezone => timezone.id;
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone)));
readonly filterStatePlaceholder = computed(() =>
this.selectedCountryId() ? 'Search state' : 'Select a country first'
);
readonly formStatePlaceholder = computed(() =>
this.cityForm.controls.countryId.value ? 'Search state' : 'Select a country first'
);
readonly canAddCity = computed(() => !!this.selectedStateId());
readonly emptyMessage = computed(() =>
this.selectedCountryId() && this.selectedStateId()
? 'No cities found'
: 'Select a country and state'
);
readonly emptyDescription = computed(() =>
this.selectedCountryId() && this.selectedStateId()
? 'There are no cities available for the selected state.'
: 'Choose a country and state to view available cities.'
);
readonly modalTitle = computed(() => {
const mode = this.modalMode();
return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City';
});
readonly submitLabel = computed(() =>
this.modalMode() === 'create' ? 'Save City' : 'Update City'
);
readonly loadingLabel = computed(() =>
this.modalMode() === 'create' ? 'Saving City...' : 'Updating City...'
);
readonly columns = signal<DataTableColumn<CityTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '70px' },
{ key: 'name', label: 'City Name', header: 'City Name', sortable: true, align: 'left' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
{ key: 'stateName', label: 'State', header: 'State', sortable: false },
{ key: 'countryName', label: 'Country', header: 'Country', sortable: false },
{
key: 'isActive',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
width: '100px',
formatter: value => value ? 'Active' : 'Inactive',
badgeClass: value => value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger'
}
]);
readonly actions = signal<DataTableAction<CityTableRow>[]>([
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
{
type: 'deactivate',
label: 'Deactivate',
icon: 'ti ti-ban',
className: 'text-danger',
visible: row => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: row => !row.isActive
}
]);
constructor() {
this.configureCityQueries();
this.configureFilterChanges();
this.configureFormChanges();
}
ngOnInit(): void {}
onFilterCountrySelected(country: CountryLookupDto): void {
this.selectedCountry.set(country);
}
onFilterCountryCleared(): void {
this.selectedCountry.set(null);
}
onFilterStateSelected(state: StateLookupDto): void {
this.selectedFilterState.set(state);
}
onFilterStateCleared(): void {
this.selectedFilterState.set(null);
}
onFormCountrySelected(country: CountryLookupDto): void {
this.selectedFormCountry.set(country);
}
onFormCountryCleared(): void {
this.selectedFormCountry.set(null);
}
onFormStateSelected(state: StateLookupDto): void {
this.selectedFormState.set(state);
}
onFormStateCleared(): void {
this.selectedFormState.set(null);
}
loadCities(query: DataTableQuery): void {
if (!this.selectedStateId()) {
this.clearGrid();
return;
}
this.cityQueryRequests$.next(query);
}
onSearch(value: string): void {
this.loadCities(this.queryState.setSearch(value.trim()));
}
onPageChange(event: DataTablePageEvent): void {
this.loadCities(this.queryState.setPage(event));
}
onSortChange(event: DataTableSortEvent): void {
this.loadCities(this.queryState.setSort(event));
}
onActionClick(event: DataTableActionEvent<CityTableRow>): void {
const city = this.toCityDto(event.row);
switch (event.action.type) {
case 'edit':
this.openExistingCity(city, 'edit', {
id: event.row.stateId,
name: event.row.stateName,
code: ''
});
break;
case 'activate':
this.updateCityStatus(city, true);
break;
case 'deactivate':
this.updateCityStatus(city, false);
break;
}
}
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.selectedCity.set(null);
this.submitAttempted.set(false);
this.selectedFormCountry.set(this.selectedCountry());
this.selectedFormState.set(this.selectedFilterState());
this.cityForm.enable({ emitEvent: false });
this.cityForm.reset({ countryId, stateId, name: '', code: '', timezoneId: null }, { emitEvent: false });
this.cityForm.controls.stateId.enable({ emitEvent: false });
this.resetFormState();
this.showCityModal.set(true);
}
closeCityModal(): void {
if (this.saving()) return;
this.showCityModal.set(false);
this.selectedCity.set(null);
this.selectedFormCountry.set(null);
this.selectedFormState.set(null);
this.submitAttempted.set(false);
}
saveCity(): void {
if (this.saving()) return;
if (this.cityForm.invalid) {
this.submitAttempted.set(true);
this.cityForm.markAllAsTouched();
this.focusFirstInvalidControl();
return;
}
this.saving.set(true);
const city = this.selectedCity();
const request$ = this.modalMode() === 'create'
? this.cityApi.createCity(this.buildCreateRequest())
: city
? this.cityApi.updateCity(city.id, this.buildUpdateRequest(city.isActive))
: null;
if (!request$) {
this.saving.set(false);
return;
}
request$.pipe(finalize(() => this.saving.set(false))).subscribe({
next: () => {
this.toastr.success(
this.modalMode() === 'create'
? 'City saved successfully.'
: 'City updated successfully.'
);
this.showCityModal.set(false);
this.selectedCity.set(null);
this.loadCities(this.queryState.getQuery());
}
});
}
private configureCityQueries(): void {
this.cityQueryRequests$.pipe(
switchMap(query => {
const stateId = this.selectedStateId();
return stateId
? this.cityApi.getCityDataTable(query, stateId).pipe(
catchError(() => {
this.toastr.error('Unable to load cities.');
this.clearGrid();
return of(null);
})
)
: of(null);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(response => {
if (!response) return;
const query = this.queryState.getQuery();
if (response.draw !== query.draw) return;
const stateName = this.selectedFilterState()?.name ?? '—';
const countryName = this.selectedCountry()?.name ?? '—';
this.cities.set(response.rows.map((city, index) => ({
...city,
serialNumber: (query.page - 1) * query.pageSize + index + 1,
stateName,
countryName
})));
this.totalRecords.set(response.filtered);
});
}
private configureFilterChanges(): void {
this.filterForm.controls.countryId.valueChanges.pipe(
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(countryId => {
if (!countryId || this.selectedCountry()?.id !== countryId) {
this.selectedCountry.set(null);
}
this.selectedCountryId.set(countryId || null);
this.selectedStateId.set(null);
this.selectedFilterState.set(null);
this.filterForm.controls.stateId.reset('', { emitEvent: false });
countryId
? this.filterForm.controls.stateId.enable({ emitEvent: false })
: this.filterForm.controls.stateId.disable({ emitEvent: false });
this.clearGrid();
this.queryState.reset();
});
this.filterForm.controls.stateId.valueChanges.pipe(
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(stateId => {
if (!stateId || this.selectedFilterState()?.id !== stateId) {
this.selectedFilterState.set(null);
}
this.selectedStateId.set(stateId || null);
this.clearGrid();
const query = this.queryState.reset();
if (stateId) this.loadCities(query);
});
}
private configureFormChanges(): void {
this.cityForm.controls.countryId.valueChanges.pipe(
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(countryId => {
if (!this.showCityModal() || this.modalMode() !== 'create') return;
if (!countryId || this.selectedFormCountry()?.id !== countryId) {
this.selectedFormCountry.set(null);
}
this.selectedFormState.set(null);
this.cityForm.controls.stateId.reset('', { emitEvent: false });
countryId
? this.cityForm.controls.stateId.enable({ emitEvent: false })
: this.cityForm.controls.stateId.disable({ emitEvent: false });
});
}
private openExistingCity(city: CityDto, mode: 'edit', stateSeed?: StateLookupDto): void {
this.cityApi.getCityById(city.id).pipe(
take(1)
).subscribe(details => {
this.selectedCity.set(details);
this.modalMode.set(mode);
this.submitAttempted.set(false);
this.selectedFormCountry.set(this.selectedCountry());
this.selectedFormState.set(
stateSeed
?? (this.selectedFilterState()?.id === details.stateId ? this.selectedFilterState() : null)
);
this.cityForm.enable({ emitEvent: false });
this.cityForm.reset({
countryId: this.selectedCountryId() ?? '',
stateId: details.stateId,
name: details.name ?? '',
code: details.code ?? '',
timezoneId: details.timezoneId
}, { emitEvent: false });
this.cityForm.controls.countryId.disable({ emitEvent: false });
this.cityForm.controls.stateId.disable({ emitEvent: false });
this.resetFormState();
this.showCityModal.set(true);
});
}
private updateCityStatus(city: CityDto, isActive: boolean): void {
this.cityApi.updateCity(city.id, {
name: city.name.trim(),
code: city.code?.trim().toUpperCase() ?? '',
timezoneId: city.timezoneId,
isActive
}).subscribe(() => {
this.toastr.success(
isActive ? 'City activated successfully.' : 'City deactivated successfully.'
);
this.loadCities(this.queryState.getQuery());
});
}
private buildCreateRequest(): CreateCityRequest {
const value = this.cityForm.getRawValue();
return {
stateId: value.stateId,
name: value.name.trim(),
code: value.code.trim().toUpperCase(),
timezoneId: value.timezoneId
};
}
private buildUpdateRequest(isActive: boolean): UpdateCityRequest {
const value = this.cityForm.getRawValue();
return {
name: value.name.trim(),
code: value.code.trim().toUpperCase(),
timezoneId: value.timezoneId,
isActive
};
}
private clearGrid(): void {
this.cities.set([]);
this.totalRecords.set(0);
}
private resetFormState(): void {
this.cityForm.markAsPristine();
this.cityForm.markAsUntouched();
this.cityForm.updateValueAndValidity();
}
private focusFirstInvalidControl(): void {
queueMicrotask(() => {
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
);
control?.focus();
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
private toCityDto(row: CityTableRow): CityDto {
return {
id: row.id,
stateId: row.stateId,
name: row.name,
code: row.code,
timezoneId: row.timezoneId,
isActive: row.isActive,
createdOn: row.createdOn,
modifiedOn: row.modifiedOn
};
}
private toTimezoneLookup(timezone: TimezoneDto): TimezoneLookupDto {
return {
id: timezone.id,
ianaId: timezone.ianaId,
displayName: timezone.displayName
};
}
}
@@ -1,9 +1,9 @@
<app-data-table [columns]="columns()" [rows]="countries()" [actions]="actions()" [loading]="loading()"
<app-data-table [columns]="columns()" [rows]="countries()" [actions]="actions()"
(addClicked)="onAddCountry()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()" [pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Countries" buttonTitle="Add"
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search countries..." [searchDebounceTime]="300"
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)">
(actionClicked)="onActionClick($event)" toolTip="Add Country">
<ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
@if (getFlagUrl(row.iso2); as flagUrl) {
@@ -18,85 +18,78 @@
</ng-template>
</app-data-table>
<!-- Start:: New Deal -->
<modal [open]="showCountryModal()" [title]="countryModalTitle()" [subtitle]="countryModalSubtitle()" size="md"
<app-confirm-dialog
title="Delete Country"
text="Do you really want to delete this country?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<modal [open]="showCountryModal()" [title]="countryModalTitle()" size="md"
[submitAction]="countrySubmitAction()" [submitLabel]="countrySubmitLabel()" [loadingLabel]="countryLoadingLabel()"
[loading]="saving()" [submitDisabled]="countryForm.invalid" (closed)="closeCountryModal()"
[loading]="saving()" (closed)="closeCountryModal()"
(submitted)="saveCountry()">
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-6">
<app-form-input formControlName="name" inputId="country-name" label="Country Name"
placeholder="Enter country name" autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
placeholder="Name" autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
required: 'Country Name is required.',
maxlength: 'Country Name cannot exceed 150 characters.'
}" />
}" [submitAttempted]="countrySubmitAttempted()" />
</div>
<div class="col-span-6">
<app-form-input formControlName="defaultCurrencyId" inputId="country-defaultCurrencyId" label="Default Currency"
placeholder="Enter country currency" autocomplete="off" [required]="true" [maxLength]="16" [validationMessages]="{
required: 'Country Currency is required.',
}" [showPlaceholder]="false"/>
<app-autocomplete
formControlName="defaultCurrencyId"
inputId="country-default-currency-id"
label="Currency"
placeholder="e.g.: USD"
[searchFn]="searchCurrencies"
[displayWith]="displayCurrency"
[valueWith]="currencyValue"
[selectedItem]="selectedCurrency()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No currencies found"
typeToSearchText="Type to search currencies"
[submitAttempted]="countrySubmitAttempted()"
wrapperClass="w-full"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="iso2" inputId="country-iso2" label="ISO2 Code" placeholder="For example: IN"
inputClass="uppercase" autocomplete="off" [required]="true" [minLength]="2" [maxLength]="2"
<app-form-input formControlName="iso2" inputId="country-iso2" label="ISO2 Code" placeholder="e.g.: IN"
autocomplete="off" [required]="true" [minLength]="2" [maxLength]="2"
[validationMessages]="{
required: 'ISO2 Code is required.',
minlength: 'ISO2 Code must contain exactly 2 letters.',
maxlength: 'ISO2 Code must contain exactly 2 letters.',
pattern: 'ISO2 Code can contain letters only.'
}" />
}" [submitAttempted]="countrySubmitAttempted()" />
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="iso3" inputId="country-iso3" label="ISO3 Code" placeholder="For example: IND"
inputClass="uppercase" autocomplete="off" [required]="true" [minLength]="3" [maxLength]="3"
<app-form-input formControlName="iso3" inputId="country-iso3" label="ISO3 Code" placeholder="e.g.: IND"
autocomplete="off" [required]="true" [minLength]="3" [maxLength]="3"
[validationMessages]="{
required: 'ISO3 Code is required.',
minlength: 'ISO3 Code must contain exactly 3 letters.',
maxlength: 'ISO3 Code must contain exactly 3 letters.',
pattern: 'ISO3 Code can contain letters only.'
}" />
}" [submitAttempted]="countrySubmitAttempted()" />
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="phoneCode" inputId="country-phone-code" label="Phone Code" type="tel"
inputMode="tel" placeholder="For example: +91" autocomplete="off" [maxLength]="20" [validationMessages]="{
maxlength: 'Phone Code cannot exceed 20 characters.',
pattern: 'Phone Code can contain a plus sign and digits only.'
}" />
</div>
<div class="col-span-12 md:col-span-6">
<!-- <app-form-field
label="Default Currency"
inputId="country-default-currency"
[control]="countryForm.controls.defaultCurrencyId"
>
<select
id="country-default-currency"
class="form-control"
formControlName="defaultCurrencyId"
>
<option [ngValue]="null">
Select default currency
</option>
@for (
currency of currencyOptions();
track currency.value
) {
<option [value]="currency.value">
{{ currency.label }}
</option>
}
</select>
</app-form-field> -->
inputMode="tel" placeholder="e.g.: +91" autocomplete="off" [maxLength]="16" [validationMessages]="{
maxlength: 'Phone Code cannot exceed 16 characters.',
pattern: 'Phone Code can contain an optional plus sign, digits, hyphens, and spaces only.'
}" [submitAttempted]="countrySubmitAttempted()" />
</div>
</div>
</form>
</modal>
<!-- End:: New Deal -->
@@ -1,52 +1,131 @@
import { Component, ElementRef, viewChild } from '@angular/core';
import { inject, signal, computed } from '@angular/core';
import type { CountryModalMode } from '../../../../../core/models/country/country.model';
import { CountryService } from '../../../../../core/services/country/country.service';
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } from '../../../../../shared/components/data-table/data-table.types';
import { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { finalize } from 'rxjs/operators';
import { Modal } from '../../../../../shared/components/modal/modal';
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } 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 { Button } from '../../../../../shared/components/button/button';
import {
CountryDto,
CountryModalMode,
CreateCountryRequest,
UpdateCountryRequest
} from '../../../../../core/models/country/country.model';
import { CurrencyLookupDto } from '../../../../../core/models/currency/currency.model';
import { CountryService } from '../../../../../core/services/country/country.service';
import { CurrencyService } from '../../../../../core/services/currency/currency.service';
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 { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { CountryDto } from '../../../../../core/models/country/country.model';
import { Modal } from '../../../../../shared/components/modal/modal';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
interface CountryTableRow extends DataTableRecord {
id: string;
iso2: string;
iso3: string;
name: string;
phoneCode: string | null;
defaultCurrencyId: string | null;
isActive: boolean;
serialNumber: number;
createdOn?: string;
modifiedOn?: string | null;
}
@Component({
selector: 'country-list',
standalone: true,
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, Button,
FormInput],
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
templateUrl: './country-list.html',
styleUrl: './country-list.scss',
})
export class CountryList {
private readonly countryApi: CountryService = inject(CountryService);
private readonly destroyRef = inject(DestroyRef);
private readonly countryApi = inject(CountryService);
private readonly currencyApi = inject(CurrencyService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly countryQueryRequests$ = new Subject<DataTableQuery>();
readonly queryState = new DataTableQueryState();
readonly countries = signal<any[]>([]);
readonly countries = signal<CountryTableRow[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly loading = signal(false);
readonly modalMode = signal<CountryModalMode>('create');
readonly saving = signal(false);
readonly showCountryModal = signal(false);
readonly countryModalMode = signal<CountryModalMode>('create');
readonly selectedCountryId = signal<string | null>(null);
readonly saving = signal(false);
readonly selectedCountry = signal<CountryDto | null>(null);
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
readonly countrySubmitAttempted = signal(false);
readonly pendingDeleteCountry = signal<CountryDto | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly countryForm = this.formBuilder.nonNullable.group({
name: [
'',
[
Validators.required,
Validators.maxLength(150)
]
],
iso2: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z]{2}$/)
]
],
iso3: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
phoneCode: [
'',
[
Validators.maxLength(16),
Validators.pattern(/^\+?[0-9\- ]{1,15}$/)
]
],
defaultCurrencyId: this.formBuilder.control<string | null>(null)
});
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> =
(term, limit) => this.currencyApi.autocomplete(term, limit);
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => {
const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - ');
return currency.symbol?.trim()
? `${baseLabel} (${currency.symbol})`
: baseLabel;
};
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
readonly countryModalTitle = computed(() =>
this.countryModalMode() === 'create'
@@ -54,12 +133,6 @@ export class CountryList {
: 'Edit Country'
);
readonly countryModalSubtitle = computed(() =>
this.countryModalMode() === 'create'
? 'Enter the country details below.'
: 'Update the country details below.'
);
readonly countrySubmitLabel = computed(() =>
this.countryModalMode() === 'create'
? 'Save Country'
@@ -78,132 +151,91 @@ export class CountryList {
: 'update'
);
readonly countryForm = this.formBuilder.nonNullable.group({
name: [
'',
[
Validators.required,
Validators.maxLength(150)
]
],
iso2: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z]{2}$/)
]
],
iso3: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
phoneCode: [
'',
[
Validators.maxLength(20),
Validators.pattern(/^\+?[0-9]*$/)
]
],
currency: [
'',
[
Validators.maxLength(3),
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
defaultCurrencyId:
this.formBuilder.control<string | null>(null)
});
readonly columns = signal<DataTableColumn[]>([
{ key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '100px' },
readonly columns = signal<DataTableColumn<CountryTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
{
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, badgeClass: value =>
key: 'isActive',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: value =>
value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: (value) => value ? 'Active' : 'Inactive'
formatter: value => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction[]>([
readonly actions = signal<DataTableAction<CountryTableRow>[]>([
{
type: 'edit',
label: 'Edit',
icon: 'ti ti-edit ti-btn-info',
className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-info me-2'
icon: 'ti ti-edit',
className: 'text-primary'
},
{
type: 'delete',
label: 'Delete',
icon: 'ti ti-trash ti-btn-danger',
className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-danger me-2',
visible: (row: any) => row.isActive
icon: 'ti ti-trash',
className: 'text-danger',
visible: row => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check ti-btn-success',
className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-success me-2',
visible: (row: any) => !row.isActive
},
icon: 'ti ti-check',
className: 'text-success',
visible: row => !row.isActive
}
]);
constructor() {
this.countryQueryRequests$
.pipe(
switchMap(query =>
this.countryApi.getCountryDataTable(query).pipe(
catchError(() => {
this.toastr.error('Unable to load countries.');
this.clearCountryGrid();
return of(null);
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(response => {
if (!response) {
return;
}
const query = this.queryState.getQuery();
if (response.draw !== query.draw) {
return;
}
const countriesWithSerialNumbers: CountryTableRow[] = response.rows.map((country, index) => ({
...country,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.countries.set(countriesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
});
}
ngOnInit(): void {
this.loadCountries(this.queryState.getQuery());
}
loadCountries(query: DataTableQuery): void {
this.loading.set(true);
this.countryApi
.getCountryDataTable(query)
.pipe(
finalize(() => {
this.loading.set(false);
})
)
.subscribe({
next: (response: any) => {
console.log('Country data loaded:', response);
if (response.draw !== this.queryState.getQuery().draw) {
return;
}
// Add serial numbers to countries
const countriesWithSerialNumbers = response.rows.map((country: any, index: number) => ({
...country,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.countries.set(countriesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
},
error: (error: any) => {
console.error('Unable to load countries.', error);
this.countries.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
});
this.countryQueryRequests$.next(query);
}
onSearch(value: string): void {
@@ -224,12 +256,10 @@ export class CountryList {
onRefresh(): void {
const currentQuery = this.queryState.getQuery();
const query: DataTableQuery = {
this.loadCountries({
...currentQuery,
draw: currentQuery.draw + 1
};
this.loadCountries(query);
});
}
onReset(): void {
@@ -237,11 +267,25 @@ export class CountryList {
this.loadCountries(query);
}
onActionClick(event: DataTableActionEvent): void {
const action = event.action.type;
const country = event.row;
onDeleteConfirmed(): void {
const country = this.pendingDeleteCountry();
switch (action) {
if (!country) {
return;
}
this.pendingDeleteCountry.set(null);
this.deleteCountry(country);
}
onDeleteCancelled(): void {
this.pendingDeleteCountry.set(null);
}
onActionClick(event: DataTableActionEvent<CountryTableRow>): void {
const country = this.toCountryDto(event.row);
switch (event.action.type) {
case 'view':
this.viewCountry(country);
break;
@@ -249,7 +293,7 @@ export class CountryList {
this.openEditCountry(country);
break;
case 'delete':
this.deleteCountry(country);
this.requestDeleteCountry(country);
break;
case 'activate':
this.activateCountry(country);
@@ -260,6 +304,9 @@ export class CountryList {
onAddCountry(): void {
this.countryModalMode.set('create');
this.selectedCountryId.set(null);
this.selectedCountry.set(null);
this.selectedCurrency.set(null);
this.countrySubmitAttempted.set(false);
this.countryForm.reset({
name: '',
@@ -268,6 +315,7 @@ export class CountryList {
phoneCode: '',
defaultCurrencyId: null
});
this.resetCountryFormState();
this.showCountryModal.set(true);
}
@@ -279,52 +327,102 @@ export class CountryList {
this.showCountryModal.set(false);
this.selectedCountryId.set(null);
this.selectedCountry.set(null);
this.selectedCurrency.set(null);
this.countrySubmitAttempted.set(false);
}
saveCountry(): void {
if (this.countryForm.invalid) {
this.countrySubmitAttempted.set(true);
this.countryForm.markAllAsTouched();
this.focusFirstInvalidCountryControl();
return;
}
if (this.saving()) {
return;
}
this.saving.set(true);
const request = this.countryForm.getRawValue();
if (this.countryModalMode() === 'create') {
this.countryApi
.createCountry(this.buildCreateCountryRequest())
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
this.toastr.success('Country saved successfully.');
this.finishCountrySave();
}
});
// Replace with the actual API request.
console.log(request);
return;
}
this.saving.set(false);
this.showCountryModal.set(false);
}
const countryId = this.selectedCountryId();
private viewCountry(country: any): void {
console.log('Viewing country:', country);
// TODO: Implement view logic (open modal, navigate to details page, etc.)
if (!countryId) {
this.saving.set(false);
return;
}
this.countryApi
.updateCountry(
countryId,
this.buildUpdateCountryRequest(this.selectedCountry()?.isActive ?? true)
)
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
this.toastr.success('Country updated successfully.');
this.finishCountrySave();
}
});
}
openEditCountry(country: CountryDto): void {
this.countryModalMode.set('edit');
this.selectedCountryId.set(country.id);
this.selectedCountry.set(null);
this.selectedCurrency.set(null);
this.countrySubmitAttempted.set(false);
this.countryForm.reset({
name: country.name ?? '',
iso2: country.iso2 ?? '',
iso3: country.iso3 ?? '',
phoneCode: country.phoneCode ?? '',
defaultCurrencyId: country.defaultCurrencyId ?? null
});
this.countryApi
.getCountryById(country.id)
.pipe(
switchMap(countryDetails => {
const currencyId = countryDetails.defaultCurrencyId;
this.showCountryModal.set(true);
}
if (!currencyId) {
return of({ countryDetails, currency: null });
}
private deleteCountry(country: any): void {
console.log('Deleting country:', country);
// TODO: Implement delete logic (API call to delete country)
}
return this.currencyApi.getCurrencyById(currencyId).pipe(
map(currency => ({ countryDetails, currency })),
catchError(() => {
this.toastr.error('Unable to load the selected currency.');
return of({ countryDetails, currency: null });
})
);
})
)
.subscribe({
next: ({ countryDetails, currency }) => {
this.selectedCountry.set(countryDetails);
this.selectedCurrency.set(currency);
this.countryForm.reset({
name: countryDetails.name ?? '',
iso2: countryDetails.iso2 ?? '',
iso3: countryDetails.iso3 ?? '',
phoneCode: countryDetails.phoneCode ?? '',
defaultCurrencyId: countryDetails.defaultCurrencyId ?? null
});
this.resetCountryFormState();
private activateCountry(country: any): void {
console.log('Activating country:', country);
// TODO: Implement activate logic (API call to activate country)
this.showCountryModal.set(true);
}
});
}
getFlagUrl(iso2: string | null | undefined): string {
@@ -340,4 +438,122 @@ export class CountryList {
image.style.display = 'none';
}
private viewCountry(country: CountryDto): void {
this.openEditCountry(country);
}
private requestDeleteCountry(country: CountryDto): void {
this.pendingDeleteCountry.set(country);
this.deleteConfirmDialog()?.open();
}
private deleteCountry(country: CountryDto): void {
this.updateCountryStatus(country, false);
}
private activateCountry(country: CountryDto): void {
this.updateCountryStatus(country, true);
}
private buildCreateCountryRequest(): CreateCountryRequest {
const value = this.countryForm.getRawValue();
return {
name: value.name.trim(),
iso2: value.iso2.trim().toUpperCase(),
iso3: value.iso3.trim().toUpperCase(),
phoneCode: this.nullWhenBlank(value.phoneCode),
defaultCurrencyId: this.nullWhenBlank(value.defaultCurrencyId)
};
}
private buildUpdateCountryRequest(isActive: boolean): UpdateCountryRequest {
return {
...this.buildCreateCountryRequest(),
isActive
};
}
private countryToUpdateRequest(country: CountryDto, isActive: boolean): UpdateCountryRequest {
return {
name: country.name?.trim() ?? '',
iso2: country.iso2?.trim().toUpperCase() ?? '',
iso3: country.iso3?.trim().toUpperCase() ?? '',
phoneCode: this.nullWhenBlank(country.phoneCode),
defaultCurrencyId: this.nullWhenBlank(country.defaultCurrencyId),
isActive
};
}
private updateCountryStatus(country: CountryDto, isActive: boolean): void {
this.countryApi
.updateCountry(country.id, this.countryToUpdateRequest(country, isActive))
.subscribe({
next: () => {
this.toastr.success(
isActive
? 'Country activated successfully.'
: 'Country deactivated successfully.'
);
this.loadCountries(this.queryState.getQuery());
}
});
}
private resetCountryFormState(): void {
this.countryForm.markAsPristine();
this.countryForm.markAsUntouched();
this.countryForm.updateValueAndValidity();
}
private finishCountrySave(): void {
this.showCountryModal.set(false);
this.selectedCountryId.set(null);
this.selectedCountry.set(null);
this.countrySubmitAttempted.set(false);
this.loadCountries(this.queryState.getQuery());
}
private clearCountryGrid(): void {
this.countries.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
private focusFirstInvalidCountryControl(): void {
queueMicrotask(() => {
const firstInvalidControl =
this.elementRef.nativeElement.querySelector<HTMLElement>(
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
);
firstInvalidControl?.focus();
firstInvalidControl?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
}
private nullWhenBlank(value: string | null | undefined): string | null {
const normalized = value?.trim();
return normalized
? normalized
: null;
}
private toCountryDto(row: CountryTableRow): CountryDto {
return {
id: row.id,
iso2: row.iso2,
iso3: row.iso3,
name: row.name,
phoneCode: row.phoneCode,
defaultCurrencyId: row.defaultCurrencyId,
isActive: row.isActive,
createdOn: row.createdOn,
modifiedOn: row.modifiedOn
};
}
}
@@ -0,0 +1,160 @@
<app-data-table
[columns]="columns()"
[rows]="currencies()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Currencies"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search currencies..."
[searchDebounceTime]="300"
toolTip="Add Currency"
(addClicked)="onAddCurrency()"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)"
>
<ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
<span
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"
>
{{ row.symbol }}
</span>
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template>
<ng-template appDataTableCell="code" let-value="value">
<span class="badge bg-primary/10 text-primary">
{{ value }}
</span>
</ng-template>
</app-data-table>
<app-confirm-dialog
title="Delete Currency"
text="Do you really want to delete this currency?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<modal
[open]="showCurrencyModal()"
[title]="currencyModalTitle()"
size="md"
[submitAction]="currencySubmitAction()"
[submitLabel]="currencySubmitLabel()"
[loadingLabel]="currencyLoadingLabel()"
[loading]="saving()"
(closed)="closeCurrencyModal()"
(submitted)="saveCurrency()"
>
<form [formGroup]="currencyForm" (ngSubmit)="saveCurrency()" 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="name"
inputId="currency-name"
label="Currency Name"
placeholder="Name"
autocomplete="off"
[required]="true"
[maxLength]="100"
[validationMessages]="{
required: 'Currency Name is required.',
maxlength: 'Currency Name cannot exceed 100 characters.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="code"
inputId="currency-code"
label="Currency Code"
placeholder="e.g.: AED"
inputClass="uppercase"
autocomplete="off"
[required]="true"
[minLength]="3"
[maxLength]="3"
[validationMessages]="{
required: 'Currency Code is required.',
minlength: 'Currency Code must contain exactly 3 letters.',
maxlength: 'Currency Code must contain exactly 3 letters.',
pattern: 'Currency Code can contain letters only.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="symbol"
inputId="currency-symbol"
label="Currency Symbol"
placeholder="e.g.: د.إ"
autocomplete="off"
[required]="true"
[maxLength]="8"
[validationMessages]="{
required: 'Currency Symbol is required.',
maxlength: 'Currency Symbol cannot exceed 8 characters.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="numericCode"
inputId="currency-numeric-code"
label="Numeric Code"
type="number"
inputMode="numeric"
placeholder="e.g.: 784"
autocomplete="off"
[required]="true"
[min]="1"
[max]="999"
[step]="1"
[validationMessages]="{
required: 'Numeric Code is required.',
min: 'Numeric Code must be at least 1.',
max: 'Numeric Code cannot exceed 999.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="decimalDigits"
inputId="currency-decimal-digits"
label="Decimal Digits"
type="number"
inputMode="numeric"
placeholder="e.g.: 2"
autocomplete="off"
[required]="true"
[min]="0"
[max]="4"
[step]="1"
[validationMessages]="{
required: 'Decimal Digits is required.',
min: 'Decimal Digits cannot be less than 0.',
max: 'Decimal Digits cannot exceed 4.'
}"
/>
</div>
</div>
</form>
</modal>
@@ -0,0 +1,507 @@
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } 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, of, switchMap } from 'rxjs';
import {
CreateCurrencyRequest,
CurrencyDto,
CurrencyModalMode,
UpdateCurrencyRequest
} from '../../../../../core/models/currency/currency.model';
import { CurrencyService } from '../../../../../core/services/currency/currency.service';
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 { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
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';
interface CurrencyTableRow extends DataTableRecord {
id: string;
code: string;
name: string;
symbol: string;
numericCode: number;
decimalDigits: number;
isActive: boolean;
serialNumber: number;
createdOn?: string;
modifiedOn?: string | null;
}
@Component({
selector: 'currency-list',
standalone: true,
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
templateUrl: './currency-list.html',
styleUrl: './currency-list.scss',
})
export class CurrencyList {
private readonly destroyRef = inject(DestroyRef);
private readonly currencyApi = inject(CurrencyService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly currencyQueryRequests$ = new Subject<DataTableQuery>();
readonly queryState = new DataTableQueryState();
readonly currencies = signal<CurrencyTableRow[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly saving = signal(false);
readonly showCurrencyModal = signal(false);
readonly currencyModalMode = signal<CurrencyModalMode>('create');
readonly selectedCurrencyId = signal<string | null>(null);
readonly selectedCurrency = signal<CurrencyDto | null>(null);
readonly currencySubmitAttempted = signal(false);
readonly pendingDeleteCurrency = signal<CurrencyDto | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly currencyForm = this.formBuilder.nonNullable.group({
name: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
code: [
'',
[
Validators.required,
Validators.minLength(3),
Validators.maxLength(3),
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
symbol: [
'',
[
Validators.required,
Validators.maxLength(8)
]
],
numericCode: [
0,
[
Validators.required,
Validators.min(1),
Validators.max(999)
]
],
decimalDigits: [
2,
[
Validators.required,
Validators.min(0),
Validators.max(4)
]
]
});
readonly currencyModalTitle = computed(() =>
this.currencyModalMode() === 'create'
? 'Add Currency'
: 'Edit Currency'
);
readonly currencySubmitLabel = computed(() =>
this.currencyModalMode() === 'create'
? 'Save Currency'
: 'Update Currency'
);
readonly currencyLoadingLabel = computed(() =>
this.currencyModalMode() === 'create'
? 'Saving Currency...'
: 'Updating Currency...'
);
readonly currencySubmitAction = computed<'save' | 'update'>(() =>
this.currencyModalMode() === 'create'
? 'save'
: 'update'
);
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
{ key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true },
{
key: 'isActive',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: value =>
value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction<CurrencyTableRow>[]>([
{
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.currencyQueryRequests$
.pipe(
switchMap(query =>
this.currencyApi.getCurrencyDataTable(query).pipe(
catchError(() => {
this.toastr.error('Unable to load currencies.');
this.clearCurrencyGrid();
return of(null);
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(response => {
if (!response) {
return;
}
const query = this.queryState.getQuery();
if (response.draw !== query.draw) {
return;
}
const currenciesWithSerialNumbers: CurrencyTableRow[] = response.rows.map((currency, index) => ({
...currency,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.currencies.set(currenciesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
});
}
ngOnInit(): void {
this.loadCurrencies(this.queryState.getQuery());
}
loadCurrencies(query: DataTableQuery): void {
this.currencyQueryRequests$.next(query);
}
onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim());
this.loadCurrencies(query);
}
onPageChange(event: DataTablePageEvent): void {
const query = this.queryState.setPage(event);
this.loadCurrencies(query);
}
onSortChange(event: DataTableSortEvent): void {
const query = this.queryState.setSort(event);
this.loadCurrencies(query);
}
onRefresh(): void {
const currentQuery = this.queryState.getQuery();
this.loadCurrencies({
...currentQuery,
draw: currentQuery.draw + 1
});
}
onReset(): void {
const query = this.queryState.reset();
this.loadCurrencies(query);
}
onDeleteConfirmed(): void {
const currency = this.pendingDeleteCurrency();
if (!currency) {
return;
}
this.pendingDeleteCurrency.set(null);
this.deleteCurrency(currency);
}
onDeleteCancelled(): void {
this.pendingDeleteCurrency.set(null);
}
onActionClick(event: DataTableActionEvent<CurrencyTableRow>): void {
const currency = this.toCurrencyDto(event.row);
switch (event.action.type) {
case 'view':
this.viewCurrency(currency);
break;
case 'edit':
this.openEditCurrency(currency);
break;
case 'delete':
this.requestDeleteCurrency(currency);
break;
case 'activate':
this.activateCurrency(currency);
break;
}
}
onAddCurrency(): void {
this.currencyModalMode.set('create');
this.selectedCurrencyId.set(null);
this.selectedCurrency.set(null);
this.currencySubmitAttempted.set(false);
this.currencyForm.reset({
name: '',
code: '',
symbol: '',
numericCode: 0,
decimalDigits: 2
});
this.resetCurrencyFormState();
this.showCurrencyModal.set(true);
}
closeCurrencyModal(): void {
if (this.saving()) {
return;
}
this.showCurrencyModal.set(false);
this.selectedCurrencyId.set(null);
this.selectedCurrency.set(null);
this.currencySubmitAttempted.set(false);
}
saveCurrency(): void {
if (this.currencyForm.invalid) {
this.currencySubmitAttempted.set(true);
this.currencyForm.markAllAsTouched();
this.focusFirstInvalidCurrencyControl();
return;
}
if (this.saving()) {
return;
}
this.saving.set(true);
if (this.currencyModalMode() === 'create') {
this.currencyApi
.createCurrency(this.buildCreateCurrencyRequest())
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
this.toastr.success('Currency saved successfully.');
this.finishCurrencySave();
}
});
return;
}
const currencyId = this.selectedCurrencyId();
if (!currencyId) {
this.saving.set(false);
return;
}
this.currencyApi
.updateCurrency(
currencyId,
this.buildUpdateCurrencyRequest(this.selectedCurrency()?.isActive ?? true)
)
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
this.toastr.success('Currency updated successfully.');
this.finishCurrencySave();
}
});
}
private viewCurrency(currency: CurrencyDto): void {
this.openEditCurrency(currency);
}
private openEditCurrency(currency: CurrencyDto): void {
this.currencyModalMode.set('edit');
this.selectedCurrencyId.set(currency.id);
this.selectedCurrency.set(currency);
this.currencySubmitAttempted.set(false);
this.currencyApi
.getCurrencyById(currency.id)
.subscribe({
next: currencyDetails => {
this.selectedCurrency.set(currencyDetails);
this.currencyForm.reset({
name: currencyDetails.name ?? '',
code: currencyDetails.code ?? '',
symbol: currencyDetails.symbol ?? '',
numericCode: currencyDetails.numericCode ?? 0,
decimalDigits: currencyDetails.decimalDigits ?? 2
});
this.resetCurrencyFormState();
this.showCurrencyModal.set(true);
}
});
}
private requestDeleteCurrency(currency: CurrencyDto): void {
this.pendingDeleteCurrency.set(currency);
this.deleteConfirmDialog()?.open();
}
private deleteCurrency(currency: CurrencyDto): void {
this.updateCurrencyStatus(currency, false);
}
private activateCurrency(currency: CurrencyDto): void {
this.updateCurrencyStatus(currency, true);
}
private buildCreateCurrencyRequest(): CreateCurrencyRequest {
const value = this.currencyForm.getRawValue();
return {
name: value.name.trim(),
code: value.code.trim().toUpperCase(),
symbol: value.symbol.trim(),
numericCode: value.numericCode,
decimalDigits: value.decimalDigits
};
}
private buildUpdateCurrencyRequest(isActive: boolean): UpdateCurrencyRequest {
return {
...this.buildCreateCurrencyRequest(),
isActive
};
}
private currencyToUpdateRequest(currency: CurrencyDto, isActive: boolean): UpdateCurrencyRequest {
return {
name: currency.name?.trim() ?? '',
code: currency.code?.trim().toUpperCase() ?? '',
symbol: currency.symbol?.trim() ?? '',
numericCode: currency.numericCode,
decimalDigits: currency.decimalDigits,
isActive
};
}
private updateCurrencyStatus(currency: CurrencyDto, isActive: boolean): void {
this.currencyApi
.updateCurrency(currency.id, this.currencyToUpdateRequest(currency, isActive))
.subscribe({
next: () => {
this.toastr.success(
isActive
? 'Currency activated successfully.'
: 'Currency deactivated successfully.'
);
this.loadCurrencies(this.queryState.getQuery());
}
});
}
private resetCurrencyFormState(): void {
this.currencyForm.markAsPristine();
this.currencyForm.markAsUntouched();
this.currencyForm.updateValueAndValidity();
}
private finishCurrencySave(): void {
this.showCurrencyModal.set(false);
this.selectedCurrencyId.set(null);
this.selectedCurrency.set(null);
this.currencySubmitAttempted.set(false);
this.loadCurrencies(this.queryState.getQuery());
}
private clearCurrencyGrid(): void {
this.currencies.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
private focusFirstInvalidCurrencyControl(): void {
queueMicrotask(() => {
const firstInvalidControl =
this.elementRef.nativeElement.querySelector<HTMLElement>(
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
);
firstInvalidControl?.focus();
firstInvalidControl?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
}
private toCurrencyDto(row: CurrencyTableRow): CurrencyDto {
return {
id: row.id,
code: row.code,
name: row.name,
symbol: row.symbol,
numericCode: row.numericCode,
decimalDigits: row.decimalDigits,
isActive: row.isActive,
createdOn: row.createdOn,
modifiedOn: row.modifiedOn
};
}
}
@@ -4,16 +4,31 @@ export const globalMastersRoutes: Routes = [
{
path: 'countries',
loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList),
data: { childTitle: 'Country Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
data: { childTitle: 'Country Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'states',
loadComponent: () => import('./states/pages/state-list/state-list').then((m) => m.StateList),
data: { childTitle: 'State Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
data: { childTitle: 'State Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'cities',
loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList),
data: { childTitle: 'City Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
data: { childTitle: 'City Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'currencies',
loadComponent: () => import('./currencies/pages/currency-list/currency-list').then((m) => m.CurrencyList),
data: { childTitle: 'Currency Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'languages',
loadComponent: () => import('./languages/pages/language-list/language-list').then((m) => m.LanguageList),
data: { childTitle: 'Language Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'timezones',
loadComponent: () => import('./timezones/pages/timezone-list/timezone-list').then((m) => m.TimezoneList),
data: { childTitle: 'Timezone Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
}
];
@@ -0,0 +1,123 @@
<app-data-table
[columns]="columns()"
[rows]="languages()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Languages"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search languages..."
[searchDebounceTime]="300"
toolTip="Add Language"
(addClicked)="onAddLanguage()"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)"
>
<ng-template appDataTableCell="name" let-value="value">
<span class="font-semibold">{{ value }}</span>
</ng-template>
<ng-template appDataTableCell="code" let-value="value">
<span class="badge bg-primary/10 text-primary">{{ value }}</span>
</ng-template>
</app-data-table>
<app-confirm-dialog
title="Delete Language"
text="Do you really want to delete this language?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<modal
[open]="showModal()"
[title]="modalTitle()"
size="md"
[submitAction]="submitAction()"
[submitLabel]="submitLabel()"
[loadingLabel]="loadingLabel()"
[loading]="saving() || modalLoading()"
(closed)="closeModal()"
(submitted)="saveLanguage()"
>
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
<span class="ms-2">Loading language...</span>
</div>
} @else {
<form [formGroup]="languageForm" (ngSubmit)="saveLanguage()" 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="name"
inputId="language-name"
label="Language Name"
placeholder="e.g.: English"
autocomplete="off"
[required]="true"
[maxLength]="100"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Language Name is required.',
maxlength: 'Language Name cannot exceed 100 characters.',
pattern: 'Language Name cannot contain only whitespace.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="code"
inputId="language-code"
label="Language Code"
placeholder="e.g.: en-US"
autocomplete="off"
[required]="true"
[maxLength]="35"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Language Code is required.',
maxlength: 'Language Code cannot exceed 35 characters.',
pattern: 'Use a valid language code, for example en-US or hi-IN.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="nativeName"
inputId="language-native-name"
label="Native Name"
placeholder="e.g.: English"
autocomplete="off"
[required]="true"
[maxLength]="100"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Native Name is required.',
maxlength: 'Native Name cannot exceed 100 characters.',
pattern: 'Native Name cannot contain only whitespace.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6 flex items-center pt-7">
<label for="language-rtl" class="inline-flex cursor-pointer items-center gap-2">
<input
id="language-rtl"
type="checkbox"
formControlName="isRightToLeft"
class="form-check-input"
/>
<span>Right To Left Language</span>
</label>
</div>
</div>
</form>
}
</modal>
@@ -0,0 +1,356 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } 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, of, switchMap } from 'rxjs';
import {
CreateLanguageRequest,
LanguageDto,
LanguageModalMode,
UpdateLanguageRequest
} from '../../../../../core/models/language/language.model';
import { LanguageService } from '../../../../../core/services/language/language.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 { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { Modal } from '../../../../../shared/components/modal/modal';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
interface LanguageTableRow extends DataTableRecord {
id: string;
code: string;
name: string;
nativeName: string;
isRightToLeft: boolean;
isActive: boolean;
serialNumber: number;
createdOn: string;
modifiedOn: string | null;
}
@Component({
selector: 'language-list',
standalone: true,
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
templateUrl: './language-list.html',
styleUrl: './language-list.scss'
})
export class LanguageList {
private readonly destroyRef = inject(DestroyRef);
private readonly languageApi = inject(LanguageService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly queryRequests$ = new Subject<DataTableQuery>();
readonly queryState = new DataTableQueryState();
readonly languages = signal<LanguageTableRow[]>([]);
readonly totalRecords = signal(0);
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly statusChangingId = signal<string | null>(null);
readonly showModal = signal(false);
readonly modalMode = signal<LanguageModalMode>('create');
readonly selectedLanguageId = signal<string | null>(null);
readonly selectedLanguage = signal<LanguageDto | null>(null);
readonly submitAttempted = signal(false);
readonly pendingDeleteLanguageId = signal<string | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly languageForm = this.formBuilder.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
code: ['', [
Validators.required,
Validators.maxLength(35),
Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/)
]],
nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
isRightToLeft: [false]
});
readonly modalTitle = computed(() =>
this.modalMode() === 'create' ? 'Add Language' : 'Edit Language'
);
readonly submitLabel = computed(() =>
this.modalMode() === 'create' ? 'Save Language' : 'Update Language'
);
readonly loadingLabel = computed(() =>
this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...'
);
readonly submitAction = computed<'save' | 'update'>(() =>
this.modalMode() === 'create' ? 'save' : 'update'
);
readonly columns = signal<DataTableColumn<LanguageTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' },
{ key: 'code', label: 'Language Code', header: 'Language Code', sortable: true },
{ key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true },
{
key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true,
formatter: value => value ? 'RTL' : 'LTR'
},
{
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
badgeClass: value => value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction<LanguageTableRow>[]>([
{
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,
disabled: row => this.statusChangingId() === row.id
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: row => !row.isActive,
disabled: row => this.statusChangingId() === row.id
}
]);
constructor() {
this.queryRequests$.pipe(
switchMap(query => this.languageApi.getDataTable(query).pipe(
catchError(() => {
this.toastr.error('Unable to load languages.');
this.languages.set([]);
this.totalRecords.set(0);
return of(null);
})
)),
takeUntilDestroyed(this.destroyRef)
).subscribe(response => {
if (!response || response.draw !== this.queryState.getQuery().draw) {
return;
}
const query = this.queryState.getQuery();
this.languages.set(response.rows.map((language, index) => ({
...language,
serialNumber: (query.page - 1) * query.pageSize + index + 1
})));
this.totalRecords.set(response.total);
});
}
ngOnInit(): void {
this.loadLanguages(this.queryState.getQuery());
}
loadLanguages(query: DataTableQuery): void { this.queryRequests$.next(query); }
onSearch(value: string): void { this.loadLanguages(this.queryState.setSearch(value.trim())); }
onPageChange(event: DataTablePageEvent): void { this.loadLanguages(this.queryState.setPage(event)); }
onSortChange(event: DataTableSortEvent): void { this.loadLanguages(this.queryState.setSort(event)); }
onDeleteConfirmed(): void {
const languageId = this.pendingDeleteLanguageId();
if (!languageId) {
return;
}
this.pendingDeleteLanguageId.set(null);
this.changeLanguageStatus(languageId, false);
}
onDeleteCancelled(): void {
this.pendingDeleteLanguageId.set(null);
}
onActionClick(event: DataTableActionEvent<LanguageTableRow>): void {
if (event.action.type === 'edit') {
this.openEditLanguage(event.row.id);
} else if (event.action.type === 'delete') {
this.requestDeleteLanguage(event.row.id);
} else if (event.action.type === 'activate') {
this.changeLanguageStatus(event.row.id, true);
}
}
private requestDeleteLanguage(id: string): void {
this.pendingDeleteLanguageId.set(id);
this.deleteConfirmDialog()?.open();
}
onAddLanguage(): void {
this.modalMode.set('create');
this.selectedLanguageId.set(null);
this.selectedLanguage.set(null);
this.submitAttempted.set(false);
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
this.resetFormState();
this.showModal.set(true);
}
openEditLanguage(id: string): void {
this.modalMode.set('edit');
this.selectedLanguageId.set(id);
this.selectedLanguage.set(null);
this.submitAttempted.set(false);
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
this.resetFormState();
this.modalLoading.set(true);
this.showModal.set(true);
this.languageApi.getById(id).pipe(
finalize(() => this.modalLoading.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: language => {
if (this.selectedLanguageId() !== language.id || !this.showModal()) {
return;
}
this.selectedLanguage.set(language);
this.languageForm.reset({
name: language.name,
code: language.code,
nativeName: language.nativeName,
isRightToLeft: language.isRightToLeft
});
this.resetFormState();
},
error: () => this.showModal.set(false)
});
}
closeModal(): void {
if (this.saving()) return;
this.showModal.set(false);
this.selectedLanguageId.set(null);
this.selectedLanguage.set(null);
this.submitAttempted.set(false);
}
saveLanguage(): void {
if (this.languageForm.invalid) {
this.submitAttempted.set(true);
this.languageForm.markAllAsTouched();
this.focusFirstInvalidControl();
return;
}
if (this.saving() || this.modalLoading()) return;
this.saving.set(true);
const request = this.buildCreateRequest();
const operation = this.modalMode() === 'create'
? this.languageApi.create(request)
: this.languageApi.update(
this.selectedLanguageId() ?? '',
{ ...request, isActive: this.selectedLanguage()?.isActive ?? true }
);
operation.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(
this.modalMode() === 'create'
? 'Language saved successfully.'
: 'Language updated successfully.'
);
this.finishSave();
},
error: (error: HttpErrorResponse) => this.handleSaveError(error)
});
}
private buildCreateRequest(): CreateLanguageRequest {
const value = this.languageForm.getRawValue();
return {
code: this.normalizeCode(value.code),
name: value.name.trim(),
nativeName: value.nativeName.trim(),
isRightToLeft: value.isRightToLeft
};
}
private normalizeCode(code: string): string {
return code.trim().split('-').map((part, index) =>
index === 0 ? part.toLowerCase() : part.toUpperCase()
).join('-');
}
private changeLanguageStatus(id: string, isActive: boolean): void {
if (this.statusChangingId()) return;
this.statusChangingId.set(id);
this.languageApi.getById(id).pipe(
switchMap(language => this.languageApi.update(id, {
code: language.code,
name: language.name,
nativeName: language.nativeName,
isRightToLeft: language.isRightToLeft,
isActive
})),
finalize(() => this.statusChangingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(isActive
? 'Language activated successfully.'
: 'Language deleted successfully.');
this.loadLanguages(this.queryState.getQuery());
},
error: (error: HttpErrorResponse) => {
if (error.status === 404) this.toastr.error('The language is no longer available.');
}
});
}
private handleSaveError(error: HttpErrorResponse): void {
if (error.status === 409) {
this.toastr.error('A language with this code already exists.', 'Duplicate language code');
}
}
private finishSave(): void {
this.showModal.set(false);
this.selectedLanguageId.set(null);
this.selectedLanguage.set(null);
this.submitAttempted.set(false);
this.loadLanguages(this.queryState.getQuery());
}
private resetFormState(): void {
this.languageForm.markAsPristine();
this.languageForm.markAsUntouched();
this.languageForm.updateValueAndValidity();
}
private focusFirstInvalidControl(): void {
queueMicrotask(() => {
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
);
control?.focus();
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
@@ -1,19 +1,80 @@
<app-data-table
[columns]="columns()"
[rows]="states()"
[actions]="actions()"
[loading]="loading()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
title="States"
[showSearch]="true"
searchPlaceholder="Search states..."
[searchDebounceTime]="300"
<!-- Start::row-1 -->
<div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12">
<div class="box custom-box">
<div class="box-body p-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>
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)">
</app-data-table>
<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">
<app-autocomplete
formControlName="countryId"
inputId="state-country-filter"
label="Country"
placeholder="Search country"
[searchFn]="searchCountries"
[displayWith]="displayCountry"
[valueWith]="countryValue"
[selectedItem]="selectedCountryLookup()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="true"
[hideLabel]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onCountryLookupSelected($event)"
/>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- End::row-1 -->
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true"
[showAddButton]="showStateAddButton()" searchPlaceholder="Search states..." [searchDebounceTime]="300"
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
<app-confirm-dialog
title="Delete State"
text="Do you really want to delete this state?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<modal [open]="showStateModal()" [title]="stateModalTitle()" size="md" [submitAction]="stateSubmitAction()"
[submitLabel]="stateSubmitLabel()" [loadingLabel]="stateLoadingLabel()" [loading]="saving()"
(closed)="closeStateModal()" (submitted)="saveState()">
<form [formGroup]="stateForm" (ngSubmit)="saveState()" 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="name" inputId="state-name" label="State Name" placeholder="Name"
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
required: 'State Name is required.',
maxlength: 'State Name cannot exceed 150 characters.'
}" [submitAttempted]="stateSubmitAttempted()" />
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="code" inputId="state-code" label="State Code" placeholder="e.g.: CA"
autocomplete="off" [required]="true" [maxLength]="16" [validationMessages]="{
required: 'State Code is required.',
maxlength: 'State Code cannot exceed 16 characters.',
pattern: 'State Code can contain letters, numbers, hyphens, and underscores only.'
}" [submitAttempted]="stateSubmitAttempted()" />
</div>
</div>
</form>
</modal>
@@ -1,50 +1,184 @@
import { Component } from '@angular/core';
import { inject, signal } from '@angular/core';
import { StateService } from '../../../../../core/services/state/state.service';
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } 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, distinctUntilChanged, finalize, of, switchMap } from 'rxjs';
import {
CountryLookupDto
} from '../../../../../core/models/country/country.model';
import {
CreateStateRequest,
StateDto,
StateModalMode,
UpdateStateRequest
} from '../../../../../core/models/state/state.model';
import { CountryService } from '../../../../../core/services/country/country.service';
import { StateService } from '../../../../../core/services/state/state.service';
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } 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 { DataTable } from '../../../../../shared/components/data-table/data-table';
import { finalize} from 'rxjs/operators';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
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';
interface StateTableRow extends DataTableRecord {
id: string;
countryId: string;
name: string;
code: string | null;
isActive: boolean;
serialNumber: number;
createdOn?: string;
modifiedOn?: string | null;
}
@Component({
selector: 'state-list',
imports: [DataTable],
standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
templateUrl: './state-list.html',
styleUrl: './state-list.scss',
})
export class StateList {
private readonly stateApi: StateService = inject(StateService);
private readonly destroyRef = inject(DestroyRef);
private readonly stateApi = inject(StateService);
private readonly countryApi = inject(CountryService);
private readonly formBuilder = inject(FormBuilder);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly stateQueryRequests$ = new Subject<DataTableQuery>();
private readonly defaultCountrySelectionApplied = signal(false);
readonly queryState = new DataTableQueryState();
readonly states = signal<any[]>([]);
readonly states = signal<StateTableRow[]>([]);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly selectedCountryId = signal<string | null>(null);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly loading = signal(false);
readonly saving = signal(false);
readonly showStateModal = signal(false);
readonly stateModalMode = signal<StateModalMode>('create');
readonly selectedStateId = signal<string | null>(null);
readonly selectedState = signal<StateDto | null>(null);
readonly stateSubmitAttempted = signal(false);
readonly pendingDeleteState = signal<StateDto | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn[]>([
{ key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '60px' },
{ key: 'name', header: 'Name', label: 'Name', sortable: true },
readonly countryFilterForm = this.formBuilder.nonNullable.group({
countryId: ['']
});
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
(term, limit) => this.countryApi.autocomplete(term, limit);
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly stateForm = this.formBuilder.nonNullable.group({
countryId: [
'',
[
Validators.required
]
],
name: [
'',
[
Validators.required,
Validators.maxLength(150)
]
],
code: [
'',
[
Validators.required,
Validators.maxLength(16),
Validators.pattern(/^[A-Za-z0-9_-]+$/)
]
]
});
readonly showStateAddButton = computed(() =>
!!this.selectedCountryId()
);
readonly emptyMessage = computed(() =>
this.selectedCountryId()
? 'No states found'
: 'No records found'
);
readonly emptyDescription = computed(() =>
this.selectedCountryId()
? 'There are no states available for the selected country.'
: 'There is currently no data to display.'
);
readonly stateModalTitle = computed(() =>
this.stateModalMode() === 'create'
? 'Add State'
: 'Edit State'
);
readonly stateSubmitLabel = computed(() =>
this.stateModalMode() === 'create'
? 'Save State'
: 'Update State'
);
readonly stateLoadingLabel = computed(() =>
this.stateModalMode() === 'create'
? 'Saving State...'
: 'Updating State...'
);
readonly stateSubmitAction = computed<'save' | 'update'>(() =>
this.stateModalMode() === 'create'
? 'save'
: 'update'
);
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
{ key: 'name', header: 'Name', label: 'Name', sortable: true ,align: 'left'},
{ key: 'code', header: 'Code', label: 'Code', sortable: true },
{ key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, badgeClass: value =>
value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
{
key: 'isActive',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: value =>
value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
width: '100px',
formatter: (value) => value ? 'Active' : 'Inactive'
formatter: value => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction[]>([
{
type: 'view',
label: 'View',
icon: 'ti ti-eye',
className: 'text-info'
},
readonly actions = signal<DataTableAction<StateTableRow>[]>([
{
type: 'edit',
label: 'Edit',
@@ -56,70 +190,98 @@ export class StateList {
label: 'Delete',
icon: 'ti ti-trash',
className: 'text-danger',
visible: (row: any) => row.isActive
visible: row => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: (row: any) => !row.isActive
visible: row => !row.isActive
}
]);
ngOnInit(): void {
this.loadStates(this.queryState.getQuery(), 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
loadStates(query: DataTableQuery, countryId: any): void {
this.loading.set(true);
this.stateApi
.getStateDataTable(query, countryId)
constructor() {
this.countryFilterForm.controls.countryId.valueChanges
.pipe(
finalize(() => {
this.loading.set(false);
})
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (response: any) => {
console.log('State data loaded:', response);
if (response.draw !== this.queryState.getQuery().draw) {
return;
.subscribe(countryId => {
this.onCountrySelected(countryId || null);
});
this.stateQueryRequests$
.pipe(
switchMap(query => {
const countryId = this.selectedCountryId();
if (!countryId) {
return of(null);
}
// Add serial numbers to states
const statesWithSerialNumbers = response.rows.map((state: any, index: number) => ({
...state,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.states.set(statesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
},
error: (error: any) => {
console.error('Unable to load states.', error);
this.states.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
return this.stateApi.getStateDataTable(query, countryId).pipe(
catchError(() => {
this.toastr.error('Unable to load states.');
this.clearStateGrid();
return of(null);
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(response => {
if (!response) {
return;
}
const query = this.queryState.getQuery();
if (response.draw !== query.draw) {
return;
}
const statesWithSerialNumbers: StateTableRow[] = response.rows.map((state, index) => ({
...state,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.states.set(statesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
});
}
ngOnInit(): void {
this.applyInitialDefaultCountry();
}
loadStates(query: DataTableQuery): void {
if (!this.selectedCountryId()) {
this.clearStateGrid();
return;
}
this.stateQueryRequests$.next(query);
}
onCountryLookupSelected(country: CountryLookupDto): void {
this.selectedCountryLookup.set(country);
}
onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim());
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
this.loadStates(query);
}
onPageChange(event: DataTablePageEvent): void {
const query = this.queryState.setPage(event);
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
this.loadStates(query);
}
onSortChange(event: DataTableSortEvent): void {
const query = this.queryState.setSort(event);
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
this.loadStates(query);
}
onRefresh(): void {
@@ -130,27 +292,42 @@ export class StateList {
draw: currentQuery.draw + 1
};
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
this.loadStates(query);
}
onReset(): void {
const query = this.queryState.reset();
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
this.loadStates(query);
}
onActionClick(event: DataTableActionEvent): void {
onDeleteConfirmed(): void {
const state = this.pendingDeleteState();
if (!state) {
return;
}
this.pendingDeleteState.set(null);
this.deleteState(state);
}
onDeleteCancelled(): void {
this.pendingDeleteState.set(null);
}
onActionClick(event: DataTableActionEvent<StateTableRow>): void {
const action = event.action.type;
const state = event.row;
const state = this.toStateDto(event.row);
switch (action) {
case 'view':
this.viewState(state);
break;
case 'edit':
this.editState(state);
this.openEditState(state);
break;
case 'delete':
this.deleteState(state);
this.requestDeleteState(state);
break;
case 'activate':
this.activateState(state);
@@ -158,24 +335,265 @@ export class StateList {
}
}
private viewState(state: any): void {
console.log('Viewing state:', state);
// TODO: Implement view logic (open modal, navigate to details page, etc.)
onAddState(): void {
const countryId = this.selectedCountryId();
if (!countryId) {
this.toastr.error('Select a country before adding a state.');
return;
}
this.stateModalMode.set('create');
this.selectedStateId.set(null);
this.selectedState.set(null);
this.stateSubmitAttempted.set(false);
this.stateForm.reset({
countryId,
name: '',
code: ''
});
this.resetStateFormState();
this.showStateModal.set(true);
}
private editState(state: any): void {
console.log('Editing state:', state);
// TODO: Implement edit logic (open modal, navigate to edit page, etc.)
closeStateModal(): void {
if (this.saving()) {
return;
}
this.showStateModal.set(false);
this.selectedStateId.set(null);
this.selectedState.set(null);
this.stateSubmitAttempted.set(false);
}
private deleteState(state: any): void {
console.log('Deleting state:', state);
// TODO: Implement delete logic (API call to delete state)
saveState(): void {
if (this.stateForm.invalid) {
this.stateSubmitAttempted.set(true);
this.stateForm.markAllAsTouched();
this.focusFirstInvalidStateControl();
return;
}
if (this.saving()) {
return;
}
this.saving.set(true);
if (this.stateModalMode() === 'create') {
this.stateApi
.createState(this.buildCreateStateRequest())
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
this.toastr.success('State saved successfully.');
this.finishStateSave();
}
});
return;
}
const stateId = this.selectedStateId();
if (!stateId) {
this.saving.set(false);
return;
}
this.stateApi
.updateState(
stateId,
this.buildUpdateStateRequest(this.selectedState()?.isActive ?? true)
)
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => {
this.toastr.success('State updated successfully.');
this.finishStateSave();
}
});
}
private activateState(state: any): void {
console.log('Activating state:', state);
// TODO: Implement activate logic (API call to activate state)
private onCountrySelected(countryId: string | null): void {
this.selectedCountryId.set(countryId);
if (!countryId || this.selectedCountryLookup()?.id !== countryId) {
this.selectedCountryLookup.set(null);
}
this.clearStateGrid();
const query = this.queryState.reset();
if (countryId) {
this.loadStates(query);
}
}
private applyInitialDefaultCountry(): void {
if (
this.defaultCountrySelectionApplied() ||
this.selectedCountryId()
) {
return;
}
this.defaultCountrySelectionApplied.set(true);
this.countryApi.autocomplete('AE', 50).pipe(
catchError(() => {
this.toastr.error('Unable to load countries.');
return of<CountryLookupDto[]>([]);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(countries => {
const uae = countries.find(country =>
country.iso2.trim().toUpperCase() === 'AE'
);
if (!uae) {
this.clearStateGrid();
return;
}
this.selectedCountryLookup.set(uae);
this.countryFilterForm.controls.countryId.setValue(uae.id);
});
}
private clearStateGrid(): void {
this.states.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
private viewState(state: StateDto): void {
this.openEditState(state);
}
private openEditState(state: StateDto): void {
this.stateModalMode.set('edit');
this.selectedStateId.set(state.id);
this.selectedState.set(state);
this.stateSubmitAttempted.set(false);
this.stateApi
.getStateById(state.id)
.subscribe({
next: stateDetails => {
this.selectedState.set(stateDetails);
this.stateForm.reset({
countryId: stateDetails.countryId ?? this.selectedCountryId() ?? '',
name: stateDetails.name ?? '',
code: stateDetails.code ?? ''
});
this.resetStateFormState();
this.showStateModal.set(true);
}
});
}
private requestDeleteState(state: StateDto): void {
this.pendingDeleteState.set(state);
this.deleteConfirmDialog()?.open();
}
private deleteState(state: StateDto): void {
this.updateStateStatus(state, false);
}
private activateState(state: StateDto): void {
this.updateStateStatus(state, true);
}
private buildCreateStateRequest(): CreateStateRequest {
const value = this.stateForm.getRawValue();
return {
countryId: value.countryId,
name: value.name.trim(),
code: value.code.trim().toUpperCase()
};
}
private buildUpdateStateRequest(isActive: boolean): UpdateStateRequest {
const value = this.stateForm.getRawValue();
return {
countryId: null,
name: value.name.trim(),
code: value.code.trim().toUpperCase(),
isActive
};
}
private stateToUpdateRequest(state: StateDto, isActive: boolean): UpdateStateRequest {
return {
countryId: null,
name: state.name?.trim() ?? '',
code: state.code?.trim().toUpperCase() ?? '',
isActive
};
}
private updateStateStatus(state: StateDto, isActive: boolean): void {
this.stateApi
.updateState(state.id, this.stateToUpdateRequest(state, isActive))
.subscribe({
next: () => {
this.toastr.success(
isActive
? 'State activated successfully.'
: 'State deactivated successfully.'
);
this.loadStates(this.queryState.getQuery());
}
});
}
private resetStateFormState(): void {
this.stateForm.markAsPristine();
this.stateForm.markAsUntouched();
this.stateForm.updateValueAndValidity();
}
private finishStateSave(): void {
this.showStateModal.set(false);
this.selectedStateId.set(null);
this.selectedState.set(null);
this.stateSubmitAttempted.set(false);
this.loadStates(this.queryState.getQuery());
}
private focusFirstInvalidStateControl(): void {
queueMicrotask(() => {
const firstInvalidControl =
this.elementRef.nativeElement.querySelector<HTMLElement>(
'modal .form-control.is-invalid, modal [aria-invalid="true"]'
);
firstInvalidControl?.focus();
firstInvalidControl?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
});
}
private toStateDto(row: StateTableRow): StateDto {
return {
id: row.id,
countryId: row.countryId,
name: row.name,
code: row.code,
isActive: row.isActive,
createdOn: row.createdOn,
modifiedOn: row.modifiedOn
};
}
}
@@ -0,0 +1,133 @@
<app-data-table
[columns]="columns()"
[rows]="timezones()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Timezones"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search timezones..."
[searchDebounceTime]="300"
toolTip="Add Timezone"
(addClicked)="onAddTimezone()"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)"
>
<ng-template appDataTableCell="ianaId" let-value="value">
<span class="block max-w-72 truncate font-semibold" title="{{ value }}">{{ value }}</span>
</ng-template>
<ng-template appDataTableCell="displayName" let-value="value">
<span class="block max-w-72 truncate" title="{{ value }}">{{ value }}</span>
</ng-template>
</app-data-table>
<app-confirm-dialog
title="Delete Timezone"
text="Do you really want to delete this timezone?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<modal
[open]="showModal()"
[title]="modalTitle()"
size="md"
[submitAction]="submitAction()"
[submitLabel]="submitLabel()"
[loadingLabel]="loadingLabel()"
[loading]="saving() || modalLoading()"
[showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
(closed)="closeModal()"
(submitted)="saveTimezone()"
>
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
<span class="ms-2">Loading timezone...</span>
</div>
} @else {
<form [formGroup]="timezoneForm" (ngSubmit)="saveTimezone()" 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="ianaId"
inputId="timezone-iana-id"
label="IANA Timezone ID"
placeholder="e.g.: Asia/Kolkata"
autocomplete="off"
[required]="true"
[readonly]="isViewMode()"
[maxLength]="64"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Timezone ID is required.',
maxlength: 'Timezone ID must be 64 characters or fewer.',
pattern: 'Use a valid IANA timezone ID, for example Asia/Kolkata or America/New_York.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="displayName"
inputId="timezone-display-name"
label="Display Name"
placeholder="e.g.: India Standard Time"
autocomplete="off"
[required]="true"
[readonly]="isViewMode()"
[maxLength]="128"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Timezone display name is required.',
maxlength: 'Timezone display name must be 128 characters or fewer.',
pattern: 'Timezone display name cannot contain only whitespace.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="utcOffsetMinutes"
inputId="timezone-utc-offset"
label="UTC Offset (minutes)"
type="number"
inputMode="numeric"
placeholder="For example: 330"
[required]="true"
[readonly]="isViewMode()"
[min]="-720"
[max]="840"
[step]="1"
hint="e.g.: Enter an offset from -720 (-12:00) to 840 (+14:00)."
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'UTC offset is required.',
min: 'UTC offset must be between -12:00 and +14:00.',
max: 'UTC offset must be between -12:00 and +14:00.'
}"
/>
</div>
@if (isViewMode() && selectedTimezone(); as timezone) {
<div class="col-span-12 md:col-span-6 pt-1">
<span class="block text-sm text-textmuted">Formatted UTC Offset</span>
<span class="mt-2 block font-semibold">{{ formatUtcOffset(timezone.utcOffsetMinutes) }}</span>
</div>
<div class="col-span-12 md:col-span-6">
<span class="block text-sm text-textmuted">Status</span>
<span class="badge mt-2" [class.bg-success]="timezone.isActive" [class.bg-danger]="!timezone.isActive">
{{ timezone.isActive ? 'Active' : 'Inactive' }}
</span>
</div>
}
</div>
</form>
}
</modal>
@@ -0,0 +1,348 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, ElementRef, OnInit, computed, inject, signal, viewChild } 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, of, switchMap } from 'rxjs';
import {
CreateTimezoneRequest,
TimezoneDto,
TimezoneModalMode,
UpdateTimezoneRequest
} from '../../../../../core/models/timezone/timezone.model';
import { TimezoneService } from '../../../../../core/services/timezone/timezone.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 { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { Modal } from '../../../../../shared/components/modal/modal';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
interface TimezoneTableRow extends DataTableRecord {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
readonly utcOffsetMinutes: number;
readonly isActive: boolean;
readonly serialNumber: number;
readonly createdOn: string;
readonly modifiedOn: string | null;
}
@Component({
selector: 'timezone-list',
standalone: true,
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
templateUrl: './timezone-list.html',
styleUrl: './timezone-list.scss'
})
export class TimezoneList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
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>();
readonly queryState = new DataTableQueryState();
readonly timezones = signal<TimezoneTableRow[]>([]);
readonly totalRecords = signal(0);
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly statusChangingId = signal<string | null>(null);
readonly showModal = signal(false);
readonly modalMode = signal<TimezoneModalMode>('create');
readonly selectedTimezoneId = signal<string | null>(null);
readonly selectedTimezone = signal<TimezoneDto | null>(null);
readonly submitAttempted = signal(false);
readonly pendingDeleteTimezoneId = signal<string | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly timezoneForm = this.formBuilder.nonNullable.group({
ianaId: ['', [
Validators.required,
Validators.maxLength(64),
Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/)
]],
displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]],
utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]]
});
readonly isViewMode = computed(() => this.modalMode() === 'view');
readonly modalTitle = computed(() => {
switch (this.modalMode()) {
case 'create': return 'Add Timezone';
case 'edit': return 'Edit Timezone';
case 'view': return 'View Timezone';
}
});
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save Timezone' : 'Update Timezone');
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving Timezone...' : 'Updating Timezone...');
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
{ key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'center', align: 'left' },
{ key: 'displayName', label: 'Display Name', header: 'Display Name', sortable: true, headerAlign: 'center', align: 'left' },
{
key: 'utcOffsetMinutes', label: 'UTC Offset', header: 'UTC Offset', sortable: true,
formatter: value => this.formatUtcOffset(Number(value))
},
{
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
badgeClass: value => value === true
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction<TimezoneTableRow>[]>([
{
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,
disabled: row => this.statusChangingId() === row.id
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: row => !row.isActive,
disabled: row => this.statusChangingId() === row.id
}
]);
constructor() {
this.queryRequests$.pipe(
switchMap(query => this.timezoneApi.getDataTable(query).pipe(
catchError(() => {
this.timezones.set([]);
this.totalRecords.set(0);
return of(null);
})
)),
takeUntilDestroyed(this.destroyRef)
).subscribe(response => {
if (!response || response.draw !== this.queryState.getQuery().draw) return;
const query = this.queryState.getQuery();
this.timezones.set(response.rows.map((timezone, index) => ({
...timezone,
serialNumber: (query.page - 1) * query.pageSize + index + 1
})));
this.totalRecords.set(response.total);
});
}
ngOnInit(): void { this.loadTimezones(this.queryState.getQuery()); }
loadTimezones(query: DataTableQuery): void { this.queryRequests$.next(query); }
onSearch(value: string): void { this.loadTimezones(this.queryState.setSearch(value.trim())); }
onPageChange(event: DataTablePageEvent): void { this.loadTimezones(this.queryState.setPage(event)); }
onSortChange(event: DataTableSortEvent): void { this.loadTimezones(this.queryState.setSort(event)); }
onDeleteConfirmed(): void {
const timezoneId = this.pendingDeleteTimezoneId();
if (!timezoneId) {
return;
}
this.pendingDeleteTimezoneId.set(null);
this.changeTimezoneStatus(timezoneId, false);
}
onDeleteCancelled(): void {
this.pendingDeleteTimezoneId.set(null);
}
onActionClick(event: DataTableActionEvent<TimezoneTableRow>): void {
if (event.action.type === 'view') this.openTimezone(event.row.id, 'view');
if (event.action.type === 'edit') this.openTimezone(event.row.id, 'edit');
if (event.action.type === 'delete') this.requestDeleteTimezone(event.row.id);
if (event.action.type === 'activate') this.changeTimezoneStatus(event.row.id, true);
}
private requestDeleteTimezone(id: string): void {
this.pendingDeleteTimezoneId.set(id);
this.deleteConfirmDialog()?.open();
}
onAddTimezone(): void {
this.modalMode.set('create');
this.prepareModal(null);
this.showModal.set(true);
}
openTimezone(id: string, mode: 'edit' | 'view'): void {
this.modalMode.set(mode);
this.prepareModal(id);
this.modalLoading.set(true);
this.showModal.set(true);
this.timezoneApi.getById(id).pipe(
finalize(() => this.modalLoading.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: timezone => {
if (this.selectedTimezoneId() !== timezone.id || !this.showModal()) return;
this.selectedTimezone.set(timezone);
this.timezoneForm.reset({
ianaId: timezone.ianaId,
displayName: timezone.displayName,
utcOffsetMinutes: timezone.utcOffsetMinutes
});
if (mode === 'view') this.timezoneForm.disable();
this.resetFormState();
},
error: (error: HttpErrorResponse) => {
this.showModal.set(false);
if (error.status === 404) this.toastr.error('The timezone is no longer available.');
}
});
}
closeModal(): void {
if (this.saving()) return;
this.showModal.set(false);
this.selectedTimezoneId.set(null);
this.selectedTimezone.set(null);
this.submitAttempted.set(false);
this.timezoneForm.enable();
}
saveTimezone(): void {
if (this.isViewMode() || this.saving() || this.modalLoading()) return;
if (this.timezoneForm.invalid) {
this.submitAttempted.set(true);
this.timezoneForm.markAllAsTouched();
this.focusFirstInvalidControl();
return;
}
const selected = this.selectedTimezone();
const id = this.selectedTimezoneId();
if (this.modalMode() === 'edit' && (!selected || !id)) return;
this.saving.set(true);
const createRequest = this.buildCreateRequest();
const operation = this.modalMode() === 'create'
? this.timezoneApi.create(createRequest)
: this.timezoneApi.update(id!, { ...createRequest, isActive: selected!.isActive });
operation.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(this.modalMode() === 'create'
? 'Timezone saved successfully.'
: 'Timezone updated successfully.');
this.finishSave();
},
error: (error: HttpErrorResponse) => this.handleSaveError(error)
});
}
formatUtcOffset(minutes: number): string {
const sign = minutes >= 0 ? '+' : '-';
const absolute = Math.abs(minutes);
return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`;
}
private prepareModal(id: string | null): void {
this.selectedTimezoneId.set(id);
this.selectedTimezone.set(null);
this.submitAttempted.set(false);
this.timezoneForm.enable();
this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 });
this.resetFormState();
}
private buildCreateRequest(): CreateTimezoneRequest {
const value = this.timezoneForm.getRawValue();
return {
ianaId: value.ianaId.trim(),
displayName: value.displayName.trim(),
utcOffsetMinutes: value.utcOffsetMinutes
};
}
private changeTimezoneStatus(id: string, isActive: boolean): void {
if (this.statusChangingId()) return;
this.statusChangingId.set(id);
this.timezoneApi.getById(id).pipe(
switchMap(timezone => this.timezoneApi.update(id, {
ianaId: timezone.ianaId,
displayName: timezone.displayName,
utcOffsetMinutes: timezone.utcOffsetMinutes,
isActive
})),
finalize(() => this.statusChangingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(isActive
? 'Timezone activated successfully.'
: 'Timezone deleted successfully.');
this.loadTimezones(this.queryState.getQuery());
},
error: (error: HttpErrorResponse) => {
if (error.status === 404) this.toastr.error('The timezone is no longer available.');
}
});
}
private handleSaveError(error: HttpErrorResponse): void {
if (error.status === 409) {
const message = this.apiErrorMessage(error) ?? 'A timezone with this IANA ID already exists.';
this.toastr.error(message, 'Duplicate IANA timezone ID');
} else if (error.status === 404) {
this.toastr.error('The timezone is no longer available.');
this.closeModal();
}
}
private apiErrorMessage(error: HttpErrorResponse): string | null {
const body: unknown = error.error;
if (!body || typeof body !== 'object') return null;
if ('detail' in body && typeof body.detail === 'string') return body.detail;
if ('message' in body && typeof body.message === 'string') return body.message;
return null;
}
private finishSave(): void {
this.showModal.set(false);
this.selectedTimezoneId.set(null);
this.selectedTimezone.set(null);
this.submitAttempted.set(false);
this.loadTimezones(this.queryState.getQuery());
}
private resetFormState(): void {
this.timezoneForm.markAsPristine();
this.timezoneForm.markAsUntouched();
this.timezoneForm.updateValueAndValidity();
}
private focusFirstInvalidControl(): void {
queueMicrotask(() => this.elementRef.nativeElement
.querySelector<HTMLElement>('modal .form-control.is-invalid, modal [aria-invalid="true"]')
?.focus());
}
}
@@ -50,4 +50,13 @@
</ng-template>
</app-data-table>
</app-data-table>
<app-confirm-dialog
title="Delete Tenant"
text="Do you really want to delete this tenant?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
></app-confirm-dialog>
@@ -1,14 +1,15 @@
import { Component } from '@angular/core';
import { Component, signal, viewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
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 { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive';
import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog';
@Component({
selector: 'tenant-list',
imports: [CommonModule, DataTable, DataTableCellDirective],
imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog],
templateUrl: './tenant-list.html',
styleUrl: './tenant-list.scss',
})
@@ -21,6 +22,8 @@ export class TenantList {
pageSize = 10;
totalRecords = 3;
searchText = '';
readonly pendingDeleteTenant = signal<{ id: number } | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
allowedPermissions: string[] = [
'tenant.view',
@@ -125,7 +128,7 @@ export class TenantList {
icon: 'ri-delete-bin-line',
className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full',
permission: 'tenant.delete',
visible: row => row.status !== 'Active'
//visible: row => row.status !== 'Active'
}
];
@@ -159,9 +162,33 @@ export class TenantList {
}
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);
}
onDeleteConfirmed(): void {
const tenant = this.pendingDeleteTenant();
if (!tenant) {
return;
}
this.pendingDeleteTenant.set(null);
this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id);
this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id);
this.totalRecords = this.tenants.length;
console.log('Deleted tenant:', tenant);
}
onDeleteCancelled(): void {
this.pendingDeleteTenant.set(null);
}
onRowClick(row: any): void {
console.log('Row clicked:', row);
}
@@ -0,0 +1,10 @@
<span
class="inline-flex"
[class.pointer-events-none]="disabled()"
[class.opacity-60]="disabled()"
[attr.aria-disabled]="disabled() ? 'true' : null"
[attr.aria-label]="ariaLabel()"
(click)="open($event)"
>
<ng-content />
</span>
@@ -0,0 +1,62 @@
import {
ChangeDetectionStrategy,
Component,
input,
output
} from '@angular/core';
import Swal, { SweetAlertIcon } from 'sweetalert2';
@Component({
selector: 'app-confirm-dialog',
standalone: true,
templateUrl: './confirm-dialog.html',
styleUrl: './confirm-dialog.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmDialog {
readonly title = input('Are you sure?');
readonly text = input("You won't be able to revert this!");
readonly icon = input<SweetAlertIcon>('warning');
readonly confirmButtonText = input('Yes, delete it!');
readonly cancelButtonText = input('No, cancel!');
readonly confirmButtonColor = input('#1f3f81');
readonly cancelButtonColor = input('#ff007f');
readonly disabled = input(false);
readonly ariaLabel = input('Confirmation dialog trigger');
readonly confirmed = output<void>();
readonly cancelled = output<void>();
async open(event?: Event): Promise<void> {
event?.preventDefault();
if (this.disabled()) {
return;
}
const result = await Swal.fire({
title: this.title(),
text: this.text(),
icon: this.icon(),
showCancelButton: false,
showDenyButton: true,
confirmButtonText: this.confirmButtonText(),
denyButtonText: this.cancelButtonText(),
confirmButtonColor: this.confirmButtonColor(),
denyButtonColor: this.cancelButtonColor(),
customClass: {
confirmButton: 'app-confirm-dialog-btn',
denyButton: 'app-confirm-dialog-btn'
}
});
if (result.isConfirmed) {
this.confirmed.emit();
return;
}
if (result.isDenied) {
this.cancelled.emit();
}
}
}
@@ -8,10 +8,17 @@
</div>
<div class="flex flex-wrap gap-2">
@if(showAddButton()){
<div>
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
(buttonClicked)="onAddClick($event)" />
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
(buttonClicked)="onAddClick($event)" appTooltip="Add Country"/>
</div>
}
<!-- @if (toolbarTemplate(); as toolbar) {
<div>
<ng-container [ngTemplateOutlet]="toolbar.templateRef" />
</div>
} -->
@if (showSearch()) {
<div>
<input #searchInput class="form-control form-control-sm" type="text" [placeholder]="searchPlaceholder()"
@@ -21,6 +28,7 @@
</div>
</div>
<div class="box-body !p-0">
<div class="table-responsive overflow-x-auto overflow-y-visible">
<table [class]="tableClass()">
@@ -48,7 +56,7 @@
<tbody>
@if (loading()) {
<tr class="border-b border-defaultborder">
<td [attr.colspan]="colspan()" class="py-10">
<td [attr.colspan]="totalVisibleColumns()" class="py-10">
<div class="flex flex-col items-center justify-center gap-3 text-center">
<i class="ti ti-loader-2 animate-spin text-[1.5rem]"></i>
<span class="text-[0.875rem]">Loading data...</span>
@@ -56,9 +64,23 @@
</td>
</tr>
} @else if (rows().length === 0) {
<tr class="border-b border-defaultborder">
<td [attr.colspan]="colspan()" class="py-10 text-center">
<span class="text-[0.875rem]">{{ emptyMessage() }}</span>
<tr class="border-b border-defaultborder bg-light/30 dark:bg-black/10">
<td [attr.colspan]="totalVisibleColumns()" class="p-0">
<div class="flex min-h-[140px] flex-col items-center justify-center px-4 py-8 text-center">
<div class="mb-3 inline-flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary dark:bg-primary/15">
<i class="ti ti-database-off text-[1.25rem]" aria-hidden="true"></i>
</div>
<p class="text-sm font-semibold text-defaulttextcolor dark:text-white/80">
{{ emptyMessage() }}
</p>
@if (emptyDescription()) {
<p class="mt-1 max-w-md text-sm text-textmuted">
{{ emptyDescription() }}
</p>
}
</div>
</td>
</tr>
} @else
@@ -6,16 +6,21 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataTableCellDirective } from '../../directives/data-table-cell.directive';
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
import { TooltipDirective } from '../../directives/tooltip/tooltip.directive';
import { DataTableAction, DataTableActionEvent, DataTableCellContext, DataTableColumn, DataTablePageEvent, DataTableRecord, DataTableSortEvent } from './data-table.types';
import { Button } from '../button/button';
import { contentChild } from '@angular/core';
import { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet, MatPaginatorModule, CdkOverlayOrigin,
CdkConnectedOverlay, Button, TooltipDirective],
imports: [NgTemplateOutlet,
MatPaginatorModule,
CdkOverlayOrigin,
CdkConnectedOverlay,
Button],
templateUrl: './data-table.html',
styleUrl: './data-table.scss',
standalone: true
@@ -27,6 +32,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
private readonly searchTerms$ = new Subject<string>();
readonly cellTemplates = contentChildren(DataTableCellDirective);
readonly toolbarTemplate = contentChild(DataTableToolbarDirective);
private readonly defaultRowClasses = [
'table-primary',
@@ -92,15 +98,18 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
/*---------------------------*/
tableTitle = input<string>('');
tableTitle = input<string>('');
toolTip = input<string>('');
buttonTitle = input<string>('');
/* --------- Search inputs ---- */
showSearch = input<boolean>(false);
showformSelect = input<boolean>(false);
showAddButton = input<boolean>(false);
searchPlaceholder = input('Search...');
searchDebounceTime = input(300);
emptyMessage = input('No records found');
emptyDescription = input('There is currently no data to display.');
/*---------------------------*/
/* --------- Permission inputs ---- */
@@ -119,7 +128,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
sortDirection = signal<'asc' | 'desc'>('asc');
openActionRowId = signal<string | number | null>(null);
tableClass = input<string>('table table-hover whitespace-nowrap min-w-full');
tableClass = input<string>('table table-bordered whitespace-nowrap min-w-full');
tableHeadClass = input<string>('');
tableBodyClass = input<string>('');
trHeadClass = input<string>('border-b border-defaultborder bg-primary/10 dark:bg-primary/15 dark:border-defaultborder/10');
@@ -133,7 +142,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
actionCellClass = input<string>('!text-center');
addClicked = output<void>();
colspan = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
allowedPermissionSet = computed(() => new Set(this.allowedPermissions()));
@@ -383,7 +392,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
}
getHeaderClass(column: DataTableColumn<T>): string {
return this.composeClass(this.defaultThClass(), this.getAlignClass(column.align), column.headerClass);
return this.composeClass(this.defaultThClass(), this.getAlignClass(column.headerAlign), column.headerClass);
}
getCellClass(column: DataTableColumn<T>): string {
@@ -1,4 +1,8 @@
export type DataTableRecord = Record<string, unknown>; /* table row is an object with string keys */
export type DataTableRecord = Record<string, unknown> & {
readonly id?: string | number;
readonly status?: unknown;
readonly isActive?: boolean;
}; /* table row is an object with string keys */
export type DataTableActionType = 'view' | 'edit' | 'delete' | string;
@@ -9,6 +13,7 @@ export interface DataTableColumn<T extends DataTableRecord = DataTableRecord> {
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
headerAlign?: 'left' | 'center' | 'right';
formatter?: (value: unknown, row: T) => string | number; /* for change the value of custom cell like date format */
badge?: boolean;
badgeClass?: (value: unknown, row: T) => string; /* this is to show the badge according to status of record */
@@ -17,7 +22,7 @@ export interface DataTableColumn<T extends DataTableRecord = DataTableRecord> {
cellClass?: string;
}
export interface DataTableAction<T extends DataTableRecord = any> {
export interface DataTableAction<T extends DataTableRecord = DataTableRecord> {
type: DataTableActionType;
label: string;
icon?: string;
@@ -38,7 +43,7 @@ export interface DataTableSortEvent {
direction: 'asc' | 'desc';
}
export interface DataTableActionEvent<T extends DataTableRecord = any> {
export interface DataTableActionEvent<T extends DataTableRecord = DataTableRecord> {
action: DataTableAction<T>;
row: T;
}
@@ -65,4 +70,4 @@ export interface DataTableResult<T> {
total: number;
filtered: number;
rows: T[];
}
}
@@ -0,0 +1,105 @@
<app-form-field
[label]="label()"
[inputId]="resolvedInputId()"
[control]="control()"
[required]="required()"
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[labelPosition]="labelPosition()"
[hideLabel]="hideLabel()"
[hideValidation]="hideValidation()"
[submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
>
<div cdkOverlayOrigin #origin="cdkOverlayOrigin" class="relative w-full">
<input
#textInput
type="text"
role="combobox"
aria-autocomplete="list"
[id]="resolvedInputId()"
[class]="resolvedInputClass()"
[value]="searchText()"
[placeholder]="placeholder()"
[autocomplete]="autocomplete()"
[disabled]="isDisabled()"
[readOnly]="readonly()"
[attr.aria-label]="ariaLabel() || label() || placeholder()"
[attr.aria-expanded]="isOpen()"
[attr.aria-controls]="panelId()"
[attr.aria-activedescendant]="activeDescendant()"
[attr.aria-describedby]="statusId()"
[attr.aria-required]="required()"
[attr.aria-readonly]="readonly()"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
(keydown)="onKeydown($event)"
/>
@if (loading()) {
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2" aria-hidden="true">
<span class="ti-spinner h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></span>
</span>
} @else if (clearable() && (hasSelectedItem() || searchText())) {
<button
type="button"
class="absolute end-3 top-1/2 -translate-y-1/2 text-textmuted hover:text-danger"
aria-label="Clear selection"
[disabled]="isDisabled() || readonly()"
(mousedown)="$event.preventDefault()"
(click)="clear($event)"
>
<i class="ri-close-line" aria-hidden="true"></i>
</button>
}
</div>
<span [id]="statusId()" class="sr-only" aria-live="polite">{{ message() }}</span>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayWidth]="panelWidth()"
[cdkConnectedOverlayViewportMargin]="8"
[cdkConnectedOverlayPush]="true"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
(backdropClick)="close()"
(detach)="close()"
>
<div
[id]="panelId()"
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]="panelClass()"
>
@if (message()) {
<div class="px-3 py-2 text-[0.8125rem] text-textmuted" [class.text-danger]="error()">
{{ message() }}
</div>
} @else {
@for (item of options(); track optionKey(item, $index); let index = $index) {
<button
type="button"
role="option"
[id]="optionId(index)"
[class]="activeIndex() === 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'
: '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()"
(mouseenter)="activeIndex.set(index)"
(click)="select(item)"
>
{{ displayWith()(item) }}
</button>
}
}
</div>
</ng-template>
</app-form-field>
@@ -0,0 +1,289 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Observable, Subject, of, throwError } from 'rxjs';
import { Autocomplete } from './autocomplete';
import { AutocompleteSearchFn } from './autocomplete.types';
interface LookupItem {
readonly code: string;
readonly title: string;
}
const INDIA: LookupItem = { code: 'IN', title: 'India' };
const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
@Component({
standalone: true,
imports: [ReactiveFormsModule, Autocomplete],
template: `
<app-autocomplete
inputId="country"
label="Country"
[formControl]="control"
[searchFn]="searchFn"
[displayWith]="displayWith"
[valueWith]="valueWith"
[selectedItem]="selectedItem()"
[minSearchLength]="minLength"
[debounceTime]="delay()"
[readonly]="readonly()"
[showDropdownOnFocus]="openOnFocus"
/>
`
})
class HostComponent {
readonly control = new FormControl<string | null>(null);
readonly selectedItem = signal<LookupItem | null>(null);
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
readonly displayWith = (item: LookupItem): string => item.title;
readonly valueWith = (item: LookupItem): string => item.code;
minLength = 2;
readonly delay = signal(300);
readonly readonly = signal(false);
openOnFocus = false;
}
describe('Autocomplete', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
let component: Autocomplete<LookupItem, string>;
let overlayContainer: OverlayContainer;
const input = (): HTMLInputElement => fixture.nativeElement.querySelector('input');
const type = (value: string): void => {
input().value = value;
input().dispatchEvent(new Event('input'));
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
overlayContainer = TestBed.inject(OverlayContainer);
fixture.detectChanges();
component = fixture.debugElement.children[0].componentInstance;
});
afterEach(() => overlayContainer.ngOnDestroy());
it('initializes with an empty selection', () => {
expect(component.searchText()).toBe('');
expect(component.options()).toEqual([]);
});
it('integrates with a reactive form control', () => {
component.select(INDIA);
expect(host.control.value).toBe('IN');
});
it('does not emit onChange from writeValue', () => {
const change = vi.fn();
component.registerOnChange(change);
component.writeValue('IN');
expect(change).not.toHaveBeenCalled();
});
it('applies a disabled form state', () => {
host.control.disable();
fixture.detectChanges();
expect(input().disabled).toBe(true);
});
it('waits for the configured debounce', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA]));
host.searchFn = search;
fixture.detectChanges();
type('in');
vi.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(search).toHaveBeenCalledWith('in', 10);
vi.useRealTimers();
});
it('does not search below the minimum length', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA]));
host.searchFn = search;
fixture.detectChanges();
type('i');
vi.advanceTimersByTime(300);
expect(search).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('cancels stale search requests', () => {
vi.useFakeTimers();
const first = new Subject<readonly LookupItem[]>();
const second = new Subject<readonly LookupItem[]>();
host.delay.set(0);
host.searchFn = term => term === 'in' ? first : second;
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
type('ind'); vi.advanceTimersByTime(0);
first.next([INDONESIA]);
second.next([INDIA]);
expect(component.options()).toEqual([INDIA]);
vi.useRealTimers();
});
it('resets loading after a successful search', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
expect(component.loading()).toBe(false);
vi.useRealTimers();
});
it('resets loading after an error', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => throwError(() => new Error('server'));
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
expect(component.loading()).toBe(false);
expect(component.error()).toBe('Unable to load results');
vi.useRealTimers();
});
it('recovers on a later search after an error', () => {
vi.useFakeTimers();
let attempts = 0;
host.delay.set(0);
host.searchFn = (): Observable<readonly LookupItem[]> =>
++attempts === 1 ? throwError(() => new Error('server')) : of([INDIA]);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
type('ind'); vi.advanceTimersByTime(0);
expect(component.options()).toEqual([INDIA]);
expect(component.error()).toBeNull();
vi.useRealTimers();
});
it('renders returned options', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('India');
vi.useRealTimers();
});
it('selects an option with the mouse', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
overlayContainer.getContainerElement().querySelector<HTMLButtonElement>('[role="option"]')?.click();
expect(host.control.value).toBe('IN');
vi.useRealTimers();
});
it('selects the highlighted option with Enter', () => {
component.options.set([INDIA]);
component.open();
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(host.control.value).toBe('IN');
});
it('only highlights on arrow navigation', () => {
component.options.set([INDIA]);
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(component.activeIndex()).toBe(0);
expect(host.control.value).toBeNull();
});
it('closes on Escape', () => {
component.open();
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(component.isOpen()).toBe(false);
});
it('clears the form value to null', () => {
component.select(INDIA);
component.clear();
expect(host.control.value).toBeNull();
});
it('clears visible state when the form resets', () => {
component.select(INDIA);
host.control.reset();
fixture.detectChanges();
expect(component.searchText()).toBe('');
expect(component.activeItem()).toBeNull();
});
it('displays an existing selected item', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
fixture.detectChanges();
expect(component.searchText()).toBe('India');
});
it('does not retain a stale label when edit values change', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
fixture.detectChanges();
host.control.setValue('ID');
fixture.detectChanges();
expect(component.searchText()).toBe('');
});
it('closes when the overlay backdrop is clicked', () => {
component.open();
fixture.detectChanges();
overlayContainer.getContainerElement().querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
expect(component.isOpen()).toBe(false);
});
it('prevents modification in read-only mode', () => {
host.readonly.set(true);
fixture.detectChanges();
component.select(INDIA);
expect(host.control.value).toBeNull();
});
it('prevents interaction while disabled', () => {
host.control.disable();
fixture.detectChanges();
component.open();
expect(component.isOpen()).toBe(false);
});
it('updates combobox ARIA state', () => {
component.open();
fixture.detectChanges();
expect(input().getAttribute('role')).toBe('combobox');
expect(input().getAttribute('aria-expanded')).toBe('true');
expect(input().getAttribute('aria-controls')).toBe('country-listbox');
});
it('renders the empty state', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => of([]);
fixture.detectChanges();
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('No results found');
vi.useRealTimers();
});
it('renders the friendly error state', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => throwError(() => new Error('raw error'));
fixture.detectChanges();
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
const text = overlayContainer.getContainerElement().textContent ?? '';
expect(text).toContain('Unable to load results');
expect(text).not.toContain('raw error');
vi.useRealTimers();
});
});
@@ -0,0 +1,338 @@
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Injector,
ViewChild,
computed,
effect,
forwardRef,
inject,
input,
output,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AbstractControl, ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
import {
Subject,
catchError,
debounce,
distinctUntilChanged,
map,
of,
switchMap,
timer
} from 'rxjs';
import { FormField, FormLabelPosition } from '../form-field/form-field';
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
import {
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteTrackFn,
AutocompleteValueFn
} from './autocomplete.types';
interface SearchResult<TItem> {
readonly term: string;
readonly options: readonly TItem[];
readonly failed: boolean;
}
@Component({
selector: 'app-autocomplete',
standalone: true,
imports: [CdkConnectedOverlay, CdkOverlayOrigin, FormField],
templateUrl: './autocomplete.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Autocomplete),
multi: true
}]
})
export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
private static nextId = 0;
private readonly injector = inject(Injector);
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
private readonly inputTerms$ = new Subject<string>();
private readonly valuesToResolve$ = new Subject<TValue>();
private formValue: TValue | null = null;
private labelEdited = false;
private onChange: (value: TValue | null) => void = () => {};
private onTouched: () => void = () => {};
@ViewChild('textInput') private textInput?: ElementRef<HTMLInputElement>;
@ViewChild(CdkOverlayOrigin, { read: ElementRef }) private origin?: ElementRef<HTMLElement>;
readonly searchFn = input.required<AutocompleteSearchFn<TItem>>();
readonly displayWith = input.required<AutocompleteDisplayFn<TItem>>();
readonly valueWith = input.required<AutocompleteValueFn<TItem, TValue>>();
readonly trackBy = input<AutocompleteTrackFn<TItem> | null>(null);
readonly selectedItem = input<TItem | null>(null);
readonly resolveValueFn = input<AutocompleteResolveValueFn<TItem, TValue> | null>(null);
readonly label = input('');
readonly inputId = input<string | null>(null);
readonly placeholder = input('Search...');
readonly minSearchLength = input(1);
readonly debounceTime = input(300);
readonly limit = input(10);
readonly disabled = input(false);
readonly readonly = input(false);
readonly clearable = input(true);
readonly required = input(false);
readonly loadingText = input('Loading...');
readonly emptyText = input('No results found');
readonly typeToSearchText = input('Type to search');
readonly errorText = input('Unable to load results');
readonly showDropdownOnFocus = input(false);
readonly closeOnSelect = input(true);
readonly autocomplete = input('off');
readonly ariaLabel = input<string | null>(null);
readonly panelClass = input('');
readonly inputClass = input('');
readonly wrapperClass = input('');
readonly hideLabel = input(false);
readonly hideValidation = input(false);
readonly submitAttempted = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly itemSelected = output<TItem>();
readonly cleared = output<void>();
readonly searchChanged = output<string>();
readonly opened = output<void>();
readonly closed = output<void>();
readonly loadError = output<void>();
readonly isOpen = signal(false);
readonly loading = signal(false);
readonly options = signal<readonly TItem[]>([]);
readonly activeIndex = signal(-1);
readonly activeItem = signal<TItem | null>(null);
readonly searchText = signal('');
readonly error = signal<string | null>(null);
readonly formDisabled = signal(false);
readonly panelWidth = signal(0);
readonly resolvedInputId = computed(() => this.inputId()?.trim() || this.generatedId);
readonly panelId = computed(() => `${this.resolvedInputId()}-listbox`);
readonly statusId = computed(() => `${this.resolvedInputId()}-status`);
readonly control = computed<AbstractControl | null>(() =>
this.injector.get(NgControl, null, { self: true, optional: true })?.control ?? null
);
readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
readonly hasSelectedItem = computed(() => this.activeItem() !== null);
readonly activeDescendant = computed(() => {
const index = this.activeIndex();
return this.isOpen() && index >= 0 ? `${this.resolvedInputId()}-option-${index}` : null;
});
readonly message = computed(() => {
if (this.loading()) return this.loadingText();
if (this.error()) return this.errorText();
if (this.searchText().trim().length < this.minSearchLength()) {
return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`;
}
return this.options().length ? '' : this.emptyText();
});
readonly resolvedInputClass = computed(() => {
const control = this.control();
const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted()));
return [
'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor',
'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70',
'focus:border-primary focus:ring-1 focus:ring-primary',
'pe-16',
invalid ? 'is-invalid border-danger' : '',
this.isDisabled() ? 'cursor-not-allowed opacity-60' : '',
this.inputClass()
].filter(Boolean).join(' ');
});
readonly overlayPositions: ConnectedPosition[] = [
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }
];
constructor() {
this.inputTerms$.pipe(
map(term => term.trim()),
debounce(() => timer(Math.max(0, this.debounceTime()))),
distinctUntilChanged(),
switchMap(term => {
if (term.length < this.minSearchLength()) {
return of<SearchResult<TItem>>({ term, options: [], failed: false });
}
this.loading.set(true);
this.error.set(null);
return this.searchFn()(term, this.limit()).pipe(
map(options => ({ term, options, failed: false })),
catchError(() => of<SearchResult<TItem>>({ term, options: [], failed: true }))
);
}),
takeUntilDestroyed()
).subscribe(result => {
this.loading.set(false);
this.options.set(result.options);
this.activeIndex.set(-1);
this.error.set(result.failed ? this.errorText() : null);
if (result.failed) this.loadError.emit();
});
this.valuesToResolve$.pipe(
switchMap(value => this.resolveValueFn()?.(value) ?? of(null)),
takeUntilDestroyed()
).subscribe(item => this.applyResolvedItem(item));
effect(() => {
const item = this.selectedItem();
if (item === null) {
if (this.formValue === null) this.applyResolvedItem(null);
return;
}
if (this.valuesEqual(this.valueWith()(item), this.formValue)) this.applyResolvedItem(item);
});
}
writeValue(value: TValue | null): void {
this.formValue = value ?? null;
this.labelEdited = false;
if (this.formValue === null) {
this.applyResolvedItem(null);
return;
}
const supplied = this.selectedItem();
if (supplied !== null && this.valuesEqual(this.valueWith()(supplied), this.formValue)) {
this.applyResolvedItem(supplied);
} else {
this.applyResolvedItem(null);
if (this.resolveValueFn()) this.valuesToResolve$.next(this.formValue);
}
}
registerOnChange(fn: (value: TValue | null) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(disabled: boolean): void {
this.formDisabled.set(disabled);
if (disabled) this.close();
}
onInput(event: Event): void {
if (!(event.target instanceof HTMLInputElement) || this.isDisabled() || this.readonly()) return;
const text = event.target.value;
const previousText = this.searchText();
this.searchText.set(text);
this.labelEdited = this.formValue !== null && text !== previousText;
this.searchChanged.emit(text.trim());
this.open();
this.inputTerms$.next(text);
}
onFocus(): void {
if (this.showDropdownOnFocus()) {
this.open();
this.inputTerms$.next(this.searchText());
}
}
onBlur(): void {
queueMicrotask(() => {
this.onTouched();
if (this.labelEdited) this.clearValue(false, false);
});
}
onKeydown(event: KeyboardEvent): void {
if (this.isDisabled() || this.readonly()) return;
const options = this.options();
switch (event.key) {
case 'ArrowDown':
event.preventDefault(); this.open(); this.setActive(Math.min(this.activeIndex() + 1, options.length - 1)); break;
case 'ArrowUp':
event.preventDefault(); this.open(); this.setActive(Math.max(this.activeIndex() - 1, 0)); break;
case 'Home':
if (this.isOpen() && options.length) { event.preventDefault(); this.setActive(0); } break;
case 'End':
if (this.isOpen() && options.length) { event.preventDefault(); this.setActive(options.length - 1); } break;
case 'Enter': {
const item = options[this.activeIndex()];
if (this.isOpen() && item !== undefined) { event.preventDefault(); this.select(item); }
break;
}
case 'Escape':
if (this.isOpen()) { event.preventDefault(); this.close(); } break;
}
}
select(item: TItem): void {
if (this.isDisabled() || this.readonly()) return;
this.activeItem.set(item);
this.formValue = this.valueWith()(item);
this.searchText.set(this.displayWith()(item));
this.labelEdited = false;
this.onChange(this.formValue);
this.onTouched();
this.itemSelected.emit(item);
if (this.closeOnSelect()) this.close();
}
clear(event?: MouseEvent): void {
event?.preventDefault();
event?.stopPropagation();
if (this.isDisabled() || this.readonly()) return;
this.clearValue(true, true);
}
open(): void {
if (this.isDisabled() || this.readonly() || this.isOpen()) return;
this.panelWidth.set(this.origin?.nativeElement.getBoundingClientRect().width ?? 0);
this.isOpen.set(true);
this.opened.emit();
}
close(): void {
if (!this.isOpen()) return;
this.isOpen.set(false);
this.activeIndex.set(-1);
this.closed.emit();
}
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; }
private setActive(index: number): void {
if (index < 0 || index >= this.options().length) return;
this.activeIndex.set(index);
queueMicrotask(() => document.getElementById(this.optionId(index))?.scrollIntoView({ block: 'nearest' }));
}
private clearValue(emitCleared: boolean, restoreFocus: boolean): void {
this.formValue = null;
this.activeItem.set(null);
this.searchText.set('');
this.options.set([]);
this.error.set(null);
this.loading.set(false);
this.labelEdited = false;
this.onChange(null);
this.onTouched();
if (emitCleared) this.cleared.emit();
this.close();
if (restoreFocus) queueMicrotask(() => this.textInput?.nativeElement.focus());
}
private applyResolvedItem(item: TItem | null, clearText = true): void {
this.activeItem.set(item);
if (item !== null) this.searchText.set(this.displayWith()(item));
else if (clearText) this.searchText.set('');
this.labelEdited = false;
}
private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); }
}
@@ -0,0 +1,16 @@
import { Observable } from 'rxjs';
export type AutocompleteSearchFn<TItem> = (
term: string,
limit: number
) => Observable<readonly TItem[]>;
export type AutocompleteDisplayFn<TItem> = (item: TItem) => string;
export type AutocompleteValueFn<TItem, TValue> = (item: TItem) => TValue;
export type AutocompleteTrackFn<TItem> = (item: TItem) => string | number;
export type AutocompleteResolveValueFn<TItem, TValue> = (
value: TValue
) => Observable<TItem | null>;
@@ -42,4 +42,4 @@
/>
}
</div>
</div>
</div>
@@ -2,7 +2,9 @@ import {
ChangeDetectionStrategy,
Component,
computed,
input
effect,
input,
signal
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
@@ -43,6 +45,8 @@ export class FormField {
readonly showValidationWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly validationMessages =
input<ValidationMessageMap>({});
@@ -64,6 +68,36 @@ export class FormField {
'ms-0.5 text-danger'
);
private readonly controlStateVersion = signal(0);
constructor() {
effect((onCleanup) => {
const control = this.control();
if (!control) {
return;
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
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();
});
});
}
readonly validationId = computed(
() => `${this.inputId()}-validation`
);
@@ -77,17 +111,19 @@ export class FormField {
);
readonly hasVisibleError = computed(() => {
this.controlStateVersion();
const control = this.control();
if (!control?.invalid) {
return false;
}
if (this.showValidationWhenDirty()) {
return control.touched || control.dirty;
}
return control.touched;
return (
control.touched ||
control.dirty ||
this.submitAttempted()
);
});
readonly resolvedWrapperClass = computed(() => {
@@ -138,4 +174,4 @@ export class FormField {
readonly showHint = computed(() => {
return !!this.hint() && !this.hasVisibleError();
});
}
}
@@ -8,6 +8,7 @@
[hint]="hint()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
[labelClass]="labelClass()"
@@ -112,4 +113,4 @@
</span>
}
</div>
</app-form-field>
</app-form-field>
@@ -65,6 +65,7 @@ export class FormInput implements ControlValueAccessor {
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
@@ -91,6 +92,7 @@ export class FormInput implements ControlValueAccessor {
readonly value = signal<string | number | null>(null);
readonly formDisabled = signal(false);
readonly passwordVisible = signal(false);
private readonly controlStateVersion = signal(0);
private onChange: (value: string | number | null) => void = () => {};
@@ -102,6 +104,32 @@ export class FormInput implements ControlValueAccessor {
this.passwordVisible.set(false);
}
});
effect((onCleanup) => {
const control = this.control();
if (!control) {
return;
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
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();
});
});
}
readonly control = computed<AbstractControl | null>(() => {
@@ -158,12 +186,23 @@ export class FormInput implements ControlValueAccessor {
});
readonly resolvedInputClass = computed(() => {
this.controlStateVersion();
const control = this.control();
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
);
return [
'form-control',
this.hasPrefixIcon() ? '!ps-10' : '',
this.hasSuffixContent() ? '!pe-10' : '',
this.control()?.invalid &&
(this.control()?.touched || this.control()?.dirty)
showInvalidState
? 'is-invalid'
: '',
this.inputClass()
@@ -173,7 +212,10 @@ export class FormInput implements ControlValueAccessor {
});
readonly describedBy = computed(() => {
this.controlStateVersion();
const ids: string[] = [];
const control = this.control();
if (this.description()) {
ids.push(`${this.inputId()}-description`);
@@ -184,8 +226,12 @@ export class FormInput implements ControlValueAccessor {
}
if (
this.control()?.invalid &&
(this.control()?.touched || this.control()?.dirty)
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
) {
ids.push(`${this.inputId()}-validation`);
}
@@ -252,4 +298,4 @@ export class FormInput implements ControlValueAccessor {
? null
: numericValue;
}
}
}
@@ -1 +1,223 @@
<p>form-select works!</p>
<app-form-field
[label]="label()"
[inputId]="resolvedInputId()"
[control]="control()"
[required]="required()"
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[labelPosition]="labelPosition()"
[hideLabel]="hideLabel()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
[labelClass]="labelClass()"
[contentClass]="fieldContentClass()"
>
<ng-select
#select
bindLabel="label"
bindValue="value"
appearance="outline"
[items]="selectItems()"
[ngModel]="modelValue()"
[multiple]="isMultiple()"
[searchable]="false"
[editableSearchTerm]="resolvedEditableSearchTerm()"
[clearable]="clearable()"
[hideSelected]="hideSelected()"
[closeOnSelect]="resolvedCloseOnSelect()"
[maxSelectedItems]="maxSelectedItems()"
[placeholder]="resolvedPlaceholder()"
[loading]="loading()"
[loadingText]="loadingText()"
[notFoundText]="notFoundText()"
[typeToSearchText]="typeToSearchText()"
[clearAllText]="clearAllText()"
[appendTo]="appendTo()"
[dropdownPosition]="dropdownPosition()"
[virtualScroll]="virtualScroll()"
[bufferAmount]="bufferAmount()"
[groupBy]="groupBy()"
[selectableGroup]="selectableGroup()"
[selectableGroupAsModel]="selectableGroupAsModel()"
[selectOnTab]="selectOnTab()"
[clearOnBackspace]="clearOnBackspace()"
[readonly]="resolvedReadonly()"
[labelForId]="resolvedInputId()"
[inputAttrs]="inputAttrs()"
[ariaLabel]="ariaLabel() || label() || resolvedPlaceholder()"
[ngClass]="resolvedSelectClass()"
(ngModelChange)="onValueChange($event)"
(open)="onOpen()"
(close)="onClose()"
(focus)="onFocus()"
(blur)="onBlur()"
(clear)="onClear()"
(scroll)="onScroll($event)"
(scrollToEnd)="onScrollToEnd()"
>
@if (showDropdownHeader()) {
<ng-template ng-header-tmp>
@if (searchable()) {
<div class="w-full">
<input
type="search"
class="form-control form-control-sm w-full rounded-sm border-defaultborder text-defaulttextcolor dark:border-defaultborder/10 dark:text-white/70"
[value]="searchTerm()"
[placeholder]="resolvedSearchPlaceholder()"
[disabled]="isDisabled()"
autocomplete="off"
(input)="onSearchInput($event)"
(keydown)="$event.stopPropagation()"
/>
</div>
}
@if (isMultiple() && showSelectAll()) {
<button
type="button"
class="flex w-full items-center gap-2 border-b border-defaultborder px-3 py-2 text-start text-[0.8125rem] text-defaulttextcolor hover:bg-light dark:border-defaultborder/10 dark:text-white/70 dark:hover:bg-black/20"
[class.text-primary]="allSelected() || partiallySelected()"
[disabled]="isDisabled()"
(click)="toggleSelectAll()"
>
@if (showCheckboxes()) {
<span
class="flex size-4 shrink-0 items-center justify-center rounded-sm border border-defaultborder bg-white text-[0.625rem] dark:border-defaultborder/10 dark:bg-bodybg"
[class.bg-primary]="allSelected() || partiallySelected()"
[class.text-white]="allSelected() || partiallySelected()"
aria-hidden="true"
>
@if (allSelected()) {
<i class="ri-check-line leading-none"></i>
} @else if (partiallySelected()) {
<i class="ri-subtract-line leading-none"></i>
}
</span>
}
<span class="min-w-0 flex-1 truncate">
{{ selectAllLabel() }}
</span>
</button>
}
</ng-template>
}
<ng-template ng-option-tmp let-item="item" let-item$="item$">
<div [class]="getOptionContainerClass(item)">
@if (isMultiple() && showCheckboxes()) {
<span
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-sm border border-defaultborder bg-white text-[0.625rem] dark:border-defaultborder/10 dark:bg-bodybg"
[class.bg-primary]="isOptionSelected(item)"
[class.text-white]="isOptionSelected(item)"
aria-hidden="true"
>
@if (isOptionSelected(item)) {
<i class="ri-check-line leading-none"></i>
}
</span>
}
@if (item.prefixText) {
<span [class]="getOptionPrefixClass(item)">
{{ item.prefixText }}
</span>
}
<span class="min-w-0 flex-1">
<span [class]="getOptionLabelClass(item)">
{{ getOptionDisplayLabel(item, item$.label) }}
</span>
@if (item.description) {
<span class="mt-0.5 block truncate text-[0.75rem] text-inherit opacity-80">
{{ item.description }}
</span>
}
</span>
</div>
</ng-template>
<ng-template ng-label-tmp let-item="item" let-clear="clear" let-label="label">
<span class="inline-flex min-w-0 items-center gap-1">
@if (getSelectionPrefixText(item)) {
<span class="shrink-0 text-primary">
{{ getSelectionPrefixText(item) }}
</span>
}
<span class="truncate">
{{ getSelectionDisplayLabel(item, label) }}
</span>
@if (isMultiple() && clearable() && !resolvedReadonly()) {
<button
type="button"
class="ms-1 inline-flex text-white/80 hover:text-white"
aria-label="Remove selected option"
(click)="clear(item)"
>
<i class="ri-close-line leading-none"></i>
</button>
}
</span>
</ng-template>
@if (isMultiple()) {
<ng-template ng-multi-label-tmp let-items="items" let-clear="clear">
<div class="flex min-w-0 items-center gap-1">
@if (items.length > 0) {
<span class="inline-flex max-w-full items-center gap-1 rounded-sm bg-primary px-2 py-0.5 text-[0.75rem] text-white">
<span class="truncate">
{{ getMultiLabelText(items) }}
</span>
@if (clearable() && !resolvedReadonly()) {
<button
type="button"
class="inline-flex text-white/80 hover:text-white"
aria-label="Remove selected option"
(click)="clear(items[0])"
>
<i class="ri-close-line leading-none"></i>
</button>
}
</span>
}
</div>
</ng-template>
}
@if (isMultiple() && showMultiSelectFooter()) {
<ng-template ng-footer-tmp>
<div class="flex items-center justify-between gap-2 border-t border-defaultborder px-3 py-2 dark:border-defaultborder/10">
<span class="text-[0.75rem] text-textmuted">
{{ pendingValue().length }} selected
</span>
<span class="inline-flex items-center gap-2">
<button
type="button"
class="ti-btn ti-btn-light !px-3 !py-1.5 !text-[0.75rem]"
(click)="cancelSelection(select)"
>
{{ cancelLabel() }}
</button>
<button
type="button"
class="ti-btn ti-btn-primary-full !px-3 !py-1.5 !text-[0.75rem]"
(click)="confirmSelection(select)"
>
{{ confirmLabel() }}
</button>
</span>
</div>
</ng-template>
}
</ng-select>
</app-form-field>
@@ -1,11 +1,723 @@
import { Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
Injector,
computed,
forwardRef,
inject,
input,
output,
signal
} from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
import {
NgFooterTemplateDirective,
NgHeaderTemplateDirective,
NgLabelTemplateDirective,
NgMultiLabelTemplateDirective,
NgOptionTemplateDirective,
NgSelectComponent
} from '@ng-select/ng-select';
import { FormField, FormLabelPosition } from '../form-field/form-field';
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
import {
FormSelectDropdownPosition,
FormSelectMode,
FormSelectOption,
FormSelectPrimitive,
FormSelectScrollEvent,
FormSelectSearchEvent,
FormSelectSearchMode,
FormSelectValue
} from '../models/form-select.models';
@Component({
selector: 'form-select',
imports: [],
selector: 'app-form-select',
standalone: true,
imports: [
FormsModule,
FormField,
NgFooterTemplateDirective,
NgHeaderTemplateDirective,
NgLabelTemplateDirective,
NgMultiLabelTemplateDirective,
NgOptionTemplateDirective,
NgSelectComponent
],
templateUrl: './form-select.html',
styleUrl: './form-select.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormSelect),
multi: true
}
]
})
export class FormSelect {
export class FormSelect<TValue extends FormSelectPrimitive = string> implements ControlValueAccessor {
private static nextId = 0;
private readonly injector = inject(Injector);
private readonly generatedInputId = `form-select-${FormSelect.nextId++}`;
readonly inputId = input<string | null>(null);
readonly label = input('');
readonly name = input<string | null>(null);
readonly options = input<readonly FormSelectOption<TValue>[]>([]);
readonly mode = input<FormSelectMode>('single');
readonly searchable = input(true);
readonly searchMode = input<FormSelectSearchMode>('client');
readonly editableSearchTerm = input<boolean | null>(null);
readonly clearable = input(true);
readonly hideSelected = input(false);
readonly closeOnSelect = input<boolean | null>(null);
readonly maxSelectedItems = input<number | null>(null);
readonly placeholder = input('');
readonly required = input(false);
readonly disabled = input(false);
readonly readonly = input(false);
readonly loading = input(false);
readonly loadingText = input('Loading...');
readonly notFoundText = input('No options found');
readonly typeToSearchText = input('Type to search');
readonly clearAllText = input('Clear all');
readonly appendTo = input('');
readonly dropdownPosition = input<FormSelectDropdownPosition>('auto');
readonly virtualScroll = input(false);
readonly bufferAmount = input(4);
readonly groupBy = input('');
readonly selectableGroup = input(false);
readonly selectableGroupAsModel = input(false);
readonly selectOnTab = input(true);
readonly clearOnBackspace = input(true);
readonly showCheckboxes = input(true);
readonly showSelectAll = input(true);
readonly showMultiSelectFooter = input(false);
readonly confirmLabel = input('OK');
readonly cancelLabel = input('Cancel');
readonly selectAllLabel = input('Select All');
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly hideLabel = input(false);
readonly wrapperClass = input('');
readonly labelClass = input('');
readonly fieldContentClass = input('');
readonly selectClass = input('');
readonly ariaLabel = input<string | null>(null);
readonly ariaDescription = input<string | null>(null);
readonly selectionChanged = output<FormSelectValue<TValue>>();
readonly searchChanged = output<FormSelectSearchEvent>();
readonly opened = output<void>();
readonly closed = output<void>();
readonly cleared = output<void>();
readonly focused = output<void>();
readonly blurred = output<void>();
readonly scrolled = output<FormSelectScrollEvent>();
readonly scrolledToEnd = output<void>();
readonly value = signal<FormSelectValue<TValue>>(null);
readonly pendingValue = signal<readonly TValue[]>([]);
readonly formDisabled = signal(false);
readonly dropdownOpen = signal(false);
readonly searchTerm = signal('');
private onChange: (value: FormSelectValue<TValue>) => void = () => {};
private onTouched: () => void = () => {};
readonly control = computed<AbstractControl | null>(() => {
return this.injector.get(NgControl, null, {
self: true,
optional: true
})?.control ?? null;
});
readonly resolvedInputId = computed(() =>
this.inputId()?.trim() || this.generatedInputId
);
readonly resolvedName = computed(() =>
this.name()?.trim() || this.resolvedInputId()
);
readonly isMultiple = computed(() => this.mode() === 'multiple');
readonly isDisabled = computed(() =>
this.disabled() ||
this.formDisabled() ||
this.loading()
);
readonly resolvedReadonly = computed(() =>
this.readonly() ||
this.isDisabled()
);
readonly resolvedCloseOnSelect = computed(() =>
this.closeOnSelect() ?? !this.isMultiple()
);
readonly resolvedEditableSearchTerm = computed(() =>
this.editableSearchTerm() ?? false
);
readonly resolvedOptions = computed<readonly FormSelectOption<TValue>[]>(() => {
const options = this.options();
const term = this.searchTerm().trim().toLocaleLowerCase();
if (
!this.searchable() ||
this.searchMode() === 'server' ||
!term
) {
return options;
}
return options.filter(option =>
this.optionMatchesSearchTerm(option, term)
);
});
readonly selectItems = computed<readonly FormSelectOption<TValue>[]>(() => {
const selected = new Set(this.activeSelectedValues());
const selectedOptions = this.options()
.filter(option => selected.has(option.value));
const optionValues = new Set(selectedOptions.map(option => option.value));
const visibleOptions = this.resolvedOptions()
.filter(option => !optionValues.has(option.value));
return [
...selectedOptions,
...visibleOptions
];
});
readonly showDropdownHeader = computed(() =>
this.searchable() ||
(
this.isMultiple() &&
this.showSelectAll()
)
);
readonly selectedValues = computed<readonly TValue[]>(() => {
const currentValue = this.value();
return Array.isArray(currentValue)
? currentValue
: [];
});
readonly modelValue = computed<FormSelectValue<TValue>>(() => {
if (
this.isMultiple() &&
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
return this.pendingValue();
}
return this.value();
});
readonly allEnabledValues = computed<readonly TValue[]>(() =>
this.options()
.filter(option => !option.disabled)
.map(option => option.value)
);
readonly allSelected = computed(() => {
const selected = new Set(
this.showMultiSelectFooter()
? this.pendingValue()
: this.selectedValues()
);
const enabledValues = this.allEnabledValues();
return (
enabledValues.length > 0 &&
enabledValues.every(value => selected.has(value))
);
});
readonly partiallySelected = computed(() => {
const selected = new Set(
this.showMultiSelectFooter()
? this.pendingValue()
: this.selectedValues()
);
const selectedCount = this.allEnabledValues()
.filter(value => selected.has(value))
.length;
return (
selectedCount > 0 &&
selectedCount < this.allEnabledValues().length
);
});
readonly selectedCount = computed(() =>
this.selectedValues().length
);
readonly resolvedPlaceholder = computed(() =>
this.placeholder().trim() ||
(
this.label().trim()
? `Select ${this.label().trim()}`
: 'Select'
)
);
readonly resolvedSearchPlaceholder = computed(() =>
this.placeholder().trim() ||
(
this.label().trim()
? `Search ${this.label().trim()}`
: 'Search'
)
);
readonly describedBy = computed(() => {
const ids: string[] = [];
const control = this.control();
if (this.description()) {
ids.push(`${this.resolvedInputId()}-description`);
}
if (this.hint()) {
ids.push(`${this.resolvedInputId()}-hint`);
}
if (
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
) {
ids.push(`${this.resolvedInputId()}-validation`);
}
return ids.length ? ids.join(' ') : null;
});
readonly inputAttrs = computed<Record<string, string>>(() => {
const attrs: Record<string, string> = {
name: this.resolvedName()
};
const describedBy = this.describedBy();
const ariaDescription = this.ariaDescription();
if (describedBy) {
attrs['aria-describedby'] = describedBy;
}
if (ariaDescription) {
attrs['aria-description'] = ariaDescription;
}
if (this.required()) {
attrs['aria-required'] = 'true';
}
return attrs;
});
readonly resolvedSelectClass = computed(() => {
const control = this.control();
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
);
return [
'ti-form-select',
'rounded-sm',
'border-defaultborder',
'text-defaulttextcolor',
'dark:border-defaultborder/10',
'dark:text-white/70',
'w-full',
'app-form-select-control',
showInvalidState ? 'is-invalid' : '',
this.isDisabled() ? 'opacity-60 pointer-events-none' : '',
this.selectClass()
]
.filter(Boolean)
.join(' ');
});
writeValue(value: FormSelectValue<TValue>): void {
this.value.set(this.normalizeValue(value));
}
registerOnChange(fn: (value: FormSelectValue<TValue>) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(disabled: boolean): void {
this.formDisabled.set(disabled);
}
onValueChange(incomingValue: FormSelectValue<TValue>): void {
const normalizedValue = this.normalizeValue(incomingValue);
if (
this.isMultiple() &&
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
this.pendingValue.set(
Array.isArray(normalizedValue)
? normalizedValue
: []
);
return;
}
this.commitValue(normalizedValue);
}
onSearchInput(event: Event): void {
const inputElement = event.target instanceof HTMLInputElement
? event.target
: null;
const term = inputElement?.value ?? '';
this.searchTerm.set(term);
this.searchChanged.emit({
term: term.trim()
});
}
toggleSelectAll(): void {
if (!this.isMultiple() || this.isDisabled()) {
return;
}
const nextValue = this.allSelected()
? []
: [...this.allEnabledValues()];
if (
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
this.pendingValue.set(nextValue);
return;
}
this.commitValue(nextValue);
}
onOpen(): void {
this.dropdownOpen.set(true);
this.searchTerm.set('');
if (
this.isMultiple() &&
this.showMultiSelectFooter()
) {
this.pendingValue.set([
...this.selectedValues()
]);
}
this.opened.emit();
}
onClose(): void {
this.dropdownOpen.set(false);
this.searchTerm.set('');
this.onTouched();
this.closed.emit();
}
onFocus(): void {
this.focused.emit();
}
onBlur(): void {
this.onTouched();
this.blurred.emit();
}
onClear(): void {
this.searchTerm.set('');
this.cleared.emit();
}
onScroll(event: FormSelectScrollEvent): void {
this.scrolled.emit(event);
}
onScrollToEnd(): void {
this.scrolledToEnd.emit();
}
confirmSelection(select: NgSelectComponent): void {
if (!this.isMultiple()) {
return;
}
this.commitValue([
...this.pendingValue()
]);
select.close();
}
cancelSelection(select: NgSelectComponent): void {
this.pendingValue.set([
...this.selectedValues()
]);
select.close();
}
isOptionSelected(option: FormSelectOption<TValue>): boolean {
const value = option.value;
const source = this.activeSelectedValues();
return source.includes(value);
}
getMultiLabelText(items: readonly FormSelectOption<TValue>[]): string {
const firstLabel = items[0]?.label ?? '';
const remainingCount = items.length - 1;
return remainingCount > 0
? `${firstLabel} +${remainingCount}`
: firstLabel;
}
trackOption(option: FormSelectOption<TValue>): TValue {
return option.value;
}
getOptionContainerClass(option: FormSelectOption<TValue>): string {
return [
'flex min-w-0 w-full items-start gap-2 text-inherit',
option.disabled ? 'text-textmuted opacity-60' : '',
this.isOptionSelected(option) && !option.disabled ? 'text-white' : ''
]
.filter(Boolean)
.join(' ');
}
getOptionLabelClass(option: FormSelectOption<TValue>): string {
return [
'block truncate text-inherit',
option.disabled ? 'text-textmuted' : ''
]
.filter(Boolean)
.join(' ');
}
getOptionDisplayLabel(
option: FormSelectOption<TValue>,
resolvedLabel: string | null | undefined
): string {
return this.cleanText(option.label) || this.cleanText(resolvedLabel);
}
getSelectionPrefixText(item: FormSelectOption<TValue> | TValue): string {
return this.isOption(item)
? item.prefixText?.trim() ?? ''
: '';
}
getSelectionDisplayLabel(
item: FormSelectOption<TValue> | TValue,
resolvedLabel: string | null | undefined
): string {
if (this.isOption(item)) {
return this.cleanText(item.label) || this.cleanText(resolvedLabel);
}
return this.cleanText(resolvedLabel) || this.findOptionLabel(item);
}
getOptionPrefixClass(option: FormSelectOption<TValue>): string {
return [
'shrink-0 rounded-sm px-1.5 py-0.5 text-[0.6875rem] font-medium',
option.disabled
? 'bg-light text-textmuted dark:bg-black/20'
: 'bg-light text-primary dark:bg-black/20'
]
.filter(Boolean)
.join(' ');
}
private commitValue(value: FormSelectValue<TValue>): void {
this.value.set(value);
this.onChange(value);
this.selectionChanged.emit(value);
}
private activeSelectedValues(): readonly TValue[] {
if (
this.isMultiple() &&
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
return this.pendingValue();
}
const currentValue = this.value();
if (this.isValueArray(currentValue)) {
return currentValue;
}
return currentValue === null
? []
: [currentValue];
}
private normalizeValue(value: FormSelectValue<TValue>): FormSelectValue<TValue> {
if (this.isMultiple()) {
return this.isValueArray(value)
? value.filter(item => !this.isEmptyStringValue(item))
: [];
}
return this.isValueArray(value)
? this.normalizeSingleValue(value[0] ?? null)
: this.normalizeSingleValue(value);
}
private normalizeSingleValue(value: TValue | null): TValue | null {
return this.isEmptyStringValue(value)
? null
: value ?? null;
}
private isValueArray(value: FormSelectValue<TValue>): value is readonly TValue[] {
return Array.isArray(value);
}
private isEmptyStringValue(value: TValue | null): boolean {
return typeof value === 'string' && value.trim() === '';
}
private isOption(value: FormSelectOption<TValue> | TValue): value is FormSelectOption<TValue> {
return (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'label' in value
);
}
private findOptionLabel(value: TValue): string {
return this.options()
.find(option => option.value === value)
?.label
?.trim() ?? '';
}
private optionMatchesSearchTerm(
option: FormSelectOption<TValue>,
term: string
): boolean {
return [
option.label,
option.prefixText ?? '',
option.description ?? ''
].some(value =>
this.cleanText(value).toLocaleLowerCase().includes(term)
);
}
private cleanText(value: string | null | undefined): string {
return value?.trim() ?? '';
}
}
@@ -2,7 +2,9 @@ import {
ChangeDetectionStrategy,
Component,
computed,
input
effect,
input,
signal
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
@@ -23,23 +25,62 @@ export class FormValidationMessage {
readonly showWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly customClass = input(
'mt-1 text-[0.75rem] text-danger'
);
private readonly controlStateVersion = signal(0);
constructor() {
effect((onCleanup) => {
const control = this.control();
if (!control) {
return;
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
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();
});
});
}
readonly shouldShow = computed(() => {
this.controlStateVersion();
const control = this.control();
if (!control || !control.invalid) {
return false;
}
return this.showWhenDirty()
? control.touched || control.dirty
: control.touched;
return (
control.touched ||
control.dirty ||
this.submitAttempted()
);
});
readonly message = computed(() => {
this.controlStateVersion();
this.submitAttempted();
const control = this.control();
if (!control?.errors) {
@@ -124,4 +165,4 @@ export class FormValidationMessage {
return `${fieldName} is invalid.`;
}
}
}
}
@@ -0,0 +1,37 @@
export type FormSelectPrimitive = string | number;
export type FormSelectMode = 'single' | 'multiple';
export type FormSelectSearchMode = 'client' | 'server';
export type FormSelectDropdownPosition =
| 'auto'
| 'bottom'
| 'top'
| 'left'
| 'right';
export type FormSelectValue<
TValue extends FormSelectPrimitive = FormSelectPrimitive
> = TValue | readonly TValue[] | null;
export interface FormSelectOption<
TValue extends FormSelectPrimitive = FormSelectPrimitive
> {
readonly value: TValue;
readonly label: string;
readonly prefixText?: string | null;
readonly description?: string | null;
readonly group?: string | null;
readonly disabled?: boolean;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface FormSelectSearchEvent {
readonly term: string;
}
export interface FormSelectScrollEvent {
readonly start: number;
readonly end: number;
}
+21 -24
View File
@@ -1,52 +1,49 @@
@if (open()) {
<div
class="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto bg-[#32325180] p-4 dark:bg-[#323251cc]"
class="hs-overlay open hs-overlay-backdrop-open:!bg-[#32325180] dark:hs-overlay-backdrop-open:!bg-[#323251cc] ti-modal pointer-events-none"
(mousedown)="onBackdropClick($event)">
<div class="modal-panel pointer-events-auto relative my-6 w-full" [class.max-w-md]="size() === 'sm'"
[class.max-w-2xl]="size() === 'md'" [class.max-w-4xl]="size() === 'lg'" [class.max-w-6xl]="size() === 'xl'"
[class.max-w-[96vw]]="size() === 'full'" role="dialog" aria-modal="true" [attr.aria-label]="title()"
<div class="fixed inset-0 pointer-events-auto bg-[#32325180] dark:bg-[#323251cc]" aria-hidden="true"
(mousedown)="onBackdropClick($event)"></div>
<div [class]="modalBoxClass()" role="dialog" aria-modal="true" [attr.aria-label]="title()" tabindex="-1"
(mousedown)="$event.stopPropagation()">
<div
class="flex max-h-[90vh] flex-col overflow-hidden rounded-lg border border-defaultborder bg-white shadow-2xl dark:border-defaultborder dark:bg-bodybg">
<div class="ti-modal-content border-defaultborder dark:border-defaultborder max-h-[90vh] overflow-hidden">
@if (showHeader()) {
<div
class="flex shrink-0 items-start justify-between gap-4 border-b border-defaultborder px-6 py-4 dark:border-defaultborder">
<div class="ti-modal-header border-defaultborder dark:border-defaultborder">
<div class="min-w-0">
<h6 class="m-0 text-[1.125rem] font-semibold leading-6 text-defaulttextcolor">
<h6 class="ti-modal-title m-0">
{{ title() }}
</h6>
@if (subtitle()) {
<p class="mb-0 mt-1 text-[0.8125rem] leading-5 text-textmuted">
{{ subtitle() }}
</p>
}
</div>
@if (showCloseButton()) {
<button type="button"
class="inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-textmuted transition-colors duration-200 hover:bg-gray-100 hover:text-defaulttextcolor disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-black/20"
<button type="button" class="ti-modal-close-btn cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close modal" [disabled]="loading()" (click)="close()">
<i class="ri-close-line text-xl leading-none"></i>
<i class="ri-close-line leading-none"></i>
</button>
}
</div>
}
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div class="ti-modal-body px-4 min-h-0 overflow-y-auto">
@if (subtitle()) {
<p class="mb-4 text-[0.8125rem] leading-5 text-textmuted">
{{ subtitle() }}
</p>
}
<ng-content />
</div>
@if (showFooter()) {
<div
class="flex shrink-0 items-center justify-end gap-2 border-t border-defaultborder bg-gray-50/50 px-6 py-4 dark:border-defaultborder dark:bg-black/10">
<div class="ti-modal-footer border-defaultborder dark:border-defaultborder">
@if (showCancelButton()) {
<app-button action="cancel" [label]="cancelLabel()" [showIcon]="false" [disabled]="loading()"
<app-button action="cancel" [label]="cancelLabel()" [showIcon]="true" [disabled]="loading()"
(buttonClicked)="close()" />
}
@if (showSubmitButton()) {
<app-button [action]="submitAction()" [label]="submitLabel()" [loadingLabel]="loadingLabel()" [showIcon]="false"
<app-button [action]="submitAction()" [label]="submitLabel()" [loadingLabel]="loadingLabel()" [showIcon]="true"
[loading]="loading()" [disabled]="submitDisabled()" (buttonClicked)="submit()" />
}
</div>
@@ -54,4 +51,4 @@
</div>
</div>
</div>
}
}
@@ -1,40 +1,3 @@
:host {
display: contents;
}
.modal-backdrop {
animation: modalBackdropIn 180ms ease-out;
}
.modal-panel {
animation: modalPanelIn 220ms ease-out;
}
@keyframes modalBackdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalPanelIn {
from {
opacity: 0;
transform: translateY(-18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.modal-backdrop,
.modal-panel {
animation: none;
}
}
+18 -5
View File
@@ -43,15 +43,28 @@ export class Modal implements OnDestroy {
readonly modalSizeClass = computed(() => {
const sizes: Record<ModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
full: 'max-w-[95vw]'
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
full: 'max-w-[96vw]'
};
return sizes[this.size()];
});
readonly modalBoxClass = computed(() => {
return [
'hs-overlay-open:mt-7',
'ti-modal-box',
'mt-0',
'ease-out',
'relative',
'z-[1]',
'pointer-events-auto',
this.modalSizeClass()
].join(' ');
});
constructor(private readonly elementRef: ElementRef<HTMLElement>) {
effect(() => {
if (this.open()) {
@@ -111,4 +124,4 @@ export class Modal implements OnDestroy {
ngOnDestroy(): void {
document.body.classList.remove('overflow-hidden');
}
}
}
@@ -0,0 +1,8 @@
import { DataTableToolbarDirective } from './data-table-toolbar.directive';
describe('DataTableToolbarDirective', () => {
it('should create an instance', () => {
const directive = new DataTableToolbarDirective();
expect(directive).toBeTruthy();
});
});
@@ -0,0 +1,15 @@
import {
Directive,
TemplateRef,
inject
} from '@angular/core';
@Directive({
selector: '[appDataTableToolbar]',
standalone: true
})
export class DataTableToolbarDirective {
readonly templateRef = inject(
TemplateRef<unknown>
);
}
@@ -2,17 +2,16 @@
class="
pointer-events-none
max-w-[250px]
rounded-md
bg-gray-900
px-2.5
py-1.5
text-[0.75rem]
whitespace-normal
rounded-sm
bg-primary
px-2
py-1
text-xs
font-medium
leading-4
text-white
shadow-lg
dark:bg-white
dark:text-gray-900
shadow-sm
"
role="tooltip"
>
+24
View File
@@ -0,0 +1,24 @@
export interface AppFirebaseConfig {
apiKey: string;
authDomain: string;
projectId: string;
storageBucket: string;
messagingSenderId: string;
appId: string;
measurementId: string;
}
export interface AppSessionTimeoutConfig {
warningAfterMs: number;
logoutAfterMs: number;
}
export interface AppEnvironment {
production: boolean;
api: {
identity: string;
masterAdmin: string;
};
sessionTimeout: AppSessionTimeoutConfig;
firebase: AppFirebaseConfig;
}
+7 -2
View File
@@ -1,6 +1,11 @@
export const environment = {
import { AppEnvironment } from './environment.model';
export const environment: AppEnvironment = {
production: true,
apiBaseUrl: '/api',
api: {
identity: '/api',
masterAdmin: '/api',
},
sessionTimeout: {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
+3 -3
View File
@@ -1,10 +1,10 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
//apiBaseUrl: 'https://localhost:5001/api',
import { AppEnvironment } from './environment.model';
export const environment: AppEnvironment = {
production: false,
api: {
identity: 'https://localhost:5001/api',
masterAdmin: 'https://localhost:5002/api',
+21 -2
View File
@@ -6,8 +6,6 @@
@forward "../node_modules/ngx-toastr/toastr.css";
/* inter */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@@ -23,3 +21,24 @@
.custom-scrollbar-width::-webkit-scrollbar {
@apply w-[5px]!;
}
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected {
background-color: var(--color-primary) !important;
color: var(--color-white) !important;
}
.ng-select.app-form-select-control .ng-dropdown-panel {
z-index: 99 !important;
}
.ng-select.app-form-select-control .ng-dropdown-header {
padding: 0;
}
.swal2-styled.app-confirm-dialog-btn {
min-width: 110px;
min-height: 44px;
padding: 0.625em 1.25em;
font-size: 1rem;
line-height: 1.25;
}
-3
View File
@@ -10,9 +10,6 @@
},
"include": [
"src/**/*.d.ts",
],
"exclude": [
"src/**/*.spec.ts"
]
}