Skip to content

Commit 1831793

Browse files
authored
Merge pull request #1067 from subratsahilgupta/feat/feature-type-config
feat(feature-entitlement): add support for config type feature with JSON editor, syntax highlighting, side sheet viewer, and violet chip styling
2 parents 1903aad + 9c0ae28 commit 1831793

25 files changed

Lines changed: 763 additions & 176 deletions

File tree

src/components/atoms/SelectFeature/SelectFeature.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
44
import Feature, { FEATURE_TYPE } from '@/models/Feature';
55
import FeatureApi from '@/api/FeatureApi';
66
import { useQuery } from '@tanstack/react-query';
7-
import { Gauge, SquareCheckBig, Wrench } from 'lucide-react';
7+
import { Gauge, Settings2, SquareCheckBig, Wrench } from 'lucide-react';
88
import { FC, useMemo } from 'react';
99
import { ENTITY_STATUS } from '@/models/base';
1010
import { useTranslation } from 'react-i18next';
@@ -33,6 +33,8 @@ export const getFeatureIcon = (featureType: string) => {
3333
return <Gauge className={className} />;
3434
} else if (featureType === FEATURE_TYPE.STATIC) {
3535
return <Wrench className={className} />;
36+
} else if (featureType === FEATURE_TYPE.CONFIG) {
37+
return <Settings2 className={className} />;
3638
}
3739
};
3840

@@ -45,7 +47,7 @@ const SelectFeature: FC<Props> = ({
4547
description,
4648
className,
4749
disabledFeatures,
48-
featureTypes = [FEATURE_TYPE.METERED, FEATURE_TYPE.BOOLEAN, FEATURE_TYPE.STATIC],
50+
featureTypes = [FEATURE_TYPE.METERED, FEATURE_TYPE.BOOLEAN, FEATURE_TYPE.STATIC, FEATURE_TYPE.CONFIG],
4951
popoverSide = 'bottom',
5052
popoverAlign = 'start',
5153
}) => {

src/components/molecules/AddEntitlementDrawer/AddEntitlementDrawer.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Button, Checkbox, Dialog, FormHeader, Input, Select, SelectFeature, Sheet, Spacer, Toggle } from '@/components/atoms';
2+
import { JsonObject } from '@/types/common';
3+
import { JsonEditor } from '@/components/molecules/JsonEditor';
24
import { getFeatureIcon } from '@/components/atoms/SelectFeature/SelectFeature';
35
import { AddChargesButton } from '@/components/organisms/PlanForm/SetupChargesSection';
46

@@ -30,6 +32,7 @@ interface Props {
3032
interface ValidationErrors {
3133
usage_limit?: string;
3234
static_value?: string;
35+
config_value?: string;
3336
usage_reset_period?: string;
3437
is_enabled?: string;
3538
general?: string;
@@ -107,8 +110,14 @@ const validateEntitlement = (
107110
case FEATURE_TYPE.STATIC:
108111
return validateStaticFeature(tempEntitlement, t);
109112
case FEATURE_TYPE.BOOLEAN:
110-
// Boolean features don't need additional validation
111113
return {};
114+
case FEATURE_TYPE.CONFIG: {
115+
const cv = tempEntitlement.config_value;
116+
if (!cv || Object.keys(cv).length === 0) {
117+
return { config_value: t('entitlements.addDrawer.configValueRequired') };
118+
}
119+
return {};
120+
}
112121
default:
113122
return { feature: t('entitlements.validation.invalidFeatureType') };
114123
}
@@ -342,6 +351,7 @@ const AddEntitlementDrawer: FC<Props> = ({
342351
usage_reset_period: entitlement.usage_reset_period as ENTITLEMENT_USAGE_RESET_PERIOD | undefined,
343352
is_soft_limit: entitlement.is_soft_limit,
344353
static_value: entitlement.static_value,
354+
config_value: entitlement.config_value ?? undefined,
345355
entity_type: entityType,
346356
entity_id: entityId,
347357
}));
@@ -404,14 +414,16 @@ const AddEntitlementDrawer: FC<Props> = ({
404414
feature: activeFeature,
405415
feature_id: activeFeature.id,
406416
feature_type: activeFeature.type,
407-
is_enabled: activeFeature.type === FEATURE_TYPE.BOOLEAN ? true : undefined,
417+
is_enabled: activeFeature.type === FEATURE_TYPE.BOOLEAN || activeFeature.type === FEATURE_TYPE.CONFIG ? true : undefined,
418+
config_value: activeFeature.type === FEATURE_TYPE.CONFIG ? tempEntitlement.config_value : undefined,
408419
entity_type: entityType,
409420
entity_id: entityId || '',
410421
};
411422

412423
setEntitlements((prev) => [...prev, newEntitlement]);
413424
setTempEntitlement({});
414425
setActiveFeature(null);
426+
setShowSelect(true);
415427
setErrors({});
416428
setIsCalculatorOpen(false);
417429
}, [activeFeature, validateCurrentEntitlement, alreadyAddedFeatureIds, tempEntitlement, entityType, entityId, t]);
@@ -599,6 +611,21 @@ const AddEntitlementDrawer: FC<Props> = ({
599611
</div>
600612
)}
601613

