@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,270 @@
|
|
|
1
|
+
<script lang="ts" context="module">
|
|
2
|
+
export interface NestedMultiselectChild {
|
|
3
|
+
value: any
|
|
4
|
+
label: string
|
|
5
|
+
}
|
|
6
|
+
export interface NestedMultiselectItem extends NestedMultiselectChild {
|
|
7
|
+
children?: NestedMultiselectChild[]
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { Field, jsonDeserialize, jsonSerialize } from '@txstate-mws/svelte-forms'
|
|
13
|
+
import { Checkbox, FloatingPortal } from 'carbon-components-svelte'
|
|
14
|
+
import Add from 'carbon-icons-svelte/lib/Add.svelte'
|
|
15
|
+
import { onDestroy, tick } from 'svelte'
|
|
16
|
+
import { equal, randomid } from 'txstate-utils'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Path to the array in the form data.
|
|
20
|
+
* @type {string}
|
|
21
|
+
*/
|
|
22
|
+
export let path: string
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Label shown above the trigger. Also used as the accessible name of the popover.
|
|
26
|
+
* @type {string}
|
|
27
|
+
*/
|
|
28
|
+
export let labelText: string
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Text shown on the trigger button.
|
|
32
|
+
* @type {string}
|
|
33
|
+
* @default 'Choose one or more'
|
|
34
|
+
*/
|
|
35
|
+
export let placeholder = 'Choose one or more'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Top-level options. Each may carry `children`, rendered indented beneath it. Children narrow their
|
|
39
|
+
* parent: the stored value holds only what should be matched, so
|
|
40
|
+
* - checking a child also shows its parent checked, and replaces the parent's own value with the child's
|
|
41
|
+
* (Approved + Rescinded matches rescinded approvals only);
|
|
42
|
+
* - unchecking a parent's last checked child puts the parent's own value back, so the parent stays selected;
|
|
43
|
+
* - unchecking a parent removes it and all of its children;
|
|
44
|
+
* - a parent with no checked children stores, and matches, its own value.
|
|
45
|
+
* Items without children are plain checkboxes.
|
|
46
|
+
* @type {NestedMultiselectItem[]}
|
|
47
|
+
*/
|
|
48
|
+
export let items: NestedMultiselectItem[] = []
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hide the visible label (it stays available as the popover's accessible name).
|
|
52
|
+
* @type {boolean}
|
|
53
|
+
* @default false
|
|
54
|
+
*/
|
|
55
|
+
export let hideLabel = false
|
|
56
|
+
|
|
57
|
+
export let conditional = true
|
|
58
|
+
export let defaultValue: any[] = []
|
|
59
|
+
export let required = false
|
|
60
|
+
export let disabled = false
|
|
61
|
+
|
|
62
|
+
const id = randomid()
|
|
63
|
+
const panelId = randomid()
|
|
64
|
+
|
|
65
|
+
let open = false
|
|
66
|
+
let buttonRef: HTMLButtonElement | null = null
|
|
67
|
+
let portalRef: HTMLElement | null = null
|
|
68
|
+
|
|
69
|
+
// values are objects, so each entry is stored as JSON in the form state
|
|
70
|
+
// same loose typing as carbon-svelte's FieldMultiselect: Field's serialize/deserialize props are
|
|
71
|
+
// declared for scalars but the array form is what it actually exchanges for array fields
|
|
72
|
+
function arraySerialize (vals: any): any {
|
|
73
|
+
return vals?.map(jsonSerialize) ?? []
|
|
74
|
+
}
|
|
75
|
+
function arrayDeserialize (vals: any): any {
|
|
76
|
+
return vals?.map(jsonDeserialize) ?? []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isSelected (value: any, rawValue: any[] | undefined) {
|
|
80
|
+
return (rawValue ?? []).some(v => equal(v, value))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// a parent reads as checked when its own value is stored or any of its children's values is
|
|
84
|
+
function isParentChecked (item: NestedMultiselectItem, rawValue: any[] | undefined) {
|
|
85
|
+
return isSelected(item.value, rawValue) || (item.children ?? []).some(c => isSelected(c.value, rawValue))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function without (list: any[], ...values: any[]) {
|
|
89
|
+
return list.filter(v => !values.some(x => equal(v, x)))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// These listen to the native change event, not carbon's `check` event: Checkbox also fires `check` when its
|
|
93
|
+
// `checked` prop changes from outside, which happens to a parent whenever a child is (un)checked, and that
|
|
94
|
+
// would be misread as the user clicking the parent.
|
|
95
|
+
function isChecked (e: Event) {
|
|
96
|
+
return (e.target as HTMLInputElement).checked
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toggleParent (item: NestedMultiselectItem, rawValue: any[] | undefined, setVal: (v: any[]) => void) {
|
|
100
|
+
return (e: Event) => {
|
|
101
|
+
const current = rawValue ?? []
|
|
102
|
+
const childValues = (item.children ?? []).map(c => c.value)
|
|
103
|
+
// checking adds the parent's own value; unchecking clears the parent and every child under it
|
|
104
|
+
setVal(isChecked(e) ? [...without(current, item.value), item.value] : without(current, item.value, ...childValues))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toggleChild (item: NestedMultiselectItem, child: NestedMultiselectChild, rawValue: any[] | undefined, setVal: (v: any[]) => void) {
|
|
109
|
+
return (e: Event) => {
|
|
110
|
+
const current = rawValue ?? []
|
|
111
|
+
if (isChecked(e)) {
|
|
112
|
+
// the child narrows the parent, so the parent's broader value is dropped
|
|
113
|
+
setVal([...without(current, item.value, child.value), child.value])
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const next = without(current, child.value)
|
|
117
|
+
const siblingsLeft = (item.children ?? []).some(c => isSelected(c.value, next))
|
|
118
|
+
// unchecking the last child leaves the parent selected on its own
|
|
119
|
+
setVal(siblingsLeft ? next : [...next, item.value])
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function openPanel () {
|
|
124
|
+
if (disabled) return
|
|
125
|
+
open = true
|
|
126
|
+
void tick().then(() => {
|
|
127
|
+
(portalRef?.querySelector('input[type="checkbox"]') as HTMLInputElement | null)?.focus()
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function closePanel (returnFocus = true) {
|
|
132
|
+
if (!open) return
|
|
133
|
+
open = false
|
|
134
|
+
if (returnFocus) buttonRef?.focus()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onTriggerClick () {
|
|
138
|
+
if (open) closePanel()
|
|
139
|
+
else openPanel()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onKeydown (e: KeyboardEvent) {
|
|
143
|
+
if (e.key === 'Escape') {
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
e.stopPropagation()
|
|
146
|
+
closePanel()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function onWindowMousedown (e: MouseEvent) {
|
|
151
|
+
if (!open) return
|
|
152
|
+
const target = e.target as Node | null
|
|
153
|
+
if (target && (portalRef?.contains(target) || buttonRef?.contains(target))) return
|
|
154
|
+
closePanel(false)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onPanelFocusout (e: FocusEvent) {
|
|
158
|
+
const next = e.relatedTarget as Node | null
|
|
159
|
+
if (next && (portalRef?.contains(next) || buttonRef?.contains(next))) return
|
|
160
|
+
// focus is leaving the control entirely (tab out or click elsewhere); the mousedown handler
|
|
161
|
+
// already closed it in the click case, this covers keyboard navigation out of the panel
|
|
162
|
+
closePanel(false)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onDestroy(() => { open = false })
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<!--
|
|
169
|
+
@component
|
|
170
|
+
|
|
171
|
+
A multi-select whose options form a shallow tree: top-level items may carry children rendered
|
|
172
|
+
indented beneath them. The popover is a carbon FloatingPortal anchored to the trigger. Values are
|
|
173
|
+
arbitrary objects (serialized to JSON in form state), so the field can hold API-shaped filter
|
|
174
|
+
entries directly.
|
|
175
|
+
-->
|
|
176
|
+
|
|
177
|
+
<svelte:window on:mousedown={onWindowMousedown} />
|
|
178
|
+
|
|
179
|
+
<Field {path} notNull {conditional} {defaultValue} serialize={arraySerialize} deserialize={arrayDeserialize} let:invalid let:messages let:rawValue let:setVal let:onBlur>
|
|
180
|
+
{@const count = rawValue?.length ?? 0}
|
|
181
|
+
{@const errorText = messages.filter(m => m.type === 'error' || m.type === 'system').map(m => m.message).join('\n')}
|
|
182
|
+
<div class="bx--list-box__wrapper nested-multiselect cs-field" class:nested-multiselect--invalid={invalid}>
|
|
183
|
+
{#if !hideLabel}
|
|
184
|
+
<label class="bx--label" class:bx--label--disabled={disabled} for={id}>{labelText}{#if required}<span aria-hidden="true">{' '}*</span>{/if}</label>
|
|
185
|
+
{/if}
|
|
186
|
+
<div class="bx--multi-select bx--list-box" class:bx--multi-select--selected={count > 0} class:bx--list-box--expanded={open} class:bx--list-box--disabled={disabled}>
|
|
187
|
+
<button
|
|
188
|
+
bind:this={buttonRef}
|
|
189
|
+
{id}
|
|
190
|
+
type="button"
|
|
191
|
+
class="bx--list-box__field nested-multiselect__trigger"
|
|
192
|
+
aria-haspopup="dialog"
|
|
193
|
+
aria-expanded={open}
|
|
194
|
+
aria-controls={open ? panelId : undefined}
|
|
195
|
+
aria-label={hideLabel ? labelText : undefined}
|
|
196
|
+
{disabled}
|
|
197
|
+
on:click={onTriggerClick}
|
|
198
|
+
on:keydown={onKeydown}
|
|
199
|
+
on:blur={() => { if (!open) onBlur() }}
|
|
200
|
+
>
|
|
201
|
+
{#if count > 0}
|
|
202
|
+
<!-- a span with Tag's classes rather than <Tag>, which renders a div (not allowed inside a button) -->
|
|
203
|
+
<span class="bx--tag bx--tag--high-contrast bx--tag--sm nested-multiselect__count">{count}</span>
|
|
204
|
+
{/if}
|
|
205
|
+
<span class="bx--list-box__label">{placeholder}</span>
|
|
206
|
+
<span class="bx--list-box__menu-icon nested-multiselect__icon" class:bx--list-box__menu-icon--open={open} aria-hidden="true"><Add /></span>
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
{#if errorText}
|
|
210
|
+
<div class="bx--form-requirement">{errorText}</div>
|
|
211
|
+
{/if}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<FloatingPortal anchor={buttonRef} {open} direction="bottom" bind:ref={portalRef}>
|
|
215
|
+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
|
216
|
+
<div id={panelId} role="dialog" aria-label={labelText} tabindex="-1" class="nested-multiselect__panel" on:keydown={onKeydown} on:focusout={onPanelFocusout}>
|
|
217
|
+
{#each items as item (jsonSerialize(item.value))}
|
|
218
|
+
<div class="nested-multiselect__item">
|
|
219
|
+
<Checkbox labelText={item.label} checked={isParentChecked(item, rawValue)} on:change={toggleParent(item, rawValue, setVal)} />
|
|
220
|
+
</div>
|
|
221
|
+
{#each item.children ?? [] as child (jsonSerialize(child.value))}
|
|
222
|
+
<div class="nested-multiselect__item nested-multiselect__item--child">
|
|
223
|
+
<Checkbox labelText={child.label} checked={isSelected(child.value, rawValue)} on:change={toggleChild(item, child, rawValue, setVal)} />
|
|
224
|
+
</div>
|
|
225
|
+
{/each}
|
|
226
|
+
{/each}
|
|
227
|
+
</div>
|
|
228
|
+
</FloatingPortal>
|
|
229
|
+
</Field>
|
|
230
|
+
|
|
231
|
+
<style>
|
|
232
|
+
.nested-multiselect__trigger {
|
|
233
|
+
width: 100%;
|
|
234
|
+
text-align: left;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
}
|
|
237
|
+
.nested-multiselect__trigger:disabled {
|
|
238
|
+
cursor: not-allowed;
|
|
239
|
+
}
|
|
240
|
+
.nested-multiselect :global(.nested-multiselect__count) {
|
|
241
|
+
margin-right: 0.5rem;
|
|
242
|
+
}
|
|
243
|
+
.nested-multiselect__icon {
|
|
244
|
+
/* carbon rotates the chevron when open; the plus should stay put */
|
|
245
|
+
transform: none;
|
|
246
|
+
}
|
|
247
|
+
.nested-multiselect--invalid .bx--list-box {
|
|
248
|
+
outline: 2px solid var(--cds-support-01, #da1e28);
|
|
249
|
+
outline-offset: -2px;
|
|
250
|
+
}
|
|
251
|
+
.nested-multiselect__panel {
|
|
252
|
+
background-color: var(--cds-field-01, #f4f4f4);
|
|
253
|
+
box-shadow: 0 2px 6px var(--cds-shadow, rgba(0, 0, 0, 0.3));
|
|
254
|
+
max-height: 15rem;
|
|
255
|
+
overflow-y: auto;
|
|
256
|
+
padding: 0.25rem 0;
|
|
257
|
+
}
|
|
258
|
+
.nested-multiselect__panel:focus {
|
|
259
|
+
outline: none;
|
|
260
|
+
}
|
|
261
|
+
.nested-multiselect__item {
|
|
262
|
+
padding: 0.125rem 1rem;
|
|
263
|
+
}
|
|
264
|
+
.nested-multiselect__item--child {
|
|
265
|
+
padding-left: 2.5rem;
|
|
266
|
+
}
|
|
267
|
+
.nested-multiselect__panel :global(.bx--checkbox:checked + .bx--checkbox-label::before) {
|
|
268
|
+
background-color: var(--maroon);
|
|
269
|
+
}
|
|
270
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { TagSet } from '@txstate-mws/carbon-svelte'
|
|
3
|
+
import type { TagItem } from '@txstate-mws/carbon-svelte'
|
|
4
|
+
|
|
5
|
+
export let title = ''
|
|
6
|
+
export let subtitle = ''
|
|
7
|
+
export let tags: TagItem[] | undefined = undefined
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<section class="bg-[var(--cds-ui-01)] py-4 px-[16px] text-2xl mb-2">
|
|
11
|
+
<div class="repel">
|
|
12
|
+
<div class="max-w-lg flex flex-col flex-wrap justify-start">
|
|
13
|
+
{#if title}
|
|
14
|
+
<div class="title-block [ flex gap-[12px] ] ">
|
|
15
|
+
<h2 class="text-[1.25rem] text-[var(--cds-text-01,#1A1A1A)] font-normal leading-7">
|
|
16
|
+
{title}
|
|
17
|
+
</h2>
|
|
18
|
+
{#if tags}
|
|
19
|
+
<TagSet tags={tags} />
|
|
20
|
+
{/if}
|
|
21
|
+
</div>
|
|
22
|
+
{#if subtitle}
|
|
23
|
+
<p class="text-sm text-[var(--cds-text-02)] leading-[18px] tracking-[0.16px] pt-2">
|
|
24
|
+
{subtitle}
|
|
25
|
+
</p>
|
|
26
|
+
{/if}
|
|
27
|
+
{/if}
|
|
28
|
+
<!-- Optional content slot below title/subtitle -->
|
|
29
|
+
{#if $$slots.default}
|
|
30
|
+
<div class="mt-2">
|
|
31
|
+
<slot />
|
|
32
|
+
</div>
|
|
33
|
+
{/if}
|
|
34
|
+
</div>
|
|
35
|
+
{#if $$slots['block-end']}
|
|
36
|
+
<div class="block-end-slot">
|
|
37
|
+
<slot name="block-end" />
|
|
38
|
+
</div>
|
|
39
|
+
{/if}
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Shown where a form or configuration component would go, when the API returned a key this UI
|
|
4
|
+
* has no definition for. The API and UI are deployed as separate images and can be built from
|
|
5
|
+
* different commits, so this is reachable in production no matter how good the build-time checks
|
|
6
|
+
* get. Naming the key here is the difference between "this page is broken" and a bug report an
|
|
7
|
+
* operator can act on.
|
|
8
|
+
*/
|
|
9
|
+
import { InlineNotification } from 'carbon-components-svelte'
|
|
10
|
+
|
|
11
|
+
export let kind: 'prompt' | 'requirement' | 'program' = 'prompt'
|
|
12
|
+
export let definitionKey: string
|
|
13
|
+
|
|
14
|
+
// built here rather than inline: an attribute holding both `{definitionKey}` and a backtick
|
|
15
|
+
// reads as a template literal to the svelte parser
|
|
16
|
+
$: title = `This ${kind} is unavailable:`
|
|
17
|
+
$: subtitle = `No ${kind} component is registered for "${definitionKey}". This usually means the application was updated but this screen was not. Please contact support.`
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<InlineNotification kind="error" {title} {subtitle} lowContrast hideCloseButton />
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ActionSet, Panel, TagSet } from '@txstate-mws/carbon-svelte'
|
|
3
|
+
import { Tab, TabContent, Tabs, Tag } from 'carbon-components-svelte'
|
|
4
|
+
import { SettingsEdit } from 'carbon-icons-svelte'
|
|
5
|
+
import { groupby, pluralize } from 'txstate-utils'
|
|
6
|
+
import { invalidate } from '$app/navigation'
|
|
7
|
+
import { page } from '$app/stores'
|
|
8
|
+
import { api } from '../api.js'
|
|
9
|
+
import type { UIRegistry } from '@reqquest/ui'
|
|
10
|
+
|
|
11
|
+
export let program: any
|
|
12
|
+
export let sharedProgramRequirements: any
|
|
13
|
+
/** program title by key, to name the program a shared requirement is listed under */
|
|
14
|
+
export let programTitles: Record<string, string> = {}
|
|
15
|
+
export let openModal: any
|
|
16
|
+
export let onClick: any
|
|
17
|
+
export let uiRegistry: UIRegistry
|
|
18
|
+
|
|
19
|
+
const disablePeriodProgram = (requirementKey: string) => async () => {
|
|
20
|
+
const res = await api.disablePeriodProgramRequirements($page.params.id!, requirementKey, true)
|
|
21
|
+
await invalidate('api:getPeriodConfigurations')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const enablePeriodProgram = (requirementKey: string) => async () => {
|
|
25
|
+
const res = await api.disablePeriodProgramRequirements($page.params.id!, requirementKey, false)
|
|
26
|
+
await invalidate('api:getPeriodConfigurations')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type PeriodRequirement = { key: string, title: string, type: string, enabled: boolean, configuration: { actions: { update: boolean } }, prompts: { key: string, title: string, configuration: { actions: { update: boolean } } }[] }
|
|
30
|
+
$: enabledRequirements = Object.entries(groupby(program.requirements.filter((r: PeriodRequirement) => r.enabled) as PeriodRequirement[], 'type'))
|
|
31
|
+
$: disabledRequirements = program.requirements.filter((r: PeriodRequirement) => !r.enabled) as PeriodRequirement[]
|
|
32
|
+
|
|
33
|
+
// configuration is stored once per period, not once per program
|
|
34
|
+
const sharedWith = (requirementKey: string): string[] => sharedProgramRequirements[requirementKey] ?? []
|
|
35
|
+
const configuredUnder = (requirementKey: string): string | undefined => {
|
|
36
|
+
const owners = sharedWith(requirementKey)
|
|
37
|
+
return owners.length > 1 && owners[0] !== program.key ? owners[0] : undefined
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
<Panel title={program.title} expandable expanded noPrimaryAction actions={[{ label: 'Rename program', onClick: onClick('program', program), disabled: !program.configuration.actions.update }]}>
|
|
41
|
+
{#each enabledRequirements as requrementEntries, i (i)}
|
|
42
|
+
{@const type = requrementEntries[0]}
|
|
43
|
+
{@const requirements = requrementEntries[1]}
|
|
44
|
+
<Panel title='' expandable expanded>
|
|
45
|
+
<div style="display: content" slot="headerLeft">
|
|
46
|
+
<TagSet tags={[{ label: `Applicant: ${type}`, type: 'purple' }]} />
|
|
47
|
+
</div>
|
|
48
|
+
<div style="display: content" slot="headerRight">
|
|
49
|
+
<TagSet tags={[{ label: `${requirements.length} ${pluralize('requirement', requirements.length)}`, type: 'yellow' }]} />
|
|
50
|
+
</div>
|
|
51
|
+
<Tabs autoWidth>
|
|
52
|
+
<Tab label={`Enabled Requirements (${enabledRequirements.length})`} />
|
|
53
|
+
<Tab label={`Disabled Requirements (${disabledRequirements.length})`} />
|
|
54
|
+
<svelte:fragment slot='content'>
|
|
55
|
+
<TabContent>
|
|
56
|
+
{#each requirements as requirement (requirement.key)}
|
|
57
|
+
{@const reqDef = uiRegistry.getRequirement(requirement.key)}
|
|
58
|
+
{@const owner = configuredUnder(requirement.key)}
|
|
59
|
+
{#if owner}
|
|
60
|
+
<Panel title={requirement.title} noPrimaryAction actions={[{ label: 'Disable Requirement', onClick: disablePeriodProgram(requirement.key) }]}>
|
|
61
|
+
<div style="display: content" slot="headerLeft">
|
|
62
|
+
<TagSet tags={[{ label: 'Requirement', type: 'yellow' }]} />
|
|
63
|
+
</div>
|
|
64
|
+
<div style="display: content" slot="headerRight">
|
|
65
|
+
<TagSet tags={[{ label: `Shared with ${sharedWith(requirement.key).length - 1} other ${pluralize('program', sharedWith(requirement.key).length - 1)}`, onClick: openModal(requirement.key) }]} />
|
|
66
|
+
</div>
|
|
67
|
+
<p class="shared-note">Configured once for the period. Its prompts and settings are listed under {programTitles[owner] ?? owner}; changes made there apply here too.</p>
|
|
68
|
+
</Panel>
|
|
69
|
+
{:else}
|
|
70
|
+
<Panel title={requirement.title} expandable noPrimaryAction actions={[{ label: 'Configure requirement', onClick: onClick('requirement', requirement), disabled: reqDef?.configureComponent == null || !requirement.configuration.actions.update }, { label: 'Disable Requirement', onClick: disablePeriodProgram(requirement.key) }]}>
|
|
71
|
+
<div style="display: content" slot="headerLeft">
|
|
72
|
+
<TagSet tags={[{ label: 'Requirement', type: 'yellow' }]} />
|
|
73
|
+
</div>
|
|
74
|
+
<!-- <Button on:click={onClick('requirement', requirement)} type="primary" size="small" icon={SettingsEdit} iconDescription="Edit Configuration" disabled={reqDef.configureComponent == null || !requirement.configuration.actions.update} /> -->
|
|
75
|
+
<div style="display: content" slot="headerRight">
|
|
76
|
+
{@const tags = sharedProgramRequirements[requirement.key]?.length > 1 ? [{ label: 'Shared', onClick: openModal(requirement.key) }] : []}
|
|
77
|
+
<TagSet tags={tags} />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<ul class="prompts">
|
|
81
|
+
{#each requirement.prompts as prompt (prompt.key)}
|
|
82
|
+
{@const promptDef = uiRegistry.getPrompt(prompt.key)}
|
|
83
|
+
<li class="prompt justify-between">
|
|
84
|
+
<span>
|
|
85
|
+
<Tag type='green'>Prompt</Tag>{prompt.title}
|
|
86
|
+
</span>
|
|
87
|
+
<ActionSet
|
|
88
|
+
actions={[
|
|
89
|
+
// { label: 'View', icon: View },
|
|
90
|
+
{ label: 'settings', icon: SettingsEdit, disabled: promptDef?.configureComponent == null || !prompt.configuration.actions.update, onClick: onClick('prompt', prompt) }
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
93
|
+
</li>
|
|
94
|
+
{/each}
|
|
95
|
+
</ul>
|
|
96
|
+
</Panel>
|
|
97
|
+
{/if}
|
|
98
|
+
{/each}
|
|
99
|
+
</TabContent>
|
|
100
|
+
<TabContent>
|
|
101
|
+
{#each disabledRequirements as requirement (requirement.key)}
|
|
102
|
+
<Panel title={requirement.title} actions={[{ label: 'Enable Requirement', onClick: enablePeriodProgram(requirement.key) }]}>
|
|
103
|
+
Requirement: {requirement.title}
|
|
104
|
+
<ul class="prompts">
|
|
105
|
+
{#each requirement.prompts as prompt (prompt.key)}
|
|
106
|
+
<li class="prompt">
|
|
107
|
+
Prompt: {prompt.title}
|
|
108
|
+
</li>
|
|
109
|
+
{/each}
|
|
110
|
+
</ul>
|
|
111
|
+
</Panel>
|
|
112
|
+
{/each}
|
|
113
|
+
</TabContent>
|
|
114
|
+
</svelte:fragment>
|
|
115
|
+
</Tabs>
|
|
116
|
+
</Panel>
|
|
117
|
+
{/each}
|
|
118
|
+
</Panel>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.shared-note {
|
|
122
|
+
margin: 0;
|
|
123
|
+
color: var(--cds-text-secondary, #525252);
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts" context="module">
|
|
2
|
+
export interface ProgramStatusRow {
|
|
3
|
+
phase: string
|
|
4
|
+
closedAt?: string | null
|
|
5
|
+
applications: { programKey: string, title: string, status: string, rescindedStatus?: string | null }[]
|
|
6
|
+
}
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { TagSet } from '@txstate-mws/carbon-svelte'
|
|
11
|
+
import { FloatingPortal, Tag } from 'carbon-components-svelte'
|
|
12
|
+
import { onDestroy } from 'svelte'
|
|
13
|
+
import { randomid } from 'txstate-utils'
|
|
14
|
+
import { enumAppRequestPhase } from '@reqquest/ui'
|
|
15
|
+
import { getApplicationStatusTags } from '../status-utils.js'
|
|
16
|
+
|
|
17
|
+
export let row: ProgramStatusRow
|
|
18
|
+
/** ColumnList passes the column definition too; unused here. */
|
|
19
|
+
export let col: unknown = undefined
|
|
20
|
+
/**
|
|
21
|
+
* When true and the request has more than `maxInline` programs, collapse to a single grey
|
|
22
|
+
* "N programs" tag whose tooltip lists each program with its status labels. The expanded
|
|
23
|
+
* row detail renders with rollup={false} to show the full list.
|
|
24
|
+
*/
|
|
25
|
+
export let rollup = true
|
|
26
|
+
export let maxInline = 2
|
|
27
|
+
|
|
28
|
+
$: programs = row.applications.map(a => ({
|
|
29
|
+
key: a.programKey,
|
|
30
|
+
title: a.title,
|
|
31
|
+
tags: getApplicationStatusTags(a.status, row.phase, row.closedAt, a.rescindedStatus)
|
|
32
|
+
}))
|
|
33
|
+
$: collapsed = rollup && programs.length > maxInline
|
|
34
|
+
$: closed = row.closedAt != null && row.phase !== enumAppRequestPhase.STARTED
|
|
35
|
+
$: void col
|
|
36
|
+
|
|
37
|
+
const tooltipId = randomid()
|
|
38
|
+
let triggerRef: HTMLElement | null = null
|
|
39
|
+
let tooltipOpen = false
|
|
40
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
41
|
+
function show () {
|
|
42
|
+
clearTimeout(closeTimer)
|
|
43
|
+
tooltipOpen = true
|
|
44
|
+
}
|
|
45
|
+
function hideSoon () {
|
|
46
|
+
clearTimeout(closeTimer)
|
|
47
|
+
closeTimer = setTimeout(() => { tooltipOpen = false }, 150)
|
|
48
|
+
}
|
|
49
|
+
function hideNow () {
|
|
50
|
+
clearTimeout(closeTimer)
|
|
51
|
+
tooltipOpen = false
|
|
52
|
+
}
|
|
53
|
+
function onKeydown (e: KeyboardEvent) {
|
|
54
|
+
if (e.key === 'Escape' && tooltipOpen) { e.stopPropagation(); hideNow() }
|
|
55
|
+
}
|
|
56
|
+
onDestroy(() => clearTimeout(closeTimer))
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
{#if collapsed}
|
|
60
|
+
<!-- focusable so keyboard users can reveal the tooltip; a <button> can't hold carbon's Tag (it renders a div) -->
|
|
61
|
+
<!-- svelte-ignore a11y-no-noninteractive-tabindex a11y-no-static-element-interactions -->
|
|
62
|
+
<span bind:this={triggerRef} class="program-rollup__trigger" tabindex="0" aria-describedby={tooltipOpen ? tooltipId : undefined}
|
|
63
|
+
on:mouseenter={show} on:mouseleave={hideSoon} on:focus={show} on:blur={hideNow} on:keydown={onKeydown}>
|
|
64
|
+
<Tag size="sm" type="gray">{programs.length} programs</Tag>
|
|
65
|
+
</span>
|
|
66
|
+
{#if closed}<TagSet small tags={[{ label: 'Closed', type: 'yellow' }]} class="program-closed" />{/if}
|
|
67
|
+
<FloatingPortal anchor={triggerRef} open={tooltipOpen} direction="bottom" intrinsicWidth intrinsicAlign="start" gapBottom={4}>
|
|
68
|
+
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
69
|
+
<div id={tooltipId} role="tooltip" class="program-rollup__tooltip" on:mouseenter={show} on:mouseleave={hideSoon}>
|
|
70
|
+
<ul class="program-rollup-list">
|
|
71
|
+
{#each programs as program (program.key)}
|
|
72
|
+
<li><strong>{program.title}</strong>: {program.tags.map(t => t.label).join(', ')}</li>
|
|
73
|
+
{/each}
|
|
74
|
+
</ul>
|
|
75
|
+
</div>
|
|
76
|
+
</FloatingPortal>
|
|
77
|
+
{:else}
|
|
78
|
+
<div class="program-list">
|
|
79
|
+
{#each programs as program (program.key)}
|
|
80
|
+
<div class="program">
|
|
81
|
+
<span class="program-title">{program.title}</span>
|
|
82
|
+
<TagSet small tags={program.tags} />
|
|
83
|
+
</div>
|
|
84
|
+
{/each}
|
|
85
|
+
{#if closed}<TagSet small tags={[{ label: 'Closed', type: 'yellow' }]} class="program-closed" />{/if}
|
|
86
|
+
</div>
|
|
87
|
+
{/if}
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.program-list {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: 0.25rem;
|
|
94
|
+
}
|
|
95
|
+
.program {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-wrap: wrap;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 0.25rem 0.5rem;
|
|
100
|
+
}
|
|
101
|
+
:global(.program-closed) {
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
margin-left: 0.25rem;
|
|
104
|
+
}
|
|
105
|
+
.program-list :global(.program-closed) {
|
|
106
|
+
margin-left: 0;
|
|
107
|
+
}
|
|
108
|
+
.program-rollup__trigger {
|
|
109
|
+
display: inline-block;
|
|
110
|
+
cursor: default;
|
|
111
|
+
}
|
|
112
|
+
.program-rollup__trigger:focus-visible {
|
|
113
|
+
outline: 2px solid var(--cds-focus, #0f62fe);
|
|
114
|
+
outline-offset: 1px;
|
|
115
|
+
}
|
|
116
|
+
.program-rollup__tooltip {
|
|
117
|
+
background: var(--cds-inverse-02, #393939);
|
|
118
|
+
color: var(--cds-inverse-01, #ffffff);
|
|
119
|
+
padding: 1rem;
|
|
120
|
+
border-radius: 2px;
|
|
121
|
+
box-shadow: 0 2px 6px var(--cds-shadow, rgba(0, 0, 0, 0.3));
|
|
122
|
+
font-size: 0.875rem;
|
|
123
|
+
line-height: 1.25rem;
|
|
124
|
+
max-width: 28rem;
|
|
125
|
+
}
|
|
126
|
+
.program-rollup-list {
|
|
127
|
+
margin: 0;
|
|
128
|
+
padding: 0;
|
|
129
|
+
list-style: none;
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
gap: 0.25rem;
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { InlineNotification } from 'carbon-components-svelte'
|
|
3
|
+
import type { PromptDefinition } from '@reqquest/ui'
|
|
4
|
+
import type { StatusReason } from '@reqquest/ui'
|
|
5
|
+
|
|
6
|
+
export let def: PromptDefinition | undefined
|
|
7
|
+
export let appRequestId: string
|
|
8
|
+
export let appData: Record<string, any>
|
|
9
|
+
export let prompt: { key: string, answered: boolean, moot: boolean | null, invalidated: boolean | null, invalidatedReason: string | null }
|
|
10
|
+
export let prestageData: Record<string, any>
|
|
11
|
+
export let configData: Record<string, any>
|
|
12
|
+
export let gatheredConfigData: Record<string, any>
|
|
13
|
+
export let showMoot = false
|
|
14
|
+
export let showInlineReviewNotification = false
|
|
15
|
+
// requirement status reasons aimed at this prompt, forwarded to the display component
|
|
16
|
+
export let statusReasons: StatusReason[] = []
|
|
17
|
+
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<svelte:boundary onerror={e => console.error(e)}>
|
|
21
|
+
{#if showMoot && prompt.moot}
|
|
22
|
+
<em>Already disqualified.</em>
|
|
23
|
+
{:else if !def?.displayComponent}
|
|
24
|
+
<em>No display component registered.</em>
|
|
25
|
+
<pre>{JSON.stringify(appData[prompt.key] ?? {}, null, 2)}</pre>
|
|
26
|
+
{:else}
|
|
27
|
+
<svelte:component this={def.displayComponent} {appRequestId} data={appData[prompt.key]} appRequestData={appData} {prestageData} {configData} {gatheredConfigData} invalidated={prompt.invalidated} invalidatedReason={prompt.invalidatedReason} {statusReasons} />
|
|
28
|
+
{/if}
|
|
29
|
+
{#if prompt.invalidated && (showMoot || !prompt.moot) && showInlineReviewNotification}
|
|
30
|
+
<InlineNotification class="mt-6 lg:mt-4" kind="warning" title="Applicant has made corrections:" subtitle={prompt.invalidatedReason ?? 'Requires review'} lowContrast hideCloseButton />
|
|
31
|
+
{/if}
|
|
32
|
+
{#snippet failed()}
|
|
33
|
+
<div class="error">
|
|
34
|
+
<div>Error Loading Component</div>
|
|
35
|
+
<p>There was an error loading the display component for this prompt.</p>
|
|
36
|
+
</div>
|
|
37
|
+
{/snippet}
|
|
38
|
+
</svelte:boundary>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.error div { font-weight: bold; }
|
|
42
|
+
</style>
|