Skip to content

Commit 2693c74

Browse files
committed
feat: User Notes on Runs
1 parent 25910f5 commit 2693c74

7 files changed

Lines changed: 200 additions & 19 deletions

File tree

src/components/PipelineRun/RunDetails.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LoadingScreen } from "@/components/shared/LoadingScreen";
77
import { StatusBar } from "@/components/shared/Status";
88
import { BlockStack, InlineStack } from "@/components/ui/layout";
99
import { Paragraph, Text } from "@/components/ui/typography";
10+
import { useUserDetails } from "@/hooks/useUserDetails";
1011
import { useBackend } from "@/providers/BackendProvider";
1112
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
1213
import { useExecutionData } from "@/providers/ExecutionDataProvider";
@@ -18,12 +19,14 @@ import {
1819
} from "@/utils/executionStatus";
1920

2021
import { TextBlock } from "../shared/ContextPanel/Blocks/TextBlock";
22+
import { RunNotesEditor } from "./RunNotesEditor";
2123

2224
const EXCLUDED_ANNOTATIONS = [PIPELINE_NOTES_ANNOTATION];
2325

2426
export const RunDetails = () => {
2527
const { configured } = useBackend();
2628
const { componentSpec } = useComponentSpec();
29+
const { data: currentUserDetails } = useUserDetails();
2730
const {
2831
rootDetails: details,
2932
rootState: state,
@@ -73,6 +76,9 @@ export const RunDetails = () => {
7376
.filter(([key]) => !EXCLUDED_ANNOTATIONS.includes(key))
7477
.map(([key, value]) => ({ label: key, value: String(value) }));
7578

79+
const isRunCreator =
80+
!!currentUserDetails?.id && metadata?.created_by === currentUserDetails.id;
81+
7682
return (
7783
<BlockStack gap="6" className="p-2 h-full">
7884
<CopyText className="text-lg font-semibold">
@@ -121,11 +127,19 @@ export const RunDetails = () => {
121127
<PipelineIO taskArguments={details.task_spec.arguments} />
122128

123129
<ContentBlock title="Notes">
124-
<BlockStack>
125-
<Paragraph size="xs">Pipeline Notes</Paragraph>
126-
<Paragraph size="xs" tone="subdued">
127-
{pipelineNotes || "No notes available for this pipeline."}
128-
</Paragraph>
130+
<BlockStack gap="2">
131+
<BlockStack>
132+
<Paragraph size="xs">Pipeline Notes</Paragraph>
133+
<Paragraph size="xs" tone="subdued">
134+
{pipelineNotes || "No notes available for this pipeline."}
135+
</Paragraph>
136+
</BlockStack>
137+
{!!metadata?.id && (
138+
<BlockStack>
139+
<Paragraph size="xs">Run Notes</Paragraph>
140+
<RunNotesEditor runId={metadata.id} readOnly={!isRunCreator} />
141+
</BlockStack>
142+
)}
129143
</BlockStack>
130144
</ContentBlock>
131145
</BlockStack>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useMutation, useQuery } from "@tanstack/react-query";
2+
import { useEffect, useState } from "react";
3+
4+
import { Textarea } from "@/components/ui/textarea";
5+
import { Paragraph } from "@/components/ui/typography";
6+
import { useBackend } from "@/providers/BackendProvider";
7+
import {
8+
fetchRunAnnotations,
9+
updateRunNotes,
10+
} from "@/services/pipelineRunService";
11+
import { PIPELINE_RUN_NOTES_ANNOTATION } from "@/utils/annotations";
12+
import { TWENTY_FOUR_HOURS_IN_MS } from "@/utils/constants";
13+
14+
interface RunNotesEditorProps {
15+
runId: string;
16+
readOnly?: boolean;
17+
}
18+
19+
export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => {
20+
const { backendUrl } = useBackend();
21+
22+
const {
23+
data: annotations,
24+
isLoading,
25+
refetch,
26+
} = useQuery({
27+
queryKey: ["pipeline-run-annotations", runId],
28+
queryFn: () => fetchRunAnnotations(runId, backendUrl),
29+
enabled: !!runId,
30+
refetchOnWindowFocus: false,
31+
staleTime: TWENTY_FOUR_HOURS_IN_MS,
32+
});
33+
34+
const { mutate, isPending } = useMutation({
35+
mutationFn: (runId: string) => updateRunNotes(runId, backendUrl, value),
36+
onSuccess: () => {
37+
refetch();
38+
},
39+
});
40+
41+
const notes = hasNotes(annotations)
42+
? decodeURIComponent(annotations[PIPELINE_RUN_NOTES_ANNOTATION])
43+
: "";
44+
45+
const [value, setValue] = useState(notes);
46+
47+
const onInputChange = (value: string) => {
48+
setValue(value);
49+
};
50+
51+
const onBlur = () => {
52+
if (!runId || isLoading || value.trim() === notes.trim()) {
53+
return;
54+
}
55+
mutate(runId);
56+
};
57+
58+
useEffect(() => {
59+
setValue(notes);
60+
}, [notes]);
61+
62+
if (readOnly) {
63+
return (
64+
<Paragraph size="xs" tone="subdued">
65+
{notes || "No notes available."}
66+
</Paragraph>
67+
);
68+
}
69+
70+
return (
71+
<Textarea
72+
value={value}
73+
onChange={(e) => onInputChange(e.target.value)}
74+
onBlur={onBlur}
75+
placeholder="Share context about this pipeline run..."
76+
className="text-xs!"
77+
disabled={isPending}
78+
/>
79+
);
80+
};
81+
82+
const hasNotes = (
83+
annotations: Record<string, unknown> | undefined,
84+
): annotations is Record<string, unknown> & {
85+
[PIPELINE_RUN_NOTES_ANNOTATION]: string;
86+
} => {
87+
if (!annotations) {
88+
return false;
89+
}
90+
return (
91+
PIPELINE_RUN_NOTES_ANNOTATION in annotations &&
92+
typeof annotations[PIPELINE_RUN_NOTES_ANNOTATION] === "string" &&
93+
annotations[PIPELINE_RUN_NOTES_ANNOTATION] !== "" &&
94+
annotations[PIPELINE_RUN_NOTES_ANNOTATION] !== null &&
95+
annotations[PIPELINE_RUN_NOTES_ANNOTATION] !== undefined
96+
);
97+
};

src/components/shared/Submitters/Oasis/OasisSubmitter.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import useToastNotification from "@/hooks/useToastNotification";
1414
import { cn } from "@/lib/utils";
1515
import { useBackend } from "@/providers/BackendProvider";
1616
import { APP_ROUTES } from "@/routes/router";
17+
import { updateRunNotes } from "@/services/pipelineRunService";
1718
import type { PipelineRun } from "@/types/pipelineRun";
18-
import type { ComponentSpec } from "@/utils/componentSpec";
19+
import {
20+
type ComponentSpec,
21+
isGraphImplementation,
22+
} from "@/utils/componentSpec";
1923
import { submitPipelineRun } from "@/utils/submitPipeline";
2024

2125
import { isAuthorizationRequired } from "../../Authentication/helpers";
@@ -89,7 +93,7 @@ const OasisSubmitter = ({
8993
isComponentTreeValid = true,
9094
}: OasisSubmitterProps) => {
9195
const { isAuthorized } = useAwaitAuthorization();
92-
const { configured, available } = useBackend();
96+
const { backendUrl, configured, available } = useBackend();
9397
const { mutate: submit, isPending: isSubmitting } = useSubmitPipeline();
9498
const isAutoRedirect = useFlagValue("redirect-on-new-pipeline-run");
9599

@@ -99,6 +103,13 @@ const OasisSubmitter = ({
99103
const notify = useToastNotification();
100104
const navigate = useNavigate();
101105

106+
const runNotes = useRef<string>("");
107+
108+
const { mutate: saveNotes } = useMutation({
109+
mutationFn: (runId: string) =>
110+
updateRunNotes(runId, backendUrl, runNotes.current),
111+
});
112+
102113
const handleError = (message: string) => {
103114
notify(message, "error");
104115
};
@@ -130,6 +141,9 @@ const OasisSubmitter = ({
130141
};
131142

132143
const onSuccess = (response: PipelineRun) => {
144+
if (runNotes.current.trim() !== "") {
145+
saveNotes(response.id.toString());
146+
}
133147
setSubmitSuccess(true);
134148
setCooldownTime(3);
135149
onSubmitComplete?.();
@@ -173,7 +187,11 @@ const OasisSubmitter = ({
173187
});
174188
};
175189

176-
const handleSubmitWithArguments = (args: Record<string, string>) => {
190+
const handleSubmitWithArguments = (
191+
args: Record<string, string>,
192+
notes: string,
193+
) => {
194+
runNotes.current = notes;
177195
setIsArgumentsDialogOpen(false);
178196
handleSubmit(args);
179197
};
@@ -197,7 +215,7 @@ const OasisSubmitter = ({
197215
!isAuthorized ||
198216
!isComponentTreeValid ||
199217
cooldownTime > 0 ||
200-
("graph" in componentSpec.implementation &&
218+
(isGraphImplementation(componentSpec.implementation) &&
201219
Object.keys(componentSpec.implementation.graph.tasks).length === 0);
202220

203221
const isArgumentsButtonVisible = hasConfigurableInputs && !isButtonDisabled;
@@ -230,7 +248,7 @@ const OasisSubmitter = ({
230248
<div
231249
className={cn(
232250
"text-xs font-light -ml-1",
233-
configured ? "text-red-700" : "text-yellow-700",
251+
configured ? "text-destructive" : "text-warning",
234252
)}
235253
>
236254
(has validation issues)
@@ -240,7 +258,7 @@ const OasisSubmitter = ({
240258
<div
241259
className={cn(
242260
"text-xs font-light -ml-1",
243-
configured ? "text-red-700" : "text-yellow-700",
261+
configured ? "text-destructive" : "text-warning",
244262
)}
245263
>
246264
{`(backend ${configured ? "unavailable" : "unconfigured"})`}

src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "@/components/ui/popover";
2525
import { ScrollArea } from "@/components/ui/scroll-area";
2626
import { Spinner } from "@/components/ui/spinner";
27+
import { Textarea } from "@/components/ui/textarea";
2728
import { Paragraph } from "@/components/ui/typography";
2829
import useToastNotification from "@/hooks/useToastNotification";
2930
import { cn } from "@/lib/utils";
@@ -41,7 +42,7 @@ type TaskArguments = TaskSpecOutput["arguments"];
4142
interface SubmitTaskArgumentsDialogProps {
4243
open: boolean;
4344
onCancel: () => void;
44-
onConfirm: (args: Record<string, string>) => void;
45+
onConfirm: (args: Record<string, string>, notes: string) => void;
4546
componentSpec: ComponentSpec;
4647
}
4748

@@ -54,6 +55,7 @@ export const SubmitTaskArgumentsDialog = ({
5455
const notify = useToastNotification();
5556
const initialArgs = getArgumentsFromInputs(componentSpec);
5657

58+
const [runNotes, setRunNotes] = useState<string>("");
5759
const [taskArguments, setTaskArguments] =
5860
useState<Record<string, string>>(initialArgs);
5961

@@ -87,10 +89,15 @@ export const SubmitTaskArgumentsDialog = ({
8789
}));
8890
};
8991

90-
const handleConfirm = () => onConfirm(taskArguments);
92+
const handleRunNotesChange = (value: string) => {
93+
setRunNotes(value);
94+
};
95+
96+
const handleConfirm = () => onConfirm(taskArguments, runNotes);
9197

9298
const handleCancel = () => {
9399
setTaskArguments(initialArgs);
100+
setRunNotes("");
94101
setHighlightedArgs(new Map());
95102
onCancel();
96103
};
@@ -146,6 +153,18 @@ export const SubmitTaskArgumentsDialog = ({
146153
</ScrollArea>
147154
)}
148155

156+
<BlockStack gap="2">
157+
<Paragraph tone="subdued" size="sm">
158+
Run Notes
159+
</Paragraph>
160+
<Textarea
161+
value={runNotes}
162+
onChange={(e) => handleRunNotesChange(e.target.value)}
163+
placeholder="Share context about this pipeline run..."
164+
className="text-xs!"
165+
/>
166+
</BlockStack>
167+
149168
<DialogFooter>
150169
<Button variant="outline" onClick={handleCancel}>
151170
Cancel

src/services/pipelineRunService.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import localForage from "localforage";
22

3-
import type { BodyCreateApiPipelineRunsPost } from "@/api/types.gen";
3+
import type {
4+
BodyCreateApiPipelineRunsPost,
5+
ListAnnotationsApiPipelineRunsIdAnnotationsGetResponse,
6+
} from "@/api/types.gen";
47
import { APP_ROUTES } from "@/routes/router";
58
import type { PipelineRun } from "@/types/pipelineRun";
9+
import { PIPELINE_RUN_NOTES_ANNOTATION } from "@/utils/annotations";
610
import { removeCachingStrategyFromSpec } from "@/utils/cache";
711
import {
812
type ComponentSpec,
@@ -216,3 +220,26 @@ export const cancelPipelineRun = async (runId: string, backendUrl: string) => {
216220
},
217221
);
218222
};
223+
224+
export const fetchRunAnnotations = async (
225+
runId: string,
226+
backendUrl: string,
227+
): Promise<ListAnnotationsApiPipelineRunsIdAnnotationsGetResponse> => {
228+
const url = `${backendUrl}/api/pipeline_runs/${runId}/annotations`;
229+
return fetchWithErrorHandling(url);
230+
};
231+
232+
export const updateRunNotes = async (
233+
runId: string,
234+
backendUrl: string,
235+
notes: string,
236+
) => {
237+
await fetchWithErrorHandling(
238+
`${backendUrl}/api/pipeline_runs/${runId}/annotations/${PIPELINE_RUN_NOTES_ANNOTATION}?value=${encodeURIComponent(
239+
notes,
240+
)}`,
241+
{
242+
method: "PUT",
243+
},
244+
);
245+
};

src/utils/annotations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,5 @@ function hasAnnotation(
5151
}
5252

5353
export const PIPELINE_NOTES_ANNOTATION = "notes";
54+
export const PIPELINE_RUN_NOTES_ANNOTATION = "notes";
55+
export const PIPELINE_CANONICAL_NAME_ANNOTATION = "canonical-pipeline-name";

src/utils/canonicalPipelineName.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { TaskSpecShape } from "@/components/shared/PipelineRunNameTemplate/types";
22

3-
import { getAnnotationValue } from "./annotations";
3+
import {
4+
getAnnotationValue,
5+
PIPELINE_CANONICAL_NAME_ANNOTATION,
6+
} from "./annotations";
47

58
/**
69
* Extracts the canonical name from the task spec
@@ -11,7 +14,10 @@ export function extractCanonicalName(
1114
taskSpec: TaskSpecShape | undefined,
1215
): string | undefined {
1316
return taskSpec
14-
? getAnnotationValue(taskSpec.annotations, CANONICAL_NAME_ANNOTATION)
17+
? getAnnotationValue(
18+
taskSpec.annotations,
19+
PIPELINE_CANONICAL_NAME_ANNOTATION,
20+
)
1521
: undefined;
1622
}
1723

@@ -28,8 +34,6 @@ export function buildAnnotationsWithCanonicalName(
2834
}
2935

3036
return {
31-
[CANONICAL_NAME_ANNOTATION]: canonicalName,
37+
[PIPELINE_CANONICAL_NAME_ANNOTATION]: canonicalName,
3238
};
3339
}
34-
35-
const CANONICAL_NAME_ANNOTATION = "canonical-pipeline-name";

0 commit comments

Comments
 (0)