614+
{/* config features */}
615+
{activeFeature.type === FEATURE_TYPE.CONFIG && (
616+
<div>
617+
<Spacer height='12px' />
618+
<JsonEditor
619+
key={activeFeature.id}
620+
value={(tempEntitlement.config_value as JsonObject) ?? null}
621+
onChange={(parsed) => {
622+
setTempEntitlement((prev) => ({ ...prev, config_value: parsed ?? undefined }));
623+
}}
624+
/>
625+
{errors.config_value && <p className='text-xs text-red-500 mt-1'>{errors.config_value}</p>}
626+
</div>
627+
)}
628+
602629
{/* static features */}
603630
{activeFeature.type === FEATURE_TYPE.STATIC && (
604631
<div>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Button } from '@/components/atoms';
2+
import { Input } from '@/components/atoms';
3+
import { Plus, Trash2 } from 'lucide-react';
4+
import { FC, useEffect, useState } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
7+
interface KVPairRaw {
8+
key: string;
9+
value: string;
10+
}
11+
12+
interface ConfigKeyValueEditorProps {
13+
value: Record<string, unknown>;
14+
onChange: (value: Record<string, unknown>, rawPairs: KVPairRaw[]) => void;
15+
}
16+
17+
type KVPair = KVPairRaw;
18+
19+
const toKVPairs = (obj: Record<string, unknown>): KVPair[] => {
20+
const pairs = Object.entries(obj).map(([key, val]) => ({
21+
key,
22+
value: typeof val === 'string' ? val : JSON.stringify(val),
23+
}));
24+
return pairs.length > 0 ? pairs : [{ key: '', value: '' }];
25+
};
26+
27+
const fromKVPairs = (pairs: KVPair[]): Record<string, unknown> => {
28+
const result: Record<string, unknown> = {};
29+
for (const { key, value } of pairs) {
30+
if (key.trim() === '') continue;
31+
try {
32+
result[key.trim()] = JSON.parse(value);
33+
} catch {
34+
result[key.trim()] = value;
35+
}
36+
}
37+
return result;
38+
};
39+
40+
export const ConfigKeyValueEditor: FC<ConfigKeyValueEditorProps> = ({ value, onChange }) => {
41+
const { t } = useTranslation('catalog');
42+
const [pairs, setPairs] = useState<KVPair[]>(() => toKVPairs(value));
43+
44+
useEffect(() => {
45+
setPairs(toKVPairs(value));
46+
}, [value]);
47+
48+
const updatePairs = (updated: KVPair[]) => {
49+
setPairs(updated);
50+
onChange(fromKVPairs(updated), updated);
51+
};
52+
53+
const addRow = () => updatePairs([...pairs, { key: '', value: '' }]);
54+
55+
const removeRow = (index: number) => {
56+
const updated = pairs.filter((_, i) => i !== index);
57+
updatePairs(updated.length > 0 ? updated : [{ key: '', value: '' }]);
58+
};
59+
60+
return (
61+
<div className='space-y-3'>
62+
<div className='flex items-center justify-between'>
63+
<span className='text-sm font-medium text-foreground'>{t('configKeyValueEditor.title')}</span>
64+
<Button type='button' variant='outline' size='sm' onClick={addRow} className='h-7 gap-1 text-xs'>
65+
<Plus className='size-3' />
66+
{t('configKeyValueEditor.addRow')}
67+
</Button>
68+
</div>
69+
70+
<div className='rounded-md border overflow-hidden'>
71+
<div className='flex items-center bg-muted/50 px-3 py-2 border-b'>
72+
<span className='flex-1 text-xs font-medium text-muted-foreground uppercase tracking-wide pl-1'>
73+
{t('configKeyValueEditor.keyHeader')}
74+
</span>
75+
<span className='w-6' />
76+
<span className='flex-1 text-xs font-medium text-muted-foreground uppercase tracking-wide pl-1'>
77+
{t('configKeyValueEditor.valueHeader')}
78+
</span>
79+
<span className='w-8' />
80+
</div>
81+
82+
<div className='divide-y'>
83+
{pairs.map((pair, index) => (
84+
<div key={index} className='flex items-center px-3 py-1 gap-0'>
85+
<div className='flex-1'>
86+
<Input
87+
value={pair.key}
88+
onChange={(v) => updatePairs(pairs.map((p, i) => (i === index ? { ...p, key: v } : p)))}
89+
placeholder={t('configKeyValueEditor.keyPlaceholder')}
90+
className='h-8 text-sm font-mono border-0 shadow-none focus-visible:ring-0 pl-1 pr-0'
91+
/>
92+
</div>
93+
<span className='w-6 text-center text-muted-foreground text-sm font-mono flex-shrink-0'>:</span>
94+
<div className='flex-1'>
95+
<Input
96+
value={pair.value}
97+
onChange={(v) => updatePairs(pairs.map((p, i) => (i === index ? { ...p, value: v } : p)))}
98+
placeholder={t('configKeyValueEditor.valuePlaceholder')}
99+
className='h-8 text-sm font-mono border-0 shadow-none focus-visible:ring-0 pl-1 pr-0'
100+
/>
101+
</div>
102+
<button
103+
type='button'
104+
aria-label={t('configKeyValueEditor.removeRow')}
105+
onClick={() => removeRow(index)}
106+
className='w-8 h-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors flex-shrink-0'>
107+
<Trash2 className='size-3.5' />
108+
</button>
109+
</div>
110+
))}
111+
</div>
112+
</div>
113+
</div>
114+
);
115+
};
116+
117+
export default ConfigKeyValueEditor;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ConfigKeyValueEditor, default } from './ConfigKeyValueEditor';

