diff options
| author | Max Nanis | 2026-09-13 19:03:55 +0000 |
|---|---|---|
| committer | Max Nanis | 2026-09-13 19:03:55 +0000 |
| commit | 8fbe8d439b418796932aaa88297e006c714e4d13 (patch) | |
| tree | 6c800476edc1e771fc570b559d483df8d59d4324 /jb-ui/src/models | |
| parent | 12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff) | |
| parent | 2c94f248d2438071a918fa9a30bf114ef9aa29b4 (diff) | |
| download | amt-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.ts | 66 | ||||
| -rw-r--r-- | jb-ui/src/models/appSlice.ts | 353 | ||||
| -rw-r--r-- | jb-ui/src/models/authAPI.ts | 24 | ||||
| -rw-r--r-- | jb-ui/src/models/cashoutMethodsSlice.ts | 37 | ||||
| -rw-r--r-- | jb-ui/src/models/grlEventsSlice.ts | 74 | ||||
| -rw-r--r-- | jb-ui/src/models/grlStatsSlice.ts | 67 | ||||
| -rw-r--r-- | jb-ui/src/models/profilingQuestionsSlice.ts | 672 | ||||
| -rw-r--r-- | jb-ui/src/models/profilingUtils.ts | 309 |
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; + }, +}; |
