tailjng 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // component-manager.js
2
2
 
3
- const { copyComponentFiles } = require("./file-operations");
3
+ const { copyComponentFiles, resetInstalledSharedFolders } = require("./file-operations");
4
4
  const { installDependencies } = require("./dependency-manager");
5
5
  const { COLORS } = require("./settings/colors");
6
6
  const { collectDependencyTree } = require("./settings/lib-utils");
@@ -50,6 +50,7 @@ function logInstallPlan(componentName, componentList) {
50
50
 
51
51
  async function addComponent(componentName, componentList) {
52
52
  resetOverwritePolicy();
53
+ resetInstalledSharedFolders();
53
54
  installedComponentsGlobal.clear();
54
55
 
55
56
  const componentData = componentList[componentName];
@@ -96,6 +97,7 @@ async function addComponent(componentName, componentList) {
96
97
 
97
98
  async function installAllComponents(componentList) {
98
99
  resetOverwritePolicy();
100
+ resetInstalledSharedFolders();
99
101
  installedComponentsGlobal.clear();
100
102
 
101
103
  const allNames = Object.keys(componentList);
@@ -5,14 +5,29 @@ const path = require("path")
5
5
  const { COLORS } = require("./settings/colors")
6
6
  const { generateHeaderComment } = require("./settings/header-generator")
7
7
  const { askOverwrite } = require("./settings/prompt-utils")
8
- const { buildTargetPath, parseComponentPath } = require("./settings/path-utils")
8
+ const { getPackageRoot } = require("./settings/lib-utils")
9
+ const {
10
+ buildTargetPath,
11
+ buildSharedFolderTargetPath,
12
+ getSharedFolderInfo,
13
+ parseComponentPath,
14
+ } = require("./settings/path-utils")
15
+
16
+ /** Avoid copying the same group `shared/` folder twice per install session. */
17
+ const installedSharedFolders = new Set()
9
18
 
10
19
  const TEXT_EXTENSIONS = new Set([".ts", ".js", ".html", ".css", ".scss", ".json", ".md"])
20
+ const LIB_PACKAGE_IMPORT_RE =
21
+ /from\s+['"](?:\.\.\/)+(?:interfaces|services|config)\/[^'"]+['"]/g
11
22
 
12
23
  function shouldAddHeader(fileName) {
13
24
  return TEXT_EXTENSIONS.has(path.extname(fileName).toLowerCase())
14
25
  }
15
26
 
27
+ function patchConsumerImports(content) {
28
+ return content.replace(LIB_PACKAGE_IMPORT_RE, "from 'tailjng'")
29
+ }
30
+
16
31
  function copyFileWithHeader(srcFile, destFile) {
17
32
  fs.mkdirSync(path.dirname(destFile), { recursive: true })
18
33
 
@@ -22,6 +37,9 @@ function copyFileWithHeader(srcFile, destFile) {
22
37
  }
23
38
 
24
39
  let content = fs.readFileSync(srcFile, "utf8")
40
+ if (path.extname(srcFile).toLowerCase() === ".ts") {
41
+ content = patchConsumerImports(content)
42
+ }
25
43
  const headerComment = generateHeaderComment(path.basename(srcFile))
26
44
  content = headerComment + "\n\n" + content
27
45
  fs.writeFileSync(destFile, content)
@@ -49,7 +67,11 @@ function copyDirectoryRecursive(srcDir, destDir, projectRoot) {
49
67
  }
50
68
  }
51
69
 
52
- async function copyComponentFiles(componentName, componentPath, isDependency = false) {
70
+ function resetInstalledSharedFolders() {
71
+ installedSharedFolders.clear()
72
+ }
73
+
74
+ function findTailjngPackageRoot() {
53
75
  let currentDir = process.cwd()
54
76
  while (!fs.existsSync(path.join(currentDir, "node_modules"))) {
55
77
  currentDir = path.dirname(currentDir)
@@ -59,7 +81,74 @@ async function copyComponentFiles(componentName, componentPath, isDependency = f
59
81
  }
60
82
  }
61
83
 
62
- const nodeModulesPath = path.join(currentDir, "node_modules", "tailjng", componentPath)
84
+ return getPackageRoot(path.join(currentDir, "node_modules", "tailjng", "cli"))
85
+ }
86
+
87
+ async function copySharedFolderIfNeeded(componentPath, isDependency = false, options = {}) {
88
+ const { forceSync = false } = options
89
+ const sharedInfo = getSharedFolderInfo(componentPath)
90
+ if (!sharedInfo) {
91
+ return true
92
+ }
93
+
94
+ if (installedSharedFolders.has(sharedInfo.groupKey)) {
95
+ return true
96
+ }
97
+
98
+ const packageRoot = findTailjngPackageRoot()
99
+ const sharedSrc = path.join(packageRoot, ...sharedInfo.sourceRelative.split("/"))
100
+ if (!fs.existsSync(sharedSrc)) {
101
+ console.error(
102
+ `${COLORS.red}${COLORS.bright}[tailjng CLI]${COLORS.reset} ${COLORS.red}ERROR: Shared utilities ${COLORS.bright}"${sharedInfo.groupKey}/shared"${COLORS.reset} ${COLORS.red}not found in package.${COLORS.reset}`,
103
+ )
104
+ console.error(`${COLORS.dim} Expected: ${sharedSrc}${COLORS.reset}`)
105
+ return false
106
+ }
107
+
108
+ const projectRoot = process.cwd()
109
+ const sharedDest = buildSharedFolderTargetPath(projectRoot, componentPath)
110
+ if (!sharedDest) {
111
+ return true
112
+ }
113
+
114
+ const sharedLabel = `${sharedInfo.groupKey}/shared`
115
+ const relativeTargetPath = path.relative(projectRoot, sharedDest)
116
+ const sharedMissing = !fs.existsSync(sharedDest)
117
+
118
+ if (!forceSync && !sharedMissing) {
119
+ const shouldOverwrite = await askOverwrite(sharedLabel, relativeTargetPath, isDependency)
120
+ if (!shouldOverwrite) {
121
+ console.log(
122
+ `${COLORS.dim}${COLORS.bright}[tailjng CLI]${COLORS.reset} ${COLORS.dim}Skipping ${COLORS.bright}"${sharedLabel}"${COLORS.reset} ${COLORS.dim}- keeping existing version.${COLORS.reset}`,
123
+ )
124
+ return true
125
+ }
126
+
127
+ console.log(
128
+ `${COLORS.yellow}${COLORS.bright}[tailjng CLI]${COLORS.reset} ${COLORS.yellow}Removing existing ${COLORS.bright}"${sharedLabel}"${COLORS.reset} ${COLORS.yellow}to overwrite...${COLORS.reset}`,
129
+ )
130
+ fs.rmSync(sharedDest, { recursive: true, force: true })
131
+ } else if (!sharedMissing) {
132
+ console.log(
133
+ `${COLORS.yellow}${COLORS.bright}[tailjng CLI]${COLORS.reset} ${COLORS.yellow}Updating shared utilities ${COLORS.bright}"${sharedLabel}"${COLORS.reset} ${COLORS.yellow}with the installed component...${COLORS.reset}`,
134
+ )
135
+ fs.rmSync(sharedDest, { recursive: true, force: true })
136
+ }
137
+
138
+ console.log(
139
+ `${COLORS.blue}${COLORS.bright}[tailjng CLI]${COLORS.reset} ${COLORS.blue}Copying shared utilities ${COLORS.bright}"${sharedLabel}"${COLORS.reset} → ${relativeTargetPath}${COLORS.reset}`,
140
+ )
141
+
142
+ fs.mkdirSync(sharedDest, { recursive: true })
143
+ copyDirectoryRecursive(sharedSrc, sharedDest, projectRoot)
144
+ installedSharedFolders.add(sharedInfo.groupKey)
145
+
146
+ return true
147
+ }
148
+
149
+ async function copyComponentFiles(componentName, componentPath, isDependency = false) {
150
+ const packageRoot = findTailjngPackageRoot()
151
+ const nodeModulesPath = path.join(packageRoot, ...componentPath.split("/"))
63
152
  const projectRoot = process.cwd()
64
153
  const targetPath = buildTargetPath(projectRoot, componentName, componentPath)
65
154
  const pathInfo = parseComponentPath(componentPath)
@@ -77,6 +166,7 @@ async function copyComponentFiles(componentName, componentPath, isDependency = f
77
166
 
78
167
  if (!shouldOverwrite) {
79
168
  console.log(`${COLORS.dim}${COLORS.bright}[tailjng CLI]${COLORS.reset} ${COLORS.dim}Skipping ${COLORS.bright}"${componentName}"${COLORS.reset} ${COLORS.dim}- keeping existing version.${COLORS.reset}`)
169
+ await copySharedFolderIfNeeded(componentPath, isDependency, { forceSync: false })
80
170
  return false
81
171
  }
82
172
 
@@ -99,8 +189,9 @@ async function copyComponentFiles(componentName, componentPath, isDependency = f
99
189
 
100
190
  fs.mkdirSync(targetPath, { recursive: true })
101
191
  copyDirectoryRecursive(nodeModulesPath, targetPath, projectRoot)
192
+ await copySharedFolderIfNeeded(componentPath, isDependency, { forceSync: true })
102
193
 
103
194
  return true
104
195
  }
105
196
 
106
- module.exports = { copyComponentFiles }
197
+ module.exports = { copyComponentFiles, resetInstalledSharedFolders }
@@ -58,9 +58,44 @@ function isComponentInstalled(projectRoot, componentName, componentPath) {
58
58
  return fs.existsSync(buildTargetPath(projectRoot, componentName, componentPath))
59
59
  }
60
60
 
61
+ /**
62
+ * When a registry path is nested (e.g. toggle-radio/toggle-radio), returns sibling `shared/` info.
63
+ * Used by the CLI to copy group-level utilities alongside the component folder.
64
+ */
65
+ function getSharedFolderInfo(componentPath) {
66
+ if (!componentPath.startsWith(COMPONENTS_SOURCE_PREFIX)) {
67
+ return null
68
+ }
69
+
70
+ const relativePath = componentPath.slice(COMPONENTS_SOURCE_PREFIX.length)
71
+ const pathParts = relativePath.split("/")
72
+ if (pathParts.length < 2) {
73
+ return null
74
+ }
75
+
76
+ const groupKey = pathParts.slice(0, -1).join("/")
77
+
78
+ return {
79
+ groupKey,
80
+ sourceRelative: `${COMPONENTS_SOURCE_PREFIX}${groupKey}/shared`,
81
+ targetRelative: `${groupKey}/shared`,
82
+ }
83
+ }
84
+
85
+ function buildSharedFolderTargetPath(projectRoot, componentPath) {
86
+ const sharedInfo = getSharedFolderInfo(componentPath)
87
+ if (!sharedInfo) {
88
+ return null
89
+ }
90
+
91
+ return path.join(getComponentsBasePath(projectRoot), sharedInfo.targetRelative)
92
+ }
93
+
61
94
  module.exports = {
62
95
  parseComponentPath,
63
96
  buildTargetPath,
97
+ buildSharedFolderTargetPath,
98
+ getSharedFolderInfo,
64
99
  isComponentInstalled,
65
100
  isConfigPath,
66
101
  CONFIG_SOURCE_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailjng",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "peerDependencies": {
5
5
  "@angular/common": "^19.2.0",
6
6
  "@angular/core": "^19.2.0",
@@ -139,68 +139,64 @@
139
139
  <div class="flex flex-col gap-2">
140
140
  @for (filter of filtersSelect; track $index) {
141
141
  @if (filter.isVisible ?? true) {
142
- @switch (filter.type) {
143
- @case ("dropdown") {
144
- <div class="relative">
145
- <JDropdownSelect
146
- [type]="'dropdown'"
147
- [(ngModel)]="filter.selected"
148
- (selectionChange)="
149
- filter.onSelected ? filter.onSelected($event) : null
150
- "
151
- [optionLabel]="filter.optionLabel ?? ''"
152
- [optionValue]="filter.optionValue ?? ''"
153
- [labelSeparator]="filter.labelSeparator ?? ''"
154
- [optionLabelTemplate]="filter.optionLabelTemplate ?? ''"
155
- [optionLabelFn]="filter.optionLabelFn"
156
- [placeholder]="filter.placeholder ?? ''"
157
- [showClear]="filter.showClear ?? false"
158
- [options]="filter.options"
159
- [sort]="filter.sort ?? 'ASC'"
160
- [showAllOption]="filter.showAllOption ?? false"
161
- [isFilterSelect]="true"
162
- />
163
- </div>
164
- }
165
- @case ("searchable") {
166
- <div class="relative">
167
- <JDropdownSelect
168
- [type]="'searchable'"
169
- [(ngModel)]="filter.selected"
170
- (selectionChange)="filter.onSelected?.($event)"
171
- [endpoint]="filter.endpoint"
172
- [optionLabel]="filter.optionLabel"
173
- [optionValue]="filter.optionValue"
174
- [labelSeparator]="filter.labelSeparator ?? ''"
175
- [optionLabelTemplate]="filter.optionLabelTemplate ?? ''"
176
- [optionLabelFn]="filter.optionLabelFn"
177
- [responseKey]="filter.responseKey ?? ''"
178
- [dataPath]="filter.dataPath ?? ''"
179
- [placeholder]="filter.placeholder ?? ''"
180
- [showClear]="filter.showClear ?? false"
181
- [loadOnInit]="filter.loadOnInit ?? false"
182
- [isSearch]="filter.isSearch ?? true"
183
- [searchFields]="filter.searchFields || []"
184
- [defaultFilters]="filter.defaultFilters || {}"
185
- [sort]="filter.sort ?? 'ASC'"
186
- [showAllOption]="filter.showAllOption ?? false"
187
- [isFilterSelect]="true"
188
- />
189
- </div>
190
- }
191
- @case ("multi-table") {
192
- <div class="relative">
193
- <JMultiTableSelect
194
- [(ngModel)]="filter.selected"
195
- (selectionChange)="
196
- filter.onSelected ? filter.onSelected($event) : null
197
- "
198
- [columns]="filter.columns"
199
- [btnText]="filter.btnText ?? ''"
200
- [isFilterSelect]="true"
201
- />
202
- </div>
203
- }
142
+ @if (isDropdownFilter(filter)) {
143
+ <div class="relative">
144
+ <JDropdownSelect
145
+ [type]="'dropdown'"
146
+ [(ngModel)]="filter.selected"
147
+ (selectionChange)="
148
+ filter.onSelected ? filter.onSelected($event) : null
149
+ "
150
+ [optionLabel]="filter.optionLabel ?? ''"
151
+ [optionValue]="filter.optionValue ?? ''"
152
+ [labelSeparator]="filter.labelSeparator ?? ''"
153
+ [optionLabelTemplate]="filter.optionLabelTemplate ?? ''"
154
+ [optionLabelFn]="filter.optionLabelFn"
155
+ [placeholder]="filter.placeholder ?? ''"
156
+ [showClear]="filter.showClear ?? false"
157
+ [options]="filter.options"
158
+ [sort]="filter.sort ?? 'ASC'"
159
+ [showAllOption]="filter.showAllOption ?? false"
160
+ [isFilterSelect]="true"
161
+ />
162
+ </div>
163
+ } @else if (isSearchableFilter(filter)) {
164
+ <div class="relative">
165
+ <JDropdownSelect
166
+ [type]="'searchable'"
167
+ [(ngModel)]="filter.selected"
168
+ (selectionChange)="filter.onSelected?.($event)"
169
+ [endpoint]="filter.endpoint"
170
+ [optionLabel]="filter.optionLabel"
171
+ [optionValue]="filter.optionValue"
172
+ [labelSeparator]="filter.labelSeparator ?? ''"
173
+ [optionLabelTemplate]="filter.optionLabelTemplate ?? ''"
174
+ [optionLabelFn]="filter.optionLabelFn"
175
+ [responseKey]="filter.responseKey ?? ''"
176
+ [dataPath]="filter.dataPath ?? ''"
177
+ [placeholder]="filter.placeholder ?? ''"
178
+ [showClear]="filter.showClear ?? false"
179
+ [loadOnInit]="filter.loadOnInit ?? false"
180
+ [isSearch]="filter.isSearch ?? true"
181
+ [searchFields]="filter.searchFields || []"
182
+ [defaultFilters]="filter.defaultFilters || {}"
183
+ [sort]="filter.sort ?? 'ASC'"
184
+ [showAllOption]="filter.showAllOption ?? false"
185
+ [isFilterSelect]="true"
186
+ />
187
+ </div>
188
+ } @else if (isMultiTableFilter(filter)) {
189
+ <div class="relative">
190
+ <JMultiTableSelect
191
+ [(ngModel)]="filter.selected"
192
+ (selectionChange)="
193
+ filter.onSelected ? filter.onSelected($event) : null
194
+ "
195
+ [columns]="filter.columns"
196
+ [btnText]="filter.btnText ?? ''"
197
+ [isFilterSelect]="true"
198
+ />
199
+ </div>
204
200
  }
205
201
  }
206
202
  }
@@ -3,20 +3,27 @@ import { NgClass } from '@angular/common';
3
3
  import { FormsModule } from '@angular/forms';
4
4
  import { Subject, Subscription, debounceTime, filter, forkJoin } from 'rxjs';
5
5
  import { TableColumn, FilterButton, JUploadFilterService, JAlertDialogService, JAlertToastService, JGenericCrudService, LoadingState, JExcelService, JExcelFilterService } from 'tailjng';
6
- import type { FilterSelect } from '../../../interfaces/crud/filter.interface';
6
+ import type {
7
+ CompleteFilterAdditionalButtonLoading,
8
+ CompleteFilterDropdownSelect,
9
+ CompleteFilterMultiTableSelect,
10
+ CompleteFilterSearchableSelect,
11
+ CompleteFilterSelect,
12
+ } from './complete-filter.types';
7
13
  import { JDropdownSelectComponent } from '../../select/select-dropdown/dropdown-select.component';
8
14
  import { JMultiTableSelectComponent } from '../../select/select-multi-table/multi-table-select.component';
9
15
  import { JSwitchCheckboxComponent } from '../../checkbox/checkbox-switch/switch-checkbox.component';
10
16
  import { JDialogComponent } from '../../dialog/dialog.component';
11
17
  import { JButtonComponent } from '../../button/button.component';
12
- import type { CompleteFilterAdditionalButtonLoading } from './complete-filter.types';
13
18
  import { hasActiveFilters } from './complete-filter.util';
14
19
  import { Icons } from '../../.config/icons/icons.lucide';
15
20
  import { JIconComponent } from '../../icon/icon.component';
16
21
 
17
22
  export type {
18
23
  CompleteFilterAdditionalButtonLoading,
19
- CompleteFilterClearReason} from './complete-filter.types';
24
+ CompleteFilterClearReason,
25
+ CompleteFilterSelect,
26
+ } from './complete-filter.types';
20
27
  export { hasActiveFilters } from './complete-filter.util';
21
28
 
22
29
  /**
@@ -111,7 +118,19 @@ export class JCompleteFilterComponent implements OnInit, OnDestroy {
111
118
  @Input() filtersButton: FilterButton[] = [];
112
119
 
113
120
  // Filtros de tabla
114
- @Input() filtersSelect: FilterSelect[] = [];
121
+ @Input() filtersSelect: CompleteFilterSelect[] = [];
122
+
123
+ isDropdownFilter(filter: CompleteFilterSelect): filter is CompleteFilterDropdownSelect {
124
+ return filter.type === 'dropdown';
125
+ }
126
+
127
+ isSearchableFilter(filter: CompleteFilterSelect): filter is CompleteFilterSearchableSelect {
128
+ return filter.type === 'searchable';
129
+ }
130
+
131
+ isMultiTableFilter(filter: CompleteFilterSelect): filter is CompleteFilterMultiTableSelect {
132
+ return filter.type === 'multi-table';
133
+ }
115
134
 
116
135
  get visibleColumns(): TableColumn<any>[] {
117
136
  return this.columns.filter(col => !col.hidden);
@@ -1,4 +1,71 @@
1
- import type { LoadingState } from 'tailjng';
1
+ import type { LoadingState, TableColumn } from 'tailjng';
2
+
3
+ /**
4
+ * Filter-select shapes for JFilter templates.
5
+ * Prefixed with `CompleteFilter*` to avoid clashing with `tailjng` exports during strict template narrowing.
6
+ */
7
+ export interface CompleteFilterDropdownSelect {
8
+ type: 'dropdown';
9
+ selected: unknown;
10
+ initSelected?: unknown;
11
+ onSelected?: (value: unknown) => void;
12
+ optionLabel?: string | string[];
13
+ optionValue?: string;
14
+ labelSeparator?: string;
15
+ optionLabelTemplate?: string;
16
+ optionLabelFn?: (option: Record<string, unknown>) => string;
17
+ placeholder?: string;
18
+ showClear?: boolean;
19
+ options: unknown[];
20
+ deep?: string;
21
+ sort?: 'ASC' | 'DESC';
22
+ isVisible?: ((data?: unknown) => boolean) | boolean;
23
+ showAllOption?: boolean;
24
+ }
25
+
26
+ export interface CompleteFilterSearchableSelect {
27
+ type: 'searchable';
28
+ selected: unknown;
29
+ initSelected?: unknown;
30
+ onSelected?: (value: unknown) => void;
31
+ endpoint: string;
32
+ isSearch?: boolean;
33
+ optionLabel: string | string[];
34
+ optionValue: string;
35
+ labelSeparator?: string;
36
+ optionLabelTemplate?: string;
37
+ optionLabelFn?: (option: Record<string, unknown>) => string;
38
+ responseKey?: string;
39
+ dataPath?: string;
40
+ placeholder?: string;
41
+ showClear?: boolean;
42
+ loadOnInit?: boolean;
43
+ searchFields?: string[];
44
+ defaultFilters?: Record<string, unknown>;
45
+ deep?: string;
46
+ sort?: 'ASC' | 'DESC';
47
+ isVisible?: ((data?: unknown) => boolean) | boolean;
48
+ showAllOption?: boolean;
49
+ }
50
+
51
+ export interface CompleteFilterMultiTableSelect {
52
+ type: 'multi-table';
53
+ selected: unknown;
54
+ initSelected?: unknown;
55
+ onSelected?: (value: unknown) => void;
56
+ columns: TableColumn<unknown>[];
57
+ btnText?: string;
58
+ placeholder?: string;
59
+ deep?: string;
60
+ sort?: 'ASC' | 'DESC';
61
+ isVisible?: ((data?: unknown) => boolean) | boolean;
62
+ showAllOption?: boolean;
63
+ }
64
+
65
+ export type CompleteFilterSelect =
66
+ | CompleteFilterDropdownSelect
67
+ | CompleteFilterSearchableSelect
68
+ | CompleteFilterMultiTableSelect;
2
69
 
3
70
  /** Event payload from `(clearFilters)` — typically `'clear'`. */
4
71
  export type CompleteFilterClearReason = string;
@@ -1,16 +1,16 @@
1
- import type { FilterSelect } from 'tailjng';
2
-
3
- /** Whether search text or any filter select differs from its initial value. */
4
- export function hasActiveFilters(
5
- searchQuery: string,
6
- filtersSelect: FilterSelect[],
7
- ): boolean {
8
- const hasActiveSearch = searchQuery?.trim().length > 0;
9
- const hasSelectedFilters = filtersSelect.some((filter) => {
10
- const actual = filter.selected;
11
- const initial = filter.initSelected ?? null;
12
- return JSON.stringify(actual) !== JSON.stringify(initial);
13
- });
14
-
15
- return hasActiveSearch || hasSelectedFilters;
16
- }
1
+ import type { CompleteFilterSelect } from './complete-filter.types';
2
+
3
+ /** Whether search text or any filter select differs from its initial value. */
4
+ export function hasActiveFilters(
5
+ searchQuery: string,
6
+ filtersSelect: CompleteFilterSelect[],
7
+ ): boolean {
8
+ const hasActiveSearch = searchQuery?.trim().length > 0;
9
+ const hasSelectedFilters = filtersSelect.some((filter) => {
10
+ const actual = filter.selected;
11
+ const initial = filter.initSelected ?? null;
12
+ return JSON.stringify(actual) !== JSON.stringify(initial);
13
+ });
14
+
15
+ return hasActiveSearch || hasSelectedFilters;
16
+ }
@@ -1,6 +1,7 @@
1
1
  export type ToggleOption<T = unknown> = {
2
2
  value: T;
3
3
  label: string;
4
+ original?: unknown;
4
5
  };
5
6
 
6
7
  export type ToggleSortOrder = 'ASC' | 'DESC';
@@ -18,22 +18,105 @@ export function buildToggleLoadParams(
18
18
  return params;
19
19
  }
20
20
 
21
+ /** Reads a nested property using dot notation (`a.b.c`). */
22
+ export function getNestedToggleRawValue(obj: unknown, path: string): unknown {
23
+ if (!path?.trim()) {
24
+ return '';
25
+ }
26
+
27
+ let value: unknown = obj;
28
+
29
+ for (const part of path.split('.').filter(Boolean)) {
30
+ if (value == null || typeof value !== 'object') {
31
+ return '';
32
+ }
33
+
34
+ value = (value as Record<string, unknown>)[part];
35
+ }
36
+
37
+ return value ?? '';
38
+ }
39
+
21
40
  /**
22
- * Reads a nested property using dot notation.
41
+ * Reads a nested property using dot notation and returns a display string.
23
42
  */
24
43
  export function getNestedToggleValue(obj: unknown, path: string): string {
25
- if (!obj || typeof obj !== 'object') {
26
- return '';
44
+ const value = getNestedToggleRawValue(obj, path);
45
+ return value == null || value === '' ? '' : String(value);
46
+ }
47
+
48
+ /**
49
+ * Resolves the options array from `response.data`.
50
+ *
51
+ * Use `dataPath` with dot notation for nested shapes, e.g.:
52
+ * - `users` → `data.users`
53
+ * - `typeAccess.options` → `data.typeAccess.options`
54
+ *
55
+ * When `dataPath` / `responseKey` are omitted, falls back to legacy behavior:
56
+ * `data[firstEndpointSegment]` if it is an array (e.g. endpoint `role/list` → `data.role`).
57
+ */
58
+ export function extractToggleOptionsFromResponse(
59
+ responseData: Record<string, unknown> | undefined | null,
60
+ endpoint = '',
61
+ responseKey?: string,
62
+ dataPath?: string,
63
+ ): unknown[] {
64
+ if (!responseData || typeof responseData !== 'object') {
65
+ return [];
27
66
  }
28
67
 
29
- const value = path.split('.').reduce<unknown>((acc, part) => {
30
- if (acc && typeof acc === 'object' && part in (acc as Record<string, unknown>)) {
31
- return (acc as Record<string, unknown>)[part];
68
+ const explicitPath = dataPath?.trim() || responseKey?.trim() || '';
69
+
70
+ if (explicitPath) {
71
+ const node = getNestedToggleRawValue(responseData, explicitPath);
72
+ return Array.isArray(node) ? node : [];
73
+ }
74
+
75
+ const legacyKey = endpoint.split('/').filter(Boolean)[0];
76
+ if (legacyKey) {
77
+ const legacyNode = responseData[legacyKey];
78
+ if (Array.isArray(legacyNode)) {
79
+ return legacyNode;
80
+ }
81
+ }
82
+
83
+ return Array.isArray(responseData) ? (responseData as unknown[]) : [];
84
+ }
85
+
86
+ /** Builds the display label from a template like `Acceso {label} (id {value})`. */
87
+ export function resolveToggleOptionLabelFromTemplate(
88
+ option: Record<string, unknown>,
89
+ template: string,
90
+ ): string {
91
+ return template.replace(/\{([^}]+)\}/g, (_, path: string) => {
92
+ const value = getNestedToggleRawValue(option, path.trim());
93
+ if (value == null || value === '') {
94
+ return '';
32
95
  }
33
- return undefined;
34
- }, obj);
35
96
 
36
- return value == null ? '' : String(value);
97
+ return String(value);
98
+ });
99
+ }
100
+
101
+ /** Builds the display label for one option object. */
102
+ export function resolveToggleOptionLabel(
103
+ option: Record<string, unknown>,
104
+ optionLabel: string | string[],
105
+ labelSeparator: string,
106
+ optionLabelTemplate?: string,
107
+ ): string {
108
+ if (optionLabelTemplate?.trim()) {
109
+ return resolveToggleOptionLabelFromTemplate(option, optionLabelTemplate);
110
+ }
111
+
112
+ if (Array.isArray(optionLabel)) {
113
+ return optionLabel
114
+ .map((key) => getNestedToggleValue(option, key))
115
+ .filter(Boolean)
116
+ .join(labelSeparator);
117
+ }
118
+
119
+ return getNestedToggleValue(option, optionLabel);
37
120
  }
38
121
 
39
122
  /**
@@ -41,18 +124,34 @@ export function getNestedToggleValue(obj: unknown, path: string): string {
41
124
  */
42
125
  export function normalizeToggleOptions(
43
126
  options: unknown[],
44
- optionLabel: string,
127
+ optionLabel: string | string[],
45
128
  optionValue: string,
129
+ labelSeparator = ' ',
130
+ optionLabelTemplate?: string,
131
+ optionLabelFn?: (option: Record<string, unknown>) => string,
46
132
  ): ToggleOption[] {
47
133
  if (!options.length) {
48
134
  return [];
49
135
  }
50
136
 
51
137
  if (typeof options[0] === 'object' && options[0] !== null) {
52
- return options.map((opt) => ({
53
- value: (opt as Record<string, unknown>)[optionValue],
54
- label: getNestedToggleValue(opt, optionLabel),
55
- }));
138
+ return options.map((opt) => {
139
+ const record = opt as Record<string, unknown>;
140
+ const label = optionLabelFn
141
+ ? optionLabelFn(record)
142
+ : resolveToggleOptionLabel(
143
+ record,
144
+ optionLabel,
145
+ labelSeparator,
146
+ optionLabelTemplate,
147
+ );
148
+
149
+ return {
150
+ value: getNestedToggleRawValue(record, optionValue),
151
+ label,
152
+ original: record,
153
+ };
154
+ });
56
155
  }
57
156
 
58
157
  return options.map((opt) => ({
@@ -14,7 +14,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
14
14
  import { JGenericCrudService } from 'tailjng';
15
15
  import { Icons } from '../../.config/icons/icons.lucide';
16
16
  import { JIconComponent } from '../../icon/icon.component';
17
- import { normalizeToggleOptions, buildToggleLoadParams } from '../shared/toggle-options.util';
17
+ import {
18
+ buildToggleLoadParams,
19
+ extractToggleOptionsFromResponse,
20
+ normalizeToggleOptions,
21
+ } from '../shared/toggle-options.util';
18
22
  import { ToggleOption, ToggleRadioLayout, ToggleSortOrder } from '../shared/toggle-options.types';
19
23
 
20
24
  export type { ToggleOption, ToggleRadioLayout, ToggleSortOrder } from './toggle-radio.types';
@@ -25,6 +29,18 @@ let radioGroupCounter = 0;
25
29
  * Classic radio group with circular indicators and labels.
26
30
  *
27
31
  * Install: `npx tailjng add toggle-radio`
32
+ *
33
+ * ```html
34
+ * <JToggleRadio
35
+ * endpoint="enum/typeAccess"
36
+ * dataPath="typeAccess.options"
37
+ * optionLabel="label"
38
+ * optionValue="value"
39
+ * optionLabelTemplate="Acceso {label} (id {value})"
40
+ * [loadOnInit]="true"
41
+ * [(ngModel)]="selected"
42
+ * />
43
+ * ```
28
44
  */
29
45
  @Component({
30
46
  selector: 'JToggleRadio',
@@ -45,8 +61,32 @@ export class JToggleRadioComponent implements OnInit, OnChanges, ControlValueAcc
45
61
  @Input() name = `j-toggle-radio-${++radioGroupCounter}`;
46
62
  @Input() endpoint = '';
47
63
  @Input() options: unknown[] = [];
48
- @Input() optionLabel = 'label';
64
+ @Input() optionLabel: string | string[] = 'label';
49
65
  @Input() optionValue = 'value';
66
+
67
+ /** Separator when `optionLabel` is an array of keys. */
68
+ @Input() labelSeparator = ' ';
69
+
70
+ /**
71
+ * Custom label template per option. Use `{field}` placeholders with dot paths.
72
+ * Example: `Acceso {label} (id {value})`.
73
+ */
74
+ @Input() optionLabelTemplate = '';
75
+
76
+ /**
77
+ * Optional formatter for full control over the displayed label.
78
+ * Takes precedence over `optionLabelTemplate` when provided.
79
+ */
80
+ @Input() optionLabelFn?: (option: Record<string, unknown>) => string;
81
+
82
+ /** @deprecated Alias of `dataPath` for a single top-level key in `response.data`. */
83
+ @Input() responseKey = '';
84
+
85
+ /**
86
+ * Dot path from `response.data` to the options array.
87
+ * Examples: `users`, `typeAccess.options`, `catalog.items`.
88
+ */
89
+ @Input() dataPath = '';
50
90
  @Input() sort: ToggleSortOrder = 'ASC';
51
91
  @Input() defaultFilters: Record<string, unknown> = {};
52
92
  @Input() loadOnInit = false;
@@ -99,7 +139,16 @@ export class JToggleRadioComponent implements OnInit, OnChanges, ControlValueAcc
99
139
  }
100
140
 
101
141
  ngOnChanges(changes: SimpleChanges): void {
102
- if (changes['options'] && !this.endpoint) {
142
+ const optionInputs = [
143
+ 'options',
144
+ 'optionLabel',
145
+ 'optionValue',
146
+ 'labelSeparator',
147
+ 'optionLabelTemplate',
148
+ 'optionLabelFn',
149
+ ];
150
+
151
+ if (optionInputs.some((key) => changes[key]) && !this.endpoint) {
103
152
  this.processOptions();
104
153
  }
105
154
  }
@@ -119,7 +168,12 @@ export class JToggleRadioComponent implements OnInit, OnChanges, ControlValueAcc
119
168
 
120
169
  this.genericService.findAll<unknown>({ endpoint: this.endpoint, params }).subscribe({
121
170
  next: (res) => {
122
- const data = (res.data as Record<string, unknown[]>)[this.endpoint] ?? [];
171
+ const data = extractToggleOptionsFromResponse(
172
+ res.data as Record<string, unknown>,
173
+ this.endpoint,
174
+ this.responseKey || undefined,
175
+ this.dataPath || undefined,
176
+ );
123
177
  this.options = data;
124
178
  this.processOptions();
125
179
  this.isLoading = false;
@@ -135,7 +189,14 @@ export class JToggleRadioComponent implements OnInit, OnChanges, ControlValueAcc
135
189
  }
136
190
 
137
191
  processOptions(): void {
138
- this.internalOptions = normalizeToggleOptions(this.options, this.optionLabel, this.optionValue);
192
+ this.internalOptions = normalizeToggleOptions(
193
+ this.options,
194
+ this.optionLabel,
195
+ this.optionValue,
196
+ this.labelSeparator,
197
+ this.optionLabelFn ? undefined : this.optionLabelTemplate || undefined,
198
+ this.optionLabelFn,
199
+ );
139
200
  }
140
201
 
141
202
  select(value: unknown): void {
@@ -14,7 +14,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
14
14
  import { JGenericCrudService } from 'tailjng';
15
15
  import { Icons } from '../../.config/icons/icons.lucide';
16
16
  import { JIconComponent } from '../../icon/icon.component';
17
- import { normalizeToggleOptions, buildToggleLoadParams } from '../shared/toggle-options.util';
17
+ import {
18
+ buildToggleLoadParams,
19
+ extractToggleOptionsFromResponse,
20
+ normalizeToggleOptions,
21
+ } from '../shared/toggle-options.util';
18
22
  import { ToggleOption, ToggleSortOrder } from '../shared/toggle-options.types';
19
23
 
20
24
  export type { ToggleOption, ToggleSortOrder } from './segment-toggle.types';
@@ -23,6 +27,18 @@ export type { ToggleOption, ToggleSortOrder } from './segment-toggle.types';
23
27
  * Segmented single-select control (pill/tab style).
24
28
  *
25
29
  * Install: `npx tailjng add toggle-segment`
30
+ *
31
+ * ```html
32
+ * <JToggleSegment
33
+ * endpoint="enum/typeAccess"
34
+ * dataPath="typeAccess.options"
35
+ * optionLabel="label"
36
+ * optionValue="value"
37
+ * optionLabelTemplate="Acceso {label} (id {value})"
38
+ * [loadOnInit]="true"
39
+ * [(ngModel)]="selected"
40
+ * />
41
+ * ```
26
42
  */
27
43
  @Component({
28
44
  selector: 'JToggleSegment',
@@ -42,8 +58,32 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
42
58
 
43
59
  @Input() endpoint = '';
44
60
  @Input() options: unknown[] = [];
45
- @Input() optionLabel = 'label';
61
+ @Input() optionLabel: string | string[] = 'label';
46
62
  @Input() optionValue = 'value';
63
+
64
+ /** Separator when `optionLabel` is an array of keys. */
65
+ @Input() labelSeparator = ' ';
66
+
67
+ /**
68
+ * Custom label template per option. Use `{field}` placeholders with dot paths.
69
+ * Example: `Acceso {label} (id {value})`.
70
+ */
71
+ @Input() optionLabelTemplate = '';
72
+
73
+ /**
74
+ * Optional formatter for full control over the displayed label.
75
+ * Takes precedence over `optionLabelTemplate` when provided.
76
+ */
77
+ @Input() optionLabelFn?: (option: Record<string, unknown>) => string;
78
+
79
+ /** @deprecated Alias of `dataPath` for a single top-level key in `response.data`. */
80
+ @Input() responseKey = '';
81
+
82
+ /**
83
+ * Dot path from `response.data` to the options array.
84
+ * Examples: `users`, `typeAccess.options`, `catalog.items`.
85
+ */
86
+ @Input() dataPath = '';
47
87
  @Input() sort: ToggleSortOrder = 'ASC';
48
88
  @Input() defaultFilters: Record<string, unknown> = {};
49
89
  @Input() loadOnInit = false;
@@ -95,7 +135,16 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
95
135
  }
96
136
 
97
137
  ngOnChanges(changes: SimpleChanges): void {
98
- if (changes['options'] && !this.endpoint) {
138
+ const optionInputs = [
139
+ 'options',
140
+ 'optionLabel',
141
+ 'optionValue',
142
+ 'labelSeparator',
143
+ 'optionLabelTemplate',
144
+ 'optionLabelFn',
145
+ ];
146
+
147
+ if (optionInputs.some((key) => changes[key]) && !this.endpoint) {
99
148
  this.processOptions();
100
149
  }
101
150
  }
@@ -115,7 +164,12 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
115
164
 
116
165
  this.genericService.findAll<unknown>({ endpoint: this.endpoint, params }).subscribe({
117
166
  next: (res) => {
118
- const data = (res.data as Record<string, unknown[]>)[this.endpoint] ?? [];
167
+ const data = extractToggleOptionsFromResponse(
168
+ res.data as Record<string, unknown>,
169
+ this.endpoint,
170
+ this.responseKey || undefined,
171
+ this.dataPath || undefined,
172
+ );
119
173
  this.options = data;
120
174
  this.processOptions();
121
175
  this.isLoading = false;
@@ -131,7 +185,14 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
131
185
  }
132
186
 
133
187
  processOptions(): void {
134
- this.internalOptions = normalizeToggleOptions(this.options, this.optionLabel, this.optionValue);
188
+ this.internalOptions = normalizeToggleOptions(
189
+ this.options,
190
+ this.optionLabel,
191
+ this.optionValue,
192
+ this.labelSeparator,
193
+ this.optionLabelFn ? undefined : this.optionLabelTemplate || undefined,
194
+ this.optionLabelFn,
195
+ );
135
196
  }
136
197
 
137
198
  select(value: unknown): void {