@reqquest/ui 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/env.template +5 -0
- package/app/runtime/apply-env.sh +10 -0
- package/app/runtime/nginx.conf +37 -0
- package/app/src/app.css +21 -0
- package/app/src/app.d.ts +13 -0
- package/app/src/app.html +12 -0
- package/app/src/internal/api.ts +1707 -0
- package/app/src/internal/appreq-utils.ts +33 -0
- package/app/src/internal/components/AnnouncementBanner.svelte +47 -0
- package/app/src/internal/components/AppRequestCard.svelte +173 -0
- package/app/src/internal/components/ApplicantOptOutModal.svelte +78 -0
- package/app/src/internal/components/ApplicantProgramList.svelte +276 -0
- package/app/src/internal/components/ApplicantProgramListTooltip.svelte +44 -0
- package/app/src/internal/components/ApplicantPromptPage.svelte +136 -0
- package/app/src/internal/components/ApplicantPromptSkeleton.svelte +12 -0
- package/app/src/internal/components/ApplicationDetailsView.svelte +369 -0
- package/app/src/internal/components/ButtonLoadingIcon.svelte +28 -0
- package/{dist → app/src/internal}/components/DelayedSkeleton.svelte +2 -2
- package/app/src/internal/components/FieldNestedMultiselect.svelte +270 -0
- package/app/src/internal/components/IntroPanel.svelte +41 -0
- package/app/src/internal/components/MissingDefinitionNotification.svelte +20 -0
- package/app/src/internal/components/PeriodPanel.svelte +125 -0
- package/app/src/internal/components/ProgramStatusCell.svelte +134 -0
- package/app/src/internal/components/RenderDisplayComponent.svelte +42 -0
- package/app/src/internal/components/ReviewerActions.svelte +171 -0
- package/app/src/internal/components/ReviewerQuestions.svelte +444 -0
- package/app/src/internal/components/StatusMessageList.svelte +54 -0
- package/app/src/internal/components/SystemDeterminationNotice.svelte +37 -0
- package/app/src/internal/components/TabLinks.svelte +265 -0
- package/app/src/internal/components/WarningIconYellow.svelte +22 -0
- package/app/src/internal/components/index.ts +19 -0
- package/app/src/internal/csv.ts +30 -0
- package/app/src/internal/index.ts +8 -0
- package/app/src/internal/prompt-save-queue.ts +91 -0
- package/app/src/internal/prompt-utils.ts +40 -0
- package/app/src/internal/status-utils.ts +503 -0
- package/app/src/internal/stores/IStateStore.ts +5 -0
- package/app/src/internal/util.ts +27 -0
- package/app/src/routes/+error.svelte +4 -0
- package/app/src/routes/+layout.svelte +134 -0
- package/app/src/routes/+layout.ts +18 -0
- package/app/src/routes/+page.ts +14 -0
- package/app/src/routes/announcement/+page.svelte +164 -0
- package/app/src/routes/announcement/+page.ts +16 -0
- package/app/src/routes/dashboards/applicant/+page.svelte +494 -0
- package/app/src/routes/dashboards/applicant/+page.ts +95 -0
- package/app/src/routes/dashboards/reviewer/+page.svelte +143 -0
- package/app/src/routes/dashboards/reviewer/+page.ts +43 -0
- package/app/src/routes/periods/+page.svelte +158 -0
- package/app/src/routes/periods/+page.ts +13 -0
- package/app/src/routes/periods/[id]/configure/+page.svelte +150 -0
- package/app/src/routes/periods/[id]/configure/+page.ts +10 -0
- package/app/src/routes/requests/+page.svelte +246 -0
- package/app/src/routes/requests/+page.ts +24 -0
- package/app/src/routes/requests/[id]/+layout.ts +11 -0
- package/app/src/routes/requests/[id]/accept/+layout.svelte +80 -0
- package/app/src/routes/requests/[id]/accept/+layout.ts +12 -0
- package/app/src/routes/requests/[id]/accept/+page.ts +13 -0
- package/app/src/routes/requests/[id]/accept/[promptId]/+page.svelte +7 -0
- package/app/src/routes/requests/[id]/accept/[promptId]/+page.ts +10 -0
- package/app/src/routes/requests/[id]/accept/review/+page.svelte +87 -0
- package/app/src/routes/requests/[id]/apply/+layout.svelte +181 -0
- package/app/src/routes/requests/[id]/apply/+layout.ts +12 -0
- package/app/src/routes/requests/[id]/apply/+page.ts +14 -0
- package/app/src/routes/requests/[id]/apply/ProgressNavContainer.svelte +42 -0
- package/app/src/routes/requests/[id]/apply/[promptId]/+page.svelte +23 -0
- package/app/src/routes/requests/[id]/apply/[promptId]/+page.ts +11 -0
- package/app/src/routes/requests/[id]/apply/programs/+page.svelte +71 -0
- package/app/src/routes/requests/[id]/apply/review/+page.svelte +102 -0
- package/app/src/routes/requests/[id]/approve/+layout.svelte +114 -0
- package/app/src/routes/requests/[id]/approve/+page.ts +14 -0
- package/app/src/routes/requests/[id]/approve/ApproveLayout.svelte +164 -0
- package/app/src/routes/requests/[id]/approve/[programKey]/+page.svelte +396 -0
- package/app/src/routes/requests/[id]/approve/[programKey]/+page.ts +15 -0
- package/app/src/routes/requests/[id]/approve/activity/+page.svelte +66 -0
- package/app/src/routes/requests/[id]/approve/activity/+page.ts +12 -0
- package/app/src/routes/requests/[id]/approve/export/+page.ts +12 -0
- package/app/src/routes/requests/[id]/approve/export/+page@[id].svelte +41 -0
- package/app/src/routes/requests/[id]/export/+page.svelte +32 -0
- package/app/src/routes/requests/[id]/export/+page.ts +12 -0
- package/app/src/routes/roles/+layout.svelte +16 -0
- package/app/src/routes/roles/+layout.ts +14 -0
- package/app/src/routes/roles/+page.svelte +225 -0
- package/app/src/routes/roles/+page.ts +13 -0
- package/app/src/routes/roles/[id]/+layout.ts +10 -0
- package/app/src/routes/roles/[id]/+page.svelte +335 -0
- package/app/src/routes/roles/[id]/+page.ts +10 -0
- package/app/src/routes/roles/[id]/ControlWithTooltip.svelte +15 -0
- package/app/src/routes/roles/[id]/TagsWithTooltip.svelte +42 -0
- package/app/src/routes/roles/users/+layout.ts +13 -0
- package/app/src/routes/roles/users/+page.svelte +46 -0
- package/app/src/routes/roles/users/+page.ts +90 -0
- package/app/static/favicon.png +0 -0
- package/bin/reqquest-ui.js +117 -0
- package/cli/config.js +105 -0
- package/cli/sync.js +296 -0
- package/cli/util.js +29 -0
- package/dist/components.md +0 -4
- package/package.json +29 -15
- package/dist/components/DelayedSkeleton.svelte.d.ts +0 -21
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Shown on the reviewer screen for a program with nothing to review: no PREAPPROVAL, APPROVAL,
|
|
4
|
+
* ACCEPTANCE or workflow requirements. Its eligibility was decided by the system at submission.
|
|
5
|
+
*/
|
|
6
|
+
export let programTitle: string
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<section class="system-determination-notice">
|
|
10
|
+
<h3>Nothing to review for {programTitle}</h3>
|
|
11
|
+
<p>
|
|
12
|
+
There are no remaining questions to answer for this program.<br>
|
|
13
|
+
A system determination was made regarding the applicant's eligibility.
|
|
14
|
+
</p>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.system-determination-notice {
|
|
19
|
+
background: var(--background-cool-light, #d6f1ff);
|
|
20
|
+
padding: 1rem 1.5rem;
|
|
21
|
+
margin-bottom: 0.5rem;
|
|
22
|
+
text-align: center;
|
|
23
|
+
}
|
|
24
|
+
.system-determination-notice h3 {
|
|
25
|
+
margin: 0 0 0.5rem;
|
|
26
|
+
font-size: 1.25rem;
|
|
27
|
+
font-weight: 600;
|
|
28
|
+
line-height: 1.75rem;
|
|
29
|
+
color: var(--cds-text-01, #161616);
|
|
30
|
+
}
|
|
31
|
+
.system-determination-notice p {
|
|
32
|
+
margin: 0;
|
|
33
|
+
font-size: 0.875rem;
|
|
34
|
+
line-height: 1.25rem;
|
|
35
|
+
color: var(--cds-text-02, #525252);
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Local replacement for `TabLinks` from `@txstate-mws/carbon-svelte`, adding support for tabs that
|
|
4
|
+
are not destinations. A tab with an `href` behaves exactly like the library's - a link that
|
|
5
|
+
highlights itself when it matches the current path. A tab **without** an `href` renders as a
|
|
6
|
+
disclosure button that toggles a dropdown panel.
|
|
7
|
+
|
|
8
|
+
The dropdown content travels with the item itself, as its `panel` snippet, which is handed a
|
|
9
|
+
`close` callback. Snippets can only be declared in markup, so a consumer that builds its tab array
|
|
10
|
+
in `<script>` composes the snippet onto the entry at the point of use:
|
|
11
|
+
|
|
12
|
+
```svelte
|
|
13
|
+
{#snippet ineligibleList(close)}
|
|
14
|
+
<ul>...</ul>
|
|
15
|
+
{/snippet}
|
|
16
|
+
<TabLinks tabs={[...others, { label: 'Ineligible programs', panel: ineligibleList }]} />
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The panel is positioned absolutely over the page content, left-aligned to the tab that opened it,
|
|
20
|
+
shifted left as needed to stay within the tab strip, and wraps its content when wider than the strip.
|
|
21
|
+
It closes on outside click, Escape, navigation, and horizontal scrolling of the tab strip.
|
|
22
|
+
-->
|
|
23
|
+
<script context="module" lang="ts">
|
|
24
|
+
import type { Snippet } from 'svelte'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One tab in a `TabLinks` set. Superset of the library's `TabLinkItem`, where `href` is required.
|
|
28
|
+
*/
|
|
29
|
+
export interface TabLinkItem {
|
|
30
|
+
/** The label text for the tab. */
|
|
31
|
+
label: string
|
|
32
|
+
/** Optional icon component to display before the label. */
|
|
33
|
+
icon?: any
|
|
34
|
+
/**
|
|
35
|
+
* The URL the tab points to. Omit to make this tab a disclosure button that toggles its
|
|
36
|
+
* `panel` instead of navigating.
|
|
37
|
+
*/
|
|
38
|
+
href?: string
|
|
39
|
+
/** Whether the tab is disabled. */
|
|
40
|
+
disabled?: boolean
|
|
41
|
+
/**
|
|
42
|
+
* Force the selected state, for when the active route is not this tab's own href. Defaults to
|
|
43
|
+
* whether `href` matches the current path.
|
|
44
|
+
*/
|
|
45
|
+
selected?: boolean
|
|
46
|
+
/**
|
|
47
|
+
* Content revealed in a dropdown when this tab is clicked, passed a `close` callback. Only
|
|
48
|
+
* used when `href` is omitted, since a tab with an `href` navigates instead of opening.
|
|
49
|
+
*/
|
|
50
|
+
panel?: Snippet<[close: () => void]>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let uidCounter = 0
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<script lang="ts">
|
|
57
|
+
import { ScrollOverflow } from '@txstate-mws/carbon-svelte'
|
|
58
|
+
import ChevronDown from 'carbon-icons-svelte/lib/ChevronDown.svelte'
|
|
59
|
+
import { onMount, tick } from 'svelte'
|
|
60
|
+
import { browser } from '$app/environment'
|
|
61
|
+
import { afterNavigate } from '$app/navigation'
|
|
62
|
+
import { page } from '$app/stores'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Array of TabLinkItem's included in the set.
|
|
66
|
+
* @type {TabLinkItem[]}
|
|
67
|
+
*/
|
|
68
|
+
export let tabs: TabLinkItem[] = []
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Index of the tab whose dropdown panel is open, or -1 when none is open. Only tabs without an
|
|
72
|
+
* `href` can be open.
|
|
73
|
+
* @type {number}
|
|
74
|
+
* @default -1
|
|
75
|
+
*/
|
|
76
|
+
export let openIndex = -1
|
|
77
|
+
|
|
78
|
+
const uid = `tablinks-${++uidCounter}`
|
|
79
|
+
const tabElements: HTMLElement[] = []
|
|
80
|
+
let scrollOverflow: ScrollOverflow
|
|
81
|
+
let wrapperEl: HTMLElement
|
|
82
|
+
let panelEl: HTMLElement | undefined
|
|
83
|
+
let panelLeft = 0
|
|
84
|
+
let mounted = false
|
|
85
|
+
|
|
86
|
+
$: activeIdx = tabs.findIndex(tab => tab.href != null && tab.href === $page.url.pathname)
|
|
87
|
+
// a tab with an href can never be open, so shifting indices can't strand an open panel
|
|
88
|
+
$: openTab = tabs[openIndex]?.href == null ? tabs[openIndex] : undefined
|
|
89
|
+
|
|
90
|
+
onMount(async () => {
|
|
91
|
+
await tick()
|
|
92
|
+
if (activeIdx >= 0 && tabElements[activeIdx]) {
|
|
93
|
+
scrollOverflow?.scrollToElement(tabElements[activeIdx], 'auto')
|
|
94
|
+
}
|
|
95
|
+
mounted = true
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
afterNavigate(() => { close() })
|
|
99
|
+
|
|
100
|
+
$: if (mounted && $page.url.pathname) {
|
|
101
|
+
if (activeIdx >= 0 && tabElements[activeIdx]) {
|
|
102
|
+
void tick().then(() => {
|
|
103
|
+
scrollOverflow?.scrollToElement(tabElements[activeIdx])
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function positionPanel () {
|
|
109
|
+
const tabEl = tabElements[openIndex]
|
|
110
|
+
if (!wrapperEl || !tabEl) return
|
|
111
|
+
const tabLeft = tabEl.getBoundingClientRect().left - wrapperEl.getBoundingClientRect().left
|
|
112
|
+
const maxLeft = wrapperEl.clientWidth - (panelEl?.offsetWidth ?? 0)
|
|
113
|
+
panelLeft = Math.max(0, Math.min(tabLeft, maxLeft))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function toggle (idx: number) {
|
|
117
|
+
openIndex = openIndex === idx ? -1 : idx
|
|
118
|
+
await tick()
|
|
119
|
+
positionPanel()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function close (returnFocus = false) {
|
|
123
|
+
if (openIndex < 0) return
|
|
124
|
+
const tabEl = tabElements[openIndex]
|
|
125
|
+
openIndex = -1
|
|
126
|
+
if (returnFocus) tabEl?.querySelector('button')?.focus()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function onScroll (e: Event) {
|
|
130
|
+
if (panelEl?.contains(e.target as Node)) return
|
|
131
|
+
close()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onWindowClick (e: MouseEvent) {
|
|
135
|
+
if (openIndex < 0) return
|
|
136
|
+
if (!wrapperEl?.contains(e.target as Node)) close()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function onWindowKeyDown (e: KeyboardEvent) {
|
|
140
|
+
if (openIndex < 0 || e.key !== 'Escape') return
|
|
141
|
+
e.preventDefault()
|
|
142
|
+
close(true)
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<svelte:window on:click={onWindowClick} on:keydown={onWindowKeyDown} on:resize={positionPanel} />
|
|
147
|
+
|
|
148
|
+
{#if tabs.length}
|
|
149
|
+
<!-- scroll doesn't bubble but does capture; the tab strip scrolling would drift the panel anchor -->
|
|
150
|
+
<div bind:this={wrapperEl} class="cs-tabs tab-links" on:scroll|capture={onScroll}>
|
|
151
|
+
<div {...$$restProps} role="navigation" class="[ w-full ] {$$restProps.class ?? ''}" class:bx--tabs={true}><ScrollOverflow bind:this={scrollOverflow}><div role="list" class:bx--tabs__nav={true}>
|
|
152
|
+
{#each tabs as tab, i}
|
|
153
|
+
{@const selected = browser && (tab.selected ?? (tab.href != null && tab.href === $page.url.pathname))}
|
|
154
|
+
<div bind:this={tabElements[i]} role="listitem"
|
|
155
|
+
class:bx--tabs__nav-item={true}
|
|
156
|
+
class:bx--tabs__nav-item--disabled={tab.disabled}
|
|
157
|
+
class:bx--tabs__nav-item--selected={selected || openIndex === i}
|
|
158
|
+
>
|
|
159
|
+
{#if tab.href != null}
|
|
160
|
+
<a
|
|
161
|
+
href={tab.href}
|
|
162
|
+
aria-disabled={tab.disabled}
|
|
163
|
+
class:bx--tabs__nav-link={true}
|
|
164
|
+
class:auto-width={true}
|
|
165
|
+
>
|
|
166
|
+
{#if tab.icon}
|
|
167
|
+
<span class="icon">
|
|
168
|
+
<svelte:component this={tab.icon} />
|
|
169
|
+
</span>
|
|
170
|
+
{/if}
|
|
171
|
+
{tab.label}
|
|
172
|
+
</a>
|
|
173
|
+
{:else}
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
id={`${uid}-tab-${i}`}
|
|
177
|
+
disabled={tab.disabled}
|
|
178
|
+
aria-expanded={openIndex === i}
|
|
179
|
+
aria-controls={`${uid}-panel`}
|
|
180
|
+
class:bx--tabs__nav-link={true}
|
|
181
|
+
class:auto-width={true}
|
|
182
|
+
on:click={() => { void toggle(i) }}
|
|
183
|
+
>
|
|
184
|
+
{#if tab.icon}
|
|
185
|
+
<span class="icon">
|
|
186
|
+
<svelte:component this={tab.icon} />
|
|
187
|
+
</span>
|
|
188
|
+
{/if}
|
|
189
|
+
{tab.label}
|
|
190
|
+
<span class="caret" class:open={openIndex === i}>
|
|
191
|
+
<ChevronDown aria-hidden={true} />
|
|
192
|
+
</span>
|
|
193
|
+
</button>
|
|
194
|
+
{/if}
|
|
195
|
+
</div>
|
|
196
|
+
{/each}
|
|
197
|
+
</div></ScrollOverflow></div>
|
|
198
|
+
{#if openTab?.panel}
|
|
199
|
+
<div
|
|
200
|
+
bind:this={panelEl}
|
|
201
|
+
id={`${uid}-panel`}
|
|
202
|
+
class="tab-panel"
|
|
203
|
+
style:left="{panelLeft}px"
|
|
204
|
+
role="group"
|
|
205
|
+
aria-labelledby={`${uid}-tab-${openIndex}`}
|
|
206
|
+
>
|
|
207
|
+
{@render openTab.panel!(() => { close() })}
|
|
208
|
+
</div>
|
|
209
|
+
{/if}
|
|
210
|
+
</div>
|
|
211
|
+
{/if}
|
|
212
|
+
|
|
213
|
+
<style>
|
|
214
|
+
.cs-tabs.tab-links {
|
|
215
|
+
position: relative;
|
|
216
|
+
}
|
|
217
|
+
.icon {
|
|
218
|
+
display: inline-block;
|
|
219
|
+
margin-right: 8px;
|
|
220
|
+
vertical-align: text-bottom;
|
|
221
|
+
}
|
|
222
|
+
/* carbon styles bx--tabs__nav-link for an anchor, so undo the button chrome */
|
|
223
|
+
button.bx--tabs__nav-link {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
background: none;
|
|
227
|
+
border: 0;
|
|
228
|
+
font: inherit;
|
|
229
|
+
text-align: left;
|
|
230
|
+
cursor: pointer;
|
|
231
|
+
}
|
|
232
|
+
button.bx--tabs__nav-link:disabled {
|
|
233
|
+
cursor: default;
|
|
234
|
+
}
|
|
235
|
+
/*
|
|
236
|
+
Mark the disclosure tab as a menu. Carbon colors the tab link per state with selectors up to
|
|
237
|
+
(0,6,0) - hover, :focus, and selected+:focus all repaint it - so this has to outrank them to
|
|
238
|
+
hold one colour across every state. Excluding disabled twice (item class and :disabled) leaves
|
|
239
|
+
carbon's disabled grey in charge.
|
|
240
|
+
*/
|
|
241
|
+
.cs-tabs.tab-links .bx--tabs__nav-item:not(.bx--tabs__nav-item--disabled) button.bx--tabs__nav-link:not(:disabled) {
|
|
242
|
+
color: var(--cds-link-01, #0071ad);
|
|
243
|
+
}
|
|
244
|
+
.caret {
|
|
245
|
+
display: inline-flex;
|
|
246
|
+
margin-left: 4px;
|
|
247
|
+
transition: transform 110ms;
|
|
248
|
+
}
|
|
249
|
+
.caret.open {
|
|
250
|
+
transform: rotate(180deg);
|
|
251
|
+
}
|
|
252
|
+
.tab-panel {
|
|
253
|
+
position: absolute;
|
|
254
|
+
top: 100%;
|
|
255
|
+
z-index: 100;
|
|
256
|
+
/* natural width, capped at the strip so long labels wrap instead of running off-screen */
|
|
257
|
+
width: -moz-max-content;
|
|
258
|
+
width: max-content;
|
|
259
|
+
max-width: 100%;
|
|
260
|
+
overflow-wrap: anywhere;
|
|
261
|
+
background-color: var(--cds-ui-01, #f4f4f4);
|
|
262
|
+
border: 1px solid var(--cds-ui-03, #e0e0e0);
|
|
263
|
+
box-shadow: 0 2px 6px rgb(0 0 0 / 0.2);
|
|
264
|
+
}
|
|
265
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { WarningAltFilled } from 'carbon-icons-svelte'
|
|
3
|
+
import type { ComponentProps } from 'svelte'
|
|
4
|
+
|
|
5
|
+
interface $$Props extends ComponentProps<typeof WarningAltFilled> {}
|
|
6
|
+
|
|
7
|
+
export let size = 20
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div>
|
|
11
|
+
<WarningAltFilled {size} {...$$restProps} />
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
div :global(svg) {
|
|
16
|
+
fill: var(--cds-support-03, #f1c21b) !important;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
div :global([data-icon-path="inner-path"]) {
|
|
20
|
+
fill: black;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { default as AnnouncementBanner } from './AnnouncementBanner.svelte'
|
|
2
|
+
export { default as ApplicantProgramList } from './ApplicantProgramList.svelte'
|
|
3
|
+
export { default as ApplicantPromptPage } from './ApplicantPromptPage.svelte'
|
|
4
|
+
export { default as ApplicationDetailsView } from './ApplicationDetailsView.svelte'
|
|
5
|
+
export { default as AppRequestCard } from './AppRequestCard.svelte'
|
|
6
|
+
export { default as ButtonLoadingIcon } from './ButtonLoadingIcon.svelte'
|
|
7
|
+
export { default as FieldNestedMultiselect } from './FieldNestedMultiselect.svelte'
|
|
8
|
+
export type { NestedMultiselectItem, NestedMultiselectChild } from './FieldNestedMultiselect.svelte'
|
|
9
|
+
export { default as IntroPanel } from './IntroPanel.svelte'
|
|
10
|
+
export { default as MissingDefinitionNotification } from './MissingDefinitionNotification.svelte'
|
|
11
|
+
export { default as PeriodPanel } from './PeriodPanel.svelte'
|
|
12
|
+
export { default as ProgramStatusCell } from './ProgramStatusCell.svelte'
|
|
13
|
+
export type { ProgramStatusRow } from './ProgramStatusCell.svelte'
|
|
14
|
+
export { default as RenderDisplayComponent } from './RenderDisplayComponent.svelte'
|
|
15
|
+
export { default as ReviewerQuestions } from './ReviewerQuestions.svelte'
|
|
16
|
+
export { default as SystemDeterminationNotice } from './SystemDeterminationNotice.svelte'
|
|
17
|
+
export { default as AppRequestActions } from './ReviewerActions.svelte'
|
|
18
|
+
export { default as TabLinks } from './TabLinks.svelte'
|
|
19
|
+
export type { TabLinkItem } from './TabLinks.svelte'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function convertJsonToCsv (jsonData: any[]) {
|
|
2
|
+
const headers = Object.keys(jsonData[0])
|
|
3
|
+
|
|
4
|
+
let csvContent = headers.join(',') + '\n'
|
|
5
|
+
|
|
6
|
+
jsonData.forEach(item => {
|
|
7
|
+
const values = Object.values(item)
|
|
8
|
+
csvContent += values.join(',') + '\n'
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
return csvContent
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function downloadCsv (data: any[], title = 'App_Requests') {
|
|
15
|
+
const csvString = convertJsonToCsv(data)
|
|
16
|
+
|
|
17
|
+
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8,' })
|
|
18
|
+
|
|
19
|
+
const url = URL.createObjectURL(blob)
|
|
20
|
+
|
|
21
|
+
const link = document.createElement('a')
|
|
22
|
+
link.setAttribute('href', url)
|
|
23
|
+
link.setAttribute('download', title + '.csv')
|
|
24
|
+
|
|
25
|
+
document.body.appendChild(link)
|
|
26
|
+
link.click()
|
|
27
|
+
|
|
28
|
+
document.body.removeChild(link)
|
|
29
|
+
URL.revokeObjectURL(url)
|
|
30
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './api.js'
|
|
2
|
+
export * from './components/index.js'
|
|
3
|
+
export * from './csv.js'
|
|
4
|
+
export * from './util.js'
|
|
5
|
+
export * from './status-utils.js'
|
|
6
|
+
export * from './prompt-utils.js'
|
|
7
|
+
export * from './prompt-save-queue.js'
|
|
8
|
+
export * from './appreq-utils.js'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type FormStore, type Feedback, type SubmitResponse } from '@txstate-mws/svelte-forms'
|
|
2
|
+
import { get } from 'svelte/store'
|
|
3
|
+
|
|
4
|
+
/** What api.updatePrompt gives back: the usual form response plus the appRequest's version after the write. */
|
|
5
|
+
export interface PromptSaveResponse extends SubmitResponse<any> {
|
|
6
|
+
dataVersion?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A queue that runs the reviewer approve screen's prompt saves one at a time. That screen saves from
|
|
11
|
+
* several forms at once - an inline autosave form for each reviewer question, plus the edit modal - and
|
|
12
|
+
* every one of those saves writes the same appRequest row, whose optimistic concurrency is a single
|
|
13
|
+
* dataVersion. Sequencing the saves lets them agree on it: each save carries the version the save ahead
|
|
14
|
+
* of it just returned, instead of the stale one every form started the page with. The API serializes
|
|
15
|
+
* concurrent updates on a FOR UPDATE row lock anyway, so the queue costs no throughput.
|
|
16
|
+
*
|
|
17
|
+
* When a save does come back with a conflict, nothing queued behind it could succeed either - it is all
|
|
18
|
+
* built on a version another session has already moved past - so the queue freezes instead of sending it.
|
|
19
|
+
* Every later save fails locally, replaying the message the API sent us, until the user reloads the page.
|
|
20
|
+
*/
|
|
21
|
+
export class PromptSaveQueue {
|
|
22
|
+
/** the version every save from this screen carries, kept current from each save's response */
|
|
23
|
+
private version: number | undefined
|
|
24
|
+
private chain: Promise<unknown> = Promise.resolve()
|
|
25
|
+
/** the conflict that froze us, held so later saves can repeat what the API said */
|
|
26
|
+
private conflict: Feedback[] | undefined
|
|
27
|
+
/**
|
|
28
|
+
* The inline forms' FormStores, keyed by prompt id, so drain() can see which ones hold unsaved typing
|
|
29
|
+
* and flush them. Each inline Form registers its own store here with `bind:store={queue.stores[prompt.id]}`.
|
|
30
|
+
*/
|
|
31
|
+
stores: Record<string, FormStore<any> | undefined> = {}
|
|
32
|
+
|
|
33
|
+
/** true while any inline form holds typing that has not reached the API yet */
|
|
34
|
+
private get hasUnsavedChanges () {
|
|
35
|
+
return Object.values(this.stores).some(store => store != null && get(store).hasUnsavedChanges)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Take the version that came with freshly loaded page data. Two things can make that version the wrong
|
|
40
|
+
* one to take: a refresh that was already in flight when a save landed comes back describing the older
|
|
41
|
+
* version we have since moved past, and a version adopted while a form is dirty would let that form's
|
|
42
|
+
* next save pass the concurrency check with data typed against what somebody else has already replaced -
|
|
43
|
+
* the exact case the check exists to catch. Skip both.
|
|
44
|
+
*/
|
|
45
|
+
adopt (version: number | undefined) {
|
|
46
|
+
if (version == null || this.conflict != null) return
|
|
47
|
+
if (this.version != null && version <= this.version) return
|
|
48
|
+
if (this.hasUnsavedChanges) return
|
|
49
|
+
this.version = version
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a save when the saves ahead of it have settled. The modal pins the version it fetched when it
|
|
54
|
+
* opened and passes it as pinnedVersion, because the data it is editing is that version's data no matter
|
|
55
|
+
* what has been saved since.
|
|
56
|
+
*/
|
|
57
|
+
async save (send: (dataVersion?: number) => Promise<PromptSaveResponse>, pinnedVersion?: number): Promise<PromptSaveResponse> {
|
|
58
|
+
const attempt = this.chain.then(async () => {
|
|
59
|
+
if (this.conflict != null) return { success: false, messages: this.conflict }
|
|
60
|
+
const sent = pinnedVersion ?? this.version
|
|
61
|
+
const response = await send(sent)
|
|
62
|
+
// a save the API refused while reporting a version other than the one we sent is a concurrency
|
|
63
|
+
// conflict; a save it refused on the version we sent is just bad data and we can try again
|
|
64
|
+
if (response.success) this.version = response.dataVersion ?? this.version
|
|
65
|
+
else if (sent != null && response.dataVersion != null && response.dataVersion !== sent) this.conflict = response.messages
|
|
66
|
+
return response
|
|
67
|
+
})
|
|
68
|
+
this.chain = attempt.catch(() => {}) // a save that throws must not break the chain for the saves behind it
|
|
69
|
+
return await attempt
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Empty the queue: save any inline form that is holding typing, then wait for everything in flight to
|
|
74
|
+
* settle. Calling submit directly cancels the form's autosave debounce and saves immediately, so a
|
|
75
|
+
* reviewer who types and then opens the modal within the debounce window still gets a modal pinned to a
|
|
76
|
+
* version that includes what they typed.
|
|
77
|
+
*
|
|
78
|
+
* A form whose save fails still counts as settled - it round-tripped and came back with feedback, so it
|
|
79
|
+
* cannot stall us here; it simply stays unsaved and carries whatever version we hold when the user fixes it.
|
|
80
|
+
*/
|
|
81
|
+
async drain () {
|
|
82
|
+
for (const store of Object.values(this.stores)) {
|
|
83
|
+
if (store != null && get(store).hasUnsavedChanges) await store.submit({ autoSave: true })
|
|
84
|
+
}
|
|
85
|
+
let settled: Promise<unknown>
|
|
86
|
+
do {
|
|
87
|
+
settled = this.chain
|
|
88
|
+
await settled
|
|
89
|
+
} while (settled !== this.chain)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { uiRegistry } from '$local'
|
|
2
|
+
import { reviewerRequirementTypes, type PromptDataLegion, type ReviewData } from '$internal'
|
|
3
|
+
import type { PromptDefinition, RequirementType } from '@reqquest/ui'
|
|
4
|
+
|
|
5
|
+
export type CoalescedAppRequest = ReturnType<typeof coalesceAppRequestPrompts>
|
|
6
|
+
|
|
7
|
+
export const getInlineReviewerEditPrompts = (appRequest: ReviewData) => {
|
|
8
|
+
const prompts = appRequest?.applications.flatMap(application => application.requirements.filter(
|
|
9
|
+
req => reviewerRequirementTypes.has(req.type))).flatMap(req => req.prompts.filter(prompt => {
|
|
10
|
+
const def = uiRegistry.getPrompt(prompt.key)
|
|
11
|
+
return isInlineReviewerEditPrompt(def, req.type, prompt)
|
|
12
|
+
}))
|
|
13
|
+
return prompts
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const isInlineReviewerEditPrompt = (def?: PromptDefinition, type?: RequirementType, prompt?: CoalescedAppRequest['applications'][0]['requirements'][0]['prompts'][0]): boolean => {
|
|
17
|
+
const isReviewerQuestion = type && reviewerRequirementTypes.has(type) && !def?.automation
|
|
18
|
+
return !!(def != null && isReviewerQuestion && prompt?.actions.update && def.formMode !== 'full' && !(prompt.invalidated && prompt.answered))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ReviewPrompt = CoalescedAppRequest['applications'][0]['requirements'][0]['prompts'][0]
|
|
22
|
+
|
|
23
|
+
export const isDisplayablePrompt = (prompt: Pick<ReviewPrompt, 'optOut' | 'noDisplay'>) => !prompt.optOut && !prompt.noDisplay
|
|
24
|
+
export const hasDisplayablePrompts = (requirement: { prompts: Pick<ReviewPrompt, 'optOut' | 'noDisplay'>[] }) => requirement.prompts.some(isDisplayablePrompt)
|
|
25
|
+
export const coalesceAppRequestPrompts =(appRequest: NonNullable<ReviewData>, prompts?: PromptDataLegion) => {
|
|
26
|
+
const coalescedApplications = appRequest.applications.map(application => ({
|
|
27
|
+
...application,
|
|
28
|
+
requirements: application.requirements.map(requirement => ({
|
|
29
|
+
...requirement,
|
|
30
|
+
prompts: requirement.prompts.map(reqPrompt => {
|
|
31
|
+
const updatedPrompt = prompts?.find(prompt => prompt.id === reqPrompt.id)
|
|
32
|
+
return { ...reqPrompt, ...updatedPrompt}
|
|
33
|
+
})
|
|
34
|
+
}))
|
|
35
|
+
}))
|
|
36
|
+
return {
|
|
37
|
+
...appRequest,
|
|
38
|
+
applications: coalescedApplications ?? []
|
|
39
|
+
}
|
|
40
|
+
}
|