@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,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>