feat(group): enhance group ratio editor with improved visibility rules and JSON parsing

This commit is contained in:
CaIon
2026-07-06 16:22:18 +08:00
parent 1e80ce03e8
commit fc26b88fd1
9 changed files with 1672 additions and 783 deletions
@@ -17,8 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Code2, Eye, HelpCircle } from 'lucide-react'
import { memo, useCallback, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { memo, useCallback, useMemo, useState, type ReactNode } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
@@ -58,6 +58,7 @@ import {
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { safeJsonParse } from '../utils/json-parser'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
@@ -100,6 +101,31 @@ export const GroupRatioForm = memo(function GroupRatioForm({
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
const watchedGroupRatio = form.watch('GroupRatio')
const watchedUserUsableGroups = form.watch('UserUsableGroups')
const watchedTopupGroupRatio = form.watch('TopupGroupRatio')
const groupNames = useMemo(() => {
const ratioMap = safeJsonParse<Record<string, number>>(watchedGroupRatio, {
fallback: {},
silent: true,
})
const usableMap = safeJsonParse<Record<string, string>>(
watchedUserUsableGroups,
{ fallback: {}, silent: true }
)
const topupMap = safeJsonParse<Record<string, number>>(
watchedTopupGroupRatio,
{ fallback: {}, silent: true }
)
return [
...new Set([
...Object.keys(ratioMap),
...Object.keys(usableMap),
...Object.keys(topupMap),
]),
]
}, [watchedGroupRatio, watchedUserUsableGroups, watchedTopupGroupRatio])
return (
<div className='space-y-6'>
<div className='flex flex-wrap justify-end gap-2'>
@@ -143,6 +169,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
userUsableGroups={form.watch('UserUsableGroups')}
groupGroupRatio={form.watch('GroupGroupRatio')}
autoGroups={form.watch('AutoGroups')}
groupSpecialUsableGroup={form.watch('GroupSpecialUsableGroup')}
onChange={(field, value) =>
handleFieldChange(field as keyof GroupFormValues, value)
}
@@ -150,6 +177,7 @@ export const GroupRatioForm = memo(function GroupRatioForm({
<GroupSpecialUsableRulesEditor
value={form.watch('GroupSpecialUsableGroup')}
groupOptions={groupNames}
onChange={(value) =>
handleFieldChange('GroupSpecialUsableGroup', value)
}
@@ -339,6 +367,23 @@ function GuideCodeBlock({ children }: { children: string }) {
)
}
function GuideStepRow({
chip,
children,
}: {
chip: string
children: ReactNode
}) {
return (
<div className='flex items-start gap-2.5 text-sm leading-6'>
<span className='bg-muted text-muted-foreground mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-medium'>
{chip}
</span>
<span className='text-muted-foreground min-w-0'>{children}</span>
</div>
)
}
function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
const { t } = useTranslation()
@@ -359,15 +404,11 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
<div className={sideDrawerFormClassName('gap-5')}>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>{t('Core concepts')}</h3>
<h3 className='text-sm font-semibold'>{t('The two roles of a group')}</h3>
<div className='text-muted-foreground space-y-2 text-sm leading-6'>
<p>
<span className='text-foreground font-medium'>
{t('User group')}
</span>
{': '}
{t(
'Assigned by administrators and used to represent a user level, such as default or vip.'
'Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.'
)}
</p>
<p>
@@ -375,28 +416,177 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
{t('Token group')}
</span>
{': '}
{t(
'Selected when creating a token and used as the default billing group for API calls.'
)}
{t('decides which channels are used and which base ratio applies.')}
</p>
<p>
<span className='text-foreground font-medium'>
{t('Ratio')}
{t('User group')}
</span>
{': '}
{t(
'A billing multiplier. Lower ratios mean lower API call costs.'
'decides the top-up ratio, which groups the user can pick for tokens, and whether an override ratio applies.'
)}
</p>
<p>
</div>
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>{t('How a call is priced')}</h3>
<ol className='text-muted-foreground list-decimal space-y-2 pl-5 text-sm leading-6'>
<li>
<span className='text-foreground font-medium'>
{t('User selectable')}
</span>
{': '}
{t('Find the billing group.')}
</span>{' '}
{t(
'When enabled, users can pick this group when creating tokens.'
'Use the group set on the token. If the token has no group, use the user group. The auto group tries the auto assignment order from top to bottom.'
)}
</p>
</li>
<li>
<span className='text-foreground font-medium'>
{t('Find the ratio.')}
</span>{' '}
{t(
'Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.'
)}
</li>
<li>
<span className='text-foreground font-medium'>
{t('Charge.')}
</span>{' '}
{t('Cost = model price × that one ratio. Nothing else from the group settings enters the formula.')}
</li>
</ol>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Common pitfall: the user group base ratio is NOT a personal discount. It only applies when the user group itself is the billing group.'
)}
</p>
</section>
<section className='space-y-3'>
<h3 className='text-sm font-semibold'>{t('Worked example')}</h3>
<p className='text-muted-foreground text-sm leading-6'>
{t('The admin configured three groups and one special ratio rule:')}
</p>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/40 border-b px-3 py-1.5 text-xs font-medium'>
{t('Pricing groups')}
</div>
<table className='w-full text-sm'>
<thead>
<tr className='text-muted-foreground border-b text-xs'>
<th className='px-3 py-1.5 text-left font-medium'>
{t('Group name')}
</th>
<th className='px-3 py-1.5 text-right font-medium'>
{t('Ratio')}
</th>
</tr>
</thead>
<tbody>
<tr className='border-b'>
<td className='px-3 py-1.5'>default</td>
<td className='px-3 py-1.5 text-right'>1.0</td>
</tr>
<tr className='border-b'>
<td className='px-3 py-1.5'>premium</td>
<td className='px-3 py-1.5 text-right'>0.5</td>
</tr>
<tr>
<td className='px-3 py-1.5'>vip</td>
<td className='px-3 py-1.5 text-right'>0.8</td>
</tr>
</tbody>
</table>
</div>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/40 border-b px-3 py-1.5 text-xs font-medium'>
{t('Special ratio rules')}
</div>
<div className='p-3 text-sm leading-6'>
{t('Users of vip, when billed as premium, pay ratio')}{' '}
<span className='bg-primary/10 ring-primary/40 rounded px-1.5 py-0.5 font-semibold ring-1'>
0.3
</span>{' '}
<span className='text-muted-foreground text-xs'>
{t('(instead of {{ratio}})', { ratio: 0.5 })}
</span>
</div>
</div>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Three calls made by the same vip user. Assume the base price of one call is 10.'
)}
</p>
<div className='space-y-3'>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/40 border-b px-3 py-2 text-sm font-medium'>
{t('Call 1: the token group is premium')}
</div>
<div className='space-y-2 p-3'>
<GuideStepRow chip='1'>
{t('Billing group = premium (the token has a group, so use it)')}
</GuideStepRow>
<GuideStepRow chip='2'>
{t(
'There is a rule for vip billed as premium → use its ratio 0.3'
)}
</GuideStepRow>
<GuideStepRow chip='='>
<span className='text-foreground font-medium'>
{t('Cost = 10 × 0.3 = 3')}
</span>
</GuideStepRow>
</div>
</div>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/40 border-b px-3 py-2 text-sm font-medium'>
{t('Call 2: the token group is default')}
</div>
<div className='space-y-2 p-3'>
<GuideStepRow chip='1'>
{t('Billing group = default (the token has a group, so use it)')}
</GuideStepRow>
<GuideStepRow chip='2'>
{t(
'No rule for vip billed as default → use the base ratio of default, 1.0 (the 0.8 of vip is not used)'
)}
</GuideStepRow>
<GuideStepRow chip='='>
<span className='text-foreground font-medium'>
{t('Cost = 10 × 1.0 = 10')}
</span>
</GuideStepRow>
</div>
</div>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/40 border-b px-3 py-2 text-sm font-medium'>
{t('Call 3: the token has no group')}
</div>
<div className='space-y-2 p-3'>
<GuideStepRow chip='1'>
{t(
'Billing group = vip (the token has no group, so use the user group)'
)}
</GuideStepRow>
<GuideStepRow chip='2'>
{t(
'No rule for vip billed as vip → use the base ratio of vip, 0.8'
)}
</GuideStepRow>
<GuideStepRow chip='='>
<span className='text-foreground font-medium'>
{t('Cost = 10 × 0.8 = 8')}
</span>
</GuideStepRow>
</div>
</div>
</div>
</section>
@@ -445,7 +635,7 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on
<AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Special ratios override the token group ratio for specific user group and token group combinations.'
'In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.'
)}
</p>
<GuideCodeBlock>{`{
@@ -456,7 +646,7 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on
}`}</GuideCodeBlock>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Only configured combinations are overridden. All other calls keep the token group base ratio.'
'Only configured combinations are overridden. All other calls keep the billing group base ratio.'
)}
</p>
</AccordionContent>
@@ -469,7 +659,7 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on
<AccordionContent className='space-y-3'>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Special usable group rules can add, remove, or append selectable token groups for a specific user group.'
'Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.'
)}
</p>
<GuideCodeBlock>{`{
@@ -481,7 +671,7 @@ vip 0.5 ${t('No')} ${t('Assigned by administrator on
}`}</GuideCodeBlock>
<p className='text-muted-foreground text-sm leading-6'>
{t(
'Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.'
'In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.'
)}
</p>
</AccordionContent>
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
import {
AlertTriangle,
ChevronDown,
ChevronUp,
Plus,
Trash2,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -44,19 +50,14 @@ import {
SelectValue,
} from '@/components/ui/select'
const OP_ADD = 'add' as const
const OP_REMOVE = 'remove' as const
const OP_APPEND = 'append' as const
const sectionCardClassName =
'relative shadow-sm ring-0 before:pointer-events-none before:absolute before:inset-0 before:rounded-xl before:border before:border-border/90'
const sectionHeaderClassName = 'border-b bg-muted/20'
type OpType = typeof OP_ADD | typeof OP_REMOVE | typeof OP_APPEND
type Rule = {
_id: string
userGroup: string
op: OpType
visible: boolean
targetGroup: string
description: string
}
@@ -66,17 +67,21 @@ function uid() {
return `gsu_${++_idCounter}`
}
function parsePrefix(rawKey: string): { op: OpType; groupName: string } {
if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) }
if (rawKey.startsWith('-:'))
return { op: OP_REMOVE, groupName: rawKey.slice(2) }
return { op: OP_APPEND, groupName: rawKey }
// Raw keys use +: (add), -: (remove), or no prefix (also add).
// The UI collapses this to visible/hidden and serializes visible rules
// with the +: prefix, which the backend treats identically to no prefix.
function parseRawKey(rawKey: string): { visible: boolean; groupName: string } {
if (rawKey.startsWith('-:')) {
return { visible: false, groupName: rawKey.slice(2) }
}
if (rawKey.startsWith('+:')) {
return { visible: true, groupName: rawKey.slice(2) }
}
return { visible: true, groupName: rawKey }
}
function toRawKey(op: OpType, groupName: string): string {
if (op === OP_ADD) return `+:${groupName}`
if (op === OP_REMOVE) return `-:${groupName}`
return groupName
function toRawKey(visible: boolean, groupName: string): string {
return visible ? `+:${groupName}` : `-:${groupName}`
}
function safeParseJson(str: string): Record<string, Record<string, string>> {
@@ -93,55 +98,87 @@ function flattenRules(nested: Record<string, Record<string, string>>): Rule[] {
for (const [userGroup, inner] of Object.entries(nested)) {
if (typeof inner !== 'object' || inner === null) continue
for (const [rawKey, desc] of Object.entries(inner)) {
const { op, groupName } = parsePrefix(rawKey)
const { visible, groupName } = parseRawKey(rawKey)
let description = ''
if (!visible) {
description = 'remove'
} else if (typeof desc === 'string') {
description = desc
}
rules.push({
_id: uid(),
userGroup,
op,
visible,
targetGroup: groupName,
description:
op === OP_REMOVE ? 'remove' : typeof desc === 'string' ? desc : '',
description,
})
}
}
return rules
}
function nestRules(rules: Rule[]): Record<string, Record<string, string>> {
function serializeRules(rules: Rule[]): string {
const result: Record<string, Record<string, string>> = {}
for (const { userGroup, op, targetGroup, description } of rules) {
for (const { userGroup, visible, targetGroup, description } of rules) {
if (!userGroup || !targetGroup) continue
if (!result[userGroup]) result[userGroup] = {}
result[userGroup][toRawKey(op, targetGroup)] = description
result[userGroup][toRawKey(visible, targetGroup)] = description
}
return result
}
function serializeRules(rules: Rule[]): string {
const nested = nestRules(rules)
return Object.keys(nested).length === 0
return Object.keys(result).length === 0
? '{}'
: JSON.stringify(nested, null, 2)
: JSON.stringify(result, null, 2)
}
const OP_BADGE_MAP: Record<
OpType,
{ variant: 'info' | 'danger' | 'neutral'; label: string }
> = {
[OP_ADD]: { variant: 'info', label: 'Add (+:)' },
[OP_REMOVE]: { variant: 'danger', label: 'Remove (-:)' },
[OP_APPEND]: { variant: 'neutral', label: 'Append' },
type GroupSelectProps = {
options: string[]
value: string
placeholder: string
onValueChange: (value: string) => void
className?: string
}
function GroupSelect(props: GroupSelectProps) {
const knownOptions = useMemo(() => {
if (props.value && !props.options.includes(props.value)) {
return [props.value, ...props.options]
}
return props.options
}, [props.options, props.value])
return (
<Select
value={props.value === '' ? null : props.value}
onValueChange={(v) => {
if (typeof v === 'string' && v !== '') props.onValueChange(v)
}}
>
<SelectTrigger className={props.className}>
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{knownOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
type GroupSpecialUsableRulesEditorProps = {
value: string
groupOptions: string[]
onChange: (value: string) => void
}
type GroupSectionProps = {
groupName: string
items: Rule[]
onUpdate: (id: string, field: keyof Rule, val: string) => void
groupOptions: string[]
onUpdate: (id: string, field: keyof Rule, val: string | boolean) => void
onRemove: (id: string) => void
onAdd: (groupName: string) => void
onRemoveGroup: (groupName: string) => void
@@ -150,6 +187,7 @@ type GroupSectionProps = {
function GroupSection(props: GroupSectionProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const isKnownGroup = props.groupOptions.includes(props.groupName)
return (
<Collapsible open={open} onOpenChange={setOpen}>
@@ -168,6 +206,12 @@ function GroupSection(props: GroupSectionProps) {
)}
</CollapsibleTrigger>
<span className='font-semibold'>{props.groupName}</span>
{!isKnownGroup && (
<StatusBadge variant='danger' copyable={false}>
<AlertTriangle className='mr-1 h-3 w-3' />
{t('Not in pricing table')}
</StatusBadge>
)}
<StatusBadge variant='neutral' copyable={false}>
{props.items.length} {t('rules')}
</StatusBadge>
@@ -196,87 +240,59 @@ function GroupSection(props: GroupSectionProps) {
{props.items.map((rule) => (
<div key={rule._id} className='flex items-center gap-2'>
<Select
items={[
{
value: OP_ADD,
label: (
<StatusBadge
label={t(OP_BADGE_MAP[OP_ADD].label)}
variant={OP_BADGE_MAP[OP_ADD].variant}
copyable={false}
/>
),
},
{
value: OP_REMOVE,
label: (
<StatusBadge
label={t(OP_BADGE_MAP[OP_REMOVE].label)}
variant={OP_BADGE_MAP[OP_REMOVE].variant}
copyable={false}
/>
),
},
{
value: OP_APPEND,
label: (
<StatusBadge
label={t(OP_BADGE_MAP[OP_APPEND].label)}
variant={OP_BADGE_MAP[OP_APPEND].variant}
copyable={false}
/>
),
},
]}
value={rule.op}
value={rule.visible ? 'visible' : 'hidden'}
onValueChange={(v) =>
v !== null && props.onUpdate(rule._id, 'op', v)
v !== null &&
props.onUpdate(rule._id, 'visible', v === 'visible')
}
>
<SelectTrigger className='w-[130px]'>
<SelectValue>
<StatusBadge
label={t(OP_BADGE_MAP[rule.op].label)}
variant={OP_BADGE_MAP[rule.op].variant}
label={rule.visible ? t('Extra visible') : t('Hidden')}
variant={rule.visible ? 'info' : 'danger'}
copyable={false}
/>
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value={OP_ADD}>
<SelectItem value='visible'>
<StatusBadge
label={t(OP_BADGE_MAP[OP_ADD].label)}
variant={OP_BADGE_MAP[OP_ADD].variant}
label={t('Extra visible')}
variant='info'
copyable={false}
/>
</SelectItem>
<SelectItem value={OP_REMOVE}>
<SelectItem value='hidden'>
<StatusBadge
label={t(OP_BADGE_MAP[OP_REMOVE].label)}
variant={OP_BADGE_MAP[OP_REMOVE].variant}
copyable={false}
/>
</SelectItem>
<SelectItem value={OP_APPEND}>
<StatusBadge
label={t(OP_BADGE_MAP[OP_APPEND].label)}
variant={OP_BADGE_MAP[OP_APPEND].variant}
label={t('Hidden')}
variant='danger'
copyable={false}
/>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Input
className='flex-1'
value={rule.targetGroup}
placeholder={t('Group name')}
onChange={(e) =>
props.onUpdate(rule._id, 'targetGroup', e.target.value)
}
/>
{rule.op !== OP_REMOVE ? (
<div className='flex flex-1 items-center gap-1.5'>
<GroupSelect
className='flex-1'
options={props.groupOptions}
value={rule.targetGroup}
placeholder={t('Group name')}
onValueChange={(v) =>
props.onUpdate(rule._id, 'targetGroup', v)
}
/>
{rule.targetGroup &&
!props.groupOptions.includes(rule.targetGroup) && (
<AlertTriangle
className='text-destructive h-4 w-4 shrink-0'
aria-label={t('Not in pricing table')}
/>
)}
</div>
{rule.visible ? (
<Input
className='flex-1'
value={rule.description}
@@ -314,7 +330,6 @@ export function GroupSpecialUsableRulesEditor(
const [rules, setRules] = useState<Rule[]>(() =>
flattenRules(safeParseJson(props.value))
)
const [newGroupName, setNewGroupName] = useState('')
const { onChange } = props
const emitChange = useCallback(
@@ -326,14 +341,14 @@ export function GroupSpecialUsableRulesEditor(
)
const updateRule = useCallback(
(id: string, field: keyof Rule, val: string) => {
(id: string, field: keyof Rule, val: string | boolean) => {
emitChange(
rules.map((r) => {
if (r._id !== id) return r
const updated = { ...r, [field]: val }
if (field === 'op' && val === OP_REMOVE)
if (field === 'visible' && val === false) {
updated.description = 'remove'
else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
} else if (field === 'visible' && val === true && !r.visible) {
if (updated.description === 'remove') updated.description = ''
}
return updated
@@ -361,7 +376,7 @@ export function GroupSpecialUsableRulesEditor(
{
_id: uid(),
userGroup: groupName,
op: OP_APPEND,
visible: true,
targetGroup: '',
description: '',
},
@@ -370,22 +385,6 @@ export function GroupSpecialUsableRulesEditor(
[rules, emitChange]
)
const addNewGroup = useCallback(() => {
const name = newGroupName.trim()
if (!name) return
emitChange([
...rules,
{
_id: uid(),
userGroup: name,
op: OP_APPEND,
targetGroup: '',
description: '',
},
])
setNewGroupName('')
}, [rules, emitChange, newGroupName])
const grouped = useMemo(() => {
const map: Record<string, Rule[]> = {}
const order: string[] = []
@@ -400,13 +399,18 @@ export function GroupSpecialUsableRulesEditor(
return order.map((name) => ({ name, items: map[name] }))
}, [rules])
const newGroupCandidates = useMemo(() => {
const used = new Set(grouped.map((g) => g.name))
return props.groupOptions.filter((name) => !used.has(name))
}, [grouped, props.groupOptions])
return (
<Card className={sectionCardClassName}>
<CardHeader className={sectionHeaderClassName}>
<CardTitle>{t('Special usable group rules')}</CardTitle>
<CardDescription>
{t(
'Define per-group rules to add, remove, or append selectable groups for specific user groups.'
'Make extra groups visible to, or hide default groups from, users of a specific group.'
)}
</CardDescription>
</CardHeader>
@@ -422,6 +426,7 @@ export function GroupSpecialUsableRulesEditor(
key={group.name}
groupName={group.name}
items={group.items}
groupOptions={props.groupOptions}
onUpdate={updateRule}
onRemove={removeRule}
onAdd={addRuleToGroup}
@@ -430,23 +435,14 @@ export function GroupSpecialUsableRulesEditor(
))
)}
<div className='flex items-center justify-center gap-2 pt-2'>
<Input
className='w-[200px]'
value={newGroupName}
placeholder={t('User group name')}
onChange={(e) => setNewGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addNewGroup()
}
}}
<div className='flex items-center justify-center pt-2'>
<GroupSelect
className='w-[240px]'
options={newGroupCandidates}
value=''
placeholder={t('Add rules for a user group')}
onValueChange={addRuleToGroup}
/>
<Button variant='outline' size='sm' onClick={addNewGroup}>
<Plus className='mr-1 h-4 w-4' />
{t('Add group rules')}
</Button>
</div>
</div>
</CardContent>