src/components/molecules/CustomerUsageTable/CustomerUsageTable.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ const CustomerUsageTable: FC<Props> = ({ data, allowRedirect = true }) => {
6161
);
6262
case FEATURE_TYPE.BOOLEAN:
6363
return usageRow.is_enabled ? t('usageTable.booleanTrue') : t('usageTable.booleanFalse');
64+
case FEATURE_TYPE.CONFIG: {
65+
const configVal = usageRow.sources?.[0]?.config_value;
66+
if (!configVal || Object.keys(configVal).length === 0) return t('usageTable.fallback');
67+
return (
68+
<span className='font-mono text-xs text-muted-foreground truncate max-w-[200px] block'>
69+
{JSON.stringify(configVal)}
70+
</span>
71+
);
72+
}
6473
default:
6574
return t('usageTable.fallback');
6675
}

src/components/molecules/CustomerUsageTable/getFeatureTypeChips.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ export const getFeatureTypeChips = ({
4343
label={showLabel ? i18n.t('usageTable.featureTypes.boolean', { ns: CUSTOMERS_NS }) : undefined}
4444
/>
4545
);
46+
case FEATURE_TYPE.CONFIG:
47+
return (
48+
<Chip
49+
textColor='#5B21B6'
50+
bgColor='#F5F3FF'
51+
icon={showIcon && icon}
52+
label={showLabel ? i18n.t('usageTable.featureTypes.config', { ns: CUSTOMERS_NS, defaultValue: 'Config' }) : undefined}
53+
/>
54+
);
4655
default:
4756
return (
4857
<Chip

src/components/molecules/EntitlementOverrides/EditSubscriptionEntitlementDrawer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ const EditSubscriptionEntitlementDrawer: FC<EditSubscriptionEntitlementDrawerPro
106106
return;
107107
}
108108
values.static_value = staticValue;
109-
} else if (entitlement.feature_type === FEATURE_TYPE.BOOLEAN) {
109+
} else if (entitlement.feature_type === FEATURE_TYPE.BOOLEAN || entitlement.feature_type === FEATURE_TYPE.CONFIG) {
110110
values.is_enabled = isEnabled;
111111
}
112112

