Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
606a032
add optOut to PromptDefinition txstate-etc/reqquest-txstate#314
kevinepena May 12, 2026
46ad12c
Merge branch 'txstate-etc:main' into optin-out
kevinepena May 15, 2026
1e3ab0c
adding optOut flag to prompt txstate-etc/reqquest-txstate#314
kevinepena May 14, 2026
a91020c
removing optout from db
kevinepena May 15, 2026
d45c0d1
Merge branch 'txstate-etc:main' into optin-out
kevinepena May 19, 2026
850f940
removing optout prompts from nav
kevinepena May 20, 2026
33ea176
prompt optOut is coming from prompt registry
kevinepena May 20, 2026
55b7f78
adding optOut prompt and requirement, adding it to software dev progr…
kevinepena May 20, 2026
52a5892
returning optOut from getAppRequest fetch txstate-etc/reqquest-txstat…
kevinepena May 20, 2026
0c1f297
adding optOut components to UI txstate-etc/reqquest-txstate#268
kevinepena May 20, 2026
acf234d
Merge branch 'txstate-etc:main' into optin-out
kevinepena May 20, 2026
72203a4
opt out ui txstate-etc/reqquest-txstate#268
kevinepena May 20, 2026
e79f1f8
needed for not opting out
kevinepena May 20, 2026
79b732d
removing optout from api and moving it to UI
kevinepena May 28, 2026
befc593
updating UI to look for optOut flag in prompt registry
kevinepena May 28, 2026
1f8423e
updating Modal to PanelFormDialog
kevinepena May 28, 2026
fe15155
updating form title to use application/program title
kevinepena May 28, 2026
89cfce8
dont pass store and use form context
kevinepena May 28, 2026
b8a35f6
only returning not applicable if modal is never opened and disqualify…
kevinepena May 28, 2026
f79fed1
updating OptOut prompt to be clenaer
kevinepena May 28, 2026
63ab1fc
clean up
kevinepena May 28, 2026
b6f1e31
removing optOut from prompt class
kevinepena May 28, 2026
39525ee
moving optOut flag back to API
kevinepena May 28, 2026
432fb8d
updating evaluate function to not mark prompt unreachable when is opt…
kevinepena May 28, 2026
6ba2669
ignoring first promptId if optout
kevinepena May 28, 2026
2df0409
also skip the first one if answered and optout
kevinepena May 28, 2026
e8922c8
awaiting promt info before opening modal
kevinepena May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions api/src/appRequest/appRequest.database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ export async function evaluateAppRequest (appRequestInternalId: number, tdb?: Qu
hasUnanswered ||= !anyOrderAllAnswered
for (const prompt of regularPrompts) {
prompt.moot = applicationIsIneligible && requirement.type !== RequirementType.WORKFLOW
if (hasUnanswered || resolveInfo.status !== RequirementStatus.PENDING) prompt.visibility = PromptVisibility.UNREACHABLE
if ((hasUnanswered || resolveInfo.status !== RequirementStatus.PENDING) && !promptRegistry.get(prompt.key).optOut) prompt.visibility = PromptVisibility.UNREACHABLE
else {
if (promptsSeenInApplication.has(prompt.key)) prompt.visibility = PromptVisibility.APPLICATION_DUPE
else if (promptsSeenInRequest.has(prompt.key)) prompt.visibility = PromptVisibility.REQUEST_DUPE
Expand Down Expand Up @@ -683,13 +683,15 @@ export async function evaluateAppRequest (appRequestInternalId: number, tdb?: Qu
if (phase === 'acceptance') application.ineligiblePhase = IneligiblePhases.ACCEPTANCE
else if (phase === 'applicant') application.ineligiblePhase = firstFailingRequirement?.type === RequirementType.PREQUAL ? IneligiblePhases.PREQUAL : IneligiblePhases.QUALIFICATION
else if (phase === 'review') {
application.ineligiblePhase =
firstFailingRequirement?.type === RequirementType.PREQUAL ? IneligiblePhases.PREQUAL :
firstFailingRequirement?.type === RequirementType.QUALIFICATION ? IneligiblePhases.QUALIFICATION :
firstFailingRequirement?.type === RequirementType.PREAPPROVAL ? IneligiblePhases.PREAPPROVAL :
IneligiblePhases.APPROVAL
}
else if (phase === 'blocking') application.ineligiblePhase ??= IneligiblePhases.WORKFLOW
application.ineligiblePhase
= firstFailingRequirement?.type === RequirementType.PREQUAL
? IneligiblePhases.PREQUAL
: firstFailingRequirement?.type === RequirementType.QUALIFICATION
? IneligiblePhases.QUALIFICATION
: firstFailingRequirement?.type === RequirementType.PREAPPROVAL
? IneligiblePhases.PREAPPROVAL
: IneligiblePhases.APPROVAL
} else if (phase === 'blocking') application.ineligiblePhase ??= IneligiblePhases.WORKFLOW
} else if (phase !== 'nonblocking' && phase !== 'blocking' && phase !== 'complete') {
application.ineligiblePhase = undefined
}
Expand Down
5 changes: 5 additions & 0 deletions api/src/prompt/prompt.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export class RequirementPromptResolver {
async prestage (@Ctx() ctx: RQContext, @Root() requirementPrompt: RequirementPrompt) {
return await ctx.svc(RequirementPromptService).requiresStaging(requirementPrompt)
}

@FieldResolver(type => Boolean)
async optOut (@Ctx() ctx: RQContext, @Root() requirementPrompt: RequirementPrompt) {
return !!promptRegistry.get(requirementPrompt.key).optOut
}
}

@Resolver(of => RequirementPromptActions)
Expand Down
4 changes: 4 additions & 0 deletions api/src/registry/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ export interface PromptDefinition<DataType = any, InputDataType = DataType, Conf
* make decisions.
*/
configuration?: ConfigurationDefinition
/**
* Ability for user to opt out
*/
optOut?: boolean
}

export interface InvalidatedResponse {
Expand Down
11 changes: 11 additions & 0 deletions demos/src/rc/definitions/models/optOut.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { SchemaObject } from '@txstate-mws/fastify-shared'
import type { FromSchema } from 'json-schema-to-ts'

export const OptOutSchema = {
type: 'object',
properties: {
optOut: { type: 'boolean' }
},
additionalProperties: false
} as const satisfies SchemaObject
export type OptOutData = FromSchema<typeof OptOutSchema>
1 change: 1 addition & 0 deletions demos/src/rc/definitions/programs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const softwareDevelopment: ProgramDefinition = {
key: 'software_development',
title: 'Software Development',
requirementKeys: [
'opt_out_req',
'step1_prequal_req',
'data_related_puzzle_req',
'assess_data_related_puzzle_req',
Expand Down
15 changes: 12 additions & 3 deletions demos/src/rc/definitions/prompts/softwareDevelopment.prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { PromptDefinition } from '@reqquest/api'
import { MutationMessageType } from '@txstate-mws/graphql-server'
import { AssessCriticalThinkingPromptData, AssessCriticalThinkingSchema, AssessOutsideClassExamplePromptData, AssessOutsideClassExampleSchema, AssessPuzzleSolutionPromptData, AssessPuzzleSolutionSchema, CriticalThinkingPromptData, CriticalThinkingSchema, DataRelatedPuzzlePromptData, DataRelatedPuzzleSchema, OutsideClassExamplePromptData, OutsideClassExampleSchema } from '../models/index.js'
import { fileHandler } from 'fastify-txstate'
import { OptOutData, OptOutSchema } from '../models/optOut.models.js'

export const opt_out_prompt: PromptDefinition<OptOutData> = {
key: 'opt_out_prompt',
title: 'Software development',
description: 'Opt Out',
schema: OptOutSchema,
optOut: true,
validate: (data, config) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this validate could be omitted once the requirement is updated to accept undefined as MET. No big deal to include it but I want to be sure it's not needed because that'd be an ugly thing for downstream to have to remember to do.

return []
}
}

export const data_related_puzzle_prompt: PromptDefinition<DataRelatedPuzzlePromptData> = {
key: 'data_related_puzzle_prompt',
Expand Down Expand Up @@ -48,15 +60,12 @@ export const outside_class_example_prompt: PromptDefinition<OutsideClassExampleP
schema: OutsideClassExampleSchema,
validate: (data, config) => {
const messages = []
console.log(data)
console.log(!data.outsideClassExample)
if (!data.outsideClassExample) {
messages.push({ type: MutationMessageType.warning, message: 'Might want to make something up', arg: 'outsideClassExample' })
}
if (data.description && data.description == null) {
messages.push({ type: MutationMessageType.error, message: 'Please add description.', arg: 'description' })
}
console.log(messages)
return messages
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export const step1_prequal_req: RequirementDefinition<PreQualPromptData> = {
description: 'Pre qualification requirements',
promptKeys: ['pre_qual_prompt'],
resolve: (data, config) => {
console.log(data)
const preQualPromptData = data['pre_qual_prompt'] as PreQualPromptData

if (preQualPromptData?.availability == null) return { status: RequirementStatus.PENDING }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export const communication_req: RequirementDefinition = {
promptKeys: ['communication_prompt'],
resolve: (data, config) => {
const writtenAutomationData = data['communication_prompt'] as CommunicationData
console.log(writtenAutomationData, '🚀🚀🚀🚀🚀🚀')
if (writtenAutomationData?.describeCommunication == null) return { status: RequirementStatus.PENDING }
return { status: RequirementStatus.MET }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { RequirementDefinition, RequirementStatus, RequirementType } from '@reqquest/api'
import { DataRelatedPuzzlePromptData, AssessPuzzleSolutionPromptData, OutsideClassExamplePromptData, AssessOutsideClassExamplePromptData, CriticalThinkingPromptData, AssessCriticalThinkingPromptData } from '../models'
import { OptOutData } from '../models/optOut.models'

export const opt_out_req: RequirementDefinition = {
type: RequirementType.QUALIFICATION,
key: 'opt_out_req',
title: 'Opt Out',
navTitle: 'Opt Out',
description: 'Opt Out',
promptKeys: ['opt_out_prompt'],
resolve: (data, config) => {
const promptData = data['opt_out_prompt'] as OptOutData
if (promptData?.optOut) return { status: RequirementStatus.DISQUALIFYING }
return { status: RequirementStatus.NOT_APPLICABLE }
}
}

export const data_related_puzzle_req: RequirementDefinition = {
type: RequirementType.QUALIFICATION,
Expand Down
3 changes: 2 additions & 1 deletion ui/src/internal/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,8 @@ class API extends APIBase {
invalidatedReason: true,
configurationData: true,
gatheredConfigData: true,
prestage: true
prestage: true,
optOut: true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need this, it's in the uiRegistry.

}
}
},
Expand Down
73 changes: 73 additions & 0 deletions ui/src/internal/components/ApplicantOptOutModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script lang="ts">
import { Form, PanelFormDialog } from '@txstate-mws/carbon-svelte'
import type { FormStore } from '@txstate-mws/svelte-forms'
import { afterNavigate, invalidate, invalidateAll } from '$app/navigation'
import { uiRegistry } from '../../local/index.js'
import { api } from '../api.js'
import { stagedprompts } from '../prompt-utils.js'
import { Loading } from "carbon-components-svelte";
import type { OptOutApplication } from '$lib'

export let open = false
export let optIn = false
export let prompt: any
export let appRequest: any
export let optOutSelected: OptOutApplication | undefined

$: def = uiRegistry.getPrompt(prompt.key)
$: loading = false


let store: FormStore | undefined

async function submit (data: any) {
loading = true
const { success, messages } = await api.updatePrompt(prompt.id, { optOut: data?.optOut }, false)
data = {}
open = false
loading = false
invalidateAll()
return {
success,
messages,
data
}
}

async function onValidate (data: any) {
const { messages } = await api.updatePrompt(prompt.id, data, true)
return messages
}

let lastPromptId: string | undefined
$: if (prompt.id !== lastPromptId) {
lastPromptId = prompt.id
store = undefined
}

afterNavigate(async () => {
stagedprompts.clear() // clear references to staged prompts since we may be navigating to a different prompt that needs staging
await invalidate('request:apply') // required to redraw the nav tree if potential staged data affects prompt visibility or status
})
</script>

{#if loading}
<Loading />
{/if}

<PanelFormDialog
let:data
bind:store
centered
open={open}
on:cancel={() => { open = false }}
on:validate={onValidate}
{submit}
title={`${optIn ? 'Opt in to' : 'Opt out of'} ${optOutSelected?.title}?`}
submitText={optIn ? 'Opt in' : 'Opt out'}
cancelText="Cancel"
preload={prompt.preloadData}
preloadAsDraft={!prompt.hasSavedData}
>
<svelte:component this={def!.formComponent} {data} appRequestId={appRequest.id} appRequestData={appRequest.data} fetched={prompt.fetchedData} configData={prompt.configurationData} gatheredConfigData={prompt.gatheredConfigData} />
</PanelFormDialog>
94 changes: 85 additions & 9 deletions ui/src/internal/components/ApplicantProgramList.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
<script lang="ts">
import { TagSet } from '@txstate-mws/carbon-svelte'
import { Button } from 'carbon-components-svelte'
import { Close, InProgress, CheckmarkFilled, Information } from 'carbon-icons-svelte'
import { Close, InProgress, CheckmarkFilled, Information, SubtractAlt } from 'carbon-icons-svelte'
import { ucfirst } from 'txstate-utils'
import { type ApplicationForDetails, enumApplicationStatus, enumIneligiblePhases, enumRequirementType } from '$lib'
import { type AnsweredPrompt, type ApplicationForDetails, enumApplicationStatus, enumIneligiblePhases, enumRequirementStatus, enumRequirementType, type OptOutApplication, type PromptDefinition } from '$lib'
import { getApplicationStatusInfo } from '../status-utils.js'
import ApplicantProgramListTooltip from './ApplicantProgramListTooltip.svelte'
import WarningIconYellow from './WarningIconYellow.svelte'
import { api } from '$internal/api.js'
import { stagedprompts } from '$internal/prompt-utils.js'
import ApplicantOptOutModal from './ApplicantOptOutModal.svelte'
import { uiRegistry } from '../../local/index.js'

export let appRequest: { phase: string, closedAt?: string | null }
export let appRequest: { phase: string, closedAt?: string | null, id: string, dataVersion: number }
export let applications: ApplicationForDetails[]
export let viewMode = false
export let showTooltipsAsText = false
export let promptsById: Record<string, any> = {}

let open = false
let optIn = false

let optOutPrompt: Omit<PromptDefinition, 'displayComponent'> | undefined


let optOutSelected: OptOutApplication | undefined


async function openOptOutModal (programId: string, openOptIn = false) {
optOutSelected = optOutPrograms[programId]
await loadOptOutPrompt()
optIn = openOptIn
open = true
}

$: promptsByApplicationId = applications.reduce<Record<string, typeof applications[0]['requirements'][0]['prompts'] | undefined>>((acc, curr) => ({
...acc,
Expand All @@ -33,11 +54,46 @@
? 'ineligible'
: 'revisit'
: 'complete'
}), {})
}), {} as Record<string, string>)

$: programFirstPromptId = applications.reduce((acc, curr) => ({
...acc,
[curr.id]: (promptsByApplicationId[curr.id]?.find(p => !p.answered || p.invalidated) ?? promptsByApplicationId[curr.id]?.[0])?.id
}), {})
[curr.id]: (promptsByApplicationId[curr.id]?.find(p => (!p.answered || p.invalidated) && !p.optOut) ?? promptsByApplicationId[curr.id]?.[0].optOut ? promptsByApplicationId[curr.id]?.[1] : promptsByApplicationId[curr.id]?.[0])?.id
}), {} as Record<string, string | undefined>)

$: optOutPrograms = applications.reduce((acc, curr) => {
const optOut = curr.requirements.flat().flatMap(r => r.prompts).find(r => r.optOut)

if (!optOut) return acc

return {
...acc,
[curr.id]: {
...curr,
prompt: optOut
}
}
}, {} as Record<string, OptOutApplication | undefined>)

$: optedOutPrograms = applications.filter(curr => curr.requirements.flatMap(r => r.prompts).find(r => r.optOut)).reduce((acc, c) => {
const optOutRequirement = c.requirements.flat().find(r => r.prompts.find(p => p.optOut))
return {
...acc,
[c.id]: optOutRequirement?.status === enumRequirementStatus.DISQUALIFYING
}
}, {} as Record<string, boolean>)

async function loadOptOutPrompt () {
const promptId = optOutSelected?.prompt?.id
if (!promptId) return
if (promptsById[promptId]?.prestage && !stagedprompts.has(promptId)) {
const response = await api.stagePrompt(promptId, appRequest.dataVersion)
if (response.success) stagedprompts.add(promptId)
}
const { prompt } = await api.getApplicantPrompt(appRequest.id, promptId)
optOutPrompt = prompt
}

</script>

<section class="programs-container">
Expand All @@ -48,11 +104,20 @@
{#each applications as application (application.id)}
{@const programStatus = programButtonStatus[application.id]}
{@const programFirstPrompt = programFirstPromptId[application.id]}
<div class="program column">{application.title}</div>
<div class="program column [ flex-col ]" style='align-items: start;'>
<span>{application.title}</span>
{#if optedOutPrograms[application.id]}
<Button on:click={() => openOptOutModal(application.id, true)} kind='ghost' style='padding: 0; min-height: 0;' class='[ p-0 justify-start ]'>Opt In</Button>
{:else if optOutPrograms[application.id]}
<Button on:click={() => openOptOutModal(application.id)} kind='ghost' style='padding: 0; min-height: 0;' class='[ p-0 justify-start ]'>Opt out</Button>
{/if}
</div>
<div class="status column" class:no-tooltip={!application.statusReason?.length}>
{#if !viewMode}
<div class="icon-and-tooltip" class:wide-icon={application.completionStatus === enumApplicationStatus.INELIGIBLE}>
{#if application.completionStatus === enumApplicationStatus.INELIGIBLE}
{#if optedOutPrograms[application.id]}
<SubtractAlt size={24} fill='#dd3b46'/>
{:else if application.completionStatus === enumApplicationStatus.INELIGIBLE}
<Close size={32} class="status-icon-ineligible" />
{:else if ['start', 'continue'].includes(programStatus)}
<InProgress size={24} class="status-icon-pending" />
Expand All @@ -63,7 +128,9 @@
{/if}
<ApplicantProgramListTooltip {application} />
</div>
{#if programFirstPrompt && programStatus !== 'ineligible'}
{#if optedOutPrograms[application.id]}
<p>Opted out</p>
{:else if programFirstPrompt && programStatus !== 'ineligible'}
<Button size="small" kind={programStatus === 'complete' ? 'ghost' : programStatus === 'revisit' ? 'secondary' : 'primary'} href={programFirstPrompt}>{ucfirst(programStatus)}</Button>
{/if}
{:else}
Expand All @@ -89,6 +156,11 @@
{/each}
</section>


{#if optOutPrompt?.key}
<ApplicantOptOutModal bind:open bind:optIn bind:prompt={optOutPrompt} {optOutSelected} {appRequest} />
{/if}

<style>
.programs-container {
display: grid;
Expand Down Expand Up @@ -179,4 +251,8 @@
display: block !important;
}
}

:global(.opt-out-modal .bx--modal-content) {
margin-bottom: 1rem;
}
</style>
4 changes: 4 additions & 0 deletions ui/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export interface PromptDefinition {
* An icon for the navigation.
*/
icon?: Component
/**
* Updating UI to show opt out
*/
optOut?: boolean
}

export interface Terminologies {
Expand Down
Loading