feat(group): enhance group ratio editor with improved visibility rules and JSON parsing
This commit is contained in:
@@ -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>
|
||||
|
||||
+849
-626
File diff suppressed because it is too large
Load Diff
+130
-134
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user