@reqquest/ui 1.16.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.
Files changed (105) hide show
  1. package/app/env.template +5 -0
  2. package/app/runtime/apply-env.sh +10 -0
  3. package/app/runtime/nginx.conf +37 -0
  4. package/app/src/app.css +21 -0
  5. package/app/src/app.d.ts +13 -0
  6. package/app/src/app.html +12 -0
  7. package/app/src/internal/api.ts +1707 -0
  8. package/app/src/internal/appreq-utils.ts +33 -0
  9. package/app/src/internal/components/AnnouncementBanner.svelte +47 -0
  10. package/app/src/internal/components/AppRequestCard.svelte +173 -0
  11. package/app/src/internal/components/ApplicantOptOutModal.svelte +78 -0
  12. package/app/src/internal/components/ApplicantProgramList.svelte +276 -0
  13. package/app/src/internal/components/ApplicantProgramListTooltip.svelte +44 -0
  14. package/app/src/internal/components/ApplicantPromptPage.svelte +136 -0
  15. package/app/src/internal/components/ApplicantPromptSkeleton.svelte +12 -0
  16. package/app/src/internal/components/ApplicationDetailsView.svelte +369 -0
  17. package/app/src/internal/components/ButtonLoadingIcon.svelte +28 -0
  18. package/{dist → app/src/internal}/components/DelayedSkeleton.svelte +2 -2
  19. package/app/src/internal/components/FieldNestedMultiselect.svelte +270 -0
  20. package/app/src/internal/components/IntroPanel.svelte +41 -0
  21. package/app/src/internal/components/MissingDefinitionNotification.svelte +20 -0
  22. package/app/src/internal/components/PeriodPanel.svelte +125 -0
  23. package/app/src/internal/components/ProgramStatusCell.svelte +134 -0
  24. package/app/src/internal/components/RenderDisplayComponent.svelte +42 -0
  25. package/app/src/internal/components/ReviewerActions.svelte +171 -0
  26. package/app/src/internal/components/ReviewerQuestions.svelte +444 -0
  27. package/app/src/internal/components/StatusMessageList.svelte +54 -0
  28. package/app/src/internal/components/SystemDeterminationNotice.svelte +37 -0
  29. package/app/src/internal/components/TabLinks.svelte +265 -0
  30. package/app/src/internal/components/WarningIconYellow.svelte +22 -0
  31. package/app/src/internal/components/index.ts +19 -0
  32. package/app/src/internal/csv.ts +30 -0
  33. package/app/src/internal/index.ts +8 -0
  34. package/app/src/internal/prompt-save-queue.ts +91 -0
  35. package/app/src/internal/prompt-utils.ts +40 -0
  36. package/app/src/internal/status-utils.ts +503 -0
  37. package/app/src/internal/stores/IStateStore.ts +5 -0
  38. package/app/src/internal/util.ts +27 -0
  39. package/app/src/routes/+error.svelte +4 -0
  40. package/app/src/routes/+layout.svelte +134 -0
  41. package/app/src/routes/+layout.ts +18 -0
  42. package/app/src/routes/+page.ts +14 -0
  43. package/app/src/routes/announcement/+page.svelte +164 -0
  44. package/app/src/routes/announcement/+page.ts +16 -0
  45. package/app/src/routes/dashboards/applicant/+page.svelte +494 -0
  46. package/app/src/routes/dashboards/applicant/+page.ts +95 -0
  47. package/app/src/routes/dashboards/reviewer/+page.svelte +143 -0
  48. package/app/src/routes/dashboards/reviewer/+page.ts +43 -0
  49. package/app/src/routes/periods/+page.svelte +158 -0
  50. package/app/src/routes/periods/+page.ts +13 -0
  51. package/app/src/routes/periods/[id]/configure/+page.svelte +150 -0
  52. package/app/src/routes/periods/[id]/configure/+page.ts +10 -0
  53. package/app/src/routes/requests/+page.svelte +246 -0
  54. package/app/src/routes/requests/+page.ts +24 -0
  55. package/app/src/routes/requests/[id]/+layout.ts +11 -0
  56. package/app/src/routes/requests/[id]/accept/+layout.svelte +80 -0
  57. package/app/src/routes/requests/[id]/accept/+layout.ts +12 -0
  58. package/app/src/routes/requests/[id]/accept/+page.ts +13 -0
  59. package/app/src/routes/requests/[id]/accept/[promptId]/+page.svelte +7 -0
  60. package/app/src/routes/requests/[id]/accept/[promptId]/+page.ts +10 -0
  61. package/app/src/routes/requests/[id]/accept/review/+page.svelte +87 -0
  62. package/app/src/routes/requests/[id]/apply/+layout.svelte +181 -0
  63. package/app/src/routes/requests/[id]/apply/+layout.ts +12 -0
  64. package/app/src/routes/requests/[id]/apply/+page.ts +14 -0
  65. package/app/src/routes/requests/[id]/apply/ProgressNavContainer.svelte +42 -0
  66. package/app/src/routes/requests/[id]/apply/[promptId]/+page.svelte +23 -0
  67. package/app/src/routes/requests/[id]/apply/[promptId]/+page.ts +11 -0
  68. package/app/src/routes/requests/[id]/apply/programs/+page.svelte +71 -0
  69. package/app/src/routes/requests/[id]/apply/review/+page.svelte +102 -0
  70. package/app/src/routes/requests/[id]/approve/+layout.svelte +114 -0
  71. package/app/src/routes/requests/[id]/approve/+page.ts +14 -0
  72. package/app/src/routes/requests/[id]/approve/ApproveLayout.svelte +164 -0
  73. package/app/src/routes/requests/[id]/approve/[programKey]/+page.svelte +396 -0
  74. package/app/src/routes/requests/[id]/approve/[programKey]/+page.ts +15 -0
  75. package/app/src/routes/requests/[id]/approve/activity/+page.svelte +66 -0
  76. package/app/src/routes/requests/[id]/approve/activity/+page.ts +12 -0
  77. package/app/src/routes/requests/[id]/approve/export/+page.ts +12 -0
  78. package/app/src/routes/requests/[id]/approve/export/+page@[id].svelte +41 -0
  79. package/app/src/routes/requests/[id]/export/+page.svelte +32 -0
  80. package/app/src/routes/requests/[id]/export/+page.ts +12 -0
  81. package/app/src/routes/roles/+layout.svelte +16 -0
  82. package/app/src/routes/roles/+layout.ts +14 -0
  83. package/app/src/routes/roles/+page.svelte +225 -0
  84. package/app/src/routes/roles/+page.ts +13 -0
  85. package/app/src/routes/roles/[id]/+layout.ts +10 -0
  86. package/app/src/routes/roles/[id]/+page.svelte +335 -0
  87. package/app/src/routes/roles/[id]/+page.ts +10 -0
  88. package/app/src/routes/roles/[id]/ControlWithTooltip.svelte +15 -0
  89. package/app/src/routes/roles/[id]/TagsWithTooltip.svelte +42 -0
  90. package/app/src/routes/roles/users/+layout.ts +13 -0
  91. package/app/src/routes/roles/users/+page.svelte +46 -0
  92. package/app/src/routes/roles/users/+page.ts +90 -0
  93. package/app/static/favicon.png +0 -0
  94. package/bin/reqquest-ui.js +117 -0
  95. package/cli/config.js +105 -0
  96. package/cli/sync.js +296 -0
  97. package/cli/util.js +29 -0
  98. package/dist/components.md +0 -4
  99. package/dist/registry.js +1 -1
  100. package/dist/typed-client/schema.d.ts +25 -3
  101. package/dist/typed-client/schema.graphql +18 -2
  102. package/dist/typed-client/types.d.ts +9 -1
  103. package/dist/typed-client/types.js +29 -5
  104. package/package.json +29 -15
  105. 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
+ }