feat(dashboard): Workflow executions (#6564)

**What**
- v1 of executions domain

**TODO in follow up PR**
I think it might be a good idea for Carlos or Adrien to go over the design with Ludvig, and make sure we are displaying what is relevant to developers, and how things should be displayed. So this PR is just the initial implementation, and does not handle displaying things like compensation steps (not part of the current design), step input (not supported by the API but part of the design), etc.

Closes CORE-1751, CORE-1755
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-09 19:46:10 +01:00
committed by GitHub
parent a838ebae1b
commit 2d00625729
31 changed files with 1545 additions and 62 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/ui": patch
"@medusajs/medusa": patch
---
fix(medusa,ui) Export param types for workflow endpoints. Add support for JSON to CodeBlock component.

View File

@@ -25,7 +25,7 @@
"@tanstack/react-query": "4.22.0",
"@tanstack/react-table": "8.10.7",
"@tanstack/react-virtual": "^3.0.4",
"@uiw/react-json-view": "2.0.0-alpha.10",
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
"date-fns": "^3.2.0",
"framer-motion": "^11.0.3",

View File

@@ -17,9 +17,6 @@
"is": "is",
"select": "Select",
"selected": "Selected",
"extensions": "Extensions",
"settings": "Settings",
"general": "General",
"details": "Details",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -41,7 +38,14 @@
"noRecordsMessage": "There are no records to show",
"unsavedChangesTitle": "Are you sure you want to leave this page?",
"unsavedChangesDescription": "You have unsaved changes that will be lost if you leave this page.",
"includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved."
"includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved.",
"timeline": "Timeline"
},
"nav": {
"general": "General",
"developer": "Developer",
"extensions": "Extensions",
"settings": "Settings"
},
"actions": {
"create": "Create",
@@ -497,6 +501,45 @@
"tokenExpiresIn": "Token expires in <0>{{time}}</0> minutes",
"successfulRequest": "We have sent you an email with instructions on how to reset your password. If you don't receive an email, please check your spam folder or try again."
},
"executions": {
"domain": "Executions",
"transactionIdLabel": "Transaction ID",
"workflowIdLabel": "Workflow ID",
"progressLabel": "Progress",
"stepsCompletedLabel_one": "{{completed}} of {{count}} step",
"stepsCompletedLabel_other": "{{completed}} of {{count}} steps",
"history": {
"sectionTitle": "History",
"runningState": "Running...",
"awaitingState": "Awaiting",
"failedState": "Failed",
"definitionLabel": "Definition",
"outputLabel": "Output",
"compensateInputLabel": "Compensate input",
"revertedLabel": "Reverted",
"errorLabel": "Error"
},
"state": {
"done": "Done",
"failed": "Failed",
"reverted": "Reverted",
"invoking": "Invoking",
"compensating": "Compensating",
"notStarted": "Not started"
},
"transaction": {
"state": {
"waitingToCompensate": "Waiting to compensate"
}
},
"step": {
"state": {
"skipped": "Skipped",
"dormant": "Dormant",
"timeout": "Timeout"
}
}
},
"errors": {
"serverError": "Server error - Try again later.",
"invalidCredentials": "Wrong email or password"

View File

@@ -1,9 +1,4 @@
import {
ArrowsPointingOut,
CheckCircleMiniSolid,
SquareTwoStackMini,
XMarkMini,
} from "@medusajs/icons"
import { ArrowsPointingOut, XMarkMini } from "@medusajs/icons"
import {
Badge,
Container,
@@ -14,20 +9,27 @@ import {
} from "@medusajs/ui"
import Primitive from "@uiw/react-json-view"
import { CSSProperties, Suspense } from "react"
import { useTranslation } from "react-i18next"
type JsonViewProps = {
type JsonViewSectionProps = {
data: object
root?: string
title?: string
}
// TODO: Fix the positioning of the copy btn
export const JsonViewSection = ({ data, root }: JsonViewProps) => {
export const JsonViewSection = ({
data,
root,
title = "JSON",
}: JsonViewSectionProps) => {
const { t } = useTranslation()
const numberOfKeys = Object.keys(data).length
return (
<Container className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading level="h2">JSON</Heading>
<Heading level="h2">{title}</Heading>
<Badge size="2xsmall">{numberOfKeys} keys</Badge>
</div>
<Drawer>
@@ -40,10 +42,10 @@ export const JsonViewSection = ({ data, root }: JsonViewProps) => {
<ArrowsPointingOut />
</IconButton>
</Drawer.Trigger>
<Drawer.Content className="border-ui-code-border bg-ui-code-bg-base text-ui-code-text-base dark overflow-hidden border shadow-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
<div className="bg-ui-code-bg-header border-ui-code-border flex items-center justify-between border-b px-6 py-4">
<Drawer.Content className="border-ui-code-border bg-ui-code-bg-base text-ui-code-fg-subtle dark overflow-hidden border shadow-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
<div className="bg-ui-code-bg-base border-ui-code-border flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-x-4">
<Heading>JSON</Heading>
<Heading className="text-ui-code-fg-base">{title}</Heading>
<Badge size="2xsmall">{numberOfKeys} keys</Badge>
</div>
<div className="flex items-center gap-x-2">
@@ -68,52 +70,53 @@ export const JsonViewSection = ({ data, root }: JsonViewProps) => {
style={
{
"--w-rjv-font-family": "Roboto Mono, monospace",
"--w-rjv-line-color": "#2E3035",
"--w-rjv-curlybraces-color": "#ADB1B8",
"--w-rjv-key-string": "#A78BFA",
"--w-rjv-info-color": "#FBBF24",
"--w-rjv-type-string-color": "#34D399",
"--w-rjv-quotes-string-color": "#34D399",
"--w-rjv-type-boolean-color": "#FBBF24",
"--w-rjv-type-int-color": "#60A5FA",
"--w-rjv-type-float-color": "#60A5FA",
"--w-rjv-type-bigint-color": "#60A5FA",
"--w-rjv-key-number": "#60A5FA",
"--w-rjv-line-color": "var(--code-border)",
"--w-rjv-curlybraces-color": "rgb(255,255,255)",
"--w-rjv-key-string": "rgb(247,208,25)",
"--w-rjv-info-color": "var(--code-fg-muted)",
"--w-rjv-type-string-color": "rgb(73,209,110)",
"--w-rjv-quotes-string-color": "rgb(73,209,110)",
"--w-rjv-type-boolean-color": "rgb(187,77,96)",
"--w-rjv-type-int-color": "rgb(247,208,25)",
"--w-rjv-type-float-color": "rgb(247,208,25)",
"--w-rjv-type-bigint-color": "rgb(247,208,25)",
"--w-rjv-key-number": "rgb(247,208,25)",
"--w-rjv-arrow-color": "rgb(255,255,255)",
"--w-rjv-copied-color": "var(--code-fg-subtle)",
"--w-rjv-copied-success-color": "var(--code-fg-base)",
"--w-rjv-colon-color": "rgb(255,255,255)",
} as CSSProperties
}
collapsed={1}
>
<Primitive.Copied
// @ts-expect-error - types are missing the 'data-copied' prop
render={({ "data-copied": copied, onClick }) => {
if (copied) {
return (
<CheckCircleMiniSolid className="text-ui-fg-subtle cursor-pointer align-middle" />
)
}
return (
<SquareTwoStackMini
className="text-ui-fg-subtle cursor-pointer align-middle"
onClick={onClick}
/>
)
}}
/>
<Primitive.Quote render={() => " "} />
<Primitive.Quote render={() => <span />} />
<Primitive.Null
render={() => (
<span className="text-ui-tag-red-text">null</span>
)}
/>
<Primitive.Undefined
render={() => (
<span className="text-ui-code-fg-muted">undefined</span>
)}
/>
<Primitive.CountInfo
render={(_props, { value }) => {
return (
<span className="text-ui-tag-neutral-text ml-2">
{Object.keys(value as object).length} items
{t("general.items", {
count: Object.keys(value as object).length,
})}
</span>
)
}}
/>
{/* <Primitive.Arrow>
<TriangleDownMini className="text-ui-code-fg-subtle -ml-[3px]" />
</Primitive.Arrow> */}
<Primitive.Colon>
<span className="mr-1">:</span>
</Primitive.Colon>
</Primitive>
</Suspense>
</Drawer.Body>

View File

@@ -1,5 +1,6 @@
import { ArrowUturnLeft } from "@medusajs/icons"
import { ArrowUturnLeft, MinusMini } from "@medusajs/icons"
import { IconButton, Text } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useLocation } from "react-router-dom"
@@ -48,10 +49,24 @@ const useSettingRoutes = (): NavItemProps[] => {
label: t("salesChannels.domain"),
to: "/settings/sales-channels",
},
],
[t]
)
}
const useDeveloperRoutes = (): NavItemProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
label: t("apiKeyManagement.domain"),
to: "/settings/api-key-management",
},
{
label: t("executions.domain"),
to: "/settings/executions",
},
],
[t]
)
@@ -59,6 +74,7 @@ const useSettingRoutes = (): NavItemProps[] => {
const SettingsSidebar = () => {
const routes = useSettingRoutes()
const developerRoutes = useDeveloperRoutes()
const { t } = useTranslation()
const location = useLocation()
@@ -80,19 +96,60 @@ const SettingsSidebar = () => {
</IconButton>
</Link>
<Text leading="compact" weight="plus" size="small">
{t("general.settings")}
{t("nav.settings")}
</Text>
</div>
</div>
<div className="px-3">
<div className="border-ui-border-strong h-px w-full border-b border-dashed" />
</div>
<div className="flex flex-1 flex-col gap-y-4 overflow-y-auto py-2">
<nav className="flex flex-col gap-y-1">
{routes.map((setting) => (
<NavItem key={setting.to} {...setting} />
))}
</nav>
<div className="flex flex-1 flex-col overflow-y-auto">
<Collapsible.Root defaultOpen className="py-3">
<div className="px-3">
<div className="text-ui-fg-muted flex h-7 items-center justify-between px-2">
<Text size="small" leading="compact">
{t("nav.general")}
</Text>
<Collapsible.Trigger asChild>
<IconButton size="2xsmall" variant="transparent">
<MinusMini className="text-ui-fg-muted" />
</IconButton>
</Collapsible.Trigger>
</div>
</div>
<Collapsible.Content>
<div className="pt-0.5">
<nav className="flex flex-col gap-y-1">
{routes.map((setting) => (
<NavItem key={setting.to} {...setting} />
))}
</nav>
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root defaultOpen className="py-3">
<div className="px-3">
<div className="text-ui-fg-muted flex h-7 items-center justify-between px-2">
<Text size="small" leading="compact">
{t("nav.developer")}
</Text>
<Collapsible.Trigger asChild>
<IconButton size="2xsmall" variant="transparent">
<MinusMini className="text-ui-fg-muted" />
</IconButton>
</Collapsible.Trigger>
</div>
</div>
<Collapsible.Content>
<div className="pt-0.5">
<nav className="flex flex-col gap-y-1">
{developerRoutes.map((setting) => (
<NavItem key={setting.to} {...setting} />
))}
</nav>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</aside>
)

View File

@@ -31,3 +31,17 @@
@apply bg-ui-bg-subtle text-ui-fg-base;
}
}
@layer components {
.worfklow-grid {
background-image: radial-gradient(black 1px, transparent 0);
background-size: 40px 40px;
background: repeat;
}
}
.worfklow-grid {
background-image: radial-gradient(black 1px, transparent 0);
background-size: 40px 40px;
background: repeat;
}

View File

@@ -688,6 +688,23 @@ const router = createBrowserRouter([
},
],
},
{
path: "executions",
element: <Outlet />,
handle: {
crumb: () => "Executions",
},
children: [
{
path: "",
lazy: () => import("../../routes/executions/execution-list"),
},
{
path: ":id",
lazy: () => import("../../routes/executions/execution-detail"),
},
],
},
...settingsExtensions,
],
},

