Skip to content

Commit d89a7e3

Browse files
committed
fix: UI and tags
1 parent 61e0e68 commit d89a7e3

File tree

9 files changed

+366
-178
lines changed

9 files changed

+366
-178
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
'use client';
2+
3+
import {
4+
CopyIcon,
5+
DotsThreeIcon,
6+
PencilIcon,
7+
TrashIcon,
8+
} from '@phosphor-icons/react';
9+
import { useState } from 'react';
10+
import { toast } from 'sonner';
11+
import {
12+
AlertDialog,
13+
AlertDialogAction,
14+
AlertDialogCancel,
15+
AlertDialogContent,
16+
AlertDialogDescription,
17+
AlertDialogFooter,
18+
AlertDialogHeader,
19+
AlertDialogTitle,
20+
} from '@/components/ui/alert-dialog';
21+
import { Button } from '@/components/ui/button';
22+
import {
23+
DropdownMenu,
24+
DropdownMenuContent,
25+
DropdownMenuItem,
26+
DropdownMenuTrigger,
27+
} from '@/components/ui/dropdown-menu';
28+
import { trpc } from '@/lib/trpc';
29+
import type { Flag } from './types';
30+
31+
interface FlagActionsProps {
32+
flag: Flag;
33+
onEdit: () => void;
34+
onDeleted?: () => void;
35+
}
36+
37+
export function FlagActions({ flag, onEdit, onDeleted }: FlagActionsProps) {
38+
const [isOpen, setIsOpen] = useState(false);
39+
const [isDeleting, setIsDeleting] = useState(false);
40+
41+
const utils = trpc.useUtils();
42+
const deleteMutation = trpc.flags.delete.useMutation();
43+
44+
const handleCopyKey = async () => {
45+
await navigator.clipboard.writeText(flag.key);
46+
toast.success('Flag key copied to clipboard');
47+
};
48+
49+
const handleConfirmDelete = async () => {
50+
setIsDeleting(true);
51+
// optimistic removal
52+
utils.flags.list.setData({ websiteId: flag.websiteId ?? '' }, (oldData) =>
53+
oldData?.filter((f) => f.id !== flag.id)
54+
);
55+
try {
56+
await deleteMutation.mutateAsync({ id: flag.id });
57+
toast.success('Flag deleted');
58+
onDeleted?.();
59+
} catch (error) {
60+
utils.flags.list.invalidate();
61+
toast.error('Failed to delete flag');
62+
} finally {
63+
setIsDeleting(false);
64+
setIsOpen(false);
65+
}
66+
};
67+
68+
return (
69+
<>
70+
<DropdownMenu>
71+
<DropdownMenuTrigger asChild>
72+
<Button
73+
aria-label="Open flag actions"
74+
className="focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
75+
size="icon"
76+
type="button"
77+
variant="ghost"
78+
>
79+
<DotsThreeIcon className="h-5 w-5" weight="bold" />
80+
</Button>
81+
</DropdownMenuTrigger>
82+
<DropdownMenuContent align="end" className="w-40">
83+
<DropdownMenuItem onClick={onEdit}>
84+
<PencilIcon className="h-4 w-4" weight="duotone" /> Edit
85+
</DropdownMenuItem>
86+
<DropdownMenuItem onClick={handleCopyKey}>
87+
<CopyIcon className="h-4 w-4" weight="duotone" /> Copy key
88+
</DropdownMenuItem>
89+
<DropdownMenuItem
90+
onClick={() => setIsOpen(true)}
91+
variant="destructive"
92+
>
93+
<TrashIcon className="h-4 w-4" weight="duotone" /> Delete
94+
</DropdownMenuItem>
95+
</DropdownMenuContent>
96+
</DropdownMenu>
97+
98+
<AlertDialog onOpenChange={setIsOpen} open={isOpen}>
99+
<AlertDialogContent>
100+
<AlertDialogHeader>
101+
<AlertDialogTitle>Delete flag?</AlertDialogTitle>
102+
<AlertDialogDescription>
103+
This action cannot be undone. This will permanently delete the
104+
flag "{flag.key}".
105+
</AlertDialogDescription>
106+
</AlertDialogHeader>
107+
<AlertDialogFooter>
108+
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
109+
<AlertDialogAction
110+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
111+
disabled={isDeleting}
112+
onClick={handleConfirmDelete}
113+
>
114+
Delete
115+
</AlertDialogAction>
116+
</AlertDialogFooter>
117+
</AlertDialogContent>
118+
</AlertDialog>
119+
</>
120+
);
121+
}

apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-row.tsx

Lines changed: 7 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
'use client';
22

