Skip to content

Commit 351a286

Browse files
[OPIK-3613] [FE] Add clone capability for online score rules (#4654)
* [OPIK-3613] [FE] Add clone capability for online score rules * Revision 2: Address PR comments from aadereiko - Simplify isEdit logic to use mode directly - Remove redundant isClone check in onSubmit - Remove unnecessary formScope and formUIRuleType from useEffect dependencies - Consolidate duplicate dialogs in OnlineEvaluationPage and RulesTab - Remove clone dialogs from empty state sections * fix linting * fix linting
1 parent 014a05d commit 351a286

File tree

4 files changed

+139
-16
lines changed

4 files changed

+139
-16
lines changed

apps/opik-frontend/src/components/pages-shared/automations/AddEditRuleDialog/AddEditRuleDialog.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ type AddEditRuleDialogProps = {
148148
datasetColumnNames?: string[]; // Optional: dataset column names from playground
149149
hideScopeSelector?: boolean; // Optional: hide scope selector (e.g., for contexts that only support one scope)
150150
defaultScope?: EVALUATORS_RULE_SCOPE; // Optional: default scope for new rules
151+
mode?: "create" | "edit" | "clone"; // Optional: dialog mode
151152
};
152153

153154
const AddEditRuleDialog: React.FC<AddEditRuleDialogProps> = ({
@@ -159,6 +160,7 @@ const AddEditRuleDialog: React.FC<AddEditRuleDialogProps> = ({
159160
datasetColumnNames,
160161
hideScopeSelector = false,
161162
defaultScope,
163+
mode,
162164
}) => {
163165
const isCodeMetricEnabled = useIsFeatureEnabled(
164166
FeatureToggleKeys.PYTHON_EVALUATOR_ENABLED,
@@ -182,12 +184,19 @@ const AddEditRuleDialog: React.FC<AddEditRuleDialogProps> = ({
182184
? getUIRuleScope(defaultRule.type)
183185
: EVALUATORS_RULE_SCOPE.trace;
184186

187+
const getInitialRuleName = () => {
188+
if (mode === "clone" && defaultRule) {
189+
return `${defaultRule.name} (Copy)`;
190+
}
191+
return defaultRule?.name || "";
192+
};
193+
185194
const form: UseFormReturn<EvaluationRuleFormType> = useForm<
186195
z.infer<typeof EvaluationRuleFormSchema>
187196
>({
188197
resolver: zodResolver(EvaluationRuleFormSchema),
189198
defaultValues: {
190-
ruleName: defaultRule?.name || "",
199+
ruleName: getInitialRuleName(),
191200
projectIds:
192201
defaultRule?.projects?.map((p) => p.project_id) ||
193202
(projectId ? [projectId] : []),
@@ -249,8 +258,49 @@ const AddEditRuleDialog: React.FC<AddEditRuleDialogProps> = ({
249258
filters: [],
250259
llmJudgeDetails: cloneDeep(DEFAULT_LLM_AS_JUDGE_DATA[initialScope]),
251260
});
261+
} else if (open && defaultRule && mode === "clone") {
262+
// For clone mode, reset the form with cloned rule data and append " (Copy)" to name
263+
const cloneFormData = {
264+
ruleName: `${defaultRule.name} (Copy)`,
265+
projectIds:
266+
defaultRule.projects?.map((p) => p.project_id) ||
267+
(projectId ? [projectId] : []),
268+
samplingRate: defaultRule.sampling_rate ?? 1,
269+
uiType: formUIRuleType,
270+
scope: formScope,
271+
type: getBackendRuleType(formScope, formUIRuleType),
272+
enabled: defaultRule.enabled ?? true,
273+
filters: normalizeFilters(
274+
defaultRule.filters ?? [],
275+
(formScope === EVALUATORS_RULE_SCOPE.thread
276+
? THREAD_FILTER_COLUMNS
277+
: formScope === EVALUATORS_RULE_SCOPE.span
278+
? SPAN_FILTER_COLUMNS
279+
: TRACE_FILTER_COLUMNS) as ColumnData<unknown>[],
280+
) as Filter[],
281+
pythonCodeDetails:
282+
defaultRule && isPythonCodeRule(defaultRule)
283+
? (defaultRule.code as PythonCodeObject)
284+
: cloneDeep(DEFAULT_PYTHON_CODE_DATA[formScope]),
285+
llmJudgeDetails:
286+
defaultRule && isLLMJudgeRule(defaultRule)
287+
? convertLLMJudgeObjectToLLMJudgeData(
288+
defaultRule.code as LLMJudgeObject,
289+
)
290+
: cloneDeep(DEFAULT_LLM_AS_JUDGE_DATA[formScope]),
291+
};
292+
form.reset(cloneFormData as EvaluationRuleFormType);
252293
}
253-
}, [open, defaultRule, projectId, defaultScope, form]);
294+
}, [
295+
open,
296+
defaultRule,
297+
projectId,
298+
defaultScope,
299+
mode,
300+
formScope,
301+
formUIRuleType,
302+
form,
303+
]);
254304

255305
const handleScopeChange = useCallback(
256306
(value: EVALUATORS_RULE_SCOPE) => {
@@ -290,8 +340,13 @@ const AddEditRuleDialog: React.FC<AddEditRuleDialogProps> = ({
290340
const { mutate: createMutate } = useRuleCreateMutation();
291341
const { mutate: updateMutate } = useRuleUpdateMutation();
292342

293-
const isEdit = Boolean(defaultRule);
294-
const title = isEdit ? "Edit rule" : "Create a new rule";
343+
const isEdit = mode === "edit";
344+
const isClone = mode === "clone";
345+
const title = isEdit
346+
? "Edit rule"
347+
: isClone
348+
? "Clone evaluation rule"
349+
: "Create a new rule";
295350
const submitText = isEdit ? "Update rule" : "Create rule";
296351

297352
const isCodeMetricEditBlock = !isCodeMetricEnabled && !isLLMJudge && isEdit;

apps/opik-frontend/src/components/pages-shared/automations/RuleRowActionsCell.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
DropdownMenuTrigger,
66
} from "@/components/ui/dropdown-menu";
77
import { Button } from "@/components/ui/button";
8-
import { MoreHorizontal, Pencil, Trash } from "lucide-react";
8+
import { MoreHorizontal, Pencil, Copy, Trash } from "lucide-react";
99
import React, { useCallback, useRef, useState } from "react";
1010
import { EvaluatorsRule } from "@/types/automations";
1111
import { CellContext } from "@tanstack/react-table";
@@ -15,11 +15,12 @@ import CellWrapper from "@/components/shared/DataTableCells/CellWrapper";
1515

1616
interface RuleRowActionsCellProps {
1717
openEditDialog: (ruleId: string) => void;
18+
openCloneDialog: (ruleId: string) => void;
1819
}
1920

2021
const RuleRowActionsCell: React.FC<
2122
RuleRowActionsCellProps & CellContext<EvaluatorsRule, unknown>
22-
> = ({ openEditDialog, row, column, table }) => {
23+
> = ({ openEditDialog, openCloneDialog, row, column, table }) => {
2324
const resetKeyRef = useRef(0);
2425
const rule = row.original;
2526
const [open, setOpen] = useState<boolean | number>(false);
@@ -63,6 +64,10 @@ Tip: To pause scoring without deleting, disable the rule.`}
6364
<Pencil className="mr-2 size-4" />
6465
Edit
6566
</DropdownMenuItem>
67+
<DropdownMenuItem onClick={() => openCloneDialog(rule.id)}>
68+
<Copy className="mr-2 size-4" />
69+
Clone
70+
</DropdownMenuItem>
6671
<DropdownMenuItem
6772
onClick={() => {
6873
setOpen(1);

apps/opik-frontend/src/components/pages/OnlineEvaluationPage/OnlineEvaluationPage.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ export const OnlineEvaluationPage: React.FC = () => {
146146
updateType: "replaceIn",
147147
});
148148

149+
const [cloneRuleId, setCloneRuleId] = useQueryParam(
150+
"cloneRule",
151+
StringParam,
152+
{
153+
updateType: "replaceIn",
154+
},
155+
);
156+
149157
const [filters = [], setFilters] = useQueryParam(`filters`, JsonParam, {
150158
updateType: "replaceIn",
151159
});
@@ -199,7 +207,13 @@ export const OnlineEvaluationPage: React.FC = () => {
199207
const rows: EvaluatorsRule[] = useMemo(() => data?.content ?? [], [data]);
200208

201209
const editingRule = rows.find((r) => r.id === editRuleId);
202-
const isDialogOpen = Boolean(editingRule) || openDialogForCreate;
210+
const cloningRule = rows.find((r) => r.id === cloneRuleId);
211+
const isDialogOpen =
212+
Boolean(editingRule) || Boolean(cloningRule) || openDialogForCreate;
213+
214+
// Determine which rule to pass and what mode to use
215+
const dialogRule = editingRule || cloningRule;
216+
const dialogMode = editingRule ? "edit" : cloningRule ? "clone" : "create";
203217

204218
const [selectedColumns, setSelectedColumns] = useLocalStorageState<string[]>(
205219
SELECTED_COLUMNS_KEY,
@@ -233,6 +247,14 @@ export const OnlineEvaluationPage: React.FC = () => {
233247
[setEditRuleId],
234248
);
235249

250+
const handleOpenCloneDialog = useCallback(
251+
(ruleId: string) => {
252+
setCloneRuleId(ruleId);
253+
resetDialogKeyRef.current = resetDialogKeyRef.current + 1;
254+
},
255+
[setCloneRuleId],
256+
);
257+
236258
const columns = useMemo(() => {
237259
return [
238260
generateSelectColumDef<EvaluatorsRule>(),
@@ -264,11 +286,18 @@ export const OnlineEvaluationPage: React.FC = () => {
264286
<RuleRowActionsCell
265287
{...props}
266288
openEditDialog={handleOpenEditDialog}
289+
openCloneDialog={handleOpenCloneDialog}
267290
/>
268291
),
269292
}),
270293
];
271-
}, [columnsOrder, selectedColumns, sortableBy, handleOpenEditDialog]);
294+
}, [
295+
columnsOrder,
296+
selectedColumns,
297+
sortableBy,
298+
handleOpenEditDialog,
299+
handleOpenCloneDialog,
300+
]);
272301

273302
const resizeConfig = useMemo(
274303
() => ({
@@ -299,9 +328,10 @@ export const OnlineEvaluationPage: React.FC = () => {
299328
setOpenDialogForCreate(open);
300329
if (!open) {
301330
setEditRuleId(undefined);
331+
setCloneRuleId(undefined);
302332
}
303333
},
304-
[setEditRuleId],
334+
[setEditRuleId, setCloneRuleId],
305335
);
306336

307337
// Filter out "type" (Scope), "enabled" (Status), "sampling_rate", and "projects" from filter options
@@ -330,7 +360,8 @@ export const OnlineEvaluationPage: React.FC = () => {
330360
key={resetDialogKeyRef.current}
331361
open={isDialogOpen}
332362
setOpen={handleCloseDialog}
333-
rule={editingRule}
363+
rule={dialogRule}
364+
mode={dialogMode}
334365
/>
335366
</>
336367
);
@@ -403,7 +434,8 @@ export const OnlineEvaluationPage: React.FC = () => {
403434
key={resetDialogKeyRef.current}
404435
open={isDialogOpen}
405436
setOpen={handleCloseDialog}
406-
rule={editingRule}
437+
rule={dialogRule}
438+
mode={dialogMode}
407439
/>
408440
</div>
409441
);

apps/opik-frontend/src/components/pages/TracesPage/RulesTab/RulesTab.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
132132
updateType: "replaceIn",
133133
});
134134

135+
const [cloneRuleId, setCloneRuleId] = useQueryParam(
136+
"cloneRule",
137+
StringParam,
138+
{
139+
updateType: "replaceIn",
140+
},
141+
);
142+
135143
const [page = 1, setPage] = useQueryParam("page", NumberParam, {
136144
updateType: "replaceIn",
137145
});
@@ -166,7 +174,13 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
166174
const rows: EvaluatorsRule[] = useMemo(() => data?.content ?? [], [data]);
167175

168176
const editingRule = rows.find((r) => r.id === editRuleId);
169-
const isDialogOpen = Boolean(editingRule) || openDialogForCreate;
177+
const cloningRule = rows.find((r) => r.id === cloneRuleId);
178+
const isDialogOpen =
179+
Boolean(editingRule) || Boolean(cloningRule) || openDialogForCreate;
180+
181+
// Determine which rule to pass and what mode to use
182+
const dialogRule = editingRule || cloningRule;
183+
const dialogMode = editingRule ? "edit" : cloningRule ? "clone" : "create";
170184

171185
const [selectedColumns, setSelectedColumns] = useLocalStorageState<string[]>(
172186
SELECTED_COLUMNS_KEY,
@@ -200,6 +214,14 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
200214
[setEditRuleId],
201215
);
202216

217+
const handleOpenCloneDialog = useCallback(
218+
(ruleId: string) => {
219+
setCloneRuleId(ruleId);
220+
resetDialogKeyRef.current = resetDialogKeyRef.current + 1;
221+
},
222+
[setCloneRuleId],
223+
);
224+
203225
const columns = useMemo(() => {
204226
return [
205227
generateSelectColumDef<EvaluatorsRule>(),
@@ -229,11 +251,17 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
229251
<RuleRowActionsCell
230252
{...props}
231253
openEditDialog={handleOpenEditDialog}
254+
openCloneDialog={handleOpenCloneDialog}
232255
/>
233256
),
234257
}),
235258
];
236-
}, [columnsOrder, selectedColumns, handleOpenEditDialog]);
259+
}, [
260+
columnsOrder,
261+
selectedColumns,
262+
handleOpenEditDialog,
263+
handleOpenCloneDialog,
264+
]);
237265

238266
const resizeConfig = useMemo(
239267
() => ({
@@ -254,9 +282,10 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
254282
setOpenDialogForCreate(open);
255283
if (!open) {
256284
setEditRuleId(undefined);
285+
setCloneRuleId(undefined);
257286
}
258287
},
259-
[setEditRuleId],
288+
[setEditRuleId, setCloneRuleId],
260289
);
261290

262291
if (isPending) {
@@ -277,7 +306,8 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
277306
open={isDialogOpen}
278307
projectId={projectId}
279308
setOpen={handleCloseDialog}
280-
rule={editingRule}
309+
rule={dialogRule}
310+
mode={dialogMode}
281311
/>
282312
</>
283313
);
@@ -352,7 +382,8 @@ export const RulesTab: React.FC<RulesTabProps> = ({ projectId }) => {
352382
open={isDialogOpen}
353383
projectId={projectId}
354384
setOpen={handleCloseDialog}
355-
rule={editingRule}
385+
rule={dialogRule}
386+
mode={dialogMode}
356387
/>
357388
</>
358389
);

0 commit comments

Comments
 (0)