View File

@@ -0,0 +1,26 @@
import { TransactionState, TransactionStepState } from "./types"
export const STEP_IN_PROGRESS_STATES = [
TransactionStepState.COMPENSATING,
TransactionStepState.INVOKING,
]
export const STEP_OK_STATES = [TransactionStepState.DONE]
export const STEP_ERROR_STATES = [
TransactionStepState.FAILED,
TransactionStepState.REVERTED,
TransactionStepState.TIMEOUT,
TransactionStepState.DORMANT,
TransactionStepState.SKIPPED,
]
export const STEP_INACTIVE_STATES = [TransactionStepState.NOT_STARTED]
export const TRANSACTION_OK_STATES = [TransactionState.DONE]
export const TRANSACTION_ERROR_STATES = [
TransactionState.FAILED,
TransactionState.REVERTED,
]
export const TRANSACTION_IN_PROGRESS_STATES = [
TransactionState.INVOKING,
TransactionState.WAITING_TO_COMPENSATE,
TransactionState.COMPENSATING,
]

View File

@@ -0,0 +1,118 @@
import {
Badge,
Container,
Copy,
Heading,
StatusBadge,
Text,
clx,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import {
TransactionStepState,
WorkflowExecutionDTO,
WorkflowExecutionStep,
} from "../../../types"
import { getTransactionState, getTransactionStateColor } from "../../../utils"
type ExecutionGeneralSectionProps = {
execution: WorkflowExecutionDTO
}
export const ExecutionGeneralSection = ({
execution,
}: ExecutionGeneralSectionProps) => {
const { t } = useTranslation()
const cleanId = execution.id.replace("wf_exec_", "")
const translatedState = getTransactionState(t, execution.state)
const stateColor = getTransactionStateColor(execution.state)
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-0.5">
<Heading>{cleanId}</Heading>
<Copy content={cleanId} className="text-ui-fg-muted" />
</div>
<StatusBadge color={stateColor}>{translatedState}</StatusBadge>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("executions.workflowIdLabel")}
</Text>
<Badge size="2xsmall" className="w-fit">
{execution.workflow_id}
</Badge>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("executions.transactionIdLabel")}
</Text>
<Badge size="2xsmall" className="w-fit">
{execution.transaction_id}
</Badge>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("executions.progressLabel")}
</Text>
<Progress steps={execution.execution?.steps} />
</div>
</Container>
)
}
const ROOT_PREFIX = "_root"
const Progress = ({
steps,
}: {
steps?: Record<string, WorkflowExecutionStep> | null
}) => {
const { t } = useTranslation()
if (!steps) {
return (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{t("executions.stepsCompletedLabel", {
completed: 0,
total: 0,
})}
</Text>
)
}
const actionableSteps = Object.values(steps).filter(
(step) => step.id !== ROOT_PREFIX
)
const completedSteps = actionableSteps.filter(
(step) => step.invoke.state === TransactionStepState.DONE
)
return (
<div className="flex w-fit items-center gap-x-2">
<div className="flex items-center gap-x-[3px]">
{actionableSteps.map((step) => (
<div
key={step.id}
className={clx(
"bg-ui-bg-switch-off shadow-details-switch-background h-3 w-1.5 rounded-full",
{
"bg-ui-fg-muted":
step.invoke.state === TransactionStepState.DONE,
}
)}
/>
))}
</div>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{t("executions.stepsCompletedLabel", {
completed: completedSteps.length,
count: actionableSteps.length,
})}
</Text>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./execution-general-section"

View File

@@ -0,0 +1,320 @@
import { Spinner, TriangleDownMini } from "@medusajs/icons"
import {
CodeBlock,
Container,
Heading,
IconButton,
Text,
clx,
} from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { format } from "date-fns"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useLocation } from "react-router-dom"
import {
STEP_ERROR_STATES,
STEP_INACTIVE_STATES,
STEP_IN_PROGRESS_STATES,
STEP_OK_STATES,
} from "../../../constants"
import {
StepError,
StepInvoke,
TransactionStepState,
TransactionStepStatus,
WorkflowExecutionDTO,
WorkflowExecutionStep,
} from "../../../types"
type ExecutionHistorySectionProps = {
execution: WorkflowExecutionDTO
}
export const ExecutionHistorySection = ({
execution,
}: ExecutionHistorySectionProps) => {
const { t } = useTranslation()
const map = Object.values(execution.execution?.steps || {})
const steps = map.filter((step) => step.id !== "_root")
// check if any of the steps have a .invoke.state of "permanent_failure" and if that is the case then return its id
const unreachableStepId = steps.find(
(step) => step.invoke.status === TransactionStepStatus.PERMANENT_FAILURE
)?.id
// return an array of step ids of all steps that come after the unreachable step if there is one
const unreachableSteps = unreachableStepId
? steps
.filter(
(step) =>
step.id !== unreachableStepId && step.id.includes(unreachableStepId)
)
.map((step) => step.id)
: []
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("executions.history.sectionTitle")}</Heading>
</div>
<div className="flex flex-col gap-y-0.5 px-6 py-4">
{steps.map((step, index) => {
const stepId = step.id.split(".").pop()
if (!stepId) {
return null
}
const context = execution.context?.data.invoke[stepId]
const error = execution.context?.errors.find(
(e) => e.action === stepId
)
return (
<Event
key={step.id}
step={step}
stepInvokeContext={context}
stepError={error}
isLast={index === steps.length - 1}
isUnreachable={unreachableSteps.includes(step.id)}
/>
)
})}
</div>
</Container>
)
}
const Event = ({
step,
stepInvokeContext,
stepError,
isLast,
isUnreachable,
}: {
step: WorkflowExecutionStep
stepInvokeContext: StepInvoke | undefined
stepError?: StepError | undefined
isLast: boolean
isUnreachable?: boolean
}) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { hash } = useLocation()
const { t } = useTranslation()
const stepId = step.id.split(".").pop()!
useEffect(() => {
if (hash === `#${stepId}`) {
setOpen(true)
}
}, [hash, stepId])
const identifier = step.id.split(".").pop()
stepInvokeContext
return (
<div
className="grid grid-cols-[20px_1fr] items-start gap-x-2 px-2"
id={stepId}
>
<div className="grid h-full grid-rows-[20px_1fr] items-center justify-center gap-y-0.5">
<div className="flex size-5 items-center justify-center">
<div className="bg-ui-bg-base shadow-borders-base flex size-2.5 items-center justify-center rounded-full">
<div
className={clx("size-1.5 rounded-full", {
"bg-ui-tag-green-icon": STEP_OK_STATES.includes(
step.invoke.state
),
"bg-ui-tag-orange-icon": STEP_IN_PROGRESS_STATES.includes(
step.invoke.state
),
"bg-ui-tag-red-icon": STEP_ERROR_STATES.includes(
step.invoke.state
),
"bg-ui-tag-neutral-icon": STEP_INACTIVE_STATES.includes(
step.invoke.state
),
})}
/>
</div>
</div>
<div className="flex h-full flex-col items-center">
<div
aria-hidden
role="presentation"
className={clx({
"bg-ui-border-base h-full min-h-[14px] w-px": !isLast,
})}
/>
</div>
</div>
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger asChild>
<div className="group flex cursor-pointer items-start justify-between outline-none">
<Text size="small" leading="compact" weight="plus">
{identifier}
</Text>
<div className="flex items-center gap-x-2">
<StepState
state={step.invoke.state}
startedAt={step.startedAt}
isUnreachable={isUnreachable}
/>
<IconButton size="2xsmall" variant="transparent">
<TriangleDownMini className="text-ui-fg-muted transition-transform group-data-[state=open]:rotate-180" />
</IconButton>
</div>
</div>
</Collapsible.Trigger>
<Collapsible.Content ref={ref}>
<div className="flex flex-col gap-y-2 pb-4 pt-2">
<div className="text-ui-fg-subtle flex flex-col gap-y-2">
<Text size="small" leading="compact">
{t("executions.history.definitionLabel")}
</Text>
<CodeBlock
snippets={[
{
code: JSON.stringify(step.definition, null, 2),
label: t("executions.history.definitionLabel"),
language: "json",
hideLineNumbers: true,
},
]}
>
<CodeBlock.Body />
</CodeBlock>
</div>
{stepInvokeContext && (
<div className="text-ui-fg-subtle flex flex-col gap-y-2">
<Text size="small" leading="compact">
{t("executions.history.outputLabel")}
</Text>
<CodeBlock
snippets={[
{
code: JSON.stringify(
stepInvokeContext.output.output,
null,
2
),
label: t("executions.history.outputLabel"),
language: "json",
hideLineNumbers: true,
},
]}
>
<CodeBlock.Body />
</CodeBlock>
</div>
)}
{!!stepInvokeContext?.output.compensateInput &&
step.compensate.state === TransactionStepState.REVERTED && (
<div className="text-ui-fg-subtle flex flex-col gap-y-2">
<Text size="small" leading="compact">
{t("executions.history.compensateInputLabel")}
</Text>
<CodeBlock
snippets={[
{
code: JSON.stringify(
stepInvokeContext.output.compensateInput,
null,
2
),
label: t("executions.history.compensateInputLabel"),
language: "json",
hideLineNumbers: true,
},
]}
>
<CodeBlock.Body />
</CodeBlock>
</div>
)}
{stepError && (
<div className="text-ui-fg-subtle flex flex-col gap-y-2">
<Text size="small" leading="compact">
{t("executions.history.errorLabel")}
</Text>
<CodeBlock
snippets={[
{
code: JSON.stringify(
{
error: stepError.error,
handlerType: stepError.handlerType,
},
null,
2
),
label: t("executions.history.errorLabel"),
language: "json",
hideLineNumbers: true,
},
]}
>
<CodeBlock.Body />
</CodeBlock>
</div>
)}
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
)
}
const StepState = ({
state,
startedAt,
isUnreachable,
}: {
state: TransactionStepState
startedAt?: number | null
isUnreachable?: boolean
}) => {
const { t } = useTranslation()
const isFailed = state === TransactionStepState.FAILED
const isRunning = state === TransactionStepState.INVOKING
if (isUnreachable) {
return null
}
if (isRunning) {
return (
<div className="flex items-center gap-x-1">
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{t("executions.history.runningState")}
</Text>
<Spinner className="text-ui-fg-interactive animate-spin" />
</div>
)
}
if (isFailed) {
return (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{t("executions.history.failedState")}
</Text>
)
}
if (startedAt) {
return (
<Text size="small" leading="compact" className="text-ui-fg-muted">
{format(startedAt, "dd MMM yyyy HH:mm:ss")}
</Text>
)
}
}

