Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion console/src/components/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,24 @@ function EmailPreviewContent({
}
}, [data?.code?.source])

// In the small thumbnail (e.g. the Journey Send node) the Gmail-style
// header chrome doesn't fit and just looks broken — show the rendered
// email itself instead, clipped to the thumbnail height.
if (size === "small") {
return compiledHtml ? (
<div className="h-full w-full overflow-hidden bg-white">
<Iframe content={compiledHtml} allowScroll={false} width="100%" />
</div>
) : (
<div className="flex h-full w-full items-center justify-center bg-white text-sm italic text-gray-400">
{labels.noContent}
</div>
)
}

return (
<EmailFrame subject={data.subject} fromName={data.from?.name} labels={labels}>
<Iframe content={compiledHtml} allowScroll={size !== "small"} />
<Iframe content={compiledHtml} allowScroll />
</EmailFrame>
)
}
Expand Down
12 changes: 12 additions & 0 deletions console/src/oapi/management.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4978,6 +4978,12 @@ export interface components {
results: components["schemas"]["OrganizationScheduled"][];
};
UpsertUserScheduledRequest: {
/**
* Format: uuid
* @description The schedule assignment instance id. Omit to create a new assignment (multiple assignments may share the same name per user); supply an existing id to update that assignment in place.
* @example a1b2c3d4-e5f6-7890-abcd-ef1234567890
*/
id?: string;
/**
* Format: uuid
* @description The scheduled definition ID. Either scheduled_id or scheduled_name must be provided.
Expand Down Expand Up @@ -5018,6 +5024,12 @@ export interface components {
} | null;
};
UpsertOrganizationScheduledRequest: {
/**
* Format: uuid
* @description The schedule assignment instance id. Omit to create a new assignment (multiple assignments may share the same name per organization); supply an existing id to update that assignment in place.
* @example a1b2c3d4-e5f6-7890-abcd-ef1234567890
*/
id?: string;
/**
* Format: uuid
* @description The scheduled definition ID. Either scheduled_id or scheduled_name must be provided.
Expand Down
8 changes: 6 additions & 2 deletions console/src/views/campaign/NewCampaign.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ export default function NewCampaign() {
)

if (!hasValidSelection) {
form.setValue("subscription_id", filteredSubscriptions[0].id)
form.setValue("subscription_id", filteredSubscriptions[0].id, {
shouldValidate: true,
})
}
}, [isTransactional, subscriptionsLoading, filteredSubscriptions, subscriptionId, form])

Expand Down Expand Up @@ -323,7 +325,9 @@ export default function NewCampaign() {
onCheckedChange={(checked) => {
field.onChange(checked)
if (checked) {
form.setValue("subscription_id", "")
form.setValue("subscription_id", "", {
shouldValidate: true,
})
}
}}
/>
Expand Down
49 changes: 49 additions & 0 deletions console/src/views/journey/editor/JourneyEditor.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Unit tests for the entrance data_key defaulting: entrances need a data_key for
// their trigger ("input") event variables to be referenceable downstream as
// `journey.<data_key>.data.*`, so we assign one automatically and backfill any
// legacy entrances that lack one.

import { describe, expect, it } from "vitest"

import { defaultEntranceDataKey, stepsToNodes } from "./JourneyEditor.utils"
import type { JourneyStepMap } from "@/types"

describe("defaultEntranceDataKey", () => {
it("uses 'entrance' when free", () => {
expect(defaultEntranceDataKey([])).toBe("entrance")
})

it("falls back to a numbered suffix when taken", () => {
expect(defaultEntranceDataKey(["entrance"])).toBe("entrance_2")
expect(defaultEntranceDataKey(["entrance", "entrance_2"])).toBe("entrance_3")
})

it("ignores gaps and unrelated keys", () => {
expect(defaultEntranceDataKey(["entrance", "purchase", "entrance_3"])).toBe("entrance_2")
})
})

describe("stepsToNodes entrance data_key backfill", () => {
it("assigns a default data_key to entrances that lack one", () => {
const steps: JourneyStepMap = {
a: { type: "entrance", name: "Entry", x: 0, y: 0 },
b: { type: "gate", name: "Gate", x: 0, y: 100 },
}
const { nodes } = stepsToNodes(steps, {})
const entrance = nodes.find((n) => n.id === "a")
const gate = nodes.find((n) => n.id === "b")
expect(entrance?.data.data_key).toBe("entrance")
// Non-entrance steps are left untouched.
expect(gate?.data.data_key).toBeUndefined()
})

it("preserves an existing data_key and de-duplicates against it", () => {
const steps: JourneyStepMap = {
a: { type: "entrance", name: "Entry 1", data_key: "entrance", x: 0, y: 0 },
b: { type: "entrance", name: "Entry 2", x: 200, y: 0 },
}
const { nodes } = stepsToNodes(steps, {})
expect(nodes.find((n) => n.id === "a")?.data.data_key).toBe("entrance")
expect(nodes.find((n) => n.id === "b")?.data.data_key).toBe("entrance_2")
})
})
29 changes: 28 additions & 1 deletion console/src/views/journey/editor/JourneyEditor.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ export function createEdge({ data, sourceId, targetId, path }: CreateEdgeParams)
}
}

/**
* The trigger event payload captured by an entrance is only referenceable from
* downstream steps (gates, campaigns, …) as `journey.<data_key>.data.*`, and the
* backend only exposes a step's state when it has a non-empty `data_key`. Pick a
* readable, unique default ("entrance", "entrance_2", …) so the input event
* variables are available without the user having to discover the data_key field.
*/
export function defaultEntranceDataKey(existingKeys: Iterable<string>): string {
const used = new Set(existingKeys)
if (!used.has("entrance")) return "entrance"
let i = 2
while (used.has(`entrance_${i}`)) i++
return `entrance_${i}`
}

export function stepsToNodes(
stepMap: JourneyStepMap,
actions: {
Expand All @@ -113,13 +128,25 @@ export function stepsToNodes(
const entries = Object.entries(stepMap)
const nodeIds = new Set(entries.map(([id]) => id))

// Backfill a default data_key for entrances that predate auto-assignment so
// their input event variables surface in downstream pickers. This only fills
// empty keys (non-destructive) and is persisted on the next manual save.
const usedDataKeys = new Set(entries.map(([, s]) => s.data_key).filter((k): k is string => !!k))

for (const [
id,
{ x, y, type, data, name, data_key, children, stats, stats_at, id: stepId },
] of entries) {
const { width, height, ...restData } = (data as Record<string, unknown>) ?? {}
const sizeStyle =
typeof width === "number" && typeof height === "number" ? { width, height } : undefined

let resolvedDataKey = data_key
if (!resolvedDataKey && type === "entrance") {
resolvedDataKey = defaultEntranceDataKey(usedDataKeys)
usedDataKeys.add(resolvedDataKey)
}

nodes.push({
id,
position: { x, y },
Expand All @@ -128,7 +155,7 @@ export function stepsToNodes(
data: {
type,
name,
data_key,
data_key: resolvedDataKey,
data: restData,
stats,
stats_at,
Expand Down
8 changes: 7 additions & 1 deletion console/src/views/journey/hooks/useJourneyEditorGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import type { EntranceTrigger } from "../components/JourneyTriggerSetup"
import type { JourneyEditorActionsValue } from "../editor/JourneyEditorActions"
import type { JourneyHintsValue } from "../editor/JourneyHints"
import type { JourneyEdge, JourneyNode } from "../editor/JourneyEditor.types"
import { cloneNodes, getStepType, isValidJourneyConnection } from "../editor/JourneyEditor.utils"
import {
cloneNodes,
defaultEntranceDataKey,
getStepType,
isValidJourneyConnection,
} from "../editor/JourneyEditor.utils"
import { useJourneyFlowHandlers } from "./useJourneyFlowHandlers"
import { useKeyboardShortcuts } from "./useKeyboardShortcuts"
import { useStepEditing } from "./useStepEditing"
Expand Down Expand Up @@ -347,6 +352,7 @@ export function useJourneyEditorGraph({
data: {
type: "entrance",
name: t("entrance"),
data_key: defaultEntranceDataKey([]),
data: { ...defaultData, trigger },
editing: true,
},
Expand Down
29 changes: 27 additions & 2 deletions console/src/views/journey/hooks/useJourneyFlowHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { addEdge, type Connection, MarkerType, type ReactFlowInstance } from "@x
import { useTranslation } from "react-i18next"
import { createUuid } from "@/utils"
import { DATA_FORMAT, STEP_STYLE } from "./JourneyEditor.constants"
import { getStepType, isValidJourneyConnection } from "../editor/JourneyEditor.utils"
import {
defaultEntranceDataKey,
getStepType,
isValidJourneyConnection,
} from "../editor/JourneyEditor.utils"
import type { JourneyEdge, JourneyNode } from "../editor/JourneyEditor.types"

export function useJourneyFlowHandlers(
Expand Down Expand Up @@ -74,14 +78,35 @@ export function useJourneyFlowHandlers(
if (entranceCount > 0) name = `${t("entrance")} ${entranceCount + 1}`
}

// Give entrances a default data_key so their trigger event data is
// immediately referenceable downstream as `journey.<data_key>.data.*`.
const data_key =
payload.type === "entrance"
? defaultEntranceDataKey(
nds.map((n) => n.data.data_key).filter((k): k is string => !!k),
)
: undefined

// An exit must target an entrance (required field). Default to the
// first entrance so single-entrance journeys need no manual pick.
let stepData = data
if (
payload.type === "exit" &&
!(stepData as { entrance_uuid?: string }).entrance_uuid
) {
const firstEntrance = nds.find((n) => n.data.type === "entrance")
if (firstEntrance) stepData = { ...stepData, entrance_uuid: firstEntrance.id }
}

const newNode: JourneyNode = {
id: createUuid(),
position: { x, y },
type: "step",
data: {
type: payload.type,
name,
data,
data_key,
data: stepData,
...(isSticky ? { width: 275, height: 150 } : {}),
},
...(isSticky ? { style: { width: 275, height: 150 } } : {}),
Expand Down
2 changes: 1 addition & 1 deletion console/src/views/settings/clients/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
// pagination if projects start exceeding this.
export function useClients(projectId: UUID) {
const [result, , reload, loading] = useResolver(
useCallback(() => api.authMethods.search(projectId, { limit: 200 }), [projectId]),
useCallback(() => api.authMethods.search(projectId, { limit: 100 }), [projectId]),
)
const clients = (result?.results ?? []).map(authMethodToClient)
return { clients, loading, reload }
Expand Down
33 changes: 29 additions & 4 deletions internal/http/controllers/v1/client/oapi/resources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,12 @@ components:
required:
- name
properties:
id:
type: string
format: uuid
nullable: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
description: The id of the schedule assignment. Omit to create a new assignment (multiple assignments may share the same name per user); supply an existing id to update that assignment in place. The id is returned in the response.
name:
type: string
example: "renewal_date"
Expand Down Expand Up @@ -1685,6 +1691,12 @@ components:
- name
- identifier
properties:
id:
type: string
format: uuid
nullable: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
description: The id of the schedule assignment. Omit to create a new assignment (multiple assignments may share the same name per organization); supply an existing id to update that assignment in place. The id is returned in the response.
name:
type: string
example: "contract_renewal"
Expand Down Expand Up @@ -1721,12 +1733,19 @@ components:
DeleteUserScheduledRequest:
type: object
required:
- name
- identifier
properties:
id:
type: string
format: uuid
nullable: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
description: The id of a specific schedule assignment to delete. When provided, only that instance is removed. Either id or name is required.
name:
type: string
nullable: true
example: "renewal_date"
description: The name of the scheduled resource to delete
description: The name of the scheduled resource to delete. When provided (and id is omitted), every assignment with this name for the user is removed. Either id or name is required.
identifier:
$ref: "#/components/schemas/UserIdentifier"

Expand Down Expand Up @@ -1761,13 +1780,19 @@ components:
DeleteOrganizationScheduledRequest:
type: object
required:
- name
- identifier
properties:
id:
type: string
format: uuid
nullable: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
description: The id of a specific schedule assignment to delete. When provided, only that instance is removed. Either id or name is required.
name:
type: string
nullable: true
example: "contract_renewal"
description: The name of the scheduled resource to delete
description: The name of the scheduled resource to delete. When provided (and id is omitted), every assignment with this name for the organization is removed. Either id or name is required.
identifier:
$ref: "#/components/schemas/OrganizationIdentifier"

Expand Down
22 changes: 17 additions & 5 deletions internal/http/controllers/v1/client/oapi/resources_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading