diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts
index 6d514aa4..75e39f8f 100644
--- a/SW.Bitween.Web/ClientApp/src/api/client.ts
+++ b/SW.Bitween.Web/ClientApp/src/api/client.ts
@@ -215,6 +215,10 @@ export interface ApiClient {
handlerId?: string | null;
handlerProperties?: Record;
schedules?: Schedule[];
+ /** Which lane it runs in. The API has always taken it; no create page used to ask. */
+ workGroupId?: number | null;
+ /** The connection its adapters go through, when one of them needs a data source. */
+ dataSourceId?: number | null;
retryPolicyId?: number | null;
responseSubscriptionId?: number | null;
responseMessageTypeName?: string | null;
diff --git a/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts b/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts
index c61f579e..f96b784f 100644
--- a/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts
+++ b/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts
@@ -437,6 +437,10 @@ export const subscriptionMethods = {
handlerId?: string | null;
handlerProperties?: Record;
schedules?: Schedule[];
+ /** Which lane it runs in. The API has always taken it; no create page used to ask. */
+ workGroupId?: number | null;
+ /** The connection its adapters go through, when one of them needs a data source. */
+ dataSourceId?: number | null;
retryPolicyId?: number | null;
responseSubscriptionId?: number | null;
responseMessageTypeName?: string | null;
@@ -465,6 +469,8 @@ export const subscriptionMethods = {
// Receiving subscription is rejected, and a job created without one is a
// legitimate (if idle) thing to have.
schedules: input.schedules?.length ? toRawSchedules(input.schedules) : undefined,
+ workGroupId: input.workGroupId ?? null,
+ dataSourceId: input.dataSourceId ?? null,
retryPolicyId: input.retryPolicyId ?? null,
customRetryPolicy: null,
responseSubscriptionId: input.responseSubscriptionId ?? null,
diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx
index 058f9994..4fe48658 100644
--- a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx
+++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx
@@ -420,6 +420,7 @@ export function AdapterConfig({
required = false,
noneLabel = "None",
mapperEditorHref,
+ onOpenMapperEditor,
}: {
kind: AdapterKind;
adapterId: string | null;
@@ -432,6 +433,12 @@ export function AdapterConfig({
/** When a mapper with a visual editor is selected, where that editor lives. */
/** Null while the subscription is still a draft — there is no page to open yet. */
mapperEditorHref?: string | null;
+ /**
+ * Opens the editor in place instead of navigating to it. The create pages hold their
+ * subscription in memory, so there is no page to link to — but the editor no longer
+ * needs one, and leaving the page would throw the draft away.
+ */
+ onOpenMapperEditor?: (() => void) | null;
}) {
const catalog = useAdapterCatalog(kind);
const adapter = catalog.data?.find((a) => a.id === adapterId);
@@ -530,7 +537,16 @@ export function AdapterConfig({
)}
{adapter && usesVisualMappingEditor(adapter.id) && (
- mapperEditorHref ? (
+ onOpenMapperEditor ? (
+
+ Open the visual mapping editor
+
+
+ ) : mapperEditorHref ? (
void;
+} = {}) {
return (
-
+
);
}
-function Editor() {
+function Editor({ target, onClose }: { target?: MappingTarget; onClose?: () => void }) {
const { id } = useParams<{ id: string }>();
const subscriptionId = Number(id);
const navigate = useNavigate();
+ const resolved: MappingTarget = target ?? { kind: "subscription", subscriptionId };
const { rules, sourceSample, selectedId, hoveredPath, loadError, dirty, match, testPartnerId } =
useRules();
const dispatch = useRulesDispatch();
- const { partnerId } = useMappingLoader(subscriptionId);
+ const { partnerId } = useMappingLoader(resolved);
const { isPreviewing } = useMappingPreview(testPartnerId ?? partnerId);
// Hiding the preview gives the rules the whole width, which is what a big mapping
// wants once it is built and being read rather than checked.
const [showPreview, setShowPreview] = useState(true);
- const { save, isSaving, justSaved, saveError, replacing } = useMappingSave(subscriptionId);
+ const { save, isSaving, justSaved, saveError, replacing } = useMappingSave(resolved);
// Everything that saves goes through here, so the keyboard cannot slip past the
// question the Save button asks.
@@ -122,8 +138,8 @@ function Editor() {
Nothing has been changed. Saving from here would replace the stored rules, so the editor
will not open them.
- navigate(`/subscriptions/${subscriptionId}`)}>
- Back to the subscription
+ (onClose ? onClose() : navigate(`/subscriptions/${subscriptionId}`))}>
+ {onClose ? "Back" : "Back to the subscription"}
);
@@ -133,6 +149,7 @@ function Editor() {
const leave = () => {
if (dirty && !window.confirm("This mapping has changes that have not been saved. Leave anyway?"))
return;
+ if (onClose) return onClose();
navigate(`/subscriptions/${subscriptionId}`);
};
diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/useMapping.ts b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/useMapping.ts
index f5e09052..bc8c0178 100644
--- a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/useMapping.ts
+++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/useMapping.ts
@@ -6,9 +6,35 @@ import { useRules, useRulesDispatch } from "../../lib/nativeMapper/RulesEditorCo
import { loadMapping, saveMapping, toWire } from "../../lib/nativeMapper/serialize";
import { NATIVE_MAPPER_ID } from "../../lib/nativeMapper/types";
-/** Loads a subscription's rules into the editor, and clears them when the id changes. */
-export function useMappingLoader(subscriptionId: number) {
+/**
+ * Where the editor reads its mapping from and writes it back to.
+ *
+ * `subscription` is the editor's original home: a saved record, read by id and written
+ * with its own request. `draft` is a subscription that does not exist yet — the create
+ * pages hold it in memory, so there is no id to read and nothing to PATCH.
+ *
+ * Only these two hooks ever knew about the id. Everything else in the editor — the
+ * rules, the samples, the preview — already worked on values alone, and the preview
+ * endpoint is stateless (rules + sample + partner), so a mapping can be built and
+ * checked against real output before anything is saved.
+ */
+export type MappingTarget =
+ | { kind: "subscription"; subscriptionId: number }
+ | {
+ kind: "draft";
+ /** What the draft's mapper slot is set to, for the "this would replace" question. */
+ mapperId: string | null;
+ mapperProperties: Record;
+ /** Whose values the preview substitutes; the create pages know it before saving. */
+ partnerId: number | null;
+ /** Hands the rules back to the page holding the draft. */
+ onSave: (mapperProperties: Record) => void;
+ };
+
+/** Loads the target's rules into the editor, and clears them when the target changes. */
+export function useMappingLoader(target: MappingTarget) {
const dispatch = useRulesDispatch();
+ const subscriptionId = target.kind === "subscription" ? target.subscriptionId : 0;
const { data } = useQuery({
queryKey: keys.subscriptions.detail(subscriptionId),
queryFn: () => api.getSubscription(subscriptionId),
@@ -35,6 +61,26 @@ export function useMappingLoader(subscriptionId: number) {
});
}, [data, subscriptionId, dispatch]);
+ // A draft has its rules already — nothing to wait for. Loaded once rather than on
+ // every render: the page builds `mapperProperties` inline, so it is a new object each
+ // time, and re-dispatching LOAD would throw away everything typed since.
+ const draftProperties = target.kind === "draft" ? target.mapperProperties : null;
+ const draftLoaded = useRef(false);
+ useEffect(() => {
+ if (draftProperties === null || draftLoaded.current) return;
+ draftLoaded.current = true;
+ const loaded = loadMapping(draftProperties);
+ dispatch({
+ type: "LOAD",
+ rules: loaded.rules,
+ sourceSample: loaded.sourceSample,
+ targetSample: loaded.targetSample,
+ error: loaded.error,
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [draftProperties === null, dispatch]);
+
+ if (target.kind === "draft") return { partnerId: target.partnerId };
return { partnerId: data?.partnerId ?? null };
}
@@ -105,12 +151,13 @@ export function useMappingPreview(partnerId: number | null) {
return { isPreviewing };
}
-/** Saves the rules onto the subscription, pointing it at this mapper. */
-export function useMappingSave(subscriptionId: number) {
+/** Saves the rules onto the target, pointing it at this mapper. */
+export function useMappingSave(target: MappingTarget) {
const { rules, sourceSample, targetSample } = useRules();
const dispatch = useRulesDispatch();
const queryClient = useQueryClient();
const [justSaved, setJustSaved] = useState(false);
+ const subscriptionId = target.kind === "subscription" ? target.subscriptionId : 0;
// Cached — the loader asked for this already.
const { data } = useQuery({
@@ -123,11 +170,18 @@ export function useMappingSave(subscriptionId: number) {
// mapping built in the other editor is replaced rather than kept alongside. Worth
// asking first: a template someone wrote by hand exists nowhere else once it is gone,
// and reaching this editor no longer requires having saved the switch deliberately.
+ //
+ // A draft is asked the same question about the same thing — its own mapper slot, which
+ // a create page can point at the old mapper before opening this one.
+ const current =
+ target.kind === "draft"
+ ? { mapperId: target.mapperId, mapperProperties: target.mapperProperties }
+ : { mapperId: data?.mapperId, mapperProperties: data?.mapperProperties };
const replacing =
- data?.mapperId &&
- data.mapperId !== NATIVE_MAPPER_ID &&
- Object.keys(data.mapperProperties ?? {}).length > 0
- ? data.mapperId
+ current.mapperId &&
+ current.mapperId !== NATIVE_MAPPER_ID &&
+ Object.keys(current.mapperProperties ?? {}).length > 0
+ ? current.mapperId
: null;
const mutation = useMutation({
@@ -146,9 +200,21 @@ export function useMappingSave(subscriptionId: number) {
},
});
+ // A draft has nowhere to PATCH: the rules go back to the page holding it, and are
+ // written when that page creates the subscription. Nothing can fail here, which is why
+ // there is no error to show and no request to be pending.
+ const onSaveDraft = target.kind === "draft" ? target.onSave : null;
+ const saveDraft = useCallback(() => {
+ onSaveDraft?.(saveMapping(rules, sourceSample, targetSample));
+ dispatch({ type: "SAVED" });
+ setJustSaved(true);
+ setTimeout(() => setJustSaved(false), 2000);
+ return Promise.resolve();
+ }, [onSaveDraft, rules, sourceSample, targetSample, dispatch]);
+
// Resolves either way. The failure is shown from `saveError`, so a rejection here
// would only ever become an unhandled one — every caller fires this and moves on.
- const save = useCallback(
+ const saveSubscription = useCallback(
() => mutation.mutateAsync().then(
() => undefined,
() => undefined,
@@ -157,10 +223,10 @@ export function useMappingSave(subscriptionId: number) {
);
return {
- save,
- isSaving: mutation.isPending,
+ save: onSaveDraft ? saveDraft : saveSubscription,
+ isSaving: onSaveDraft ? false : mutation.isPending,
justSaved,
- saveError: mutation.error ? (mutation.error as Error).message : null,
+ saveError: onSaveDraft || !mutation.error ? null : (mutation.error as Error).message,
/** The other mapper whose stored mapping this save would replace, if any. */
replacing,
};
diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx
index 5510e23d..9781990d 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx
@@ -18,6 +18,11 @@ import { adapterIncomplete, faceOf } from "../subscriptions/studio/faces";
import { ResponseFields } from "../subscriptions/studio/ResponseFields";
import type { Draft as StudioDraft } from "../subscriptions/studio/model";
import { BackLink } from "../../components/ui/BackLink";
+import { DataSourceBinding } from "../subscriptions/studio/DataSourceBinding";
+import { LaneAndRetry } from "../subscriptions/studio/LaneAndRetry";
+import { useBindsToDataSource } from "../data-sources/providers";
+import NativeMapperEditor from "../../components/nativeMapper/NativeMapperEditor";
+import { NATIVE_MAPPER_ID } from "../../lib/nativeMapper/types";
/** Local draft state with the patch-and-clear shape the other create pages use. */
function useDraft(initial: T) {
@@ -40,6 +45,9 @@ type Draft = Pick<
| "handlerProperties"
| "responseSubscriptionId"
| "responseMessageTypeName"
+ | "workGroupId"
+ | "retryPolicyId"
+ | "dataSourceId"
> & {
aggregationForId: number | null;
partnerId: number | null;
@@ -67,6 +75,9 @@ const EMPTY: Draft = {
handlerProperties: {},
responseSubscriptionId: null,
responseMessageTypeName: null,
+ workGroupId: null,
+ retryPolicyId: null,
+ dataSourceId: null,
enable: false,
};
@@ -87,6 +98,8 @@ export function NewAggregationPage() {
const fixedSourceId = params.get("source") ? Number(params.get("source")) : null;
const [stage, setStage] = useState("aggregation");
+ /** The visual mapper, over the page — there is no subscription page to send you to yet. */
+ const [mapping, setMapping] = useState(false);
const allSubscriptions = useSubscriptionsCache();
const receivers = useAdapterCatalog("receiver");
@@ -95,6 +108,7 @@ export function NewAggregationPage() {
const handlers = useAdapterCatalog("handler");
const [draft, update] = useDraft({ ...EMPTY, aggregationForId: fixedSourceId });
+ const bindsToDataSource = useBindsToDataSource();
const source = allSubscriptions.data?.find((s) => s.id === draft.aggregationForId) ?? null;
@@ -124,6 +138,9 @@ export function NewAggregationPage() {
handlerProperties: draft.handlerProperties,
responseSubscriptionId: draft.responseSubscriptionId,
responseMessageTypeName: draft.responseMessageTypeName,
+ workGroupId: draft.workGroupId,
+ retryPolicyId: draft.retryPolicyId,
+ dataSourceId: draft.dataSourceId,
enabled: draft.enable,
}),
onSuccess: (created) => {
@@ -136,14 +153,12 @@ export function NewAggregationPage() {
const studioDraft: StudioDraft = {
...draft,
enabled: draft.enable,
- workGroupId: null,
- retryPolicyId: null,
+ // An aggregation is fed by its source, not by a receiver, and has no Validation
+ // stage — see stages.ts.
receiverId: null,
receiverProperties: {},
validatorId: null,
validatorProperties: {},
- // A new subscription binds no connection until an adapter that needs one is chosen.
- dataSourceId: null,
matchExpression: null,
};
@@ -210,11 +225,25 @@ export function NewAggregationPage() {
onChange={(mapperId, mapperProperties) => update({ mapperId, mapperProperties })}
disabled={false}
noneLabel="None — the list of links is delivered as it is"
+ onOpenMapperEditor={() => setMapping(true)}
/>
What arrives here is a JSON list of links, not the documents themselves. Combining
them into one file is this step's job, or the delivery's.
+ {bindsToDataSource(draft.mapperId, "mapper") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(mapperProperties) => update({ mapperProperties })}
+ disabled={false}
+ />
+
+ )}
);
case "delivery":
@@ -228,6 +257,19 @@ export function NewAggregationPage() {
disabled={false}
required
/>
+ {bindsToDataSource(draft.handlerId, "handler") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(handlerProperties) => update({ handlerProperties })}
+ disabled={false}
+ />
+
+ )}
);
case "response":
@@ -309,6 +351,20 @@ export function NewAggregationPage() {
+ {/* The two settings that belong to no stage, in the strip the subscription's own
+ page keeps them in. Offered here because the API has always accepted them on a
+ create — leaving them out is what made a new aggregation need a second visit. */}
+
+ update({ workGroupId })}
+ onRetryPolicyChange={(retryPolicyId) => update({ retryPolicyId })}
+ canEdit
+ idPrefix="na"
+ />
+
+
{stage !== null && (
@@ -357,6 +413,22 @@ export function NewAggregationPage() {
{create.error?.message}
+
+ {/* Over the page rather than a route of its own: the aggregation exists only in this
+ component's state, so navigating to the editor would throw it away. Saving in
+ there hands the rules back to the draft; they are written on Create. */}
+ {mapping && (
+ update({ mapperId: NATIVE_MAPPER_ID, mapperProperties }),
+ }}
+ onClose={() => setMapping(false)}
+ />
+ )}
);
}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx
index 546f5724..d95a66c5 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx
@@ -4,14 +4,18 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { api } from "../../api";
import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics";
-import { Field, TextInput } from "../../components/ui/forms";
+import { Checkbox, Field, TextInput } from "../../components/ui/forms";
import { Panel } from "../../components/ui/Panel";
import {
AdapterConfig,
useAdapterCatalog,
- usesVisualMappingEditor,
} from "../../components/config/AdapterConfig";
import { InfoTypePicker } from "../../components/config/pickers";
+import { DataSourceBinding } from "../subscriptions/studio/DataSourceBinding";
+import { LaneAndRetry } from "../subscriptions/studio/LaneAndRetry";
+import { useBindsToDataSource } from "../data-sources/providers";
+import NativeMapperEditor from "../../components/nativeMapper/NativeMapperEditor";
+import { NATIVE_MAPPER_ID } from "../../lib/nativeMapper/types";
import { useSubscriptionsCache } from "../../components/config/shared";
import { STAGES, stagesFor, type StageId } from "../subscriptions/studio/stages";
import { StageRail } from "../subscriptions/studio/StageRail";
@@ -40,6 +44,10 @@ type Draft = Pick<
| "handlerProperties"
| "responseSubscriptionId"
| "responseMessageTypeName"
+ | "enabled"
+ | "workGroupId"
+ | "retryPolicyId"
+ | "dataSourceId"
> & { informationTypeId: number | null };
const EMPTY: Draft = {
@@ -53,6 +61,12 @@ const EMPTY: Draft = {
handlerProperties: {},
responseSubscriptionId: null,
responseMessageTypeName: null,
+ // Live as soon as it exists: it only runs once a partner is attached to it, which is
+ // the thing you are in the middle of doing.
+ enabled: true,
+ workGroupId: null,
+ retryPolicyId: null,
+ dataSourceId: null,
};
const STAGES_HERE = stagesFor("GatewayApiCall");
@@ -67,6 +81,11 @@ const STAGES_HERE = stagesFor("GatewayApiCall");
* until the attachment page does, so there is nothing to leave half-wired if you
* stop after creating it, and the return trip lands you back there with it
* already picked.
+ *
+ * It offers every setting the subscription's own page does, because the create call
+ * has always accepted them all. What it used to leave out — the lane, the retry
+ * policy, the connection, the mapping — was the whole reason a new subscription had
+ * to be opened a second time to finish it.
*/
export function NewGatewaySubscriptionPage() {
const { id = "" } = useParams();
@@ -76,6 +95,8 @@ export function NewGatewaySubscriptionPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [stage, setStage] = useState("delivery");
+ /** The visual mapper, over the page — there is no subscription page to send you to yet. */
+ const [mapping, setMapping] = useState(false);
const gateway = useQuery({
queryKey: keys.apiGateways.detail(gatewayId),
@@ -88,6 +109,7 @@ export function NewGatewaySubscriptionPage() {
const handlers = useAdapterCatalog("handler");
const [draft, update] = useDraft(EMPTY);
+ const bindsToDataSource = useBindsToDataSource();
/**
* Fills the name in from the gateway, so the field is one to accept rather than one
@@ -129,9 +151,10 @@ export function NewGatewaySubscriptionPage() {
handlerProperties: draft.handlerProperties,
responseSubscriptionId: draft.responseSubscriptionId,
responseMessageTypeName: draft.responseMessageTypeName,
- // Safe to enable: it waits for an attachment, which is what you are in
- // the middle of making.
- enabled: true,
+ workGroupId: draft.workGroupId,
+ retryPolicyId: draft.retryPolicyId,
+ dataSourceId: draft.dataSourceId,
+ enabled: draft.enabled,
}),
onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all });
@@ -157,13 +180,8 @@ export function NewGatewaySubscriptionPage() {
...draft,
// Aggregation only, and this page never creates one.
aggregationTarget: "Input",
- enabled: true,
- workGroupId: null,
- retryPolicyId: null,
receiverId: null,
receiverProperties: {},
- // A new subscription binds no connection until an adapter that needs one is chosen.
- dataSourceId: null,
matchExpression: null,
schedules: [],
};
@@ -224,11 +242,20 @@ export function NewGatewaySubscriptionPage() {
onChange={(mapperId, mapperProperties) => update({ mapperId, mapperProperties })}
disabled={false}
noneLabel="None — the document passes through unchanged"
+ onOpenMapperEditor={() => setMapping(true)}
/>
- {usesVisualMappingEditor(draft.mapperId) && (
-
- The visual mapping editor opens from the subscription's own page, once it exists.
-
+ {bindsToDataSource(draft.mapperId, "mapper") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(mapperProperties) => update({ mapperProperties })}
+ disabled={false}
+ />
+
)}
);
@@ -243,6 +270,19 @@ export function NewGatewaySubscriptionPage() {
disabled={false}
required
/>
+ {bindsToDataSource(draft.handlerId, "handler") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(handlerProperties) => update({ handlerProperties })}
+ disabled={false}
+ />
+
+ )}
);
case "response":
@@ -272,7 +312,7 @@ export function NewGatewaySubscriptionPage() {
New subscription for {g.name}
- Set up as much of it as you'd like — the rest is still here, on its own page, once it exists.
+ Everything it needs, in one go — nothing here has to wait until after it is created.
@@ -305,6 +345,20 @@ export function NewGatewaySubscriptionPage() {
+ {/* The two settings that belong to no stage, in the strip the subscription's own
+ page keeps them in. Offered here because the API has always accepted them on a
+ create — leaving them out is what made a new subscription need a second visit. */}
+
+ update({ workGroupId })}
+ onRetryPolicyChange={(retryPolicyId) => update({ retryPolicyId })}
+ canEdit
+ idPrefix="ngi"
+ />
+
+
{stage !== null && (
@@ -322,25 +376,49 @@ export function NewGatewaySubscriptionPage() {
)}
-
- {missing.length > 0 && (
-
- Still needs {missing.slice(0, -1).join(", ")}
- {missing.length > 1 ? " and " : ""}
- {missing.at(-1)}.
-
- )}
-
backToAttach({})}>Cancel
-
0}
- onClick={() => create.mutate()}
- >
- Create subscription
-
+
+
update({ enabled: e.target.checked })}
+ />
+
+ {missing.length > 0 && (
+
+ Still needs {missing.slice(0, -1).join(", ")}
+ {missing.length > 1 ? " and " : ""}
+ {missing.at(-1)}.
+
+ )}
+
backToAttach({})}>Cancel
+
0}
+ onClick={() => create.mutate()}
+ >
+ Create subscription
+
+
{create.error?.message}
+
+ {/* Over the page rather than a route of its own: the subscription exists only in
+ this component's state, so navigating to the editor would throw it away. Saving
+ in there hands the rules back to the draft; they are written on Create. */}
+ {mapping && (
+
update({ mapperId: NATIVE_MAPPER_ID, mapperProperties }),
+ }}
+ onClose={() => setMapping(false)}
+ />
+ )}
);
}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts b/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts
index b54c5e7b..9c0beb25 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts
+++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts
@@ -89,3 +89,20 @@ export const isSecretName = (
setting?.secret === true ||
declared.some((d) => d.toLowerCase() === name.toLowerCase()) ||
CREDENTIAL.test(name);
+
+/**
+ * Which adapters bind to a data source at all, as a hook so every page asking the
+ * question shares one answer — the subscription's own page and all three create pages.
+ *
+ * A relational one always does; a BROKER one does in a delivery, because that is how a
+ * subscription answers on the customer's own queue — ingress from a broker comes through
+ * a bus gateway rather than through a subscription slot.
+ */
+export function useBindsToDataSource() {
+ const providers = useDataSourceProviders();
+ return (adapterId: string | null, slot: "receiver" | "mapper" | "handler") => {
+ const kind = adapterId == null ? null : providerOf(providers.data, adapterId)?.kind;
+ if (kind === "Relational") return true;
+ return kind === "Broker" && slot !== "receiver";
+ };
+}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/NewScheduledJobPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/NewScheduledJobPage.tsx
index 5c835e50..a222b367 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/NewScheduledJobPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/NewScheduledJobPage.tsx
@@ -8,7 +8,6 @@ import { Panel } from "../../components/ui/Panel";
import {
AdapterConfig,
useAdapterCatalog,
- usesVisualMappingEditor,
} from "../../components/config/AdapterConfig";
import { ScheduleEditor } from "../../components/config/ScheduleEditor";
import { InfoTypePicker } from "../../components/config/pickers";
@@ -20,6 +19,11 @@ import { adapterIncomplete, faceOf } from "../subscriptions/studio/faces";
import { ResponseFields } from "../subscriptions/studio/ResponseFields";
import type { Draft as StudioDraft } from "../subscriptions/studio/model";
import { BackLink } from "../../components/ui/BackLink";
+import { DataSourceBinding } from "../subscriptions/studio/DataSourceBinding";
+import { LaneAndRetry } from "../subscriptions/studio/LaneAndRetry";
+import { useBindsToDataSource } from "../data-sources/providers";
+import NativeMapperEditor from "../../components/nativeMapper/NativeMapperEditor";
+import { NATIVE_MAPPER_ID } from "../../lib/nativeMapper/types";
/** Local draft state with the patch-and-clear shape the form bodies already use. */
function useDraft(initial: T) {
@@ -45,6 +49,9 @@ type Draft = Pick<
| "handlerProperties"
| "responseSubscriptionId"
| "responseMessageTypeName"
+ | "workGroupId"
+ | "retryPolicyId"
+ | "dataSourceId"
> & {
informationTypeId: number | null;
enable: boolean;
@@ -64,6 +71,9 @@ const EMPTY: Draft = {
handlerProperties: {},
responseSubscriptionId: null,
responseMessageTypeName: null,
+ workGroupId: null,
+ retryPolicyId: null,
+ dataSourceId: null,
enable: true,
};
@@ -79,6 +89,8 @@ export function NewScheduledJobPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [stage, setStage] = useState("source");
+ /** The visual mapper, over the page — there is no subscription page to send you to yet. */
+ const [mapping, setMapping] = useState(false);
const allSubscriptions = useSubscriptionsCache();
const receivers = useAdapterCatalog("receiver");
@@ -87,6 +99,7 @@ export function NewScheduledJobPage() {
const handlers = useAdapterCatalog("handler");
const [draft, update, clear] = useDraft(EMPTY);
+ const bindsToDataSource = useBindsToDataSource();
const create = useMutation({
@@ -104,6 +117,9 @@ export function NewScheduledJobPage() {
schedules: draft.schedules,
responseSubscriptionId: draft.responseSubscriptionId,
responseMessageTypeName: draft.responseMessageTypeName,
+ workGroupId: draft.workGroupId,
+ retryPolicyId: draft.retryPolicyId,
+ dataSourceId: draft.dataSourceId,
enabled: draft.enable,
}),
onSuccess: (created) => {
@@ -113,19 +129,16 @@ export function NewScheduledJobPage() {
},
});
- // faceOf works off the studio's full draft shape; the fields this page can't
- // set yet are simply empty.
+ // faceOf works off the studio's full draft shape; the fields this type never has
+ // are simply empty.
const studioDraft: StudioDraft = {
...draft,
// Aggregation only, and this page never creates one.
aggregationTarget: "Input",
enabled: draft.enable,
- workGroupId: null,
- retryPolicyId: null,
+ // Receiving has no Validation stage — see stages.ts.
validatorId: null,
validatorProperties: {},
- // A new subscription binds no connection until an adapter that needs one is chosen.
- dataSourceId: null,
matchExpression: null,
};
@@ -169,6 +182,22 @@ export function NewScheduledJobPage() {
disabled={false}
required
/>
+ {bindsToDataSource(draft.receiverId, "receiver") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(receiverProperties) => update({ receiverProperties })}
+ disabled={false}
+ />
+
+ )}
);
case "schedule":
@@ -191,11 +220,23 @@ export function NewScheduledJobPage() {
onChange={(mapperId, mapperProperties) => update({ mapperId, mapperProperties })}
disabled={false}
noneLabel="None — the document passes through unchanged"
+ onOpenMapperEditor={() => setMapping(true)}
/>
- {usesVisualMappingEditor(draft.mapperId) && (
-
- The visual mapping editor opens from the job's own page, once it exists.
-
+ {bindsToDataSource(draft.mapperId, "mapper") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(mapperProperties) => update({ mapperProperties })}
+ disabled={false}
+ />
+
)}
);
@@ -224,6 +265,22 @@ export function NewScheduledJobPage() {
disabled={false}
required
/>
+ {bindsToDataSource(draft.handlerId, "handler") && (
+
+ update({ dataSourceId })}
+ onPropertiesChange={(handlerProperties) => update({ handlerProperties })}
+ disabled={false}
+ />
+
+ )}
);
default:
@@ -263,6 +320,20 @@ export function NewScheduledJobPage() {
+ {/* The two settings that belong to no stage, in the strip the subscription's own
+ page keeps them in. Offered here because the API has always accepted them on a
+ create — leaving them out is what made a new job need a second visit. */}
+
+ update({ workGroupId })}
+ onRetryPolicyChange={(retryPolicyId) => update({ retryPolicyId })}
+ canEdit
+ idPrefix="nj"
+ />
+
+
{stage !== null && (
@@ -307,6 +378,24 @@ export function NewScheduledJobPage() {
{create.error?.message}
+
+ {/* Over the page rather than a route of its own: the job exists only in this
+ component's state, so navigating to the editor would throw it away. Saving in
+ there hands the rules back to the draft; they are written on Create. */}
+ {mapping && (
+ update({ mapperId: NATIVE_MAPPER_ID, mapperProperties }),
+ }}
+ onClose={() => setMapping(false)}
+ />
+ )}
);
}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx
index 0a7d73ef..45f2c1e0 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx
@@ -14,7 +14,7 @@ import { AggregationFields } from "../../components/config/AggregationFields";
import { TypeBadge, scheduleFault, useSubscriptionsCache } from "../../components/config/shared";
import { STAGES, stagesFor, type StageId } from "./studio/stages";
import { DataSourceBinding } from "./studio/DataSourceBinding";
-import { providerOf, useDataSourceProviders } from "../data-sources/providers";
+import { useBindsToDataSource } from "../data-sources/providers";
import { StageRail } from "./studio/StageRail";
import { faceOf } from "./studio/faces";
import { EntryPointsTable, Overview } from "./studio/Overview";
@@ -30,7 +30,6 @@ export function SubscriptionPage() {
const queryClient = useQueryClient();
const canEdit = useSessionCan("subscriptions.edit");
const canOperate = useSessionCan("subscriptions.operate");
- const canCreateWorkGroup = useSessionCan("workgroups.create");
const [params, setParams] = useSearchParams();
const subscription = useQuery({
@@ -58,7 +57,7 @@ export function SubscriptionPage() {
// Which adapters are database providers, so the connection controls appear only where they mean
// something. Called with the other queries because this component returns early below, and a
// hook after that point runs in a different order on the two paths.
- const dataSourceProviders = useDataSourceProviders();
+ const bindsToDataSource = useBindsToDataSource();
const scheduleHealth = useQuery({
queryKey: keys.subscriptions.scheduleHealth,
@@ -176,14 +175,6 @@ export function SubscriptionPage() {
const entryPoints = entryPointsOf(s);
const stages = stagesFor(s.type);
- // Which adapters bind to a data source at all. A relational one always does; a BROKER one does
- // in a delivery, because that is how a subscription answers on the customer's own queue —
- // ingress from a broker comes through a bus gateway rather than through a subscription slot.
- const bindsToDataSource = (adapterId: string | null, slot: "receiver" | "mapper" | "handler") => {
- const kind = adapterId == null ? null : providerOf(dataSourceProviders.data, adapterId)?.kind;
- if (kind === "Relational") return true;
- return kind === "Broker" && slot !== "receiver";
- };
const stageParam = params.get("stage") as StageId | null;
const stage = stageParam && stages.includes(stageParam) ? stageParam : null;
@@ -501,7 +492,6 @@ export function SubscriptionPage() {
draft={draft}
set={set}
canEdit={canEdit}
- canCreateWorkGroup={canCreateWorkGroup}
entryPoints={entryPoints}
scheduled={isReceiver || isAggregation}
/>
diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Fact.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Fact.tsx
new file mode 100644
index 00000000..bdaee3fd
--- /dev/null
+++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Fact.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from "react";
+
+/**
+ * One labelled cell of the facts strip.
+ *
+ * Its own file so `LaneAndRetry` and `Overview` can both use it without either importing
+ * the other — `Overview` pulls in the whole run history, which a create page has no use for.
+ */
+export function Fact({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/LaneAndRetry.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/LaneAndRetry.tsx
new file mode 100644
index 00000000..ded02619
--- /dev/null
+++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/LaneAndRetry.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import { Link } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import { api } from "../../../api";
+import { useSessionCan } from "../../../auth/guards";
+import { SearchSelect } from "../../../components/ui/SearchSelect";
+import { WorkGroupDialog } from "../../../components/config/WorkGroupDialog";
+import { keys } from "../../../api/queryKeys";
+import { Fact } from "./Fact";
+
+/**
+ * Which lane a subscription runs in, and what happens when it fails.
+ *
+ * Extracted from `Overview` so the create pages can offer the same two settings the
+ * edit page does. They belong to no pipeline stage — they are properties of the
+ * subscription itself — which is why they sit beside the facts rather than in the rail.
+ *
+ * Both were create-time blind spots: the API has always accepted them on a create, but
+ * no create page asked, so every new subscription was born ungrouped and un-retried and
+ * had to be reopened to fix that.
+ */
+export function LaneAndRetry({
+ workGroupId,
+ retryPolicyId,
+ onWorkGroupChange,
+ onRetryPolicyChange,
+ canEdit,
+ idPrefix,
+}: {
+ workGroupId: number | null;
+ retryPolicyId: number | null;
+ onWorkGroupChange: (id: number | null) => void;
+ onRetryPolicyChange: (id: number | null) => void;
+ canEdit: boolean;
+ /** Ids have to be unique per page — the create pages render this beside their own fields. */
+ idPrefix: string;
+}) {
+ const canCreateWorkGroup = useSessionCan("workgroups.create");
+ const workGroups = useQuery({ queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups() });
+ const retryPolicies = useQuery({
+ queryKey: keys.retryPolicies.list,
+ queryFn: () => api.listRetryPolicies(),
+ });
+ /** undefined = closed, null = creating, number = editing that group. */
+ const [groupDialog, setGroupDialog] = useState(undefined);
+
+ return (
+ <>
+
+
+ onWorkGroupChange(v === "" ? null : Number(v))}
+ clearLabel="Ungrouped (default lane)"
+ options={(workGroups.data ?? []).map((w) => ({ value: String(w.id), label: w.name }))}
+ />
+
+
+ {workGroupId !== null && (
+ setGroupDialog(workGroupId)}
+ className="text-[12px] font-medium text-crimson-700 hover:underline"
+ >
+ Edit it
+
+ )}
+ {canCreateWorkGroup && (
+ setGroupDialog(null)}
+ className="text-[12px] font-medium text-crimson-700 hover:underline"
+ >
+ + New
+
+ )}
+
+
+
+
+ onRetryPolicyChange(v === "" ? null : Number(v))}
+ clearLabel="None — failures are not retried"
+ options={(retryPolicies.data ?? []).map((p) => ({ value: String(p.id), label: p.name }))}
+ />
+
+ {retryPolicyId !== null && (
+
+ View
+
+ )}
+
+ {groupDialog !== undefined && (
+ setGroupDialog(undefined)}
+ onSaved={onWorkGroupChange}
+ />
+ )}
+ >
+ );
+}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx
index ad6f04b7..0c0d5ac6 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx
@@ -1,18 +1,17 @@
-import { useState, type ReactNode } from "react";
import { Link } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { api, type SubscriptionDetail, type SubscriptionRun } from "../../../api";
import { Can } from "../../../auth/guards";
import { Badge, LoadingBlock } from "../../../components/ui/basics";
-import { SearchSelect } from "../../../components/ui/SearchSelect";
import { MiniTable } from "../../../components/ui/Table";
import { Panel } from "../../../components/ui/Panel";
import { HistoryCard } from "../../../components/config/HistoryCard";
import { ExchangesList, HealthBadge } from "../../../components/config/shared";
-import { WorkGroupDialog } from "../../../components/config/WorkGroupDialog";
import { formatDate, formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../../lib/dates";
import { ReceiveAttemptsPanel, type AttemptKind } from "./ReceiveAttemptsPanel";
import { RetryBudget } from "./RetryBudget";
+import { Fact } from "./Fact";
+import { LaneAndRetry } from "./LaneAndRetry";
import type { Draft, EntryPoint } from "./model";
import { keys } from "../../../api/queryKeys";
@@ -61,15 +60,6 @@ export function EntryPointsTable({ rows, empty }: { rows: EntryPoint[]; empty: s
);
}
-/** One labelled cell of the facts strip. */
-function Fact({ label, children }: { label: string; children: ReactNode }) {
- return (
-
- );
-}
function LastRunFact({ run }: { run: SubscriptionRun | undefined }) {
if (!run) return Never ;
@@ -158,7 +148,6 @@ export function Overview({
draft,
set,
canEdit,
- canCreateWorkGroup,
entryPoints,
scheduled,
}: {
@@ -166,16 +155,10 @@ export function Overview({
draft: Draft;
set: (key: K, value: Draft[K]) => void;
canEdit: boolean;
- canCreateWorkGroup: boolean;
entryPoints: EntryPoint[];
/** Receiving and Aggregation run on a schedule, so they have run history. */
scheduled: boolean;
}) {
- const workGroups = useQuery({
- queryKey: keys.workGroups.list,
- queryFn: () => api.listWorkGroups(),
- });
- const retryPolicies = useQuery({ queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies() });
// Both scheduled types keep their own attempt history and show it in one table
// (ReceiveAttemptsPanel) instead of the scheduler's run history beside a separate exchange
// list. The scheduler's history is Quartz vocabulary an operator has no reason to know, it
@@ -209,8 +192,6 @@ export function Overview({
};
})();
const paused = s.pausedOn !== null;
- /** undefined = closed, null = creating, number = editing that group. */
- const [groupDialog, setGroupDialog] = useState(undefined);
return (
@@ -238,58 +219,14 @@ export function Overview({
Nothing — it never runs
)}
-
-
- set("workGroupId", v === "" ? null : Number(v))}
- clearLabel="Ungrouped (default lane)"
- options={(workGroups.data ?? []).map((w) => ({ value: String(w.id), label: w.name }))}
- />
-
-
- {draft.workGroupId !== null && (
- setGroupDialog(draft.workGroupId)}
- className="text-[12px] font-medium text-crimson-700 hover:underline"
- >
- Edit it
-
- )}
- {canCreateWorkGroup && (
- setGroupDialog(null)}
- className="text-[12px] font-medium text-crimson-700 hover:underline"
- >
- + New
-
- )}
-
-
-
-
- set("retryPolicyId", v === "" ? null : Number(v))}
- clearLabel="None — failures are not retried"
- options={(retryPolicies.data ?? []).map((p) => ({ value: String(p.id), label: p.name }))}
- />
-
- {draft.retryPolicyId !== null && (
-
- View
-
- )}
-
+
set("workGroupId", id)}
+ onRetryPolicyChange={(id) => set("retryPolicyId", id)}
+ canEdit={canEdit}
+ idPrefix="in"
+ />
@@ -308,14 +245,6 @@ export function Overview({
)}
- {groupDialog !== undefined && (
- setGroupDialog(undefined)}
- onSaved={(workGroupId) => set("workGroupId", workGroupId)}
- />
- )}
-
{attemptKind !== null && (