View File

@@ -0,0 +1 @@
export * from "./execution-history-section"

View File

@@ -0,0 +1,24 @@
import { JsonViewSection } from "../../../../../components/common/json-view-section"
import { WorkflowExecutionDTO } from "../../../types"
type ExecutionPayloadSectionProps = {
execution: WorkflowExecutionDTO
}
export const ExecutionPayloadSection = ({
execution,
}: ExecutionPayloadSectionProps) => {
let payload = execution.context?.data?.payload
if (!payload) {
return null
}
// payloads may be simple primitives, so we need to wrap them in an object
// to ensure the JsonViewSection component can render them
if (typeof payload !== "object") {
payload = { input: payload }
}
return <JsonViewSection title="Payload" data={payload as object} />
}

View File

@@ -0,0 +1 @@
export * from "./execution-payload-section"

View File

@@ -0,0 +1,399 @@
import { ArrowPathMini, MinusMini, PlusMini } from "@medusajs/icons"
import { Container, Heading, Text, clx } from "@medusajs/ui"
import {
motion,
useAnimationControls,
useDragControls,
useMotionValue,
} from "framer-motion"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import {
STEP_ERROR_STATES,
STEP_INACTIVE_STATES,
STEP_IN_PROGRESS_STATES,
STEP_OK_STATES,
} from "../../../constants"
import { WorkflowExecutionDTO, WorkflowExecutionStep } from "../../../types"
type ExecutionTimelineSectionProps = {
execution: WorkflowExecutionDTO
}
export const ExecutionTimelineSection = ({
execution,
}: ExecutionTimelineSectionProps) => {
const { t } = useTranslation()
return (
<Container className="overflow-hidden px-0 pb-8 pt-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("general.timeline")}</Heading>
</div>
<div className="w-full overflow-hidden border-y">
<Canvas execution={execution} />
</div>
</Container>
)
}
const createNodeClusters = (steps: Record<string, WorkflowExecutionStep>) => {
const actionableSteps = Object.values(steps).filter(
(step) => step.id !== "_root"
)
const clusters: Record<number, WorkflowExecutionStep[]> = {}
actionableSteps.forEach((step) => {
if (!clusters[step.depth]) {
clusters[step.depth] = []
}
clusters[step.depth].push(step)
})
return clusters
}
const getNextCluster = (
clusters: Record<number, WorkflowExecutionStep[]>,
depth: number
) => {
const nextDepth = depth + 1
return clusters[nextDepth]
}
type ZoomScale = 0.5 | 0.75 | 1
const defaultState = {
x: -860,
y: -1020,
scale: 1,
}
const MAX_ZOOM = 1.5
const MIN_ZOOM = 0.5
const ZOOM_STEP = 0.25
const Canvas = ({ execution }: { execution: WorkflowExecutionDTO }) => {
const [zoom, setZoom] = useState<number>(1)
const scale = useMotionValue(defaultState.scale)
const x = useMotionValue(defaultState.x)
const y = useMotionValue(defaultState.y)
const controls = useAnimationControls()
const dragControls = useDragControls()
const dragConstraints = useRef<HTMLDivElement>(null)
const canZoomIn = zoom < MAX_ZOOM
const canZoomOut = zoom > MIN_ZOOM
useEffect(() => {
const unsubscribe = scale.on("change", (latest) => {
setZoom(latest as ZoomScale)
})
return () => {
unsubscribe()
}
}, [scale])
const clusters = createNodeClusters(execution.execution?.steps || {})
function scaleXandY(
prevScale: number,
newScale: number,
x: number,
y: number
) {
const scaleRatio = newScale / prevScale
return {
x: x * scaleRatio,
y: y * scaleRatio,
}
}
const changeZoom = (newScale: number) => {
const { x: newX, y: newY } = scaleXandY(zoom, newScale, x.get(), y.get())
setZoom(newScale)
controls.set({ scale: newScale, x: newX, y: newY })
}
const zoomIn = () => {
const curr = scale.get()
if (curr < 1.5) {
const newScale = curr + ZOOM_STEP
changeZoom(newScale)
}
}
const zoomOut = () => {
const curr = scale.get()
if (curr > 0.5) {
const newScale = curr - ZOOM_STEP
changeZoom(newScale)
}
}
const resetCanvas = () => {
controls.start(defaultState)
}
return (
<div className="h-[400px] w-full">
<div ref={dragConstraints} className="relative size-full">
<div className="relative size-full overflow-hidden object-contain">
<div>
<motion.div
drag
dragConstraints={dragConstraints}
dragElastic={0}
dragMomentum={false}
dragControls={dragControls}
initial={false}
animate={controls}
transition={{ duration: 0.25 }}
style={{
x,
y,
scale,
}}
className="bg-ui-bg-subtle relative size-[500rem] origin-top-left items-start justify-start overflow-hidden bg-[radial-gradient(var(--border-base)_1.5px,transparent_0)] bg-[length:20px_20px] bg-repeat"
>
<main className="size-full">
<div className="absolute left-[1100px] top-[1100px] flex select-none items-start">
{Object.entries(clusters).map(([depth, cluster]) => {
const next = getNextCluster(clusters, Number(depth))
return (
<div key={depth} className="flex items-start">
<div className="flex flex-col justify-center gap-y-2">
{cluster.map((step) => (
<Node key={step.id} step={step} />
))}
</div>
<Line next={next} />
</div>
)
})}
</div>
</main>
</motion.div>
</div>
</div>
<div className="bg-ui-bg-base shadow-borders-base text-ui-fg-subtle absolute bottom-4 left-6 h-7 overflow-hidden rounded-md">
<button
onClick={zoomIn}
type="button"
disabled={!canZoomIn}
aria-label="Zoom in"
className="disabled:text-ui-fg-disabled transition-fg hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed focus-visible:bg-ui-bg-base-pressed border-r p-1 outline-none"
>
<PlusMini />
</button>
<button
onClick={zoomOut}
type="button"
disabled={!canZoomOut}
aria-label="Zoom out"
className="disabled:text-ui-fg-disabled transition-fg hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed focus-visible:bg-ui-bg-base-pressed border-r p-1 outline-none"
>
<MinusMini />
</button>
<button
onClick={resetCanvas}
type="button"
aria-label="Reset canvas"
className="disabled:text-ui-fg-disabled transition-fg hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed focus-visible:bg-ui-bg-base-pressed p-1 outline-none"
>
<ArrowPathMini />
</button>
</div>
</div>
</div>
)
}
const HorizontalArrow = () => {
return (
<svg
width="42"
height="12"
viewBox="0 0 42 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M41.5303 6.53033C41.8232 6.23744 41.8232 5.76256 41.5303 5.46967L36.7574 0.696699C36.4645 0.403806 35.9896 0.403806 35.6967 0.696699C35.4038 0.989593 35.4038 1.46447 35.6967 1.75736L39.9393 6L35.6967 10.2426C35.4038 10.5355 35.4038 11.0104 35.6967 11.3033C35.9896 11.5962 36.4645 11.5962 36.7574 11.3033L41.5303 6.53033ZM0.999996 5.25C0.585785 5.25 0.249996 5.58579 0.249996 6C0.249996 6.41421 0.585785 6.75 0.999996 6.75V5.25ZM41 5.25L0.999996 5.25V6.75L41 6.75V5.25Z"
fill="var(--border-strong)"
/>
</svg>
)
}
const MiddleArrow = () => {
return (
<svg
width="22"
height="38"
viewBox="0 0 22 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="-mt-[6px]"
>
<path
d="M0.999878 32H0.249878V32.75H0.999878V32ZM21.5284 32.5303C21.8213 32.2374 21.8213 31.7626 21.5284 31.4697L16.7554 26.6967C16.4625 26.4038 15.9876 26.4038 15.6947 26.6967C15.4019 26.9896 15.4019 27.4645 15.6947 27.7574L19.9374 32L15.6947 36.2426C15.4019 36.5355 15.4019 37.0104 15.6947 37.3033C15.9876 37.5962 16.4625 37.5962 16.7554 37.3033L21.5284 32.5303ZM0.249878 0L0.249878 32H1.74988L1.74988 0H0.249878ZM0.999878 32.75L20.998 32.75V31.25L0.999878 31.25V32.75Z"
fill="var(--border-strong)"
/>
</svg>
)
}
const EndArrow = () => {
return (
<svg
width="22"
height="38"
viewBox="0 0 22 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="-mt-[6px]"
>
<path
d="M21.5284 32.5303C21.8213 32.2374 21.8213 31.7626 21.5284 31.4697L16.7554 26.6967C16.4625 26.4038 15.9876 26.4038 15.6947 26.6967C15.4019 26.9896 15.4019 27.4645 15.6947 27.7574L19.9374 32L15.6947 36.2426C15.4019 36.5355 15.4019 37.0104 15.6947 37.3033C15.9876 37.5962 16.4625 37.5962 16.7554 37.3033L21.5284 32.5303ZM0.249878 0L0.249878 28H1.74988L1.74988 0H0.249878ZM4.99988 32.75L20.998 32.75V31.25L4.99988 31.25V32.75ZM0.249878 28C0.249878 30.6234 2.37653 32.75 4.99988 32.75V31.25C3.20495 31.25 1.74988 29.7949 1.74988 28H0.249878Z"
fill="var(--border-strong)"
/>
</svg>
)
}
const Arrow = ({ depth }: { depth: number }) => {
if (depth === 1) {
return <HorizontalArrow />
}
if (depth === 2) {
return (
<div className="flex flex-col items-end">
<HorizontalArrow />
<EndArrow />
</div>
)
}
const inbetween = Array.from({ length: depth - 2 }).map((_, index) => (
<MiddleArrow key={index} />
))
return (
<div className="flex flex-col items-end">
<HorizontalArrow />
{inbetween}
<EndArrow />
</div>
)
}
const Line = ({ next }: { next?: WorkflowExecutionStep[] }) => {
if (!next) {
return null
}
return (
<div className="-ml-[5px] -mr-[7px] w-[60px] pr-[7px]">
<div className="flex min-h-[24px] w-full items-start">
<div className="flex h-6 w-2.5 items-center justify-center">
<div className="bg-ui-button-neutral shadow-borders-base size-2.5 shrink-0 rounded-full" />
</div>
<div className="pt-1.5">
<Arrow depth={next.length} />
</div>
</div>
</div>
)
}
const Node = ({ step }: { step: WorkflowExecutionStep }) => {
if (step.id === "_root") {
return null
}
const stepId = step.id.split(".").pop()
/**
* We can't rely on the built-in hash scrolling because the collapsible,
* so we instead need to manually scroll to the step when the hash changes
*/
const handleScrollTo = () => {
if (!stepId) {
return
}
const historyItem = document.getElementById(stepId)
if (!historyItem) {
return
}
/**
* Scroll to the step if it's the one we're looking for but
* we need to wait for the collapsible to open before scrolling
*/
setTimeout(() => {
historyItem.scrollIntoView({
behavior: "smooth",
block: "end",
})
}, 100)
}
return (
<Link
to={`#${stepId}`}
onClick={handleScrollTo}
className="focus-visible:shadow-borders-focus transition-fg rounded-md outline-none"
>
<div
className="bg-ui-bg-base shadow-borders-base flex min-w-[120px] items-center gap-x-0.5 rounded-md p-0.5"
data-step-id={step.id}
>
<div className="flex size-5 items-center justify-center">
<div
className={clx(
"size-2 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.12)]",
{
"bg-ui-tag-green-icon": STEP_OK_STATES.includes(
step.invoke.state
),
"bg-ui-tag-orange-icon": STEP_IN_PROGRESS_STATES.includes(
step.invoke.state
),
"bg-ui-tag-red-icon": STEP_ERROR_STATES.includes(
step.invoke.state
),
"bg-ui-tag-neutral-icon": STEP_INACTIVE_STATES.includes(
step.invoke.state
),
}
)}
/>
</div>
<Text
size="xsmall"
leading="compact"
weight="plus"
className="select-none"
>
{stepId}
</Text>
</div>
</Link>
)
}