@@ -208,7 +208,7 @@ const EditSubscriptionEntitlementDrawer: FC<EditSubscriptionEntitlementDrawerPro
208208
</div>
209209
)}
210210

211-
{entitlement.feature_type === FEATURE_TYPE.BOOLEAN && (
211+
{(entitlement.feature_type === FEATURE_TYPE.BOOLEAN || entitlement.feature_type === FEATURE_TYPE.CONFIG) && (
212212
<div className='space-y-2'>
213213
<Label label={t('entitlements.editDrawer.enabledLabel')} />
214214
<div className='flex items-center gap-2'>

src/components/molecules/Events/JsonCodeBlock.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { FC } from 'react';
1+
import { FC, useState } from 'react';
22
import { Highlight, themes } from 'prism-react-renderer';
3-
import { Copy } from 'lucide-react';
3+
import { Copy, Check } from 'lucide-react';
44
import toast from 'react-hot-toast';
55
import { Button } from '@/components/atoms';
66
import { cn } from '@/lib/utils';
@@ -15,6 +15,7 @@ interface JsonCodeBlockProps {
1515

1616
const JsonCodeBlock: FC<JsonCodeBlockProps> = ({ value, title, onCopy, className }) => {
1717
const { t } = useTranslation(['developers', 'common']);
18+
const [copied, setCopied] = useState(false);
1819

1920
const handleCopy = () => {
2021
if (onCopy) {
@@ -23,15 +24,17 @@ const JsonCodeBlock: FC<JsonCodeBlockProps> = ({ value, title, onCopy, className
2324
navigator.clipboard.writeText(JSON.stringify(value ?? {}, null, 2));
2425
toast.success(t('common:toast.copySuccess'));
2526
}
27+
setCopied(true);
28+
setTimeout(() => setCopied(false), 2000);
2629
};
2730

2831
return (
2932
<div className={cn('rounded-lg overflow-hidden border border-gray-200 bg-gray-50', className)}>
3033
<div className='px-4 py-2 border-b border-gray-200 bg-white flex items-center justify-between'>
3134
<p className='text-xs font-medium text-foreground'>{title || t('common:labels.payload')}</p>
32-
<Button onClick={handleCopy} variant='ghost' size='sm' className='h-7'>
33-
<Copy size={12} className='me-1' />
34-
<span className='text-xs'>{t('common:actions.copy')}</span>
35+
<Button onClick={handleCopy} variant='ghost' size='sm' className={cn('h-7 transition-colors', copied && 'text-green-500')}>
36+
{copied ? <Check size={12} className='me-1 text-green-500' /> : <Copy size={12} className='me-1' />}
37+
<span className='text-xs'>{copied ? 'Copied' : t('common:actions.copy')}</span>
3538
</Button>
3639
</div>
3740
<div className='relative'>

0 commit comments

Comments
 (0)