@fgv/ts-res-browser 1.0.0

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.
Files changed (95) hide show
  1. package/.rush/temp/5fc90bc7c2ccf812114ea099111568e5429be2a2.tar.log +15 -0
  2. package/.rush/temp/chunked-rush-logs/ts-res-browser.build.chunks.jsonl +35 -0
  3. package/.rush/temp/operation/build/all.log +35 -0
  4. package/.rush/temp/operation/build/log-chunks.jsonl +35 -0
  5. package/.rush/temp/operation/build/state.json +3 -0
  6. package/.rush/temp/shrinkwrap-deps.json +1217 -0
  7. package/README.md +95 -0
  8. package/TS_RES_BROWSER_PLAN.md +689 -0
  9. package/babel.config.js +17 -0
  10. package/config/jest.config.json +13 -0
  11. package/config/rig.json +16 -0
  12. package/dist/bundle.js +2 -0
  13. package/dist/bundle.js.LICENSE.txt +62 -0
  14. package/dist/index.html +1 -0
  15. package/package.json +78 -0
  16. package/postcss.config.js +3 -0
  17. package/public/index.html +15 -0
  18. package/rush-logs/ts-res-browser.build.cache.log +3 -0
  19. package/rush-logs/ts-res-browser.build.log +35 -0
  20. package/src/App.tsx +153 -0
  21. package/src/cli.ts +44 -0
  22. package/src/components/common/FileImporter.tsx +138 -0
  23. package/src/components/common/NavigationWarningModal.tsx +62 -0
  24. package/src/components/layout/AppLayout.tsx +46 -0
  25. package/src/components/layout/Header.tsx +22 -0
  26. package/src/components/layout/MessagesWindow.tsx +108 -0
  27. package/src/components/layout/Sidebar.tsx +92 -0
  28. package/src/components/tools/CompiledBrowser.tsx +949 -0
  29. package/src/components/tools/ConfigurationTool.tsx +1402 -0
  30. package/src/components/tools/FilterTool.tsx +1027 -0
  31. package/src/components/tools/ImportTool.tsx +246 -0
  32. package/src/components/tools/ResolutionViewer.tsx +1672 -0
  33. package/src/components/tools/SourceBrowser.tsx +496 -0
  34. package/src/hooks/useAppState.ts +107 -0
  35. package/src/hooks/useFileImport.ts +86 -0
  36. package/src/hooks/useNavigationWarning.ts +67 -0
  37. package/src/hooks/useResourceManager.ts +244 -0
  38. package/src/index.css +38 -0
  39. package/src/main.tsx +16 -0
  40. package/src/types/app.ts +62 -0
  41. package/src/types/fileSystemAccess.d.ts +94 -0
  42. package/src/utils/browserFileTreeAccessors.ts +245 -0
  43. package/src/utils/fileImport.ts +334 -0
  44. package/src/utils/filterResources.ts +677 -0
  45. package/src/utils/tsResIntegration.ts +467 -0
  46. package/tailwind.config.js +21 -0
  47. package/test-data/README.md +184 -0
  48. package/test-data/config-variations/README.md +64 -0
  49. package/test-data/config-variations/composition-terminology.json +112 -0
  50. package/test-data/config-variations/enterprise-complex.json +129 -0
  51. package/test-data/config-variations/gaming-app.json +87 -0
  52. package/test-data/config-variations/high-priority-language.json +65 -0
  53. package/test-data/config-variations/minimal-basic.json +37 -0
  54. package/test-data/config-variations/qualifier-default-values.json +71 -0
  55. package/test-data/config-variations/territory-first.json +68 -0
  56. package/test-data/config-variations/territory-hierarchy-test.json +31 -0
  57. package/test-data/config-variations/territory-hierarchy-unconstrained.json +32 -0
  58. package/test-data/default-matching-test/greeting.res.json +83 -0
  59. package/test-data/default-matching-test/only-defaults.res.json +48 -0
  60. package/test-data/sample-project/package.json +27 -0
  61. package/test-data/sample-project/src/resources/app-config.json +27 -0
  62. package/test-data/sample-project/src/resources/app.json +177 -0
  63. package/test-data/sample-project/src/resources/config/app.env=development.json +21 -0
  64. package/test-data/sample-project/src/resources/config/app.json +21 -0
  65. package/test-data/sample-project/src/resources/config/app.platform=mobile.json +23 -0
  66. package/test-data/sample-project/src/resources/feature-flags.json +23 -0
  67. package/test-data/sample-project/src/resources/home=CA/common.json +16 -0
  68. package/test-data/sample-project/src/resources/images/density=hdpi.json +29 -0
  69. package/test-data/sample-project/src/resources/images/home.json +29 -0
  70. package/test-data/sample-project/src/resources/images/logout.json +14 -0
  71. package/test-data/sample-project/src/resources/images/settings.json +14 -0
  72. package/test-data/sample-project/src/resources/images/ui-icons.json +29 -0
  73. package/test-data/sample-project/src/resources/images/user.density=hdpi.json +14 -0
  74. package/test-data/sample-project/src/resources/images/user.json +14 -0
  75. package/test-data/sample-project/src/resources/lang=en-US/common.json +15 -0
  76. package/test-data/sample-project/src/resources/language=en-CA/ui-terms.json +19 -0
  77. package/test-data/sample-project/src/resources/language=en-GB/ui-terms.json +30 -0
  78. package/test-data/sample-project/src/resources/language=fr-CA/ui-terms.json +25 -0
  79. package/test-data/sample-project/src/resources/language=nl-BE/ui-terms.json +17 -0
  80. package/test-data/sample-project/src/resources/region=europe/feature-flags.json +17 -0
  81. package/test-data/sample-project/src/resources/region=north-america/feature-flags.json +18 -0
  82. package/test-data/sample-project/src/resources/strings/dashboard.home=CA,language=fr.json +20 -0
  83. package/test-data/sample-project/src/resources/strings/dashboard.json +18 -0
  84. package/test-data/sample-project/src/resources/strings/dashboard.role=admin.json +23 -0
  85. package/test-data/sample-project/src/resources/strings/dashboard.role=user.json +21 -0
  86. package/test-data/sample-project/src/resources/strings/language=en-US.json +16 -0
  87. package/test-data/sample-project/src/resources/strings.json +78 -0
  88. package/test-data/sample-project/src/resources/territory=BE/app-config.json +19 -0
  89. package/test-data/sample-project/src/resources/territory=CA/app-config.json +21 -0
  90. package/test-data/sample-project/src/resources/territory=GB/app-config.json +25 -0
  91. package/test-data/sample-project/src/resources/territory=NL/app-config.json +25 -0
  92. package/test-data/sample-project/src/resources/ui-terms.json +96 -0
  93. package/test-data/sample-project/src/resources-config.json +111 -0
  94. package/tsconfig.json +27 -0
  95. package/webpack.config.js +54 -0