View File

@@ -0,0 +1 @@
export * from "./execution-timeline-section"

View File

@@ -0,0 +1,35 @@
import { useAdminCustomQuery } from "medusa-react"
import { useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { adminExecutionKey } from "../utils"
import { ExecutionGeneralSection } from "./components/execution-general-section"
import { ExecutionHistorySection } from "./components/execution-history-section"
import { ExecutionPayloadSection } from "./components/execution-payload-section"
import { ExecutionTimelineSection } from "./components/execution-timeline-section"
export const ExecutionDetail = () => {
const { id } = useParams()
const { data, isLoading, isError, error } = useAdminCustomQuery(
`/workflows-executions/${id}`,
adminExecutionKey.detail(id!)
)
if (isLoading || !data) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<ExecutionGeneralSection execution={data.workflow_execution} />
<ExecutionPayloadSection execution={data.workflow_execution} />
<ExecutionTimelineSection execution={data.workflow_execution} />
<ExecutionHistorySection execution={data.workflow_execution} />
<JsonViewSection data={data.workflow_execution} />
</div>
)
}

View File

@@ -0,0 +1 @@
export { ExecutionDetail as Component } from "./execution-detail"

View File

@@ -0,0 +1,21 @@
import { AdminProductsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
const executionDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.custom.get(`/workflows-executions/${id}`),
})
export const executionLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = executionDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminProductsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,78 @@
import { AdminGetWorkflowExecutionsParams } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { useAdminCustomQuery } from "medusa-react"
import { useTranslation } from "react-i18next"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { WorkflowExecutionDTO } from "../../../types"
import { adminExecutionKey } from "../../../utils"
import { useExecutionTableColumns } from "./use-execution-table-columns"
import { useExecutionTableQuery } from "./use-execution-table-query"
/**
* Type isn't exported from the package
*/
type WorkflowExecutionsRes = {
workflow_executions: WorkflowExecutionDTO[]
count: number
offset: number
limit: number
}
const PAGE_SIZE = 20
export const ExecutionsListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useExecutionTableQuery({
pageSize: PAGE_SIZE,
})
const { data, isLoading, isError, error } = useAdminCustomQuery<
AdminGetWorkflowExecutionsParams,
WorkflowExecutionsRes
>(
"/workflows-executions",
adminExecutionKey.list(searchParams),
{
...searchParams,
fields: "execution,state",
},
{
keepPreviousData: true,
}
)
const columns = useExecutionTableColumns()
const { table } = useDataTable({
data: data?.workflow_executions || [],
columns,
count: data?.count,
pageSize: PAGE_SIZE,
enablePagination: true,
getRowId: (row) => row.id,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("executions.domain")}</Heading>
</div>
<DataTable
table={table}
columns={columns}
count={data?.count}
isLoading={isLoading}
pageSize={PAGE_SIZE}
navigateTo={(row) => `${row.id}`}
search
pagination
queryObject={raw}
/>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./executions-list-table"

View File

@@ -0,0 +1,72 @@
import { Badge } from "@medusajs/ui"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
import {
TransactionStepState,
WorkflowExecutionDTO,
WorkflowExecutionStep,
} from "../../../types"
import { getTransactionState, getTransactionStateColor } from "../../../utils"
const columnHelper = createColumnHelper<WorkflowExecutionDTO>()
export const useExecutionTableColumns = (): ColumnDef<
WorkflowExecutionDTO,
any
>[] => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("transaction_id", {
header: t("executions.transactionIdLabel"),
cell: ({ getValue }) => <Badge size="2xsmall">{getValue()}</Badge>,
}),
columnHelper.accessor("state", {
header: t("fields.state"),
cell: ({ getValue }) => {
const state = getValue()
const color = getTransactionStateColor(state)
const translatedState = getTransactionState(t, state)
return (
<StatusCell color={color}>
<span className="capitalize">{translatedState}</span>
</StatusCell>
)
},
}),
columnHelper.accessor("execution", {
header: t("executions.progressLabel"),
cell: ({ getValue }) => {
const steps = getValue()?.steps as
| Record<string, WorkflowExecutionStep>
| undefined
if (!steps) {
return "0 of 0 steps"
}
const actionableSteps = Object.values(steps).filter(
(step) => step.id !== ROOT_PREFIX
)
const completedSteps = actionableSteps.filter(
(step) => step.invoke.state === TransactionStepState.DONE
)
return t("executions.stepsCompletedLabel", {
completed: completedSteps.length,
count: actionableSteps.length,
})
},
}),
],
[t]
)
}
const ROOT_PREFIX = "_root"

