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: 2 additions & 2 deletions src/api/__tests__/mock-use-subscription-response.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ vi.mock("@urql/vue", () => ({
useSubscription: vi.fn(),
}));

export const query = gql`
const query = gql`
subscription CtrlStateS {
ctrlState
}
`;

export type Data = { ctrlState: string };
type Data = { ctrlState: string };

const fcState = fc.string({ minLength: 1 });
const fcData = fc.oneof(fc.constant(undefined), fc.record({ ctrlState: fcState }));
Expand Down
55 changes: 55 additions & 0 deletions src/api/__tests__/use-mapped-with-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import fc from "fast-check";

import { useMappedWithFallback } from "../use-mapped-with-fallback";

import { mockUseQueryResponse } from "./mock-use-query-response";
import { mockUseSubscriptionResponse } from "./mock-use-subscription-response";

type QueryData = { ctrl: { state: string } };
type SubscriptionData = { ctrlState: string };

const fcState = fc.string();
const fcQueryData = fc.oneof(
fc.constant(undefined),
fc.record({ ctrl: fc.record({ state: fcState }) }),
);
const fcSubData = fc.oneof(fc.constant(undefined), fc.record({ ctrlState: fcState }));

const fcErrorInstance = fc.string().map((msg) => new Error(msg));
const fcError = fc.oneof(fc.constant(undefined), fcErrorInstance);

const fcQueryArg = fc.record({ data: fcQueryData, error: fcError });
const fcSubArg = fc.record({ data: fcSubData, error: fcError });

describe("useMappedWithFallback()", () => {
it("Property test", async () => {
await fc.assert(
fc.asyncProperty(fcQueryArg, fc.array(fcSubArg), async (queryArg, subArg) => {
const { response: response1, issue } =
mockUseSubscriptionResponse<SubscriptionData>(subArg);
const response2 = await mockUseQueryResponse<QueryData>(queryArg);

const map1 = (d: typeof response1.data) => d.value?.ctrlState;
const map2 = (d: typeof response2.data) => d.value?.ctrl.state;

const options = { response1, response2, map1, map2 };
const { data, error } = useMappedWithFallback(options);

// Assert initial values are from query.
expect(error.value).toBe(queryArg.error);
expect(data.value).toBe(queryArg.error ? undefined : queryArg.data?.ctrl.state);

// Assert the subsequent values are issued from subscription backed up by query.
for (const issued of issue) {
const expectedError = issued.error ?? queryArg.error;
const expectedState = expectedError
? undefined
: (issued.data?.ctrlState ?? queryArg.data?.ctrl.state);
expect(error.value).toBe(expectedError);
expect(data.value).toBe(expectedState);
}
}),
);
});
});
79 changes: 79 additions & 0 deletions src/api/__tests__/use-run-no-subscription.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fc from "fast-check";

import type {
CtrlRunNoQuery,
CtrlRunNoSSubscription,
} from "@/graphql/codegen/generated";
import {
useCtrlRunNoQuery,
useCtrlRunNoSSubscription,
} from "@/graphql/codegen/generated";

import { useSubscribeRunNo } from "../use-run-no-subscription";

import { mockUseQueryResponse } from "./mock-use-query-response";
import { mockUseSubscriptionResponse } from "./mock-use-subscription-response";

vi.mock("@/graphql/codegen/generated", () => ({
useCtrlRunNoQuery: vi.fn(),
useCtrlRunNoSSubscription: vi.fn(),
}));

const fcRunNo = fc.integer();
const fcQueryData = fc.oneof(
fc.constant(undefined),
fc.record({ ctrl: fc.record({ runNo: fcRunNo }) }),
);
const fcSubData = fc.oneof(fc.constant(undefined), fc.record({ ctrlRunNo: fcRunNo }));

const fcErrorInstance = fc.string().map((msg) => new Error(msg));
const fcError = fc.oneof(fc.constant(undefined), fcErrorInstance);

const fcQueryArg = fc.record({ data: fcQueryData, error: fcError });
const fcSubArg = fc.record({ data: fcSubData, error: fcError });

describe("useSubscribeRunNo()", () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.resetAllMocks();
});

it("Property test", async () => {
await fc.assert(
fc.asyncProperty(fcQueryArg, fc.array(fcSubArg), async (queryArg, subArg) => {
// Mock useCtrlRunNoQuery()
const queryRes = mockUseQueryResponse<CtrlRunNoQuery>(queryArg);
type Query = ReturnType<typeof useCtrlRunNoQuery>;
vi.mocked(useCtrlRunNoQuery).mockReturnValue(queryRes as Query);

// Mock useCtrlRunNoSSubscription()
const { response: subRes, issue } =
mockUseSubscriptionResponse<CtrlRunNoSSubscription>(subArg);
type SubRes = ReturnType<typeof useCtrlRunNoSSubscription>;
vi.mocked(useCtrlRunNoSSubscription).mockReturnValue(subRes as SubRes);

const { runNo, error } = await useSubscribeRunNo();

// Assert initial values are from query.
expect(error.value).toBe(queryArg.error);
expect(runNo.value).toBe(
queryArg.error ? undefined : queryArg.data?.ctrl.runNo,
);

// Assert the subsequent values are issued from subscription backed up by query.
for (const issued of issue) {
const expectedError = issued.error || queryArg.error;
const expectedRunNo = expectedError
? undefined
: (issued.data?.ctrlRunNo ?? queryArg.data?.ctrl.runNo);
expect(error.value).toBe(expectedError);
expect(runNo.value).toBe(expectedRunNo);
}
}),
);
});
});
4 changes: 2 additions & 2 deletions src/api/__tests__/use-state-subscription.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock("@/graphql/codegen/generated", () => ({
useCtrlStateSSubscription: vi.fn(),
}));

const fcState = fc.string({ minLength: 1 });
const fcState = fc.string();
const fcQueryData = fc.oneof(
fc.constant(undefined),
fc.record({ ctrl: fc.record({ state: fcState }) }),
Expand Down Expand Up @@ -69,7 +69,7 @@ describe("useSubscribeState()", () => {
const expectedError = issued.error || queryArg.error;
const expectedState = expectedError
? undefined
: issued.data?.ctrlState || queryArg.data?.ctrl.state;
: (issued.data?.ctrlState ?? queryArg.data?.ctrl.state);
expect(error.value).toBe(expectedError);
expect(state.value).toBe(expectedState);
}
Expand Down
107 changes: 107 additions & 0 deletions src/api/use-mapped-with-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { computed } from "vue";
import type { ComputedRef, Ref } from "vue";

/**
* Response returned by `useQuery()` / `useSubscription()`.
*
* `T` is the data type, the equivalent to the first argument of `UseQueryResponse` and
* `UseSubscriptionResponse`.
*/
interface Response<T> {
data: Ref<T | undefined>;
error: Ref<Error | undefined>;
}

/**
* Options for the `useMappedWithFallback` composable.
*
* The interface includes two responses and two mapping functions. The two responses are
* primary and secondary, which are typically returned by `useSubscription()` and
* `useQuery()`, respectively. The two mapping functions convert the respective response
* data into a common target type `T`. The secondary response is used as a fallback when
* the primary response data is `undefined`.
*
* @template T The target type that both mappers should produce
* @template D1 The data type of Response<D1>["data"]
* @template D2 The data type of Response<D2>["data"]
* @template R1 The primary response type, typically `UseSubscriptionResponse<D1>`
* @template R2 The fallback response type, typically `UseQueryResponse<D2>`
*
*/
export interface UseMappedWithFallbackOptions<
T,
D1,
D2,
R1 extends Response<D1>,
R2 extends Response<D2>,
> {
/** The primary response source, typically returned by `useSubscription()` */
response1: R1;

/** The fallback response source, typically returned by `useQuery()` */
response2: R2;

/** Mapping function for the primary response data. `d` is `response1.data` */
map1: (d: Ref<D1 | undefined>) => T | undefined;

/** Mapping function for the fallback response data. `d` is `response2.data` */
map2: (d: Ref<D2 | undefined>) => T | undefined;
}

/**
* Return type of `useMappedWithFallback()`.
*
* The returned object contains two reactive properties: `data` and `error`.
*/
interface UseMappedWithFallbackReturn<T> {
/**
* The reactive value mapped primarily from `response1` with fallback to `response2`.
* `undefined` if either response has an error.
*/
data: ComputedRef<T | undefined>;

/**
* The error from `response1` otherwise from `response2` else `undefined`
*/
error: ComputedRef<Error | undefined>;
}

/**
* A reactive mapped value from a primary response with secondary fallback.
*
* @param options {@link UseMappedWithFallbackOptions}
* @returns {@link UseMappedWithFallbackReturn}
* @example
* ```typescript
* const subscription = useSubscription<D1>({ query: SUBSCRIPTION_QUERY });
* const query = useQuery<D2>({ query: QUERY });
*
* const { data, error } = useMappedWithFallback({
* response1: subscription, // primary: real-time updates
* response2: query, // fallback: initial data
* map1: (d) => d.value?.ctrlState,
* map2: (d) => d.value?.ctrl.state,
* });
* ```
*/
export function useMappedWithFallback<
T,
D1,
D2,
R1 extends Response<D1>,
R2 extends Response<D2>,
>(
options: UseMappedWithFallbackOptions<T, D1, D2, R1, R2>,
): UseMappedWithFallbackReturn<T> {
const error = computed(
() => options.response1.error?.value ?? options.response2.error?.value,
);

const data = computed(() =>
error.value
? undefined
: (options.map1(options.response1.data) ?? options.map2(options.response2.data)),
);

return { data, error };
}
32 changes: 18 additions & 14 deletions src/api/use-run-no-subscription.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ComputedRef } from "vue";
import { computed } from "vue";

import {
useCtrlRunNoQuery,
Expand All @@ -8,31 +7,36 @@ import {
import { onReady } from "@/utils/on-ready";
import type { OnReady } from "@/utils/on-ready";

import { useMappedWithFallback } from "./use-mapped-with-fallback";

type Query = ReturnType<typeof useCtrlRunNoQuery>;
type Subscription = ReturnType<typeof useCtrlRunNoSSubscription>;

interface _RunNoSubscription {
runNo: ComputedRef<number | undefined>;
error: ComputedRef<Error | undefined>;
subscription: ReturnType<typeof useCtrlRunNoSSubscription>;
query: ReturnType<typeof useCtrlRunNoQuery>;
subscription: Subscription;
query: Query;
}

type RunNoSubscription = OnReady<_RunNoSubscription>;

export function useSubscribeRunNo(): RunNoSubscription {
const query = useCtrlRunNoQuery({
requestPolicy: "network-only",
variables: {},
});
const query = useCtrlRunNoQuery({ requestPolicy: "network-only", variables: {} });
const subscription = useCtrlRunNoSSubscription({ variables: {} });

const error = computed(() => subscription.error?.value || query.error?.value);
const mapQueryData = (d: typeof query.data) => d.value?.ctrl.runNo;
const mapSubscriptionData = (d: typeof subscription.data) => d.value?.ctrlRunNo;

const runNo = computed(() =>
error.value
? undefined
: subscription.data?.value?.ctrlRunNo || query.data?.value?.ctrl.runNo,
);
const options = {
response1: subscription,
response2: query,
map1: mapSubscriptionData,
map2: mapQueryData,
};
const { data, error } = useMappedWithFallback(options);

const ret = { runNo, error, subscription, query };
const ret = { runNo: data, error, subscription, query };

return onReady(ret, query);
}
32 changes: 18 additions & 14 deletions src/api/use-state-subscription.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ComputedRef } from "vue";
import { computed } from "vue";

import {
useCtrlStateQuery,
Expand All @@ -8,31 +7,36 @@ import {
import { onReady } from "@/utils/on-ready";
import type { OnReady } from "@/utils/on-ready";

import { useMappedWithFallback } from "./use-mapped-with-fallback";

type Query = ReturnType<typeof useCtrlStateQuery>;
type Subscription = ReturnType<typeof useCtrlStateSSubscription>;

interface _StateSubscription {
state: ComputedRef<string | undefined>;
error: ComputedRef<Error | undefined>;
subscription: ReturnType<typeof useCtrlStateSSubscription>;
query: ReturnType<typeof useCtrlStateQuery>;
subscription: Subscription;
query: Query;
}

type StateSubscription = OnReady<_StateSubscription>;

export function useSubscribeState(): StateSubscription {
const query = useCtrlStateQuery({
requestPolicy: "network-only",
variables: {},
});
const query = useCtrlStateQuery({ requestPolicy: "network-only", variables: {} });
const subscription = useCtrlStateSSubscription({ variables: {} });

const error = computed(() => subscription.error?.value || query.error?.value);
const mapQueryData = (d: typeof query.data) => d.value?.ctrl.state;
const mapSubscriptionData = (d: typeof subscription.data) => d.value?.ctrlState;

const state = computed(() =>
error.value
? undefined
: subscription.data?.value?.ctrlState || query.data?.value?.ctrl.state,
);
const options = {
response1: subscription,
response2: query,
map1: mapSubscriptionData,
map2: mapQueryData,
};
const { data, error } = useMappedWithFallback(options);

const ret = { state, error, subscription, query };
const ret = { state: data, error, subscription, query };

return onReady(ret, query);
}
Loading