@@ -0,0 +1,1027 @@
1
+ import React, { useState, useMemo, useCallback, useEffect } from 'react';
2
+ import {
3
+ FunnelIcon,
4
+ DocumentTextIcon,
5
+ ExclamationTriangleIcon,
6
+ ArrowPathIcon,
7
+ CheckIcon,
8
+ XMarkIcon,
9
+ DocumentArrowDownIcon,
10
+ CodeBracketIcon,
11
+ ChevronDownIcon,
12
+ ChevronUpIcon
13
+ } from '@heroicons/react/24/outline';
14
+ import { UseResourceManagerReturn } from '../../hooks/useResourceManager';
15
+ import { Message, FilterState } from '../../types/app';
16
+ import { NoMatch } from '@fgv/ts-res';
17
+ import { DEFAULT_SYSTEM_CONFIGURATION } from '../../utils/tsResIntegration';
18
+ import {
19
+ createFilteredResourceManager,
20
+ createFilteredResourceManagerAlternative,
21
+ createFilteredResourceManagerSimple,
22
+ analyzeFilteredResources,
23
+ hasFilterValues,
24
+ getFilterSummary,
25
+ FilterResult,
26
+ FilteredResource
27
+ } from '../../utils/filterResources';
28
+
29
+ interface FilterToolProps {
30
+ onMessage?: (type: Message['type'], message: string) => void;
31
+ resourceManager: UseResourceManagerReturn;
32
+ filterState: FilterState;
33
+ filterActions: {
34
+ updateFilterEnabled: (enabled: boolean) => void;
35
+ updateFilterValues: (values: Record<string, string>) => void;
36
+ applyFilterValues: () => void;
37
+ resetFilterValues: () => void;
38
+ updateReduceQualifiers: (reduceQualifiers: boolean) => void;
39
+ };
40
+ onFilterResult?: (result: FilterResult | null) => void;
41
+ }
42
+
43
+ const FilterTool: React.FC<FilterToolProps> = ({
44
+ onMessage,
45
+ resourceManager,
46
+ filterState,
47
+ filterActions,
48
+ onFilterResult
49
+ }) => {
50
+ const { state: resourceState } = resourceManager;
51
+
52
+ // Local UI state
53
+ const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
54
+ const [filterResult, setFilterResult] = useState<FilterResult | null>(null);
55
+ const [isFiltering, setIsFiltering] = useState(false);
56
+ const [showFilteredJsonView, setShowFilteredJsonView] = useState(false);
57
+
58
+ // Available qualifiers (same logic as ResolutionViewer)
59
+ const availableQualifiers = useMemo(() => {
60
+ if (resourceState.processedResources?.compiledCollection.qualifiers) {
61
+ return resourceState.processedResources.compiledCollection.qualifiers.map((q) => q.name);
62
+ }
63
+
64
+ const config = resourceState.activeConfiguration || DEFAULT_SYSTEM_CONFIGURATION;
65
+ return config.qualifiers.map((q) => q.name);
66
+ }, [resourceState.processedResources?.compiledCollection.qualifiers, resourceState.activeConfiguration]);
67
+
68
+ // Check if we have any applied filter values set
69
+ const hasAppliedFilterValues = useMemo(() => {
70
+ return hasFilterValues(filterState.appliedValues);
71
+ }, [filterState.appliedValues]);
72
+
73
+ // Determine if filtering is active (enabled AND has applied values)
74
+ const isFilteringActive = filterState.enabled && hasAppliedFilterValues;
75
+
76
+ // Get filtered resource collection data
77
+ const getFilteredResourceCollectionData = useCallback(() => {
78
+ if (!filterResult?.processedResources?.system.resourceManager) {
79
+ return null;
80
+ }
81
+
82
+ try {
83
+ const collectionResult =
84
+ filterResult.processedResources.system.resourceManager.getResourceCollectionDecl();
85
+ if (collectionResult.isSuccess()) {
86
+ return {
87
+ ...collectionResult.value,
88
+ metadata: {
89
+ exportedAt: new Date().toISOString(),
90
+ totalResources: filterResult.processedResources.resourceCount,
91
+ type: 'ts-res-filtered-resource-collection',
92
+ filterContext: filterState.appliedValues,
93
+ reduceQualifiers: filterState.reduceQualifiers
94
+ }
95
+ };
96
+ } else {
97
+ onMessage?.('error', `Failed to get filtered resource collection: ${collectionResult.message}`);
98
+ return null;
99
+ }
100
+ } catch (error) {
101
+ onMessage?.(
102
+ 'error',
103
+ `Error getting filtered resource collection: ${
104
+ error instanceof Error ? error.message : String(error)
105
+ }`
106
+ );
107
+ return null;
108
+ }
109
+ }, [filterResult, onMessage, filterState.appliedValues]);
110
+
111
+ // Export filtered resource collection data
112
+ const handleExportFilteredData = useCallback(() => {
113
+ try {
114
+ const collectionData = getFilteredResourceCollectionData();
115
+ if (!collectionData) {
116
+ onMessage?.('error', 'No filtered collection data available to export');
117
+ return;
118
+ }
119
+
120
+ const filterSummary = getFilterSummary(filterState.appliedValues);
121
+ const sourceJson = JSON.stringify(collectionData, null, 2);
122
+ const blob = new Blob([sourceJson], { type: 'application/json' });
123
+ const url = URL.createObjectURL(blob);
124
+
125
+ const a = document.createElement('a');
126
+ a.href = url;
127
+ a.download = `filtered-resource-collection-${filterSummary.replace(/[^a-z0-9]/gi, '-')}.json`;
128
+ document.body.appendChild(a);
129
+ a.click();
130
+ document.body.removeChild(a);
131
+ URL.revokeObjectURL(url);
132
+
133
+ onMessage?.('success', 'Filtered resource collection exported successfully');
134
+ } catch (error) {
135
+ onMessage?.(
136
+ 'error',
137
+ `Failed to export filtered resource collection: ${
138
+ error instanceof Error ? error.message : String(error)
139
+ }`
140
+ );
141
+ }
142
+ }, [getFilteredResourceCollectionData, onMessage, filterState.appliedValues]);
143
+
144
+ // Apply filtering when applied filter values change
145
+ useEffect(() => {
146
+ if (!resourceState.processedResources || !isFilteringActive) {
147
+ setFilterResult(null);
148
+ onFilterResult?.(null);
149
+ return;
150
+ }
151
+
152
+ const applyFilter = async () => {
153
+ setIsFiltering(true);
154
+
155
+ try {
156
+ // Try the new simple filtering approach first
157
+ let filteredResult = await createFilteredResourceManagerSimple(
158
+ resourceState.processedResources!.system,
159
+ filterState.appliedValues,
160
+ {
161
+ partialContextMatch: true,
162
+ enableDebugLogging: false,
163
+ reduceQualifiers: filterState.reduceQualifiers
164
+ }
165
+ );
166
+
167
+ if (filteredResult.isFailure()) {
168
+ onMessage?.(
169
+ 'warning',
170
+ `Simple filtering failed, trying legacy approach: ${filteredResult.message}`
171
+ );
172
+
173
+ // Fall back to the original complex implementation
174
+ filteredResult = await createFilteredResourceManager(
175
+ resourceState.processedResources!.system,
176
+ filterState.appliedValues,
177
+ {
178
+ partialContextMatch: true,
179
+ enableDebugLogging: false,
180
+ reduceQualifiers: filterState.reduceQualifiers
181
+ }
182
+ );
183
+
184
+ if (filteredResult.isFailure()) {
185
+ onMessage?.(
186
+ 'warning',
187
+ `Legacy filtering failed, trying alternative approach: ${filteredResult.message}`
188
+ );
189
+
190
+ // Last resort: try the alternative approach
191
+ filteredResult = await createFilteredResourceManagerAlternative(
192
+ resourceState.processedResources!.system,
193
+ filterState.appliedValues,
194
+ {
195
+ partialContextMatch: true,
196
+ enableDebugLogging: false,
197
+ reduceQualifiers: filterState.reduceQualifiers
198
+ }
199
+ );
200
+
201
+ if (filteredResult.isFailure()) {
202
+ setFilterResult({
203
+ success: false,
204
+ filteredResources: [],
205
+ error: `All filtering approaches failed. Simple: ${filteredResult.message}`,
206
+ warnings: []
207
+ });
208
+ onMessage?.('error', `Filtering failed: ${filteredResult.message}`);
209
+ return;
210
+ } else {
211
+ onMessage?.('success', 'Alternative filtering approach succeeded');
212
+ }
213
+ } else {
214
+ onMessage?.('success', 'Legacy filtering approach succeeded');
215
+ }
216
+ } else {
217
+ onMessage?.('success', 'Simple filtering approach succeeded');
218
+ }
219
+
220
+ // Analyze filtered resources
221
+ const originalResources = resourceState.processedResources!.summary.resourceIds || [];
222
+ const analysis = analyzeFilteredResources(
223
+ originalResources,
224
+ filteredResult.value,
225
+ resourceState.processedResources!
226
+ );
227
+
228
+ setFilterResult(analysis);
229
+ onFilterResult?.(analysis);
230
+
231
+ if (analysis.warnings.length > 0) {
232
+ onMessage?.('warning', `Filtering completed with ${analysis.warnings.length} warning(s)`);
233
+ } else {
234
+ onMessage?.('success', `Filtering completed: ${analysis.filteredResources.length} resources`);
235
+ }
236
+ } catch (error) {
237
+ const errorMessage = error instanceof Error ? error.message : String(error);
238
+ const errorResult = {
239
+ success: false,
240
+ filteredResources: [],
241
+ error: errorMessage,
242
+ warnings: []
243
+ };
244
+ setFilterResult(errorResult);
245
+ onFilterResult?.(errorResult);
246
+ onMessage?.('error', `Filtering error: ${errorMessage}`);
247
+ } finally {
248
+ setIsFiltering(false);
249
+ }
250
+ };
251
+
252
+ applyFilter();
253
+ }, [filterState.appliedValues, isFilteringActive, resourceState.processedResources, onMessage]);
254
+
255
+ // Get resources to display (filtered or original)
256
+ const displayResources = useMemo(() => {
257
+ if (!resourceState.processedResources) return [];
258
+
259
+ let resources: FilteredResource[] = [];
260
+
261
+ if (isFilteringActive && filterResult?.success) {
262
+ resources = filterResult.filteredResources;
263
+ } else {
264
+ // Return original resources
265
+ const originalResources = resourceState.processedResources.summary.resourceIds || [];
266
+ resources = originalResources.map((id) => {
267
+ const resourceResult = resourceState.processedResources!.system.resourceManager.getBuiltResource(id);
268
+ const candidateCount = resourceResult.isSuccess() ? resourceResult.value.candidates.length : 0;
269
+
270
+ return {
271
+ id,
272
+ originalCandidateCount: candidateCount,
273
+ filteredCandidateCount: candidateCount,
274
+ hasWarning: false
275
+ } as FilteredResource;
276
+ });
277
+ }
278
+
279
+ // Sort resources alphabetically by id
280
+ return resources.sort((a, b) => a.id.localeCompare(b.id));
281
+ }, [resourceState.processedResources, isFilteringActive, filterResult]);
282
+
283
+ // Handle filter value changes
284
+ const handleFilterChange = useCallback(
285
+ (qualifierName: string, value: string) => {
286
+ const newValues = { ...filterState.values, [qualifierName]: value };
287
+ filterActions.updateFilterValues(newValues);
288
+ },
289
+ [filterState.values, filterActions]
290
+ );
291
+
292
+ // Handle resource selection
293
+ const handleResourceSelect = useCallback((resourceId: string) => {
294
+ setSelectedResourceId(resourceId);
295
+ }, []);
296
+
297
+ // Handle filter toggle
298
+ const handleFilterToggle = useCallback(
299
+ (enabled: boolean) => {
300
+ filterActions.updateFilterEnabled(enabled);
301
+ if (!enabled) {
302
+ onMessage?.('info', 'Filtering disabled - showing all resources');
303
+ } else {
304
+ onMessage?.('info', 'Filtering enabled - set qualifier values and click Apply to filter resources');
305
+ }
306
+ },
307
+ [filterActions, onMessage]
308
+ );
309
+
310
+ // Handle apply filter values
311
+ const handleApplyFilter = useCallback(() => {
312
+ filterActions.applyFilterValues();
313
+ onMessage?.('info', 'Filter applied - processing resources...');
314
+ }, [filterActions, onMessage]);
315
+
316
+ // Handle reset filter values
317
+ const handleResetFilter = useCallback(() => {
318
+ filterActions.resetFilterValues();
319
+ onMessage?.('info', 'Filter values reset');
320
+ }, [filterActions, onMessage]);
321
+
322
+ if (!resourceState.processedResources) {
323
+ return (
324
+ <div className="p-6">
325
+ <div className="flex items-center space-x-3 mb-6">
326
+ <FunnelIcon className="h-8 w-8 text-purple-600" />
327
+ <h2 className="text-2xl font-bold text-gray-900">Filter Tool</h2>
328
+ </div>
329
+
330
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center">
331
+ <div className="max-w-2xl mx-auto">
332
+ <h3 className="text-xl font-semibold text-gray-900 mb-4">No Resources Loaded</h3>
333
+ <p className="text-gray-600 mb-6">
334
+ Import resources first to use the filter tool for context-based resource filtering.
335
+ </p>
336
+ <div className="bg-purple-50 rounded-lg p-4">
337
+ <p className="text-sm text-purple-800">
338
+ <strong>Filter Tool:</strong> Allows you to filter resources based on partial context
339
+ matching, creating focused subsets for analysis and testing.
340
+ </p>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ );
346
+ }
347
+
348
+ // Get both original and filtered resources for the selected resource
349
+ const selectedResourceData = useMemo(() => {
350
+ if (!selectedResourceId || !resourceState.processedResources) {
351
+ return null;
352
+ }
353
+
354
+ const originalResourceResult =
355
+ resourceState.processedResources.system.resourceManager.getBuiltResource(selectedResourceId);
356
+ const originalResource = originalResourceResult.isSuccess() ? originalResourceResult.value : null;
357
+
358
+ let filteredResource = null;
359
+ if (isFilteringActive && filterResult?.processedResources) {
360
+ const filteredResourceResult =
361
+ filterResult.processedResources.system.resourceManager.getBuiltResource(selectedResourceId);
362
+ filteredResource = filteredResourceResult.isSuccess() ? filteredResourceResult.value : null;
363
+ }
364
+
365
+ return {
366
+ original: originalResource,
367
+ filtered: filteredResource
368
+ };
369
+ }, [selectedResourceId, resourceState.processedResources, filterResult, isFilteringActive]);
370
+
371
+ // Categorize candidates when filtering is active
372
+ const categorizedCandidates = useMemo(() => {
373
+ if (!selectedResourceData?.original || !isFilteringActive) {
374
+ return { matching: [], filteredOut: [] };
375
+ }
376
+
377
+ const originalCandidates = selectedResourceData.original.candidates;
378
+ const filteredCandidates = selectedResourceData.filtered?.candidates || [];
379
+
380
+ // Create a lookup for filtered candidates by comparing JSON content and conditions
381
+ const filteredCandidatesSet = new Set(
382
+ filteredCandidates.map((candidate) =>
383
+ JSON.stringify({
384
+ json: candidate.json,
385
+ conditions:
386
+ candidate.conditions?.conditions?.map((c) => ({
387
+ qualifier: c.qualifier.name,
388
+ operator: c.operator,
389
+ value: c.value
390
+ })) || []
391
+ })
392
+ )
393
+ );
394
+
395
+ const matching = [];
396
+ const filteredOut = [];
397
+
398
+ for (const candidate of originalCandidates) {
399
+ const candidateKey = JSON.stringify({
400
+ json: candidate.json,
401
+ conditions:
402
+ candidate.conditions?.conditions?.map((c) => ({
403
+ qualifier: c.qualifier.name,
404
+ operator: c.operator,
405
+ value: c.value
406
+ })) || []
407
+ });
408
+
409
+ if (filteredCandidatesSet.has(candidateKey)) {
410
+ matching.push(candidate);
411
+ } else {
412
+ filteredOut.push(candidate);
413
+ }
414
+ }
415
+
416
+ return { matching, filteredOut };
417
+ }, [selectedResourceData, isFilteringActive]);
418
+
419
+ return (
420
+ <div className="p-6">
421
+ <div className="flex items-center space-x-3 mb-6">
422
+ <FunnelIcon className="h-8 w-8 text-purple-600" />
423
+ <h2 className="text-2xl font-bold text-gray-900">Filter Tool</h2>
424
+ </div>
425
+
426
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
427
+ {/* Filter Controls */}
428
+ <div className="mb-6">
429
+ <div className="flex items-center justify-between mb-4">
430
+ <h3 className="text-lg font-semibold text-gray-900">Filter Controls</h3>
431
+ <div className="flex items-center justify-between">
432
+ <div className="flex items-center space-x-4">
433
+ <label className="flex items-center">
434
+ <input
435
+ type="checkbox"
436
+ checked={filterState.enabled}
437
+ onChange={(e) => handleFilterToggle(e.target.checked)}
438
+ className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
439
+ />
440
+ <span className="ml-2 text-sm text-gray-700">Enable Filtering</span>
441
+ </label>
442
+ <label
443
+ className="flex items-center"
444
+ title="Remove perfectly matching qualifier conditions from filtered resources to create cleaner bundles for comparison"
445
+ >
446
+ <input
447
+ type="checkbox"
448
+ checked={filterState.reduceQualifiers}
449
+ onChange={(e) => filterActions.updateReduceQualifiers(e.target.checked)}
450
+ disabled={!filterState.enabled}
451
+ className="rounded border-gray-300 text-purple-600 focus:ring-purple-500 disabled:text-gray-400"
452
+ />
453
+ <span
454
+ className={`ml-2 text-sm ${!filterState.enabled ? 'text-gray-400' : 'text-gray-700'}`}
455
+ >
456
+ Reduce Qualifiers
457
+ </span>
458
+ </label>
459
+ {isFilteringActive && (
460
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
461
+ Active{filterState.reduceQualifiers ? ' + Reducing' : ''}
462
+ </span>
463
+ )}
464
+ {filterState.hasPendingChanges && filterState.enabled && (
465
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
466
+ Pending Changes
467
+ </span>
468
+ )}
469
+ </div>
470
+
471
+ {filterState.enabled && (
472
+ <div className="flex items-center space-x-2">
473
+ <button
474
+ onClick={handleResetFilter}
475
+ className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
476
+ >
477
+ <XMarkIcon className="h-4 w-4 mr-1" />
478
+ Reset
479
+ </button>
480
+ <button
481
+ onClick={handleApplyFilter}
482
+ disabled={!filterState.hasPendingChanges || isFiltering}
483
+ className={`inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 ${
484
+ filterState.hasPendingChanges && !isFiltering
485
+ ? 'text-white bg-purple-600 hover:bg-purple-700'
486
+ : 'text-gray-400 bg-gray-300 cursor-not-allowed'
487
+ }`}
488
+ >
489
+ {isFiltering ? (
490
+ <ArrowPathIcon className="h-4 w-4 mr-1 animate-spin" />
491
+ ) : (
492
+ <CheckIcon className="h-4 w-4 mr-1" />
493
+ )}
494
+ Apply
495
+ </button>
496
+ </div>
497
+ )}
498
+ </div>
499
+ </div>
500
+ </div>
501
+
502
+ {/* Qualifier Selection Panel */}
503
+ <div className="mb-6">
504
+ <h3 className="text-lg font-semibold text-gray-900 mb-4">Context Filters</h3>
505
+ <div className="bg-gray-50 rounded-lg p-4">
506
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
507
+ {availableQualifiers.map((qualifierName) => (
508
+ <div key={qualifierName} className="bg-white rounded border border-gray-200 p-2">
509
+ <div className="flex items-center gap-2">
510
+ <label className="text-sm font-medium text-gray-700 min-w-0 flex-shrink-0">
511
+ {qualifierName}:
512
+ </label>
513
+ <input
514
+ type="text"
515
+ value={filterState.values[qualifierName] || ''}
516
+ onChange={(e) => handleFilterChange(qualifierName, e.target.value)}
517
+ disabled={!filterState.enabled}
518
+ className={`flex-1 px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent text-sm min-w-0 ${
519
+ !filterState.enabled ? 'bg-gray-100 text-gray-400' : ''
520
+ }`}
521
+ placeholder={filterState.enabled ? `Filter by ${qualifierName}` : 'Disabled'}
522
+ />
523
+ </div>
524
+ </div>
525
+ ))}
526
+ </div>
527
+ {filterState.enabled && (
528
+ <div className="mt-3 text-sm text-gray-600">
529
+ <div className="flex items-center justify-between">
530
+ <div className="space-y-1">
531
+ <p>
532
+ <strong>Pending:</strong> {getFilterSummary(filterState.values)}
533
+ </p>
534
+ {isFilteringActive && (
535
+ <p>
536
+ <strong>Applied:</strong> {getFilterSummary(filterState.appliedValues)}
537
+ </p>
538
+ )}
539
+ </div>
540
+ {isFiltering && (
541
+ <div className="flex items-center text-blue-600">
542
+ <ArrowPathIcon className="h-4 w-4 mr-1 animate-spin" />
543
+ <span className="text-xs">Filtering...</span>
544
+ </div>
545
+ )}
546
+ </div>
547
+ {filterResult && !filterResult.success && filterResult.error && (
548
+ <p className="text-red-600 text-xs mt-1">
549
+ <strong>Error:</strong> {filterResult.error}
550
+ </p>
551
+ )}
552
+ </div>
553
+ )}
554
+ </div>
555
+ </div>
556
+
557
+ {/* Filtered Resource Collection JSON View */}
558
+ {isFilteringActive && filterResult?.success && (
559
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
560
+ <div className="flex items-center justify-between mb-2">
561
+ <button
562
+ onClick={() => setShowFilteredJsonView(!showFilteredJsonView)}
563
+ className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
564
+ >
565
+ <CodeBracketIcon className="h-4 w-4 mr-2" />
566
+ {showFilteredJsonView ? 'Hide' : 'Show'} Filtered JSON Resource Collection
567
+ {showFilteredJsonView ? (
568
+ <ChevronUpIcon className="h-4 w-4 ml-2" />
569
+ ) : (
570
+ <ChevronDownIcon className="h-4 w-4 ml-2" />
571
+ )}
572
+ </button>
573
+
574
+ <button
575
+ onClick={handleExportFilteredData}
576
+ className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
577
+ >
578
+ <DocumentArrowDownIcon className="h-4 w-4 mr-1" />
579
+ Export Filtered JSON
580
+ </button>
581
+ </div>
582
+
583
+ {/* JSON View */}
584
+ {showFilteredJsonView && (
585
+ <div className="mt-4">
586
+ <div className="bg-gray-50 rounded-lg border border-gray-200 p-4">
587
+ <div className="flex items-center justify-between mb-2">
588
+ <h3 className="text-sm font-medium text-gray-900">
589
+ Filtered Resource Collection ({getFilterSummary(filterState.appliedValues)})
590
+ </h3>
591
+ <button
592
+ onClick={handleExportFilteredData}
593
+ className="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
594
+ >
595
+ <DocumentArrowDownIcon className="h-3 w-3 mr-1" />
596
+ Export
597
+ </button>
598
+ </div>
599
+ <pre className="text-xs text-gray-800 bg-white p-3 rounded border overflow-x-auto max-h-64 overflow-y-auto">
600
+ {JSON.stringify(getFilteredResourceCollectionData(), null, 2)}
601
+ </pre>
602
+ </div>
603
+ </div>
604
+ )}
605
+ </div>
606
+ )}
607
+
608
+ {/* Main Browser/Details Layout */}
609
+ <div className="flex flex-col lg:flex-row gap-6 h-[600px]">
610
+ {/* Left side: Resource List */}
611
+ <div className="lg:w-1/2 flex flex-col">
612
+ <div className="flex items-center justify-between mb-4">
613
+ <h3 className="text-lg font-semibold text-gray-900">
614
+ {isFilteringActive ? 'Filtered Resources' : 'All Resources'}
615
+ </h3>
616
+ <div className="flex items-center space-x-2 text-sm text-gray-500">
617
+ <span>{displayResources.length} resources</span>
618
+ {isFilteringActive && displayResources.some((r) => r.hasWarning) && (
619
+ <>
620
+ <span>•</span>
621
+ <span className="text-amber-600 flex items-center">
622
+ <ExclamationTriangleIcon className="h-4 w-4 mr-1" />
623
+ {displayResources.filter((r) => r.hasWarning).length} warnings
624
+ </span>
625
+ </>
626
+ )}
627
+ </div>
628
+ </div>
629
+
630
+ <div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg bg-gray-50">
631
+ {displayResources.map((resource) => (
632
+ <div
633
+ key={resource.id}
634
+ className={`flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-100 border-b border-gray-100 last:border-b-0 ${
635
+ selectedResourceId === resource.id ? 'bg-purple-50 border-r-2 border-purple-500' : ''
636
+ }`}
637
+ onClick={() => handleResourceSelect(resource.id)}
638
+ >
639
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
640
+ <DocumentTextIcon className="w-4 h-4 text-green-500 flex-shrink-0" />
641
+ <span
642
+ className={`text-sm truncate ${
643
+ selectedResourceId === resource.id ? 'font-medium text-purple-900' : 'text-gray-700'
644
+ }`}
645
+ >
646
+ {resource.id}
647
+ </span>
648
+ </div>
649
+ <div className="flex items-center space-x-2 flex-shrink-0">
650
+ {isFilteringActive && (
651
+ <div className="flex items-center space-x-1 text-xs">
652
+ <span className="text-gray-400">{resource.originalCandidateCount}</span>
653
+ <span className="text-gray-400">→</span>
654
+ <span
655
+ className={`font-medium ${
656
+ resource.filteredCandidateCount === 0
657
+ ? 'text-red-600'
658
+ : resource.filteredCandidateCount < resource.originalCandidateCount
659
+ ? 'text-amber-600'
660
+ : 'text-green-600'
661
+ }`}
662
+ >
663
+ {resource.filteredCandidateCount}
664
+ </span>
665
+ </div>
666
+ )}
667
+ {!isFilteringActive && (
668
+ <span className="text-xs text-gray-500">
669
+ {resource.originalCandidateCount} candidates
670
+ </span>
671
+ )}
672
+ {resource.hasWarning && (
673
+ <ExclamationTriangleIcon
674
+ className="h-4 w-4 text-amber-500"
675
+ title="No matching candidates"
676
+ />
677
+ )}
678
+ </div>
679
+ </div>
680
+ ))}
681
+ </div>
682
+ </div>
683
+
684
+ {/* Right side: Resource Details */}
685
+ <div className="lg:w-1/2 flex flex-col">
686
+ <div className="flex items-center justify-between mb-4">
687
+ <h3 className="text-lg font-semibold text-gray-900">Resource Details</h3>
688
+ </div>
689
+
690
+ <div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg p-4 bg-gray-50">
691
+ {!selectedResourceId ? (
692
+ <div className="flex items-center justify-center h-full">
693
+ <div className="text-center">
694
+ <FunnelIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
695
+ <p className="text-gray-500">Select a resource to view details</p>
696
+ {isFilteringActive && (
697
+ <p className="text-sm text-gray-400 mt-2">
698
+ Showing resources that match your filter criteria
699
+ </p>
700
+ )}
701
+ </div>
702
+ </div>
703
+ ) : selectedResourceData?.original ? (
704
+ <div className="space-y-4">
705
+ <div>
706
+ <h4 className="font-medium text-gray-800 mb-2">Resource Information</h4>
707
+ <div className="bg-white p-3 rounded border text-sm">
708
+ <div className="grid grid-cols-2 gap-2">
709
+ <div>
710
+ <strong>ID:</strong> {selectedResourceData.original.id}
711
+ </div>
712
+ <div>
713
+ <strong>Type:</strong> {selectedResourceData.original.resourceType.key}
714
+ </div>
715
+ <div>
716
+ <strong>Candidates:</strong> {selectedResourceData.original.candidates.length}
717
+ </div>
718
+ <div>
719
+ <strong>Decision:</strong> {selectedResourceData.original.decision.key}
720
+ </div>
721
+ </div>
722
+ </div>
723
+ </div>
724
+
725
+ {isFilteringActive && (
726
+ <div>
727
+ <h4 className="font-medium text-gray-800 mb-2">Filter Impact</h4>
728
+ <div className="bg-white p-3 rounded border text-sm">
729
+ <div className="space-y-1">
730
+ <div>Original candidates: {selectedResourceData.original.candidates.length}</div>
731
+ <div className="text-purple-600">
732
+ Filtered candidates:{' '}
733
+ {displayResources.find((r) => r.id === selectedResourceId)
734
+ ?.filteredCandidateCount || 0}
735
+ </div>
736
+ {displayResources.find((r) => r.id === selectedResourceId)?.hasWarning && (
737
+ <div className="text-amber-600 text-xs mt-2 flex items-center">
738
+ <ExclamationTriangleIcon className="h-4 w-4 mr-1" />
739
+ Warning: No candidates match the current filter criteria
740
+ </div>
741
+ )}
742
+ </div>
743
+ </div>
744
+ </div>
745
+ )}
746
+
747
+ <div>
748
+ <h4 className="font-medium text-gray-800 mb-2">
749
+ Candidates ({selectedResourceData.original.candidates.length})
750
+ </h4>
751
+ <div className="bg-white rounded border max-h-64 overflow-y-auto">
752
+ {selectedResourceData.original.candidates.length > 0 ? (
753
+ <div className="space-y-4 p-4">
754
+ {/* Show matching candidates first when filtering is active */}
755
+ {isFilteringActive && categorizedCandidates.matching.length > 0 && (
756
+ <div className="space-y-4">
757
+ <div className="border-b border-green-200 pb-2">
758
+ <h5 className="text-sm font-semibold text-green-700 flex items-center">
759
+ <CheckIcon className="h-4 w-4 mr-1" />
760
+ Matching Candidates ({categorizedCandidates.matching.length})
761
+ </h5>
762
+ </div>
763
+ {categorizedCandidates.matching.map((candidate, index) => (
764
+ <div key={`matching-${index}`} className="border-l-4 border-green-500 pl-4">
765
+ <div className="bg-green-50 p-4 rounded-lg border border-green-200">
766
+ <div className="flex items-center justify-between mb-3">
767
+ <h6 className="font-medium text-gray-800">
768
+ Matching Candidate {index + 1}
769
+ </h6>
770
+ <div className="flex items-center space-x-2 text-xs">
771
+ {candidate.isPartial && (
772
+ <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
773
+ Partial
774
+ </span>
775
+ )}
776
+ <span className="bg-green-100 text-green-700 px-2 py-1 rounded">
777
+ {candidate.mergeMethod}
778
+ </span>
779
+ </div>
780
+ </div>
781
+
782
+ {/* Conditions */}
783
+ {candidate.conditions &&
784
+ candidate.conditions.conditions &&
785
+ candidate.conditions.conditions.length > 0 ? (
786
+ <div className="mb-3">
787
+ <h6 className="text-sm font-medium text-gray-600 mb-2">
788
+ Conditions:
789
+ </h6>
790
+ <div className="space-y-1">
791
+ {candidate.conditions.conditions.map((condition, condIndex) => (
792
+ <div
793
+ key={condIndex}
794
+ className="flex items-center text-xs bg-green-100 px-2 py-1 rounded"
795
+ >
796
+ <span className="font-medium text-green-800">
797
+ {condition.qualifier.name}
798
+ </span>
799
+ <span className="mx-1 text-green-600">
800
+ {condition.operator}
801
+ </span>
802
+ <span className="text-green-700">{condition.value}</span>
803
+ <div className="ml-auto flex items-center space-x-2">
804
+ <span className="text-green-500">
805
+ priority: {condition.priority}
806
+ </span>
807
+ {condition.scoreAsDefault !== undefined && (
808
+ <span className="text-amber-600 font-medium">
809
+ default: {condition.scoreAsDefault}
810
+ </span>
811
+ )}
812
+ </div>
813
+ </div>
814
+ ))}
815
+ </div>
816
+ </div>
817
+ ) : (
818
+ <div className="mb-3">
819
+ <span className="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
820
+ No conditions (default candidate)
821
+ </span>
822
+ </div>
823
+ )}
824
+
825
+ {/* JSON Content */}
826
+ <div>
827
+ <h6 className="text-sm font-medium text-gray-600 mb-2">Content:</h6>
828
+ <pre className="text-xs bg-white p-3 rounded border overflow-x-auto max-h-40">
829
+ {JSON.stringify(candidate.json, null, 2)}
830
+ </pre>
831
+ </div>
832
+ </div>
833
+ </div>
834
+ ))}
835
+ </div>
836
+ )}
837
+
838
+ {/* Show filtered out candidates when filtering is active */}
839
+ {isFilteringActive && categorizedCandidates.filteredOut.length > 0 && (
840
+ <div className="space-y-4">
841
+ <div className="border-b border-gray-300 pb-2">
842
+ <h5 className="text-sm font-semibold text-gray-600 flex items-center">
843
+ <XMarkIcon className="h-4 w-4 mr-1" />
844
+ Filtered Out Candidates ({categorizedCandidates.filteredOut.length})
845
+ </h5>
846
+ </div>
847
+ {categorizedCandidates.filteredOut.map((candidate, index) => (
848
+ <div
849
+ key={`filtered-${index}`}
850
+ className="border-l-4 border-gray-400 pl-4 opacity-60"
851
+ >
852
+ <div className="bg-gray-50 p-4 rounded-lg border border-gray-300">
853
+ <div className="flex items-center justify-between mb-3">
854
+ <h6 className="font-medium text-gray-600">
855
+ Filtered Out Candidate {index + 1}
856
+ </h6>
857
+ <div className="flex items-center space-x-2 text-xs">
858
+ {candidate.isPartial && (
859
+ <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
860
+ Partial
861
+ </span>
862
+ )}
863
+ <span className="bg-gray-200 text-gray-600 px-2 py-1 rounded">
864
+ {candidate.mergeMethod}
865
+ </span>
866
+ </div>
867
+ </div>
868
+
869
+ {/* Conditions */}
870
+ {candidate.conditions &&
871
+ candidate.conditions.conditions &&
872
+ candidate.conditions.conditions.length > 0 ? (
873
+ <div className="mb-3">
874
+ <h6 className="text-sm font-medium text-gray-500 mb-2">
875
+ Conditions:
876
+ </h6>
877
+ <div className="space-y-1">
878
+ {candidate.conditions.conditions.map((condition, condIndex) => (
879
+ <div
880
+ key={condIndex}
881
+ className="flex items-center text-xs bg-gray-200 px-2 py-1 rounded"
882
+ >
883
+ <span className="font-medium text-gray-700">
884
+ {condition.qualifier.name}
885
+ </span>
886
+ <span className="mx-1 text-gray-500">{condition.operator}</span>
887
+ <span className="text-gray-600">{condition.value}</span>
888
+ <div className="ml-auto flex items-center space-x-2">
889
+ <span className="text-gray-400">
890
+ priority: {condition.priority}
891
+ </span>
892
+ {condition.scoreAsDefault !== undefined && (
893
+ <span className="text-amber-500 font-medium">
894
+ default: {condition.scoreAsDefault}
895
+ </span>
896
+ )}
897
+ </div>
898
+ </div>
899
+ ))}
900
+ </div>
901
+ </div>
902
+ ) : (
903
+ <div className="mb-3">
904
+ <span className="text-xs text-gray-500 bg-gray-200 px-2 py-1 rounded">
905
+ No conditions (default candidate)
906
+ </span>
907
+ </div>
908
+ )}
909
+
910
+ {/* JSON Content */}
911
+ <div>
912
+ <h6 className="text-sm font-medium text-gray-500 mb-2">Content:</h6>
913
+ <pre className="text-xs bg-gray-100 p-3 rounded border overflow-x-auto max-h-40">
914
+ {JSON.stringify(candidate.json, null, 2)}
915
+ </pre>
916
+ </div>
917
+ </div>
918
+ </div>
919
+ ))}
920
+ </div>
921
+ )}
922
+
923
+ {/* Show all candidates when filtering is not active */}
924
+ {!isFilteringActive &&
925
+ selectedResourceData.original.candidates.map((candidate, index) => (
926
+ <div key={index}>
927
+ <div className="bg-white p-4 rounded-lg border">
928
+ <div className="flex items-center justify-between mb-3">
929
+ <h5 className="font-medium text-gray-800">Candidate {index + 1}</h5>
930
+ <div className="flex items-center space-x-2 text-xs">
931
+ {candidate.isPartial && (
932
+ <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
933
+ Partial
934
+ </span>
935
+ )}
936
+ <span className="bg-gray-100 text-gray-700 px-2 py-1 rounded">
937
+ {candidate.mergeMethod}
938
+ </span>
939
+ </div>
940
+ </div>
941
+
942
+ {/* Conditions */}
943
+ {candidate.conditions &&
944
+ candidate.conditions.conditions &&
945
+ candidate.conditions.conditions.length > 0 ? (
946
+ <div className="mb-3">
947
+ <h6 className="text-sm font-medium text-gray-600 mb-2">Conditions:</h6>
948
+ <div className="space-y-1">
949
+ {candidate.conditions.conditions.map((condition, condIndex) => (
950
+ <div
951
+ key={condIndex}
952
+ className="flex items-center text-xs bg-blue-50 px-2 py-1 rounded"
953
+ >
954
+ <span className="font-medium text-blue-800">
955
+ {condition.qualifier.name}
956
+ </span>
957
+ <span className="mx-1 text-blue-600">{condition.operator}</span>
958
+ <span className="text-blue-700">{condition.value}</span>
959
+ <div className="ml-auto flex items-center space-x-2">
960
+ <span className="text-blue-500">
961
+ priority: {condition.priority}
962
+ </span>
963
+ {condition.scoreAsDefault !== undefined && (
964
+ <span className="text-amber-600 font-medium">
965
+ default: {condition.scoreAsDefault}
966
+ </span>
967
+ )}
968
+ </div>
969
+ </div>
970
+ ))}
971
+ </div>
972
+ </div>
973
+ ) : (
974
+ <div className="mb-3">
975
+ <span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
976
+ No conditions (default candidate)
977
+ </span>
978
+ </div>
979
+ )}
980
+
981
+ {/* JSON Content */}
982
+ <div>
983
+ <h6 className="text-sm font-medium text-gray-600 mb-2">Content:</h6>
984
+ <pre className="text-xs bg-gray-50 p-3 rounded border overflow-x-auto max-h-40">
985
+ {JSON.stringify(candidate.json, null, 2)}
986
+ </pre>
987
+ </div>
988
+ </div>
989
+ </div>
990
+ ))}
991
+ </div>
992
+ ) : (
993
+ <div className="p-4 text-center text-gray-500 text-sm">No candidates available</div>
994
+ )}
995
+ </div>
996
+ </div>
997
+
998
+ <div>
999
+ <h4 className="font-medium text-gray-800 mb-2">Raw Resource Data</h4>
1000
+ <pre className="text-xs bg-white p-3 rounded border overflow-x-auto max-h-32 overflow-y-auto">
1001
+ {JSON.stringify(
1002
+ {
1003
+ id: selectedResourceData.original.id,
1004
+ resourceType: selectedResourceData.original.resourceType.key,
1005
+ decision: selectedResourceData.original.decision.key,
1006
+ candidateCount: selectedResourceData.original.candidates.length
1007
+ },
1008
+ null,
1009
+ 2
1010
+ )}
1011
+ </pre>
1012
+ </div>
1013
+ </div>
1014
+ ) : (
1015
+ <div className="text-center text-gray-500">
1016
+ <p>Failed to load resource details</p>
1017
+ </div>
1018
+ )}
1019
+ </div>
1020
+ </div>
1021
+ </div>
1022
+ </div>
1023
+ </div>
1024
+ );
1025
+ };
1026
+
1027
+ export default FilterTool;