View File

@@ -0,0 +1,25 @@
import { AdminGetWorkflowExecutionsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useExecutionTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(["q", "offset"], prefix)
const { offset, ...rest } = raw
const searchParams: AdminGetWorkflowExecutionsParams = {
limit: pageSize,
offset: offset ? parseInt(offset) : 0,
...rest,
}
return {
searchParams,
raw,
}
}

View File

@@ -0,0 +1,9 @@
import { ExecutionsListTable } from "./components/executions-list-table"
export const ExcecutionList = () => {
return (
<div className="flex flex-col gap-y-2">
<ExecutionsListTable />
</div>
)
}

View File

@@ -0,0 +1 @@
export { ExcecutionList as Component } from "./execution-list"

View File

@@ -0,0 +1,95 @@
export type WorkflowExecutionStep = {
id: string
invoke: {
state: TransactionStepState
status: TransactionStepStatus
}
definition: {
async?: boolean
compensateAsync?: boolean
noCompensation?: boolean
continueOnPermanentFailure?: boolean
maxRetries?: number
noWait?: boolean
retryInterval?: number
retryIntervalAwaiting?: number
saveResponse?: boolean
timeout?: number
}
compensate: {
state: TransactionStepState
status: TransactionStepStatus
}
depth: number
startedAt: number
}
export type StepInvoke = {
output: {
output: unknown
compensateInput: unknown
}
}
export type StepError = {
error: Record<string, unknown>
action: string
handlerType: string
}
export type WorkflowExecutionContext = {
data: {
invoke: Record<string, StepInvoke>
payload?: unknown
}
compensate: Record<string, unknown>
errors: StepError[]
}
type WorflowExecutionExecution = {
steps: Record<string, WorkflowExecutionStep>
}
/**
* Re-implements WorkflowExecutionDTO as it is currently only exported from `@medusajs/workflows-sdk`.
* Also adds type definitions for fields that have vague types, such as `execution` and `context`.
*/
export interface WorkflowExecutionDTO {
id: string
workflow_id: string
transaction_id: string
execution: WorflowExecutionExecution | null
context: WorkflowExecutionContext | null
state: any
created_at: Date
updated_at: Date
deleted_at: Date
}
export enum TransactionStepStatus {
IDLE = "idle",
OK = "ok",
WAITING = "waiting_response",
TEMPORARY_FAILURE = "temp_failure",
PERMANENT_FAILURE = "permanent_failure",
}
export enum TransactionState {
NOT_STARTED = "not_started",
INVOKING = "invoking",
WAITING_TO_COMPENSATE = "waiting_to_compensate",
COMPENSATING = "compensating",
DONE = "done",
REVERTED = "reverted",
FAILED = "failed",
}
export enum TransactionStepState {
NOT_STARTED = "not_started",
INVOKING = "invoking",
COMPENSATING = "compensating",
DONE = "done",
REVERTED = "reverted",
FAILED = "failed",
DORMANT = "dormant",
SKIPPED = "skipped",
TIMEOUT = "timeout",
}