3-
import {
4-
CaretDownIcon,
5-
CaretUpIcon,
6-
CopyIcon,
7-
FlagIcon,
8-
PencilIcon,
9-
TrashIcon,
10-
} from '@phosphor-icons/react';
3+
import { CaretDownIcon, CaretUpIcon, FlagIcon } from '@phosphor-icons/react';
114
import { useState } from 'react';
12-
import { toast } from 'sonner';
135
import { Button } from '@/components/ui/button';
146
import { Card } from '@/components/ui/card';
157
import { trpc } from '@/lib/trpc';
8+
import { FlagActions } from './flag-actions';
169
import type { Flag } from './types';
1710

1811
interface FlagRowProps {
@@ -35,31 +28,6 @@ export function FlagRow({
3528
const [isArchiving, setIsArchiving] = useState(false);
3629

3730
const utils = trpc.useUtils();
38-
const deleteMutation = trpc.flags.delete.useMutation();
39-
40-
const handleCopyKey = () => {
41-
navigator.clipboard.writeText(flag.key);
42-
toast.success('Flag key copied to clipboard');
43-
};
44-
45-
const handleArchive = async () => {
46-
setIsArchiving(true);
47-
48-
utils.flags.list.setData({ websiteId: flag.websiteId ?? '' }, (oldData) =>
49-
oldData?.filter((f) => f.id !== flag.id)
50-
);
51-
52-
try {
53-
await deleteMutation.mutateAsync({ id: flag.id });
54-
toast.success('Flag archived successfully');
55-
} catch (error) {
56-
// Revert optimistic update on error
57-
utils.flags.list.invalidate();
58-
toast.error('Failed to archive flag');
59-
} finally {
60-
setIsArchiving(false);
61-
}
62-
};
6331

6432
const handleCardClick = (e: React.MouseEvent<HTMLDivElement>) => {
6533
const target = e.target as HTMLElement;
@@ -174,42 +142,11 @@ export function FlagRow({
174142
)}
175143
</div>
176144
<div className="flex items-center gap-2">
177-
<Button
178-
className="focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
179-
onClick={(e) => {
180-
e.stopPropagation();
181-
handleCopyKey();
182-
}}
183-
size="icon"
184-
type="button"
185-
variant="ghost"
186-
>
187-
<CopyIcon className="h-4 w-4" weight="duotone" />
188-
</Button>
189-
<Button
190-
className="focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
191-
onClick={(e) => {
192-
e.stopPropagation();
193-
onEdit();
194-
}}
195-
size="icon"
196-
type="button"
197-
variant="ghost"
198-
>
199-
<PencilIcon className="h-4 w-4" weight="duotone" />
200-
</Button>
201-
<Button
202-
className="focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
203-
onClick={(e) => {
204-
e.stopPropagation();
205-
handleArchive();
206-
}}
207-
size="icon"
208-
type="button"
209-
variant="ghost"
210-
>
211-
<TrashIcon className="h-4 w-4" weight="duotone" />
212-
</Button>
145+
<FlagActions
146+
flag={flag}
147+
onDeleted={() => utils.flags.list.invalidate()}
148+
onEdit={() => onEdit()}
149+
/>
213150
{onToggle && (
214151
<Button
215152
className="focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"

apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx

Lines changed: 33 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
SheetTitle,
3333
} from '@/components/ui/sheet';
3434
import { Switch } from '@/components/ui/switch';
35+
import { TagsChat } from '@/components/ui/tags';
3536
import { Textarea } from '@/components/ui/textarea';
3637
import { trpc } from '@/lib/trpc';
3738
import type { Flag, UserRule } from './types';
@@ -574,10 +575,6 @@ interface UserRulesBuilderProps {
574575
}
575576

576577
function UserRulesBuilder({ rules, onChange }: UserRulesBuilderProps) {
577-
const [batchTextValues, setBatchTextValues] = useState<
578-
Record<number, string>
579-
>({});
580-
581578
const addRule = () => {
582579
const newRule: UserRule = {
583580
type: 'user_id',
@@ -600,50 +597,9 @@ function UserRulesBuilder({ rules, onChange }: UserRulesBuilderProps) {
600597
};
601598

602599
const removeRule = (index: number) => {
603-
// Clean up local state when removing a rule
604-
setBatchTextValues((prev) => {
605-
const newValues = { ...prev };
606-
delete newValues[index];
607-
// Shift indices down for rules after the removed one
608-
const shiftedValues: Record<number, string> = {};
609-
Object.entries(newValues).forEach(([key, value]) => {
610-
const numKey = Number(key);
611-
if (numKey > index) {
612-
shiftedValues[numKey - 1] = value;
613-
} else {
614-
shiftedValues[numKey] = value;
615-
}
616-
});
617-
return shiftedValues;
618-
});
619600
onChange(rules.filter((_, i) => i !== index));
620601
};
621602

622-
const updateBatchText = (index: number, rawValue: string) => {
623-
// Update local state for textarea display
624-
setBatchTextValues((prev) => ({ ...prev, [index]: rawValue }));
625-
626-
// Process the value for the rule
627-
const batchValues = rawValue
628-
.split('\n')
629-
.map((v) => v.trim())
630-
.filter(Boolean);
631-
632-
updateRule(index, { batchValues });
633-
};
634-
635-
useEffect(() => {
636-
const initialValues: Record<number, string> = {};
637-
rules.forEach((rule, index) => {
638-
if (rule.batch && rule.batchValues?.length) {
639-
initialValues[index] = rule.batchValues.join('\n');
640-
}
641-
});
642-
if (Object.keys(initialValues).length > 0) {
643-
setBatchTextValues((prev) => ({ ...prev, ...initialValues }));
644-
}
645-
}, [rules]);
646-
647603
if (rules.length === 0) {
648604
return (
649605
<div className="rounded border border-primary/30 border-dashed bg-primary/5 p-8 text-center">
@@ -779,35 +735,26 @@ function UserRulesBuilder({ rules, onChange }: UserRulesBuilderProps) {
779735
{/* Condition & Value */}
780736
{rule.batch ? (
781737
<div>
782-
<label
783-
className="mb-1 block font-medium text-sm"
784-
htmlFor={`${ruleId}-batch`}
785-
>
738+
<div className="mb-1 block font-medium text-sm">
786739
{rule.type === 'user_id' && 'User IDs'}
787740
{rule.type === 'email' && 'Email Addresses'}
788741
{rule.type === 'property' && 'Property Values'}
789-
{' (one per line)'}
790-
</label>
791-
<Textarea
792-
id={`${ruleId}-batch`}
793-
onChange={(e) => updateBatchText(index, e.target.value)}
742+
</div>
743+
<TagsChat
744+
allowDuplicates={false}
745+
maxTags={100}
746+
onChange={(values) =>
747+
updateRule(index, { batchValues: values })
748+
}
794749
placeholder={
795750
rule.type === 'user_id'
796-
? 'user_123\nuser_456\nuser_789'
751+
? 'Type user ID and press Enter...'
797752
: rule.type === 'email'
798-
799-
: 'premium\nenterprise\nvip'
800-
}
801-
rows={4}
802-
value={
803-
batchTextValues[index] ||
804-
rule.batchValues?.join('\n') ||
805-
''
753+
? 'Type email address and press Enter...'
754+
: 'Type property value and press Enter...'
806755
}
756+
values={rule.batchValues || []}
807757
/>
808-
<p className="mt-1 text-muted-foreground text-xs">
809-
{rule.batchValues?.length || 0} values entered
810-
</p>
811758
</div>
812759
) : (
813760
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -854,30 +801,37 @@ function UserRulesBuilder({ rules, onChange }: UserRulesBuilderProps) {
854801
htmlFor={`${ruleId}-value`}
855802
>
856803
{rule.operator === 'in' || rule.operator === 'not_in'
857-
? 'Values (comma-separated)'
804+
? 'Values'
858805
: 'Value'}
859806
</label>
860807
{rule.operator === 'in' ||
861808
rule.operator === 'not_in' ? (
862-
<Input
863-
id={`${ruleId}-value`}
864-
onChange={(e) => {
865-
const values = e.target.value
866-
.split(',')
867-
.map((v) => v.trim())
868-
.filter(Boolean);
869-
updateRule(index, { values });
870-
}}
871-
placeholder="value1, value2, value3"
872-
value={rule.values?.join(', ') || ''}
809+
<TagsChat
810+
allowDuplicates={false}
811+
maxTags={20}
812+
onChange={(values) => updateRule(index, { values })}
813+
placeholder={
814+
rule.type === 'user_id'
815+
? 'Type user ID and press Enter...'
816+
: rule.type === 'email'
817+
? 'Type email address and press Enter...'
818+
: 'Type property value and press Enter...'
819+
}
820+
values={rule.values || []}
873821
/>
874822
) : (
875823
<Input
876824
id={`${ruleId}-value`}
877825
onChange={(e) =>
878826
updateRule(index, { value: e.target.value })
879827
}
880-
placeholder="Enter value"
828+
placeholder={
829+
rule.type === 'user_id'
830+
? 'Enter user ID'
831+
: rule.type === 'email'
832+
? 'Enter email address'
833+
: 'Enter property value'
834+
}
881835
value={rule.value || ''}
882836
/>
883837
)}

0 commit comments

Comments
 (0)