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
4 changes: 4 additions & 0 deletions SW.Bitween.Web/ClientApp/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ export interface ApiClient {
handlerId?: string | null;
handlerProperties?: Record<string, string>;
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;
Expand Down
6 changes: 6 additions & 0 deletions SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,10 @@ export const subscriptionMethods = {
handlerId?: string | null;
handlerProperties?: Record<string, string>;
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;
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export function AdapterConfig({
required = false,
noneLabel = "None",
mapperEditorHref,
onOpenMapperEditor,
}: {
kind: AdapterKind;
adapterId: string | null;
Expand All @@ -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);
Expand Down Expand Up @@ -530,7 +537,16 @@ export function AdapterConfig({
</div>
)}
{adapter && usesVisualMappingEditor(adapter.id) && (
mapperEditorHref ? (
onOpenMapperEditor ? (
<button
type="button"
onClick={onOpenMapperEditor}
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-200 bg-white px-3 py-2 text-[13px] font-medium text-crimson-700 hover:border-ink-300 hover:bg-ink-50"
>
Open the visual mapping editor
<ArrowUpRight className="size-3.5" aria-hidden />
</button>
) : mapperEditorHref ? (
<Link
// Which mapper is *picked*, which is not yet which mapper is saved. Without
// it the editor asks the server and gets the one being replaced, so choosing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,33 +30,49 @@ import {
useMappingPreview,
useMappingSave,
useMappingShortcuts,
type MappingTarget,
} from "./useMapping";

export default function NativeMapperEditor() {
/**
* @param target What the editor reads and writes. Omitted, it takes the subscription in
* the route — which is how the `/subscriptions/:id/mapper` page has always opened it.
* A create page passes a draft instead, so a mapping can be built before the
* subscription it belongs to exists.
* @param onClose Where "Back" goes. Omitted, it returns to the subscription's page; a
* draft has no page to return to, so its host closes the overlay instead.
*/
export default function NativeMapperEditor({
target,
onClose,
}: {
target?: MappingTarget;
onClose?: () => void;
} = {}) {
return (
<RulesEditorProvider>
<Editor />
<Editor target={target} onClose={onClose} />
</RulesEditorProvider>
);
}

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.
Expand Down Expand Up @@ -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.
</p>
<Button onClick={() => navigate(`/subscriptions/${subscriptionId}`)}>
Back to the subscription
<Button onClick={() => (onClose ? onClose() : navigate(`/subscriptions/${subscriptionId}`))}>
{onClose ? "Back" : "Back to the subscription"}
</Button>
</div>
);
Expand All @@ -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}`);
};

Expand Down
90 changes: 78 additions & 12 deletions SW.Bitween.Web/ClientApp/src/components/nativeMapper/useMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** 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<string, string>) => 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),
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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,
};
Expand Down
Loading
Loading