View File

@@ -0,0 +1,101 @@
import { AdminGetWorkflowExecutionsParams } from "@medusajs/medusa"
import { TFunction } from "i18next"
import {
STEP_ERROR_STATES,
STEP_INACTIVE_STATES,
STEP_IN_PROGRESS_STATES,
TRANSACTION_ERROR_STATES,
TRANSACTION_IN_PROGRESS_STATES,
} from "./constants"
import { TransactionState, TransactionStepState } from "./types"
export const adminExecutionKey = {
detail: (id: string) => ["workflow_executions", "detail", id],
list: (query?: AdminGetWorkflowExecutionsParams) => [
"workflow_executions",
"list",
{ query },
],
}
export const getTransactionStateColor = (
state: TransactionState
): "green" | "orange" | "red" => {
let statusColor: "green" | "red" | "orange" = "green"
if (TRANSACTION_ERROR_STATES.includes(state)) {
statusColor = "red"
}
if (TRANSACTION_IN_PROGRESS_STATES.includes(state)) {
statusColor = "orange"
}
return statusColor
}
export const getTransactionState = (
t: TFunction<"translation", any>,
state: TransactionState
) => {
switch (state) {
case TransactionState.DONE:
return t("executions.state.done")
case TransactionState.FAILED:
return t("executions.state.failed")
case TransactionState.REVERTED:
return t("executions.state.reverted")
case TransactionState.INVOKING:
return t("executions.state.invoking")
case TransactionState.WAITING_TO_COMPENSATE:
return t("executions.transaction.state.waitingToCompensate")
case TransactionState.COMPENSATING:
return t("executions.state.compensating")
case TransactionState.NOT_STARTED:
return t("executions.state.notStarted")
}
}
export const getStepStateColor = (state: TransactionStepState) => {
let statusColor: "green" | "red" | "orange" | "grey" = "green"
if (STEP_ERROR_STATES.includes(state)) {
statusColor = "red"
}
if (STEP_INACTIVE_STATES.includes(state)) {
statusColor = "grey"
}
if (STEP_IN_PROGRESS_STATES.includes(state)) {
statusColor = "orange"
}
return statusColor
}
export const getStepState = (
t: TFunction<"translation", any>,
state: TransactionStepState
) => {
switch (state) {
case TransactionStepState.DONE:
return t("executions.state.done")
case TransactionStepState.FAILED:
return t("executions.state.failed")
case TransactionStepState.REVERTED:
return t("executions.state.reverted")
case TransactionStepState.INVOKING:
return t("executions.state.invoking")
case TransactionStepState.COMPENSATING:
return t("executions.state.compensating")
case TransactionStepState.NOT_STARTED:
return t("executions.state.notStarted")
case TransactionStepState.SKIPPED:
return t("executions.step.state.skipped")
case TransactionStepState.DORMANT:
return t("executions.step.state.dormant")
case TransactionStepState.TIMEOUT:
return t("executions.step.state.timeout")
}
}

