aboutsummaryrefslogtreecommitdiff
path: root/jb-ui/src/models
diff options
context:
space:
mode:
authorMax Nanis2026-09-13 19:03:55 +0000
committerMax Nanis2026-09-13 19:03:55 +0000
commit8fbe8d439b418796932aaa88297e006c714e4d13 (patch)
tree6c800476edc1e771fc570b559d483df8d59d4324 /jb-ui/src/models
parent12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff)
parent2c94f248d2438071a918fa9a30bf114ef9aa29b4 (diff)
downloadamt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.tar.gz
amt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.zip
Merges pull request #3
Off of Amazon!!!
Diffstat (limited to 'jb-ui/src/models')
-rw-r--r--jb-ui/src/models/app.ts66
-rw-r--r--jb-ui/src/models/appSlice.ts353
-rw-r--r--jb-ui/src/models/authAPI.ts24
-rw-r--r--jb-ui/src/models/cashoutMethodsSlice.ts37
-rw-r--r--jb-ui/src/models/grlEventsSlice.ts74
-rw-r--r--jb-ui/src/models/grlStatsSlice.ts67
-rw-r--r--jb-ui/src/models/profilingQuestionsSlice.ts672
-rw-r--r--jb-ui/src/models/profilingUtils.ts309
8 files changed, 870 insertions, 732 deletions
diff --git a/jb-ui/src/models/app.ts b/jb-ui/src/models/app.ts
index 95b4c11..db54e0d 100644
--- a/jb-ui/src/models/app.ts
+++ b/jb-ui/src/models/app.ts
@@ -1,37 +1,47 @@
import {
- UserWalletBalance, TopNPlusBucket, TaskStatusResponse,
- UserLedgerTransactionTypesSummary,
- UserLedgerTransactionsResponseTransactionsInner,
- OfferwallReason
+ UserWalletBalance,
+ TopNPlusBucket,
+ TaskStatusResponse,
+ UserLedgerTransactionTypesSummary,
+ UserLedgerTransactionsResponseTransactionsInner,
+ OfferwallReason,
+ UserLedgerWallet,
+ UserDisplayedWalletBalance,
} from "@/api_fsb";
-import { PaginationState } from '@tanstack/react-table';
+import { PaginationState } from "@tanstack/react-table";
export interface App {
- // Global IDs needed by GRL + MTurk
- bpuid?: string;
- assignment_id?: string;
- turkSubmitTo?: string;
+ // Global IDs needed by GRL + MTurk
+ bpuid?: string;
+ assignment_id?: string;
+ turkSubmitTo?: string;
- // Global UI settings
- loi: number;
- availability_count?: number;
- attempted_live_eligible_count?: number;
- offerwall_reasons: OfferwallReason[];
+ // Global UI settings
+ loi: number;
+ availability_count?: number;
+ attempted_live_eligible_count?: number;
+ offerwall_reasons: OfferwallReason[];
- // The timestamp of when the currently requested bucket was requested. We want
- // to save this to prevent it from sitting for longer than 120 seconds.
- currentBucketRequested?: number; // Date.now() is a timestamp in ms
- currentBuckets?: TopNPlusBucket[];
- currentBucketEntered?: number; // Date.now() is a timestamp in ms
- taskStatus?: TaskStatusResponse;
+ // The timestamp of when the currently requested bucket was requested. We want
+ // to save this to prevent it from sitting for longer than 120 seconds.
+ currentBucketRequested?: number; // Date.now() is a timestamp in ms
+ currentBuckets?: TopNPlusBucket[];
+ currentBucketEntered?: number; // Date.now() is a timestamp in ms
+ taskStatus?: TaskStatusResponse;
- // Wallet stuff
- userWalletBalance?: UserWalletBalance;
- userLedgerSummary?: UserLedgerTransactionTypesSummary;
- userLedgerTxCount?: number;
- userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[];
+ // Wallet stuff
+ userWalletBalance?: UserWalletBalance;
+ userLedgerSummary?: UserLedgerTransactionTypesSummary;
+ userLedgerTxCount?: number;
+ userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[];
- txPagination: PaginationState;
- txTotalItems?: number;
- txTotalPages?: number;
+ // --- new approach
+ userDisplayedWalletBalance?: UserDisplayedWalletBalance;
+ userWallet?: UserLedgerWallet;
+ userAttemptCreditWallet?: UserLedgerWallet;
+ // ---
+
+ txPagination: PaginationState;
+ txTotalItems?: number;
+ txTotalPages?: number;
}
diff --git a/jb-ui/src/models/appSlice.ts b/jb-ui/src/models/appSlice.ts
index 87952d9..42a776e 100644
--- a/jb-ui/src/models/appSlice.ts
+++ b/jb-ui/src/models/appSlice.ts
@@ -1,188 +1,221 @@
import {
- TaskStatusResponse,
- TopNPlusBucket,
- UserLedgerTransactionsResponseTransactionsInner,
- UserLedgerTransactionTypesSummary,
- UserWalletBalance,
- OfferwallReason
+ TaskStatusResponse,
+ TopNPlusBucket,
+ UserLedgerTransactionsResponseTransactionsInner,
+ UserLedgerTransactionTypesSummary,
+ UserWalletBalance,
+ UserDisplayedWalletBalance,
+ UserLedgerWallet,
+ OfferwallReason,
} from "@/api_fsb";
import { App } from "@/models/app";
import { RootState } from "@/store";
-import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
-import { PaginationState } from '@tanstack/react-table';
+import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { PaginationState } from "@tanstack/react-table";
import moment from "moment";
const initialState: App = {
- bpuid: undefined,
- assignment_id: undefined,
- loi: 1800,
- currentBuckets: undefined,
- currentBucketEntered: undefined,
- taskStatus: undefined,
- offerwall_reasons: [],
-
- userWalletBalance: undefined,
- userLedgerSummary: undefined,
- userLedgerTxCount: undefined,
- userLedgerTxs: [],
-
- txPagination: { pageIndex: 0, pageSize: 10 },
- txTotalItems: undefined,
- txTotalPages: undefined
-} as App
+ bpuid: undefined,
+ assignment_id: undefined,
+ loi: 1800,
+ currentBuckets: undefined,
+ currentBucketEntered: undefined,
+ taskStatus: undefined,
+ offerwall_reasons: [],
+
+ userWalletBalance: undefined,
+ userLedgerSummary: undefined,
+ userLedgerTxCount: undefined,
+ userLedgerTxs: [],
+
+ // --- new approach
+ userDisplayedWalletBalance: undefined,
+ userWallet: undefined,
+ userAttemptCreditWallet: undefined,
+ // ---
+
+ txPagination: { pageIndex: 0, pageSize: 10 },
+ txTotalItems: undefined,
+ txTotalPages: undefined,
+} as App;
const appSlice = createSlice({
- name: 'app',
- initialState,
- reducers: {
-
- setProductUserID(state, action: PayloadAction<string>) {
- state.bpuid = action.payload;
- },
-
- setAssignmentID(state, action: PayloadAction<string>) {
- // This is really so silly. Amazon should simply not send
- // anything if it's unavailable.
- if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") {
- state.assignment_id = undefined;
- }
- state.assignment_id = action.payload;
- },
-
- setTurkSubmitTo(state, action: PayloadAction<string>) {
- state.turkSubmitTo = action.payload;
- },
-
- setLOI(state, action: PayloadAction<number>) {
- state.loi = action.payload;
- },
-
- setAvailabilityCount(state, action: PayloadAction<number>) {
- state.availability_count = action.payload
- },
-
- setAttemptedLiveEligibleCount(state, action: PayloadAction<number>) {
- state.attempted_live_eligible_count = action.payload
- },
-
- setCurrentBuckets(state, action: PayloadAction<TopNPlusBucket[] | undefined>) {
- state.currentBucketRequested = moment.utc().unix();
- state.currentBuckets = action.payload
- },
-
- setOfferwallReasons(state, action: PayloadAction<OfferwallReason[]>) {
- state.offerwall_reasons = action.payload;
- },
-
- setEnteredTimestamp(state) {
- // Go back by 2 seconds to account for any time drift
- state.currentBucketEntered = moment.utc().unix() - 2;
- },
-
- setTaskStatus(state, action: PayloadAction<TaskStatusResponse>) {
- state.taskStatus = action.payload;
-
- state.bpuid = action.payload.product_user_id;
- },
-
- setUserWalletBalance(state, action: PayloadAction<UserWalletBalance>) {
- state.userWalletBalance = action.payload;
- },
-
- setUserLedgerSummary(state, action: PayloadAction<UserLedgerTransactionTypesSummary>) {
- state.userLedgerSummary = action.payload;
- },
-
- setUserLedgerTxs(state, action: PayloadAction<UserLedgerTransactionsResponseTransactionsInner[]>) {
- // We're not appending the transaction details, it's only going
- // to reassign the current page of Transactions.
- state.userLedgerTxs = action.payload;
- },
-
- setTxPagination(state, action: PayloadAction<PaginationState>) {
- state.txPagination = action.payload;
- },
-
- setTxTotalItems(state, action: PayloadAction<number>) {
- state.txTotalItems = action.payload;
- },
-
- setTxTotalPages(state, action: PayloadAction<number>) {
- state.txTotalPages = action.payload;
- }
-
- }
-})
+ name: "app",
+ initialState,
+ reducers: {
+ setProductUserID(state, action: PayloadAction<string>) {
+ state.bpuid = action.payload;
+ },
+
+ setAssignmentID(state, action: PayloadAction<string>) {
+ // This is really so silly. Amazon should simply not send
+ // anything if it's unavailable.
+ if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") {
+ state.assignment_id = undefined;
+ }
+ state.assignment_id = action.payload;
+ },
+
+ setTurkSubmitTo(state, action: PayloadAction<string>) {
+ state.turkSubmitTo = action.payload;
+ },
+
+ setLOI(state, action: PayloadAction<number>) {
+ state.loi = action.payload;
+ },
+
+ setAvailabilityCount(state, action: PayloadAction<number>) {
+ state.availability_count = action.payload;
+ },
+
+ setAttemptedLiveEligibleCount(state, action: PayloadAction<number>) {
+ state.attempted_live_eligible_count = action.payload;
+ },
+
+ setCurrentBuckets(
+ state,
+ action: PayloadAction<TopNPlusBucket[] | undefined>,
+ ) {
+ state.currentBucketRequested = moment.utc().unix();
+ state.currentBuckets = action.payload;
+ },
+
+ setOfferwallReasons(state, action: PayloadAction<OfferwallReason[]>) {
+ state.offerwall_reasons = action.payload;
+ },
+
+ setEnteredTimestamp(state) {
+ // Go back by 2 seconds to account for any time drift
+ state.currentBucketEntered = moment.utc().unix() - 2;
+ },
+
+ setTaskStatus(state, action: PayloadAction<TaskStatusResponse>) {
+ state.taskStatus = action.payload;
+
+ state.bpuid = action.payload.product_user_id;
+ },
+
+ setUserWalletBalance(state, action: PayloadAction<UserWalletBalance>) {
+ state.userWalletBalance = action.payload;
+ },
+
+ // --- New approach ---
+ setUserDisplayedWalletBalance(
+ state,
+ action: PayloadAction<UserDisplayedWalletBalance>,
+ ) {
+ state.userDisplayedWalletBalance = action.payload;
+ },
+
+ setUserWallet(state, action: PayloadAction<UserLedgerWallet>) {
+ state.userWallet = action.payload;
+ },
+
+ setUserAttemptCreditWallet(state, action: PayloadAction<UserLedgerWallet>) {
+ state.userAttemptCreditWallet = action.payload;
+ },
+
+ // --- ---
+
+ setUserLedgerSummary(
+ state,
+ action: PayloadAction<UserLedgerTransactionTypesSummary>,
+ ) {
+ state.userLedgerSummary = action.payload;
+ },
+
+ setUserLedgerTxs(
+ state,
+ action: PayloadAction<UserLedgerTransactionsResponseTransactionsInner[]>,
+ ) {
+ // We're not appending the transaction details, it's only going
+ // to reassign the current page of Transactions.
+ state.userLedgerTxs = action.payload;
+ },
+
+ setTxPagination(state, action: PayloadAction<PaginationState>) {
+ state.txPagination = action.payload;
+ },
+
+ setTxTotalItems(state, action: PayloadAction<number>) {
+ state.txTotalItems = action.payload;
+ },
+
+ setTxTotalPages(state, action: PayloadAction<number>) {
+ state.txTotalPages = action.payload;
+ },
+ },
+});
export const {
- setProductUserID,
- setAssignmentID,
- setTurkSubmitTo,
- setLOI,
- setAvailabilityCount,
- setAttemptedLiveEligibleCount,
- setOfferwallReasons,
- setCurrentBuckets,
- setEnteredTimestamp,
- setTaskStatus,
-
- setUserWalletBalance,
-
- setUserLedgerSummary,
- setUserLedgerTxs,
-
- setTxPagination,
- setTxTotalItems,
- setTxTotalPages
+ setProductUserID,
+ setAssignmentID,
+ setTurkSubmitTo,
+ setLOI,
+ setAvailabilityCount,
+ setAttemptedLiveEligibleCount,
+ setOfferwallReasons,
+ setCurrentBuckets,
+ setEnteredTimestamp,
+ setTaskStatus,
+
+ setUserWalletBalance,
+
+ setUserLedgerSummary,
+ setUserLedgerTxs,
+
+ setUserDisplayedWalletBalance,
+ setUserWallet,
+ setUserAttemptCreditWallet,
+
+ setTxPagination,
+ setTxTotalItems,
+ setTxTotalPages,
} = appSlice.actions;
-export default appSlice.reducer
+export default appSlice.reducer;
export const getLOIText = createSelector(
- [(state: RootState) => state.app],
- (app_config): string => {
-
- const lookup: Record<number, string> = {
- 600: "ten",
- 1200: "twenty",
- 1800: "thirty",
- };
-
- return lookup[app_config.loi] || " – ";
- }
+ [(state: RootState) => state.app],
+ (app_config): string => {
+ const lookup: Record<number, string> = {
+ 600: "ten",
+ 1200: "twenty",
+ 1800: "thirty",
+ };
+
+ return lookup[app_config.loi] || " – ";
+ },
);
export const getAvailabilityCount = createSelector(
- [(state: RootState) => state.app],
- (app_config): number | undefined => {
- return app_config.availability_count;
- }
+ [(state: RootState) => state.app],
+ (app_config): number | undefined => {
+ return app_config.availability_count;
+ },
);
export const isLowBalance = createSelector(
- [(state: RootState) => state.app],
- (app_config): boolean => {
- const bal_amt = app_config.userWalletBalance?.amount ?? 0
- return bal_amt <= -90;
- }
+ [(state: RootState) => state.app],
+ (app_config): boolean => {
+ const bal_amt = app_config.userWalletBalance?.amount ?? 0;
+ return bal_amt <= -90;
+ },
);
-
export const selectBucket = createSelector(
- [
- (state: RootState) => state.app.currentBuckets,
- ],
- (buckets): TopNPlusBucket | null => {
- if (buckets && buckets.length >= 1) {
- return buckets[0]
- }
- return null;
+ [(state: RootState) => state.app.currentBuckets],
+ (buckets): TopNPlusBucket | null => {
+ if (buckets && buckets.length >= 1) {
+ return buckets[0];
}
+ return null;
+ },
);
export const getSurveyURL = createSelector(
- [selectBucket],
- (bucket): string | null => {
- return bucket?.uri || null;
- }
-); \ No newline at end of file
+ [selectBucket],
+ (bucket): string | null => {
+ return bucket?.uri || null;
+ },
+);
diff --git a/jb-ui/src/models/authAPI.ts b/jb-ui/src/models/authAPI.ts
new file mode 100644
index 0000000..fa08bc7
--- /dev/null
+++ b/jb-ui/src/models/authAPI.ts
@@ -0,0 +1,24 @@
+import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
+import type { User } from "@/types/authApi";
+
+export const authApi = createApi({
+ reducerPath: "authApi",
+ baseQuery: fetchBaseQuery({
+ baseUrl: "/auth",
+ credentials: "include", // sends the httpOnly cookie
+ }),
+ tagTypes: ["User"],
+ endpoints: (builder) => ({
+ getMe: builder.query<User, void>({
+ query: () => "/session",
+ providesTags: ["User"],
+ }),
+ // logout: builder.mutation({
+ // query: () => ({ url: "/de-session", method: "POST" }),
+ // invalidatesTags: ["User"],
+ // }),
+ }),
+});
+
+// export const { useGetMeQuery, useLogoutMutation } = authApi;
+export const { useGetMeQuery } = authApi;
diff --git a/jb-ui/src/models/cashoutMethodsSlice.ts b/jb-ui/src/models/cashoutMethodsSlice.ts
new file mode 100644
index 0000000..92c03da
--- /dev/null
+++ b/jb-ui/src/models/cashoutMethodsSlice.ts
@@ -0,0 +1,37 @@
+import { CashoutMethodOut } from "@/api_fsb";
+import type { RootState } from "@/store";
+import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
+
+// Entity adapter for normalized storage
+const cashoutMethodsAdapter = createEntityAdapter({
+ selectId: (model: CashoutMethodOut) => model.id!,
+ sortComparer: (a, b) => {
+ return (b.min_value ?? 0) - (a.min_value ?? 0);
+ },
+});
+
+const cashoutMethodsSlice = createSlice({
+ name: "cashoutMethods",
+ initialState: cashoutMethodsAdapter.getInitialState(),
+
+ reducers: {
+ addCashoutMethod: cashoutMethodsAdapter.addOne,
+ addCashoutMethods: cashoutMethodsAdapter.addMany,
+ upsertCashoutMethod: cashoutMethodsAdapter.upsertOne, // Add or Update
+ },
+});
+
+export const { addCashoutMethod, addCashoutMethods, upsertCashoutMethod } =
+ cashoutMethodsSlice.actions;
+
+export const cashoutMethodsSelectors = cashoutMethodsAdapter.getSelectors(
+ (state: RootState) => state.cashoutMethods,
+);
+
+export const selectAllCashoutMethods = (
+ state: RootState,
+): CashoutMethodOut[] => {
+ return cashoutMethodsSelectors.selectAll(state) ?? [];
+};
+
+export default cashoutMethodsSlice.reducer;
diff --git a/jb-ui/src/models/grlEventsSlice.ts b/jb-ui/src/models/grlEventsSlice.ts
index 912c4ca..681c22e 100644
--- a/jb-ui/src/models/grlEventsSlice.ts
+++ b/jb-ui/src/models/grlEventsSlice.ts
@@ -1,58 +1,56 @@
import { EventEnvelope } from "@/api_fsb";
import { RootState } from "@/store";
-import { createEntityAdapter, createSelector, createSlice } from '@reduxjs/toolkit';
+import {
+ createEntityAdapter,
+ createSelector,
+ createSlice,
+} from "@reduxjs/toolkit";
const eventAdapter = createEntityAdapter({
- selectId: (model: EventEnvelope) => model.event_uuid!,
- sortComparer: (a, b) => {
- // ASC by timestamp
- return a.timestamp!.localeCompare(b.timestamp!);
- },
+ selectId: (model: EventEnvelope) => model.event_uuid!,
+ sortComparer: (a, b) => {
+ // ASC by timestamp
+ return a.timestamp!.localeCompare(b.timestamp!);
+ },
});
-
const grlEventsSlice = createSlice({
- name: 'grlEvents',
- initialState: eventAdapter.getInitialState(),
-
- reducers: {
- addEvent: eventAdapter.addOne,
- addEvents: eventAdapter.addMany,
- upsertEvent: eventAdapter.upsertOne, // Add or Update
-
- }
-})
-
-
-export const {
- addEvent, addEvents, upsertEvent
-} = grlEventsSlice.actions;
+ name: "grlEvents",
+ initialState: eventAdapter.getInitialState(),
+
+ reducers: {
+ addEvent: eventAdapter.addOne,
+ addEvents: eventAdapter.addMany,
+ upsertEvent: eventAdapter.upsertOne, // Add or Update
+ },
+});
+export const { addEvent, addEvents, upsertEvent } = grlEventsSlice.actions;
export const eventSelectors = eventAdapter.getSelectors(
- (state: RootState) => state.events
+ (state: RootState) => state.events,
);
-export const selectAllEvents = (state: RootState): EventEnvelope[] => eventSelectors.selectAll(state);
-
-const BASE_SPEED = 40; // px/sec at idle
-const MAX_SPEED = 380; // px/sec at surge
+export const selectAllEvents = (state: RootState): EventEnvelope[] =>
+ eventSelectors.selectAll(state);
-const RATE_WINDOW_MS = 10_000; // ms window to measure message frequency
-const RATE_TO_SPEED = 55; // px/sec added per msg/sec
+const BASE_SPEED = 40; // px/sec at idle
+const MAX_SPEED = 380; // px/sec at surge
+const RATE_WINDOW_MS = 10_000; // ms window to measure message frequency
+const RATE_TO_SPEED = 55; // px/sec added per msg/sec
-// --- Speed: derived from arrival rate of recent items ---
+// --- Speed: derived from arrival rate of recent items ---
export const selectCurrentSpeed = createSelector(selectAllEvents, (items) => {
- const now = Date.now();
+ const now = Date.now();
- const recentCount = items.filter((m) => {
- const ts = new Date(m.timestamp!).getTime();
- return now - ts < RATE_WINDOW_MS;
- }).length;
+ const recentCount = items.filter((m) => {
+ const ts = new Date(m.timestamp!).getTime();
+ return now - ts < RATE_WINDOW_MS;
+ }).length;
- const rate = recentCount / (RATE_WINDOW_MS / 1_000); // msgs/sec
- return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED);
+ const rate = recentCount / (RATE_WINDOW_MS / 1_000); // msgs/sec
+ return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED);
});
-export default grlEventsSlice.reducer
+export default grlEventsSlice.reducer;
diff --git a/jb-ui/src/models/grlStatsSlice.ts b/jb-ui/src/models/grlStatsSlice.ts
index 9385256..004d321 100644
--- a/jb-ui/src/models/grlStatsSlice.ts
+++ b/jb-ui/src/models/grlStatsSlice.ts
@@ -1,54 +1,47 @@
import { StatsSnapshot } from "@/api_fsb";
import { RootState } from "@/store";
-import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit";
-
-const initialState: StatsSnapshot[] = []
+const initialState: StatsSnapshot[] = [];
const grlStatsSlice = createSlice({
- name: 'grlStats',
- initialState,
- reducers: {
-
- addStatsData(state, action: PayloadAction<StatsSnapshot>) {
- state.push(action.payload);
- }
- }
-})
-
-export const {
- addStatsData
-} = grlStatsSlice.actions;
-export default grlStatsSlice.reducer
-
+ name: "grlStats",
+ initialState,
+ reducers: {
+ addStatsData(state, action: PayloadAction<StatsSnapshot>) {
+ state.push(action.payload);
+ },
+ },
+});
+
+export const { addStatsData } = grlStatsSlice.actions;
+export default grlStatsSlice.reducer;
export const selectRecentStats = createSelector(
- [
- (state: RootState) => state.stats,
- ],
- (stats): StatsSnapshot | null => {
- const lastStat = stats[stats.length - 1];
- return lastStat ?? null;
- }
+ [(state: RootState) => state.stats],
+ (stats): StatsSnapshot | null => {
+ const lastStat = stats[stats.length - 1];
+ return lastStat ?? null;
+ },
);
export const activeUsers = createSelector(
- [selectRecentStats],
- (recentStats): number | null => {
- return recentStats?.active_users_last_24h ?? null;
- }
+ [selectRecentStats],
+ (recentStats): number | null => {
+ return recentStats?.active_users_last_24h ?? null;
+ },
);
export const activeSurveys = createSelector(
- [selectRecentStats],
- (recentStats): number | null => {
- return recentStats?.live_task_count?.total ?? null;
- }
+ [selectRecentStats],
+ (recentStats): number | null => {
+ return recentStats?.live_task_count?.total ?? null;
+ },
);
export const maxPayout = createSelector(
- [selectRecentStats],
- (recentStats): number | null => {
- return recentStats?.live_tasks_max_payout?.value ?? null;
- }
+ [selectRecentStats],
+ (recentStats): number | null => {
+ return recentStats?.live_tasks_max_payout?.value ?? null;
+ },
);
diff --git a/jb-ui/src/models/profilingQuestionsSlice.ts b/jb-ui/src/models/profilingQuestionsSlice.ts
index cc36f17..577566f 100644
--- a/jb-ui/src/models/profilingQuestionsSlice.ts
+++ b/jb-ui/src/models/profilingQuestionsSlice.ts
@@ -1,313 +1,327 @@
import {
- BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost,
- ProfilingQuestionsApi,
- StatusResponse,
- UpkQuestion, UserQuestionAnswerIn
+ BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost,
+ ProfilingQuestionsApi,
+ StatusResponse,
+ UpkQuestion,
+ UserQuestionAnswerIn,
} from "@/api_fsb";
import { assert, bpid } from "@/lib/utils";
import { questionUtils } from "@/models/profilingUtils";
-import type { RootState } from '@/store';
+import type { RootState } from "@/store";
import {
- createAsyncThunk,
- createEntityAdapter, createSlice,
- PayloadAction,
- WritableDraft
-} from '@reduxjs/toolkit';
+ createAsyncThunk,
+ createEntityAdapter,
+ createSlice,
+ PayloadAction,
+ WritableDraft,
+} from "@reduxjs/toolkit";
interface UpkQuestionMetadata {
- // The data we need to conduct profiling questions and manager user
- // submission along with the User Interfaces
+ // The data we need to conduct profiling questions and manager user
+ // submission along with the User Interfaces
- // Only one Question can be active at a time, which is the one
- // being shown to the Respondent
- isActive: boolean;
+ // Only one Question can be active at a time, which is the one
+ // being shown to the Respondent
+ isActive: boolean;
- answers: string[];
+ answers: string[];
- // If it's actively being submitted to the server
- isProcessing: boolean;
+ // If it's actively being submitted to the server
+ isProcessing: boolean;
- // If we recieved a successful response from the server after submitting
- isSaved: boolean;
+ // If we recieved a successful response from the server after submitting
+ isSaved: boolean;
}
interface ValidationResult {
- // If isValid or isComplete are undefined, that means the question
- // hasn't been validated yet. This is different from false, which means
- // it has been validated and is not valid.
- isValid?: boolean;
- isComplete?: boolean;
- errors: ValidationError[];
-};
+ // If isValid or isComplete are undefined, that means the question
+ // hasn't been validated yet. This is different from false, which means
+ // it has been validated and is not valid.
+ isValid?: boolean;
+ isComplete?: boolean;
+ errors: ValidationError[];
+}
export interface ValidationError {
- path: string; // e.g., "user.email"
- message: string;
- severity: 'error' | 'warning';
-};
+ path: string; // e.g., "user.email"
+ message: string;
+ severity: "error" | "warning";
+}
export interface ProfileQuestion extends UpkQuestion {
- _metadata: UpkQuestionMetadata;
- _validation: ValidationResult;
+ _metadata: UpkQuestionMetadata;
+ _validation: ValidationResult;
}
// Entity adapter for normalized storage
const questionsAdapter = createEntityAdapter({
- selectId: (model: ProfileQuestion) => model.question_id!,
- sortComparer: (a, b) => {
- return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0);
- },
+ selectId: (model: ProfileQuestion) => model.question_id!,
+ sortComparer: (a, b) => {
+ return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0);
+ },
});
-
function getDefaultMetadata(): UpkQuestionMetadata {
- return {
- isActive: false,
- answers: [],
- isProcessing: false,
- isSaved: false,
- };
+ return {
+ isActive: false,
+ answers: [],
+ isProcessing: false,
+ isSaved: false,
+ };
}
function getDefaultValidation(): ValidationResult {
-
- return {
- isValid: undefined,
- isComplete: undefined,
- errors: [],
- };
+ return {
+ isValid: undefined,
+ isComplete: undefined,
+ errors: [],
+ };
}
// Create the async thunk (outside your slice)
export const saveAnswer = createAsyncThunk(
- 'profilingQuestions/saveAnswer',
- async ({ questionId, bpuid }: { questionId: string; bpuid: string }, { getState, dispatch }) => {
- const state = getState() as RootState;
- const question = state.profilingQuestions.entities[questionId];
-
- // Validations
- const answers = {
- 'question_id': question.question_id!,
- 'answer': question._metadata.answers
- } as UserQuestionAnswerIn;
-
- const answers_body = {
- 'answers': [answers]
- } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost;
-
- // Make API call
- const res = await new ProfilingQuestionsApi()
- .submitProfilingQuestionsProductIdProfilingQuestionsPost(
- bpid,
- bpuid,
- answers_body,
- undefined,
- true
- );
-
- const response = res.data as StatusResponse;
-
- // Check if we need to fetch new questions BEFORE returning
- if (response.status === "success" && isFirstNonGR(state.profilingQuestions)) {
- // NOW dispatch it
- dispatch(fetchNewQuestions(bpuid));
- }
-
- return { questionId, response };
-
+ "profilingQuestions/saveAnswer",
+ async (
+ { questionId, bpuid }: { questionId: string; bpuid: string },
+ { getState, dispatch },
+ ) => {
+ const state = getState() as RootState;
+ const question = state.profilingQuestions.entities[questionId];
+
+ // Validations
+ const answers = {
+ question_id: question.question_id!,
+ answer: question._metadata.answers,
+ } as UserQuestionAnswerIn;
+
+ const answers_body = {
+ answers: [answers],
+ } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost;
+
+ // Make API call
+ const res =
+ await new ProfilingQuestionsApi().submitProfilingQuestionsProductIdProfilingQuestionsPost(
+ bpid,
+ bpuid,
+ answers_body,
+ undefined,
+ true,
+ );
+
+ const response = res.data as StatusResponse;
+
+ // Check if we need to fetch new questions BEFORE returning
+ if (
+ response.status === "success" &&
+ isFirstNonGR(state.profilingQuestions)
+ ) {
+ // NOW dispatch it
+ dispatch(fetchNewQuestions(bpuid));
}
-)
-export const fetchNewQuestions = createAsyncThunk(
- 'profilingQuestions/fetchNewQuestions',
- async (bpuid: string) => {
-
- console.log("profilingQuestions/fetchNewQuestions")
- const res = await new ProfilingQuestionsApi()
- .getProfilingQuestionsProductIdProfilingQuestionsGet(
- bpid,
- bpuid,
- undefined, // "104.9.125.144", // ip
- undefined, // countryIso
- undefined, // languageIso
- 2_500
- );
- return res.data.questions;
- }
+ return { questionId, response };
+ },
);
-type ProfilingQuestionsState = ReturnType<typeof profilingQuestionSlice.getInitialState>;
+export const fetchNewQuestions = createAsyncThunk(
+ "profilingQuestions/fetchNewQuestions",
+ async (bpuid: string) => {
+ console.log("profilingQuestions/fetchNewQuestions");
+ const res =
+ await new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet(
+ bpid,
+ bpuid,
+ undefined, // "104.9.125.144", // ip
+ undefined, // countryIso
+ undefined, // languageIso
+ 2_500,
+ );
+ return res.data.questions;
+ },
+);
+type ProfilingQuestionsState = ReturnType<
+ typeof profilingQuestionSlice.getInitialState
+>;
// Helper function that contains the logic (outside the slice)
function goToNextQuestionLogic(state: WritableDraft<ProfilingQuestionsState>) {
- const allQuestions = questionsAdapter.getSelectors().selectAll(state);
- const currentIndex = allQuestions.findIndex(q => q._metadata.isActive);
-
- if (currentIndex === -1) return;
-
- const totalQuestions = allQuestions.length;
- let searchIndex = (currentIndex + 1) % totalQuestions;
- let iterations = 0;
-
- while (iterations < totalQuestions) {
- if (!allQuestions[searchIndex]._metadata.isSaved) {
- questionsAdapter.updateMany(state, [
- {
- id: allQuestions[currentIndex].question_id!,
- changes: {
- _metadata: {
- ...allQuestions[currentIndex]._metadata,
- isActive: false
- }
- }
- },
- {
- id: allQuestions[searchIndex].question_id!,
- changes: {
- _metadata: {
- ...allQuestions[searchIndex]._metadata,
- isActive: true
- }
- }
- }
- ]);
- return;
- }
-
- searchIndex = (searchIndex + 1) % totalQuestions;
- iterations++;
+ const allQuestions = questionsAdapter.getSelectors().selectAll(state);
+ const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive);
+
+ if (currentIndex === -1) return;
+
+ const totalQuestions = allQuestions.length;
+ let searchIndex = (currentIndex + 1) % totalQuestions;
+ let iterations = 0;
+
+ while (iterations < totalQuestions) {
+ if (!allQuestions[searchIndex]._metadata.isSaved) {
+ questionsAdapter.updateMany(state, [
+ {
+ id: allQuestions[currentIndex].question_id!,
+ changes: {
+ _metadata: {
+ ...allQuestions[currentIndex]._metadata,
+ isActive: false,
+ },
+ },
+ },
+ {
+ id: allQuestions[searchIndex].question_id!,
+ changes: {
+ _metadata: {
+ ...allQuestions[searchIndex]._metadata,
+ isActive: true,
+ },
+ },
+ },
+ ]);
+ return;
}
+
+ searchIndex = (searchIndex + 1) % totalQuestions;
+ iterations++;
+ }
}
function setProfilingQuestionsLogic(
- state: WritableDraft<ProfilingQuestionsState>,
- questions: ProfileQuestion[]) {
-
- const hasActiveQuestion = Object.values(state.entities).some(
- entity => entity?._metadata.isActive
- );
-
- const entities = questions.map((serverQuestion, index) => {
- const existing = state.entities[serverQuestion.question_id!];
- const shouldActivate = !hasActiveQuestion && index === 0;
-
- return {
- ...serverQuestion,
- _metadata: existing?._metadata || {
- ...getDefaultMetadata(),
- isActive: shouldActivate,
- },
- _validation: existing?._validation || getDefaultValidation(),
- };
- });
+ state: WritableDraft<ProfilingQuestionsState>,
+ questions: ProfileQuestion[],
+) {
+ const hasActiveQuestion = Object.values(state.entities).some(
+ (entity) => entity?._metadata.isActive,
+ );
- questionsAdapter.setAll(state, entities);
-}
+ const entities = questions.map((serverQuestion, index) => {
+ const existing = state.entities[serverQuestion.question_id!];
+ const shouldActivate = !hasActiveQuestion && index === 0;
+ return {
+ ...serverQuestion,
+ _metadata: existing?._metadata || {
+ ...getDefaultMetadata(),
+ isActive: shouldActivate,
+ },
+ _validation: existing?._validation || getDefaultValidation(),
+ };
+ });
+
+ questionsAdapter.setAll(state, entities);
+}
function isGR(ext_question_id: string): boolean {
- return ext_question_id.startsWith("gr:")
+ return ext_question_id.startsWith("gr:");
}
function isFirstNonGR(state: WritableDraft<ProfilingQuestionsState>): boolean {
- // We want to identify if the next question is the first non-GR question
- // because will want to trigger a full profile question refresh.
+ // We want to identify if the next question is the first non-GR question
+ // because will want to trigger a full profile question refresh.
- const allQuestions = questionsAdapter.getSelectors().selectAll(state);
- const currentIndex = allQuestions.findIndex(q => q._metadata.isActive);
+ const allQuestions = questionsAdapter.getSelectors().selectAll(state);
+ const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive);
- if (currentIndex === -1) false;
+ if (currentIndex === -1) false;
- const totalQuestions = allQuestions.length;
- let searchIndex = (currentIndex + 1) % totalQuestions;
- let iterations = 0;
+ const totalQuestions = allQuestions.length;
+ let searchIndex = (currentIndex + 1) % totalQuestions;
+ let iterations = 0;
- while (iterations < totalQuestions) {
- if (!allQuestions[searchIndex]._metadata.isSaved) {
+ while (iterations < totalQuestions) {
+ if (!allQuestions[searchIndex]._metadata.isSaved) {
+ const current = isGR(allQuestions[currentIndex].ext_question_id!);
+ const next = isGR(allQuestions[searchIndex].ext_question_id!);
- const current = isGR(allQuestions[currentIndex].ext_question_id!)
- const next = isGR(allQuestions[searchIndex].ext_question_id!)
-
- // We want to identify transitions from GR to non-GR questions,
- // as we use GR questions to pre-calculate some non-GR questions,
- // we should force a refresh.
- return current && !next;
- }
-
- searchIndex = (searchIndex + 1) % totalQuestions;
- iterations++;
+ // We want to identify transitions from GR to non-GR questions,
+ // as we use GR questions to pre-calculate some non-GR questions,
+ // we should force a refresh.
+ return current && !next;
}
- return false;
+ searchIndex = (searchIndex + 1) % totalQuestions;
+ iterations++;
+ }
+
+ return false;
}
const profilingQuestionSlice = createSlice({
- name: 'profilingQuestions',
- initialState: questionsAdapter.getInitialState(),
- reducers: {
+ name: "profilingQuestions",
+ initialState: questionsAdapter.getInitialState(),
+ reducers: {
+ setProfilingQuestions: (
+ state,
+ action: PayloadAction<ProfileQuestion[]>,
+ ) => {
+ setProfilingQuestionsLogic(state, action.payload);
+ },
- setProfilingQuestions: (state, action: PayloadAction<ProfileQuestion[]>) => {
- setProfilingQuestionsLogic(state, action.payload);
- },
+ metadataUpdated: (
+ state,
+ action: PayloadAction<{
+ question_id: string;
+ metadata: Partial<UpkQuestionMetadata>;
+ }>,
+ ) => {
+ const { question_id, metadata } = action.payload;
+ const entity = state.entities[question_id];
+ if (entity) {
+ entity._metadata = { ...entity._metadata, ...metadata };
+ }
+ },
- metadataUpdated: (
- state,
- action: PayloadAction<{ question_id: string; metadata: Partial<UpkQuestionMetadata> }>
- ) => {
- const { question_id, metadata } = action.payload;
- const entity = state.entities[question_id];
- if (entity) {
- entity._metadata = { ...entity._metadata, ...metadata };
- }
- },
+ validationUpdated: (
+ state,
+ action: PayloadAction<{
+ question_id: string;
+ validation: Partial<ValidationResult>;
+ }>,
+ ) => {
+ const { question_id, validation } = action.payload;
+ const entity = state.entities[question_id];
+ if (entity) {
+ entity._validation = { ...entity._validation, ...validation };
+ }
+ },
- validationUpdated: (
- state,
- action: PayloadAction<{ question_id: string; validation: Partial<ValidationResult> }>
- ) => {
- const { question_id, validation } = action.payload;
- const entity = state.entities[question_id];
- if (entity) {
- entity._validation = { ...entity._validation, ...validation };
- }
- },
+ // Set one question as active, deactivate all others
+ setActiveQuestion: (state, action: PayloadAction<string>) => {
+ const questionId = action.payload;
- // Set one question as active, deactivate all others
- setActiveQuestion: (state, action: PayloadAction<string>) => {
- const questionId = action.payload;
-
- // Deactivate all questions
- Object.values(state.entities).forEach(entity => {
- if (entity) {
- entity._metadata.isActive = false;
- }
- });
-
- // Activate the selected one
- const entity = state.entities[questionId];
- if (entity) {
- entity._metadata.isActive = true;
- }
- },
+ // Deactivate all questions
+ Object.values(state.entities).forEach((entity) => {
+ if (entity) {
+ entity._metadata.isActive = false;
+ }
+ });
- // Clear active state from all questions
- clearActiveQuestion: (state) => {
- Object.values(state.entities).forEach(entity => {
- if (entity) {
- entity._metadata.isActive = false;
- }
- });
- },
+ // Activate the selected one
+ const entity = state.entities[questionId];
+ if (entity) {
+ entity._metadata.isActive = true;
+ }
+ },
- goToNextQuestion: (state) => {
- goToNextQuestionLogic(state);
- },
+ // Clear active state from all questions
+ clearActiveQuestion: (state) => {
+ Object.values(state.entities).forEach((entity) => {
+ if (entity) {
+ entity._metadata.isActive = false;
+ }
+ });
+ },
+
+ goToNextQuestion: (state) => {
+ goToNextQuestionLogic(state);
+ },
- // -----------------------
+ // -----------------------
- addAnswers(state, action: PayloadAction<{ questionId: string, answers: string[] }>) {
- /* When changing the answers in anyway, we want to:
+ addAnswers(
+ state,
+ action: PayloadAction<{ questionId: string; answers: string[] }>,
+ ) {
+ /* When changing the answers in anyway, we want to:
1. Add the new answers to the question metadata
This does not perform validation, it simply reassigns
@@ -325,112 +339,124 @@ const profilingQuestionSlice = createSlice({
3. Re-calculate the isComplete and isValid state
*/
- // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers)
- let entity = state.entities[action.payload.questionId];
- const newMetadata = { ...entity._metadata, answers: action.payload.answers };
+ // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers)
+ let entity = state.entities[action.payload.questionId];
+ const newMetadata = {
+ ...entity._metadata,
+ answers: action.payload.answers,
+ };
- if (entity) {
- entity._metadata = newMetadata;
+ if (entity) {
+ entity._metadata = newMetadata;
- entity = questionUtils.validate(entity)
- entity = questionUtils.assignValidationState(entity)
+ entity = questionUtils.validate(entity);
+ entity = questionUtils.assignValidationState(entity);
- state.entities[action.payload.questionId] = entity
- }
- },
+ state.entities[action.payload.questionId] = entity;
+ }
},
-
- extraReducers: (builder) => {
- builder
- .addCase(saveAnswer.pending, (state, action) => {
- const questionId = action.meta.arg.questionId;
- const bpuid = action.meta.arg.bpuid;
- assert(bpuid, "Worker must be defined");
-
- const question = state.entities[questionId];
- if (question) {
- assert(question._validation.isComplete, "Must submit Completed Questions");
- assert(!question._metadata.isProcessing, "Can't submit processing Answer");
- assert(!question._metadata.isSaved, "Can't submit completed Answer");
- question._metadata.isProcessing = true;
- }
- })
- .addCase(saveAnswer.fulfilled, (state, action) => {
- const { questionId, response } = action.payload;
- const question = state.entities[questionId];
-
- if (question) {
- question._metadata.isProcessing = false;
- if (response.status === "success") {
- question._metadata.isSaved = true;
-
- // fetchNewQuestions is already being dispatched from
- // the THUNK
- goToNextQuestionLogic(state);
-
- } else {
- question._metadata.isSaved = false;
- }
- }
- })
- .addCase(saveAnswer.rejected, (state, action) => {
- const questionId = action.meta.arg.questionId;
- const question = state.entities[questionId];
-
- if (question) {
- question._metadata.isProcessing = false;
- question._metadata.isSaved = false;
- }
- })
- .addCase(fetchNewQuestions.fulfilled, (state, action) => {
- console.log(".addCase(fetchNewQuestions.fulfilled, (state, action) => {")
- setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]);
- });
- ;
- }
-
-})
+ },
+
+ extraReducers: (builder) => {
+ builder
+ .addCase(saveAnswer.pending, (state, action) => {
+ const questionId = action.meta.arg.questionId;
+ const bpuid = action.meta.arg.bpuid;
+ assert(bpuid, "Worker must be defined");
+
+ const question = state.entities[questionId];
+ if (question) {
+ assert(
+ question._validation.isComplete,
+ "Must submit Completed Questions",
+ );
+ assert(
+ !question._metadata.isProcessing,
+ "Can't submit processing Answer",
+ );
+ assert(!question._metadata.isSaved, "Can't submit completed Answer");
+ question._metadata.isProcessing = true;
+ }
+ })
+ .addCase(saveAnswer.fulfilled, (state, action) => {
+ const { questionId, response } = action.payload;
+ const question = state.entities[questionId];
+
+ if (question) {
+ question._metadata.isProcessing = false;
+ if (response.status === "success") {
+ question._metadata.isSaved = true;
+
+ // fetchNewQuestions is already being dispatched from
+ // the THUNK
+ goToNextQuestionLogic(state);
+ } else {
+ question._metadata.isSaved = false;
+ }
+ }
+ })
+ .addCase(saveAnswer.rejected, (state, action) => {
+ const questionId = action.meta.arg.questionId;
+ const question = state.entities[questionId];
+
+ if (question) {
+ question._metadata.isProcessing = false;
+ question._metadata.isSaved = false;
+ }
+ })
+ .addCase(fetchNewQuestions.fulfilled, (state, action) => {
+ console.log(
+ ".addCase(fetchNewQuestions.fulfilled, (state, action) => {",
+ );
+ setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]);
+ });
+ },
+});
export const {
- setProfilingQuestions,
- metadataUpdated,
- setActiveQuestion,
- goToNextQuestion,
- addAnswers,
+ setProfilingQuestions,
+ metadataUpdated,
+ setActiveQuestion,
+ goToNextQuestion,
+ addAnswers,
} = profilingQuestionSlice.actions;
// Selectors
export const questionsSelectors = questionsAdapter.getSelectors(
- (state: RootState) => state.profilingQuestions
+ (state: RootState) => state.profilingQuestions,
);
// Custom selector to get the active question
export const selectAllQuestions = (state: RootState): ProfileQuestion[] => {
- return questionsSelectors.selectAll(state) ?? [];
+ return questionsSelectors.selectAll(state) ?? [];
};
// Custom selector to get the active question
-export const selectActiveQuestion = (state: RootState): ProfileQuestion | null => {
- const allQuestions = questionsSelectors.selectAll(state);
- return allQuestions.find(q => q._metadata.isActive) || null;
+export const selectActiveQuestion = (
+ state: RootState,
+): ProfileQuestion | null => {
+ const allQuestions = questionsSelectors.selectAll(state);
+ return allQuestions.find((q) => q._metadata.isActive) || null;
};
// Custom selector to get active question ID
export const selectActiveQuestionId = (state: RootState): string | null => {
- const activeQuestion = selectActiveQuestion(state);
- return activeQuestion?.question_id || null;
+ const activeQuestion = selectActiveQuestion(state);
+ return activeQuestion?.question_id || null;
};
-export const selectActiveQuestionMetadata = (state: RootState): UpkQuestionMetadata | null => {
- const activeQuestion = selectActiveQuestion(state);
- return activeQuestion?._metadata || null;
+export const selectActiveQuestionMetadata = (
+ state: RootState,
+): UpkQuestionMetadata | null => {
+ const activeQuestion = selectActiveQuestion(state);
+ return activeQuestion?._metadata || null;
};
-export const selectActiveQuestionValidation = (state: RootState): ValidationResult | null => {
- const activeQuestion = selectActiveQuestion(state);
- return activeQuestion?._validation || null;
+export const selectActiveQuestionValidation = (
+ state: RootState,
+): ValidationResult | null => {
+ const activeQuestion = selectActiveQuestion(state);
+ return activeQuestion?._validation || null;
};
-
-export default profilingQuestionSlice.reducer
-
+export default profilingQuestionSlice.reducer;
diff --git a/jb-ui/src/models/profilingUtils.ts b/jb-ui/src/models/profilingUtils.ts
index 104a5bb..a3ef7d2 100644
--- a/jb-ui/src/models/profilingUtils.ts
+++ b/jb-ui/src/models/profilingUtils.ts
@@ -1,29 +1,36 @@
-import { PatternValidation, UpkQuestionConfigurationMC, UpkQuestionConfigurationTE } from "@/api_fsb";
+import {
+ PatternValidation,
+ UpkQuestionConfigurationMC,
+ UpkQuestionConfigurationTE,
+} from "@/api_fsb";
import { assert } from "@/lib/utils";
-import { ProfileQuestion, ValidationError } from '@/models/profilingQuestionsSlice';
-import { Filter } from 'bad-words';
+import {
+ ProfileQuestion,
+ ValidationError,
+} from "@/models/profilingQuestionsSlice";
+import { Filter } from "bad-words";
const filter = new Filter();
export const questionUtils = {
-
- assignValidationState: (question: ProfileQuestion): ProfileQuestion => {
- /* This function performs validation on the question's current answers and
+ assignValidationState: (question: ProfileQuestion): ProfileQuestion => {
+ /* This function performs validation on the question's current answers and
assigns any error messages to question._metadata.error_msg.
If the question is MC, validate:
*/
- const errors = question._validation.errors ?? []
+ const errors = question._validation.errors ?? [];
- question._validation.isComplete = errors.length === 0
- question._validation.isValid = errors.filter(e => e.severity === 'error').length === 0
+ question._validation.isComplete = errors.length === 0;
+ question._validation.isValid =
+ errors.filter((e) => e.severity === "error").length === 0;
- return question;
- },
+ return question;
+ },
- validate: (question: ProfileQuestion): ProfileQuestion => {
- /* If the question is MC, validate:
+ validate: (question: ProfileQuestion): ProfileQuestion => {
+ /* If the question is MC, validate:
- validate selector SA vs MA (1 selected vs >1 selected)
- the answers match actual codes in the choices
@@ -35,121 +42,132 @@ export const questionUtils = {
- validation.patterns
*/
- assert(question.question_id, "Question must have question_id")
- // assert(question.configuration, "Question must have configuration")
- const answers = question._metadata.answers ?? []
-
- // Declare it here, and they we'll reassign any specific
- // configuration types based on the question type.
-
- // Start fresh without any error messages before validating
- const errors: ValidationError[] = []
-
- switch (question.question_type) {
- case "TE":
-
- answers.length === 0 && errors.push({
- path: `question_id:${question.question_id}`,
- message: "No answer provided",
- severity: "warning",
- } as ValidationError)
-
- answers.length > 1 && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Only one answer allowed",
- severity: "error",
- } as ValidationError)
-
- let answer_text: string = answers[0]
-
- const te_config = question.configuration as UpkQuestionConfigurationTE
- const min_length = te_config.min_length ?? 0
- const max_length = te_config.max_length ?? 100_000
-
- answer_text.length < min_length && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Answer shorter than allowed",
- severity: "warning",
- } as ValidationError)
-
- answer_text.length > max_length && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Answer longer than allowed",
- severity: "error",
- } as ValidationError)
-
- filter.isProfane(answer_text) && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Answer has inappropriate content",
- severity: "error",
- } as ValidationError)
-
- const patterns: PatternValidation[] = (question.validation ?? {})["patterns"] ?? []
- patterns.forEach((pv) => {
- let re = new RegExp(pv.pattern)
- answer_text.search(re) == -1 && errors.push({
- path: `question_id:${question.question_id}`,
- message: pv.message,
- severity: "error",
- } as ValidationError)
-
- })
-
- break;
-
- case "MC":
-
- answers.length === 0 && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Multiple Choice question with no selected answers",
- severity: "warning",
- } as ValidationError)
-
- const choice_codes: string[] = question.choices?.map((c) => c.choice_id) ?? [];
-
- const mc_config = (question.configuration ?? {}) as UpkQuestionConfigurationMC
- const max_select = mc_config.max_select ?? choice_codes.length
-
- switch (question.selector) {
- case "SA":
- answers.length > 1 && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Single Answer MC question with >1 selected answers",
- severity: "error",
- } as ValidationError)
-
- break;
-
- case "MA":
- answers.length > max_select && errors.push({
- path: `question_id:${question.question_id}`,
- message: "More options selected than allowed",
- severity: "error",
- } as ValidationError)
-
- answers.length === max_select && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Selected all the options",
- severity: "error",
- } as ValidationError)
-
- break;
- }
-
- // Now validate Multiple Choice answers regardless of if
- // they're Single Answer (SA) or Multiple Answer (MA)
- !answers.every(item => choice_codes.includes(item)) && errors.push({
- path: `question_id:${question.question_id}`,
- message: "Invalid options selected",
- severity: "error",
- })
-
- // const max_select: number = question.configuration?.max_select ?? choice_codes.length
- // if (answer.values.length > max_select) {
- // answer.error_msg = "More options selected than allowed"
- // }
-
- /*
+ assert(question.question_id, "Question must have question_id");
+ // assert(question.configuration, "Question must have configuration")
+ const answers = question._metadata.answers ?? [];
+
+ // Declare it here, and they we'll reassign any specific
+ // configuration types based on the question type.
+
+ // Start fresh without any error messages before validating
+ const errors: ValidationError[] = [];
+
+ switch (question.question_type) {
+ case "TE":
+ answers.length === 0 &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "No answer provided",
+ severity: "warning",
+ } as ValidationError);
+
+ answers.length > 1 &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Only one answer allowed",
+ severity: "error",
+ } as ValidationError);
+
+ let answer_text: string = answers[0];
+
+ const te_config = question.configuration as UpkQuestionConfigurationTE;
+ const min_length = te_config.min_length ?? 0;
+ const max_length = te_config.max_length ?? 100_000;
+
+ answer_text.length < min_length &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Answer shorter than allowed",
+ severity: "warning",
+ } as ValidationError);
+
+ answer_text.length > max_length &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Answer longer than allowed",
+ severity: "error",
+ } as ValidationError);
+
+ filter.isProfane(answer_text) &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Answer has inappropriate content",
+ severity: "error",
+ } as ValidationError);
+
+ const patterns: PatternValidation[] =
+ (question.validation ?? {})["patterns"] ?? [];
+ patterns.forEach((pv) => {
+ let re = new RegExp(pv.pattern);
+ answer_text.search(re) == -1 &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: pv.message,
+ severity: "error",
+ } as ValidationError);
+ });
+
+ break;
+
+ case "MC":
+ answers.length === 0 &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Multiple Choice question with no selected answers",
+ severity: "warning",
+ } as ValidationError);
+
+ const choice_codes: string[] =
+ question.choices?.map((c) => c.choice_id) ?? [];
+
+ const mc_config = (question.configuration ??
+ {}) as UpkQuestionConfigurationMC;
+ const max_select = mc_config.max_select ?? choice_codes.length;
+
+ switch (question.selector) {
+ case "SA":
+ answers.length > 1 &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Single Answer MC question with >1 selected answers",
+ severity: "error",
+ } as ValidationError);
+
+ break;
+
+ case "MA":
+ answers.length > max_select &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "More options selected than allowed",
+ severity: "error",
+ } as ValidationError);
+
+ answers.length === max_select &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Selected all the options",
+ severity: "error",
+ } as ValidationError);
+
+ break;
+ }
+
+ // Now validate Multiple Choice answers regardless of if
+ // they're Single Answer (SA) or Multiple Answer (MA)
+ !answers.every((item) => choice_codes.includes(item)) &&
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Invalid options selected",
+ severity: "error",
+ });
+
+ // const max_select: number = question.configuration?.max_select ?? choice_codes.length
+ // if (answer.values.length > max_select) {
+ // answer.error_msg = "More options selected than allowed"
+ // }
+
+ /*
exclusive_choice = next((x for x in question["choices"] if x.get("exclusive")), None)
if exclusive_choice:
exclusive_choice_id = exclusive_choice["choice_id"]
@@ -157,21 +175,20 @@ export const questionUtils = {
exclusive_choice_id not in answer, "Invalid exclusive selection"
*/
- break;
-
- default:
- errors.push({
- path: `question_id:${question.question_id}`,
- message: "Incorrect Question Type provided",
- severity: "error",
- } as ValidationError)
- }
-
- // Assign the errors back to the question's _validation field and
- // then return the question
- // TODO: does this edit in place, maybe I don't need to return it
- question._validation.errors = errors
- return question;
- },
-
-}; \ No newline at end of file
+ break;
+
+ default:
+ errors.push({
+ path: `question_id:${question.question_id}`,
+ message: "Incorrect Question Type provided",
+ severity: "error",
+ } as ValidationError);
+ }
+
+ // Assign the errors back to the question's _validation field and
+ // then return the question
+ // TODO: does this edit in place, maybe I don't need to return it
+ question._validation.errors = errors;
+ return question;
+ },
+};