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 ? ( + + ) : 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.

- ); @@ -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)}. -

- )} - - +
+ update({ enabled: e.target.checked })} + /> +
+ {missing.length > 0 && ( +

+ Still needs {missing.slice(0, -1).join(", ")} + {missing.length > 1 ? " and " : ""} + {missing.at(-1)}. +

+ )} + + +
{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 ( +
+

{label}

+
{children}
+
+ ); +} 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 && ( + + )} + {canCreateWorkGroup && ( + + )} +
+
+ +
+ 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 ( -
-

{label}

-
{children}
-
- ); -} 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 && ( - - )} - {canCreateWorkGroup && ( - - )} -
-
- -
- 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 && (