View File

@@ -105,6 +105,7 @@
"cva": "1.0.0-beta.1",
"date-fns": "^2.30.0",
"prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0",
"react-currency-input-field": "^3.6.11",
"react-day-picker": "^8.8.0",
"tailwind-merge": "^2.2.1"

View File

@@ -1,6 +1,10 @@
"use client"
import { Highlight, themes } from "prism-react-renderer"
import { Highlight, Prism, themes } from "prism-react-renderer"
import * as React from "react"
;(typeof global !== "undefined" ? global : window).Prism = Prism
// @ts-ignore
import("prismjs/components/prism-json")
import { Copy } from "@/components/copy"
import { clx } from "@/utils/clx"
@@ -215,6 +219,12 @@ const Body = ({
color: "rgb(247,208,25)",
},
},
{
types: ["property"],
style: {
color: "rgb(247,208,25)",
},
},
{
types: ["maybe-class-name"],
style: {
@@ -230,7 +240,7 @@ const Body = ({
{
types: ["comment"],
style: {
color: "rgb(52,211,153)",
color: "var(--code-fg-subtle)",
},
},
],

View File

@@ -81,3 +81,4 @@ export default (app) => {
}
export * from "./query-config"
export * from "./validators"

View File

@@ -8156,7 +8156,7 @@ __metadata:
"@types/node": ^20.11.15
"@types/react": 18.2.43
"@types/react-dom": 18.2.17
"@uiw/react-json-view": 2.0.0-alpha.10
"@uiw/react-json-view": ^2.0.0-alpha.17
"@vitejs/plugin-react": 4.2.1
autoprefixer: ^10.4.17
cmdk: ^0.2.0
@@ -9047,6 +9047,7 @@ __metadata:
jsdom: ^22.1.0
postcss: ^8.4.33
prism-react-renderer: ^2.0.6
prismjs: ^1.29.0
prop-types: ^15.8.1
react: ^18.2.0
react-currency-input-field: ^3.6.11
@@ -18958,14 +18959,14 @@ __metadata:
languageName: node
linkType: hard
"@uiw/react-json-view@npm:2.0.0-alpha.10":
version: 2.0.0-alpha.10
resolution: "@uiw/react-json-view@npm:2.0.0-alpha.10"
"@uiw/react-json-view@npm:^2.0.0-alpha.17":
version: 2.0.0-alpha.17
resolution: "@uiw/react-json-view@npm:2.0.0-alpha.17"
peerDependencies:
"@babel/runtime": ">=7.10.0"
react: ">=18.0.0"
react-dom: ">=18.0.0"
checksum: d1278e92320251b1b61ecacd5c701989bd732ce4155b14a00b674d151c5b0da1b4a043b8696d23675006bf345559e56492960e33a9c0f40bc3c18eed031a136d
checksum: 6c8f10af40db9ae60da4b799fc391a544d2e6eec071d882d64050869b057e26fa6104c00de0406693a341c03a8017cb37c14136d803c9ff60e86c6e0d0187cbc
languageName: node
linkType: hard
@@ -43008,7 +43009,7 @@ __metadata:
languageName: node
linkType: hard
"prismjs@npm:^1.27.0":
"prismjs@npm:^1.27.0, prismjs@npm:^1.29.0":
version: 1.29.0
resolution: "prismjs@npm:1.29.0"
checksum: d906c4c4d01b446db549b4f57f72d5d7e6ccaca04ecc670fb85cea4d4b1acc1283e945a9cbc3d81819084a699b382f970e02f9d1378e14af9808d366d9ed7ec6