FunctionResult.tsx•6.34 kB
import { LockOpen2Icon, PlayIcon } from "@radix-ui/react-icons";
import classNames from "classnames";
import type { FunctionResult as FunctionResultType } from "convex/browser";
import { useContext, useEffect, useState } from "react";
import { useSessionStorage } from "react-use";
import { Value } from "convex/values";
import { Button } from "@ui/Button";
import { DeploymentInfoContext } from "@common/lib/deploymentContext";
import { toast } from "@common/lib/utils";
import { RequestFilter } from "@common/lib/appMetrics";
import { ComponentId } from "@common/lib/useNents";
import { Result } from "@common/features/functionRunner/components/Result";
import {
useRunHistory,
RunHistoryItem,
useImpersonatedUser,
useIsImpersonating,
} from "@common/features/functionRunner/components/RunHistory";
// This is a hook because we want to return composable components that can be arranged
// vertically or horizontally.
export function useFunctionResult({
udfType,
onSubmit,
disabled,
functionIdentifier,
componentId,
args,
runHistoryItem,
}: {
udfType?: "Mutation" | "Action" | "Query" | "HttpAction";
onSubmit(): {
requestFilter: RequestFilter | null;
runFunctionPromise: Promise<FunctionResultType> | null;
};
disabled: boolean;
functionIdentifier?: string;
componentId: ComponentId;
args: Record<string, Value>;
runHistoryItem?: RunHistoryItem;
}) {
const { appendRunHistory } = useRunHistory(
functionIdentifier || "",
componentId,
);
const [isInFlight, setIsInFlight] = useState(false);
const [lastRequestTiming, setLastRequestTiming] = useState<{
startedAt: number;
endedAt: number;
}>();
const [, setIsImpersonating] = useIsImpersonating();
const [, setImpersonatedUser] = useImpersonatedUser();
const [result, setResult] = useState<FunctionResultType>();
const [requestFilter, setRequestFilter] = useState<RequestFilter | null>(
null,
);
const [startCursor, setStartCursor] = useState<number>(0);
const isInvalidUdfType =
!udfType || !["Mutation", "Action"].includes(udfType);
useEffect(() => {
if (!isInvalidUdfType) {
setResult(undefined);
setLastRequestTiming(undefined);
setIsInFlight(false);
}
}, [isInvalidUdfType]);
useEffect(() => {
setResult(undefined);
setLastRequestTiming(undefined);
setIsInFlight(false);
}, [functionIdentifier]);
useEffect(() => {
if (runHistoryItem) {
setResult(undefined);
setLastRequestTiming(undefined);
setStartCursor(0);
if (runHistoryItem.type === "arguments") {
setIsImpersonating(!!runHistoryItem.user);
runHistoryItem.user && setImpersonatedUser(runHistoryItem.user);
}
}
}, [runHistoryItem, setImpersonatedUser, setIsImpersonating]);
const {
useCurrentDeployment,
useHasProjectAdminPermissions,
useLogDeploymentEvent,
} = useContext(DeploymentInfoContext);
const deployment = useCurrentDeployment();
const isProd = deployment?.deploymentType === "prod";
const [prodEditsEnabled, setProdEditsEnabled] = useSessionStorage(
"prodEditsEnabled",
false,
);
const log = useLogDeploymentEvent();
const hasAdminPermissions = useHasProjectAdminPermissions(
deployment?.projectId,
);
const canRunFunction =
udfType === "Query" ||
deployment?.deploymentType !== "prod" ||
hasAdminPermissions;
if (isInvalidUdfType) {
return { button: null, result: null };
}
const runFunction = async () => {
const startedAt = Date.now();
setResult(undefined);
setIsInFlight(true);
const { requestFilter: requestFilterResult, runFunctionPromise } =
onSubmit();
setRequestFilter(requestFilterResult);
setStartCursor(startedAt);
let functionResult: FunctionResultType | undefined;
try {
functionResult = await runFunctionPromise!;
log("run function", {
function: {
identifier: functionIdentifier,
udfType,
},
success: functionResult.success,
isProd,
});
} catch (e: any) {
functionResult = {
success: false,
errorMessage: e.message,
logLines: [],
};
} finally {
// Wait a moment before re-enable the button to
// avoid the user accidently re-running the function.
setTimeout(() => {
setIsInFlight(false);
}, 100);
const endedAt = Date.now();
setLastRequestTiming({
startedAt,
endedAt,
});
setResult(functionResult);
appendRunHistory({
type: "arguments",
startedAt,
endedAt,
arguments: args,
});
}
};
return {
button: (
<div className={classNames("flex items-center gap-2 mx-4")}>
<Button
tip={
disabled
? "Fix the errors above to continue."
: !canRunFunction
? "You do not have permission to run this function in production."
: isProd && !prodEditsEnabled
? `You are about to run a ${udfType.toLowerCase()} in Production. Unlock Production to continue.`
: undefined
}
size="sm"
className="w-full max-w-[48rem] items-center justify-center"
disabled={
disabled || (isProd && !prodEditsEnabled) || !canRunFunction
}
loading={isInFlight}
onClick={runFunction}
icon={<PlayIcon />}
>
Run {udfType.toLowerCase()}
</Button>
{canRunFunction && isProd && !prodEditsEnabled && (
<Button
tip="Enables changes to Production for the remainder of this dashboard session"
size="sm"
onClick={() => {
setProdEditsEnabled(true);
toast(
"success",
"Production edits enabled for the remainder of this dashboard session",
);
}}
icon={<LockOpen2Icon />}
>
Unlock Production
</Button>
)}
</div>
),
result: (
<Result
result={result}
loading={isInFlight}
lastRequestTiming={lastRequestTiming}
requestFilter={requestFilter}
startCursor={startCursor}
/>
),
runFunction,
};
}