diff --git a/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx b/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx index e1b4ed3f..e5dd4081 100644 --- a/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx +++ b/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx @@ -1,450 +1,192 @@ import "./CreateDestination.scss"; import Button from "../../common/Button/Button"; -import { - AddIcon, - CloseIcon, - DropdownIcon, - HelpIcon, - Loading, -} from "../../common/Icons"; +import { CloseIcon } from "../../common/Icons"; import Badge from "../../common/Badge/Badge"; -import { useNavigate } from "react-router-dom"; -import { useContext, useEffect, useState } from "react"; -import { ApiContext, formatError } from "../../app"; -import { showToast } from "../../common/Toast/Toast"; -import { mutate } from "swr"; -import TopicPicker from "../../common/TopicPicker/TopicPicker"; -import { DestinationTypeReference, Filter } from "../../typings/Destination"; +import { + Routes, + Route, + Navigate, + useLocation, + useNavigate, + useSearchParams, +} from "react-router-dom"; +import { useState, useMemo, useCallback, useEffect, createContext, useContext } from "react"; +import { DestinationTypeReference } from "../../typings/Destination"; import { useDestinationTypes } from "../../destination-types"; -import DestinationConfigFields from "../../common/DestinationConfigFields/DestinationConfigFields"; -import FilterField from "../../common/FilterField/FilterField"; -import { FilterSyntaxGuide } from "../../common/FilterSyntaxGuide/FilterSyntaxGuide"; -import { useSidebar } from "../../common/Sidebar/Sidebar"; -import { getFormValues } from "../../utils/formHelper"; import CONFIGS from "../../config"; +import TopicsStep from "./steps/TopicsStep"; +import TypeStep from "./steps/TypeStep"; +import ConfigStep from "./steps/ConfigStep"; -type Step = { - title: string; +type StepDef = { + path: string; sidebar_shortname: string; - description: string; - isValid: (values: Record) => boolean; - FormFields: (props: { - defaultValue: Record; - onChange: (value: Record) => void; - destinationTypes?: Record; - }) => React.ReactNode; - action: string; - autoAdvance?: boolean; }; -const EVENT_TOPICS_STEP: Step = { - title: "Select event topics", +const TOPICS_STEP: StepDef = { + path: "topics", sidebar_shortname: "Event topics", - description: "Select the event topics you want to send to your destination", - isValid: (values: Record) => { - if (values.topics?.length > 0) { - return true; - } - return false; - }, - FormFields: ({ - defaultValue, - onChange, - }: { - defaultValue: Record; - onChange: (value: Record) => void; - }) => { - const [selectedTopics, setSelectedTopics] = useState( - defaultValue.topics - ? Array.isArray(defaultValue.topics) - ? defaultValue.topics - : defaultValue.topics.split(",") - : [], - ); - - useEffect(() => { - onChange({ topics: selectedTopics }); - }, [selectedTopics]); - - return ( - <> - - 0 ? selectedTopics.join(",") : ""} - /> - - ); - }, - action: "Next", }; -const DESTINATION_TYPE_STEP: Step = { - title: "Select destination type", +const TYPE_STEP: StepDef = { + path: "type", sidebar_shortname: "Destination type", - description: - "Select the destination type you want to send to your destination", - isValid: (values: Record) => { - if (!values.type) { - return false; - } - return true; - }, - FormFields: ({ - destinationTypes, - defaultValue, - onChange, - }: { - destinationTypes?: Record; - defaultValue: Record; - onChange?: (value: Record) => void; - }) => ( -
-
- {Object.values(destinationTypes ?? {}).map((destination) => ( - - ))} -
-
- ), - action: "Next", - autoAdvance: true, }; -const CONFIGURATION_STEP: Step = { - title: "Configure destination", +const CONFIG_STEP: StepDef = { + path: "config", sidebar_shortname: "Configure destination", - description: "Configure the destination you want to send to your destination", - isValid: (values: Record) => { - // Check if filter is valid (filterValid is set by onValidChange callback) - if (values.filterValid === false) { - return false; - } - return true; - }, - FormFields: ({ - defaultValue, - destinationTypes, - onChange, - }: { - defaultValue: Record; - destinationTypes?: Record; - onChange?: (value: Record) => void; - }) => { - const destinationType = destinationTypes?.[defaultValue.type]; - const [filter, setFilter] = useState(defaultValue.filter || null); - const [showFilter, setShowFilter] = useState(!!defaultValue.filter); - const [filterValid, setFilterValid] = useState(true); - const sidebar = useSidebar(); +}; - const isFilterEnabled = CONFIGS.ENABLE_DESTINATION_FILTER === "true"; +export type CreateDestinationContextValue = { + stepValues: Record; + setStepValues: React.Dispatch>>; + destinationTypes: Record; + hasDestinationTypes: boolean; + nextPath: string | null; + steps: StepDef[]; + buildSearchParams: (extra?: Record) => string; +}; - useEffect(() => { - if (onChange) { - onChange({ ...defaultValue, filter, filterValid }); - } - }, [filter, filterValid]); +const CreateDestinationContext = + createContext(null); - return ( - <> - - {isFilterEnabled && ( -
-
- {showFilter ? ( - <> -

Event Filter

- - - ) : ( - - )} -
- {showFilter && ( -
-

- Add a filter to only receive events that match specific - criteria. Leave empty to receive all events matching the - selected topics. -

- - - -
- )} -
- )} - +export function useCreateDestinationContext(): CreateDestinationContextValue { + const ctx = useContext(CreateDestinationContext); + if (!ctx) { + throw new Error( + "useCreateDestinationContext must be used within CreateDestination", ); - }, - action: "Create Destination", -}; + } + return ctx; +} export default function CreateDestination() { - const apiClient = useContext(ApiContext); - const AVAILABLE_TOPICS = CONFIGS.TOPICS.split(",").filter(Boolean); - let steps = [EVENT_TOPICS_STEP, DESTINATION_TYPE_STEP, CONFIGURATION_STEP]; - - // If there are no topics, skip the first step - if (AVAILABLE_TOPICS.length === 0 && steps.length === 3) { - steps = [DESTINATION_TYPE_STEP, CONFIGURATION_STEP]; - } + const steps = useMemo(() => { + if (AVAILABLE_TOPICS.length === 0) { + return [TYPE_STEP, CONFIG_STEP]; + } + return [TOPICS_STEP, TYPE_STEP, CONFIG_STEP]; + }, [AVAILABLE_TOPICS.length]); + const location = useLocation(); const navigate = useNavigate(); - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [stepValues, setStepValues] = useState>({}); - const [isCreating, setIsCreating] = useState(false); + const [searchParams] = useSearchParams(); const destinationTypes = useDestinationTypes(); const hasDestinationTypes = Object.keys(destinationTypes).length > 0; - const [isValid, setIsValid] = useState(false); - const currentStep = steps[currentStepIndex]; - const nextStep = steps[currentStepIndex + 1] || null; - - // Validate the current step when it changes or stepValues change - useEffect(() => { - if (currentStep.isValid) { - setIsValid(currentStep.isValid(stepValues)); - } else { - setIsValid(false); + // Hydrate stepValues from URL search params on mount (supports page refresh) + const [stepValues, setStepValues] = useState>(() => { + const initial: Record = {}; + const topicsParam = searchParams.get("topics"); + if (topicsParam) { + initial.topics = topicsParam.split(",").filter(Boolean); } - }, [currentStepIndex, stepValues, currentStep]); - - const createDestination = (values: Record) => { - setIsCreating(true); - - const destination_type = destinationTypes[values.type]; - - let topics: string[]; - if (typeof values.topics === "string") { - topics = values.topics.split(",").filter(Boolean); - } else if (typeof values.topics === "undefined") { - topics = ["*"]; - } else if (Array.isArray(values.topics)) { - topics = values.topics; - } else { - // Default to all topics - topics = ["*"]; + const typeParam = searchParams.get("type"); + if (typeParam) { + initial.type = typeParam; } - - // Parse filter from JSON string if provided - let filter: Filter = null; - if (values.filter) { - try { - filter = - typeof values.filter === "string" - ? JSON.parse(values.filter) - : values.filter; - } catch (e) { - // Invalid JSON, ignore filter - } + return initial; + }); + const [maxReachedIndex, setMaxReachedIndex] = useState(0); + + // Derive current step index from URL + const currentStepIndex = useMemo(() => { + const currentPath = location.pathname.split("/new/")[1]?.split("/")[0]; + const index = steps.findIndex((s) => s.path === currentPath); + return index >= 0 ? index : 0; + }, [location.pathname, steps]); + + // Update max reached step when navigating forward + useEffect(() => { + if (currentStepIndex > maxReachedIndex) { + setMaxReachedIndex(currentStepIndex); } + }, [currentStepIndex, maxReachedIndex]); + + // Compute next step path for child components + const nextPath = useMemo(() => { + const nextStep = steps[currentStepIndex + 1]; + return nextStep ? `/new/${nextStep.path}` : null; + }, [steps, currentStepIndex]); + + // Build search params string from current stepValues, with optional extras + const buildSearchParams = useCallback( + (extra?: Record) => { + const params = new URLSearchParams(); + const topics = extra?.topics ?? stepValues.topics; + if (topics) { + const topicsStr = Array.isArray(topics) ? topics.join(",") : topics; + if (topicsStr) params.set("topics", topicsStr); + } + const type = extra?.type ?? stepValues.type; + if (type) params.set("type", type); + const qs = params.toString(); + return qs ? `?${qs}` : ""; + }, + [stepValues], + ); - apiClient - .fetch(`destinations`, { - method: "POST", - body: JSON.stringify({ - type: values.type, - topics: topics, - ...(filter && Object.keys(filter).length > 0 ? { filter } : {}), - config: Object.fromEntries( - Object.entries(values) - .filter(([key]) => - destination_type?.config_fields.some( - (field) => field.key === key, - ), - ) - .map(([key, value]) => [key, String(value)]), - ), - credentials: Object.fromEntries( - Object.entries(values).filter(([key]) => - destination_type?.credential_fields.some( - (field) => field.key === key, - ), - ), - ), - }), - }) - .then((data) => { - showToast("success", `Destination created`); - mutate(`destinations/${data.id}`, data, false); - navigate(`/destinations/${data.id}`); - }) - .catch((error) => { - showToast("error", formatError(error)); - }) - .finally(() => { - setIsCreating(false); - }); - }; + const handleSidebarClick = useCallback( + (index: number) => { + navigate(`/new/${steps[index].path}${buildSearchParams()}`); + }, + [navigate, steps, buildSearchParams], + ); + + const contextValue = useMemo( + () => ({ + stepValues, + setStepValues, + destinationTypes, + hasDestinationTypes, + nextPath, + steps, + buildSearchParams, + }), + [stepValues, setStepValues, destinationTypes, hasDestinationTypes, nextPath, steps, buildSearchParams], + ); return ( -
-
- -
- {steps.map((step, index) => ( - - ))} + +
+
+ +
+ {steps.map((step, index) => ( + + ))} +
-
-
-
-

{currentStep.title}

-

{currentStep.description}

+
+ + } /> + } /> + } /> + } + /> +
-
{ - const formData = new FormData(e.currentTarget); - const values = Object.fromEntries(formData.entries()); - const allValues = { ...stepValues, ...values }; - - if (currentStep.autoAdvance && nextStep && currentStep.isValid?.(allValues)) { - setStepValues(allValues); - setCurrentStepIndex(currentStepIndex + 1); - return; - } - - if (currentStep.isValid) { - setIsValid(currentStep.isValid(allValues)); - } else { - setIsValid(e.currentTarget.checkValidity()); - } - }} - onSubmit={(e) => { - e.preventDefault(); - const form = e.target as HTMLFormElement; - const values = getFormValues(form); - - const newValues = { ...stepValues, ...values }; - if (nextStep) { - setStepValues(newValues); - setCurrentStepIndex(currentStepIndex + 1); - } else { - createDestination(newValues); - } - }} - > -
- {hasDestinationTypes ? ( - { - setStepValues((prev) => ({ ...prev, ...values })); - if (currentStep.isValid) { - setIsValid( - currentStep.isValid({ ...stepValues, ...values }), - ); - } - }} - /> - ) : ( -
- -
- )} -
- {!currentStep.autoAdvance && ( -
- -
- )} -
-
+
); } diff --git a/internal/portal/src/scenes/CreateDestination/steps/ConfigStep.tsx b/internal/portal/src/scenes/CreateDestination/steps/ConfigStep.tsx new file mode 100644 index 00000000..4ea1322e --- /dev/null +++ b/internal/portal/src/scenes/CreateDestination/steps/ConfigStep.tsx @@ -0,0 +1,243 @@ +import { useContext, useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import Button from "../../../common/Button/Button"; +import DestinationConfigFields from "../../../common/DestinationConfigFields/DestinationConfigFields"; +import FilterField from "../../../common/FilterField/FilterField"; +import { FilterSyntaxGuide } from "../../../common/FilterSyntaxGuide/FilterSyntaxGuide"; +import { + AddIcon, + CloseIcon, + HelpIcon, + Loading, +} from "../../../common/Icons"; +import { useSidebar } from "../../../common/Sidebar/Sidebar"; +import { Filter } from "../../../typings/Destination"; +import { getFormValues } from "../../../utils/formHelper"; +import { ApiContext, formatError } from "../../../app"; +import { showToast } from "../../../common/Toast/Toast"; +import { mutate } from "swr"; +import CONFIGS from "../../../config"; +import { useCreateDestinationContext } from "../CreateDestination"; + +export default function ConfigStep() { + const { + stepValues, + setStepValues, + destinationTypes, + hasDestinationTypes, + steps, + } = useCreateDestinationContext(); + const apiClient = useContext(ApiContext); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const sidebar = useSidebar(); + + // Hydrate type from URL search params if context is empty (page refresh) + const type = stepValues.type || searchParams.get("type"); + const destinationType = destinationTypes[type]; + const [filter, setFilter] = useState(stepValues.filter || null); + const [showFilter, setShowFilter] = useState(!!stepValues.filter); + const [filterValid, setFilterValid] = useState(true); + const [isCreating, setIsCreating] = useState(false); + + const isFilterEnabled = CONFIGS.ENABLE_DESTINATION_FILTER === "true"; + + // Redirect to first step if type is missing from both context and URL + useEffect(() => { + if (hasDestinationTypes && !type) { + navigate(`/new/${steps[0].path}`, { replace: true }); + } + }, [hasDestinationTypes, type, navigate, steps]); + + useEffect(() => { + setStepValues((prev) => ({ ...prev, filter, filterValid })); + }, [filter, filterValid, setStepValues]); + + const isValid = filterValid; + + const createDestination = (formValues: Record) => { + // Merge search params as fallback for values lost from context (e.g. page refresh) + const topicsFromUrl = searchParams.get("topics"); + const values = { + ...(topicsFromUrl ? { topics: topicsFromUrl } : {}), + type, + ...stepValues, + ...formValues, + }; + setIsCreating(true); + + const destination_type = destinationTypes[values.type]; + + let topics: string[]; + if (typeof values.topics === "string") { + topics = values.topics.split(",").filter(Boolean); + } else if (typeof values.topics === "undefined") { + topics = ["*"]; + } else if (Array.isArray(values.topics)) { + topics = values.topics; + } else { + topics = ["*"]; + } + + let parsedFilter: Filter = null; + if (values.filter) { + try { + parsedFilter = + typeof values.filter === "string" + ? JSON.parse(values.filter) + : values.filter; + } catch { + // Invalid JSON, ignore filter + } + } + + apiClient + .fetch(`destinations`, { + method: "POST", + body: JSON.stringify({ + type: values.type, + topics: topics, + ...(parsedFilter && Object.keys(parsedFilter).length > 0 + ? { filter: parsedFilter } + : {}), + config: Object.fromEntries( + Object.entries(values) + .filter(([key]) => + destination_type?.config_fields.some( + (field) => field.key === key, + ), + ) + .map(([key, value]) => [key, String(value)]), + ), + credentials: Object.fromEntries( + Object.entries(values).filter(([key]) => + destination_type?.credential_fields.some( + (field) => field.key === key, + ), + ), + ), + }), + }) + .then((data) => { + showToast("success", `Destination created`); + mutate(`destinations/${data.id}`, data, false); + navigate(`/destinations/${data.id}`); + }) + .catch((error) => { + showToast("error", formatError(error)); + }) + .finally(() => { + setIsCreating(false); + }); + }; + + if (!destinationType && hasDestinationTypes && !type) { + return null; // Redirecting + } + + return ( + <> +
+

Configure destination

+

+ Configure the destination you want to send to your destination +

+
+
{ + e.preventDefault(); + const form = e.target as HTMLFormElement; + const values = getFormValues(form); + createDestination(values); + }} + > +
+ {hasDestinationTypes && destinationType ? ( + <> + + {isFilterEnabled && ( +
+
+ {showFilter ? ( + <> +

Event Filter

+ + + ) : ( + + )} +
+ {showFilter && ( +
+

+ Add a filter to only receive events that match specific + criteria. Leave empty to receive all events matching the + selected topics. +

+ + + +
+ )} +
+ )} + + ) : ( +
+ +
+ )} +
+
+ +
+
+ + ); +} diff --git a/internal/portal/src/scenes/CreateDestination/steps/TopicsStep.tsx b/internal/portal/src/scenes/CreateDestination/steps/TopicsStep.tsx new file mode 100644 index 00000000..5c9ce1a2 --- /dev/null +++ b/internal/portal/src/scenes/CreateDestination/steps/TopicsStep.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import Button from "../../../common/Button/Button"; +import TopicPicker from "../../../common/TopicPicker/TopicPicker"; +import { useCreateDestinationContext } from "../CreateDestination"; + +export default function TopicsStep() { + const { stepValues, setStepValues, nextPath, buildSearchParams } = + useCreateDestinationContext(); + const navigate = useNavigate(); + + const [selectedTopics, setSelectedTopics] = useState( + stepValues.topics + ? Array.isArray(stepValues.topics) + ? stepValues.topics + : stepValues.topics.split(",") + : [], + ); + + const isValid = selectedTopics.length > 0; + + useEffect(() => { + setStepValues((prev) => ({ ...prev, topics: selectedTopics })); + }, [selectedTopics, setStepValues]); + + return ( + <> +
+

Select event topics

+

+ Select the event topics you want to send to your destination +

+
+
{ + e.preventDefault(); + if (isValid && nextPath) { + navigate( + nextPath + + buildSearchParams({ topics: selectedTopics.join(",") }), + ); + } + }} + > +
+ +
+
+ +
+
+ + ); +} diff --git a/internal/portal/src/scenes/CreateDestination/steps/TypeStep.tsx b/internal/portal/src/scenes/CreateDestination/steps/TypeStep.tsx new file mode 100644 index 00000000..147a9fbd --- /dev/null +++ b/internal/portal/src/scenes/CreateDestination/steps/TypeStep.tsx @@ -0,0 +1,80 @@ +import { useNavigate } from "react-router-dom"; +import { Loading } from "../../../common/Icons"; +import { useCreateDestinationContext } from "../CreateDestination"; + +export default function TypeStep() { + const { + stepValues, + setStepValues, + destinationTypes, + hasDestinationTypes, + nextPath, + buildSearchParams, + } = useCreateDestinationContext(); + const navigate = useNavigate(); + + return ( + <> +
+

Select destination type

+

+ Select the destination type you want to send to your destination +

+
+
{ + const formData = new FormData(e.currentTarget); + const values = Object.fromEntries(formData.entries()); + if (values.type) { + const type = values.type as string; + setStepValues((prev) => ({ ...prev, type })); + if (nextPath) { + navigate(nextPath + buildSearchParams({ type })); + } + } + }} + onSubmit={(e) => e.preventDefault()} + > +
+ {hasDestinationTypes ? ( +
+
+ {Object.values(destinationTypes).map((destination) => ( + + ))} +
+
+ ) : ( +
+ +
+ )} +
+
+ + ); +}