add endpoint configuration for city, currency, language, and timezone
This commit is contained in:
@@ -45,3 +45,10 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
|
||||
# Ignore local ESLint config
|
||||
eslint.config.js
|
||||
|
||||
# Ignore preview folder
|
||||
preview/
|
||||
+27
-6
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1789
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' ;
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user