@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,335 @@
1
+ <script lang="ts">
2
+ import { ColumnList, FieldCheckboxList, FieldHidden, FieldMultiselect, FieldSelect, Panel, PanelFormDialog, Toasts } from '@txstate-mws/carbon-svelte'
3
+ import { toasts } from '@txstate-mws/svelte-components'
4
+ import { Modal, TooltipDefinition } from 'carbon-components-svelte'
5
+ import Add from 'carbon-icons-svelte/lib/Add.svelte'
6
+ import Edit from 'carbon-icons-svelte/lib/Edit.svelte'
7
+ import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte'
8
+ import { setContext } from 'svelte'
9
+ import { pick, ucfirst } from 'txstate-utils'
10
+ import { invalidate } from '$app/navigation'
11
+ import { api, IntroPanel } from '$internal'
12
+ import type { AccessRoleGrantCreate, AccessRoleGrantUpdate, AccessTagInput } from '@reqquest/ui'
13
+ import type { PageData } from './$types'
14
+ import ControlWithTooltip from './ControlWithTooltip.svelte'
15
+ import TagsWithTooltip from './TagsWithTooltip.svelte'
16
+
17
+ export let data: PageData
18
+ $: ({ role, controlGroups, controlGroupLookup } = data)
19
+
20
+ $: grants = role.grants.filter(g => g.allow)
21
+ $: exceptions = role.grants.filter(g => !g.allow)
22
+
23
+ setContext('controlGroupLookup', () => controlGroupLookup)
24
+
25
+ // the multiselect only renders option text, so a tag's description (e.g. a program's other titles)
26
+ // goes on a second line of the label, styled as helper text below - and the filter matches it too
27
+ function tagItems (tags: { value: string, label: string, description?: string | null }[]) {
28
+ return tags.map(t => ({ value: t.value, label: t.description ? `${t.label}\n${t.description}` : t.label }))
29
+ }
30
+ type AccessRoleGrantCreateForm = Omit<AccessRoleGrantCreate, 'tags'> & { tags: Record<string, string[]> }
31
+ type AccessRoleGrantUpdateForm = Omit<AccessRoleGrantUpdate, 'tags'> & { tags: Record<string, string[]> }
32
+ function transformFromAPI (data: PageData['role']['grants'][number]): AccessRoleGrantUpdateForm {
33
+ return {
34
+ ...pick(data, 'controls', 'allow'),
35
+ controlGroup: data.controlGroup.name,
36
+ tags: data.tags.reduce((acc: Record<string, string[]>, curr) => ({ ...acc, [curr.category]: [...(acc[curr.category] ?? []), curr.tag] }), {})
37
+ }
38
+ }
39
+ function transformToAPI<T extends { tags?: Record<string, string[]> }> (data: T): Omit<T, 'tags'> & { tags: AccessTagInput[] } {
40
+ return {
41
+ ...data,
42
+ tags: Object.entries(data.tags ?? {}).flatMap(([category, tags]) => tags.map(tag => ({ category, tag })))
43
+ }
44
+ }
45
+
46
+ let showGrantEdit = false
47
+ let grantToEdit: PageData['role']['grants'][number] | undefined
48
+ let grantToEditInput: AccessRoleGrantUpdateForm | undefined
49
+ function onGrantEdit (row: PageData['role']['grants'][number]) {
50
+ return () => {
51
+ showGrantEdit = true
52
+ grantToEdit = row
53
+ grantToEditInput = transformFromAPI(row)
54
+ }
55
+ }
56
+ function onGrantDelete (row: PageData['role']['grants'][number]) {
57
+ return () => {
58
+ grantToDelete = row
59
+ }
60
+ }
61
+ function onCloseEdit () {
62
+ showGrantCreate = false
63
+ showGrantEdit = false
64
+ grantToEdit = undefined
65
+ grantToEditInput = undefined
66
+ }
67
+ async function onSaveGrant (event: CustomEvent<AccessRoleGrantCreateForm | AccessRoleGrantUpdateForm>) {
68
+ const controlGroupName = event.detail.controlGroup
69
+ if (!controlGroupName) {
70
+ console.error('No control group in form submission')
71
+ return
72
+ }
73
+ const grantName = controlGroupLookup[controlGroupName].title
74
+ if (!grantName) {
75
+ console.error('Control group not found in lookup')
76
+ return
77
+ }
78
+ const isEdit = !!grantToEdit
79
+ const type = isEdit ? (grantToEdit!.allow ? 'grant' : 'exception') : (grantCreateAllow ? 'grant' : 'exception')
80
+ const action = isEdit ? 'edited' : 'added'
81
+ toasts.add({
82
+ type: 'success',
83
+ title: `${ucfirst(type)} ${action}`,
84
+ message: `The ${type} ${grantName} was successfully ${action}.`
85
+ })
86
+ await invalidate('api:getRoleDetails')
87
+ onCloseEdit()
88
+ }
89
+ async function onEditSubmit (data: AccessRoleGrantUpdateForm) {
90
+ const response = await api.updateGrant(grantToEdit!.id, transformToAPI(data), false)
91
+ return { ...response, data }
92
+ }
93
+ async function onEditValidate (data: AccessRoleGrantUpdateForm) {
94
+ const response = await api.updateGrant(grantToEdit!.id, transformToAPI(data), true)
95
+ return response.messages
96
+ }
97
+
98
+ let showGrantCreate = false
99
+ let grantCreateAllow = true
100
+ async function onCreateSubmit (data: AccessRoleGrantCreateForm) {
101
+ const response = await api.createGrant(role.id, transformToAPI(data), false)
102
+ return { ...response, data }
103
+ }
104
+ async function onCreateValidate (data: AccessRoleGrantCreateForm) {
105
+ const response = await api.createGrant(role.id, transformToAPI(data), true)
106
+ return response.messages
107
+ }
108
+
109
+ let grantToDelete: PageData['role']['grants'][number] | undefined
110
+ function closeDeleteConfirmation () {
111
+ grantToDelete = undefined
112
+ }
113
+ async function executeDelete () {
114
+ try {
115
+ if (!grantToDelete) {
116
+ console.error('No grant selected for deletion')
117
+ return
118
+ }
119
+ const type = grantToDelete.allow ? 'grant' : 'exception'
120
+ const grantName = controlGroupLookup[grantToDelete.controlGroup.name].title
121
+ if (!grantName) {
122
+ console.error('Control group not found in lookup')
123
+ return
124
+ }
125
+ await api.deleteGrant(grantToDelete.id)
126
+ toasts.add({
127
+ type: 'success',
128
+ title: `${ucfirst(type)} deleted`,
129
+ message: `The ${type} ${grantName} was successfully deleted.`
130
+ })
131
+ await invalidate('api:getRoleDetails')
132
+ } finally {
133
+ closeDeleteConfirmation()
134
+ }
135
+ }
136
+ </script>
137
+
138
+ <IntroPanel title="{role.name} permissions" subtitle={role.description ?? ''}>
139
+ <div class="groups [ text-sm ] flow" style="--flow-space:0.5rem;">
140
+ <h3 class="font-medium ">Groups</h3>
141
+ <ul>
142
+ {#each role.groups as group (group.groupName)}
143
+ <li>{group.groupName}</li>
144
+ {/each}
145
+ </ul>
146
+ </div>
147
+ </IntroPanel>
148
+ <div class="columnlist-wrap flow" style="--flow-space:3em;">
149
+ <div class="role-list">
150
+ <ColumnList
151
+ title="Grants for Role: {role.name}"
152
+ listActions={[
153
+ {
154
+ label: 'Add Grant',
155
+ icon: Add,
156
+ onClick: () => {
157
+ showGrantCreate = true
158
+ grantCreateAllow = true
159
+ }
160
+ }
161
+ ]}
162
+ columns={[
163
+ { id: 'controlGroup', label: 'Grants', render: grant => grant.controlGroup.title },
164
+ { id: 'controls', label: 'Controls', component: ControlWithTooltip },
165
+ { id: 'tags', label: 'Restrictions', component: TagsWithTooltip }
166
+ ]}
167
+ rows={grants}
168
+ actions={row => [
169
+ {
170
+ label: 'Edit',
171
+ icon: Edit,
172
+ onClick: onGrantEdit(row),
173
+ disabled: !row.actions.update
174
+ },
175
+ {
176
+ label: 'Delete',
177
+ icon: TrashCan,
178
+ onClick: onGrantDelete(row),
179
+ disabled: !row.actions.delete
180
+ }
181
+ ]}
182
+ />
183
+ </div>
184
+
185
+ <div class="role-list">
186
+ <ColumnList
187
+ title="Exceptions for Role: {role.name}"
188
+ columns={[
189
+ { id: 'controlGroup', label: 'Exceptions', render: grant => grant.controlGroup.title },
190
+ { id: 'controls', label: 'Controls', render: grant => grant.controls.join(', ') },
191
+ { id: 'tags', label: 'Restrictions', component: TagsWithTooltip }
192
+ ]}
193
+ noItemsKind='info'
194
+ noItemsTitle=''
195
+ noItemsSubtitle='No exceptions on this role.'
196
+ rows={exceptions}
197
+ listActions={[
198
+ {
199
+ label: 'Add Exception',
200
+ icon: Add,
201
+ onClick: () => {
202
+ showGrantCreate = true
203
+ grantCreateAllow = false
204
+ }
205
+ }
206
+ ]}
207
+ actions={row => [
208
+ {
209
+ label: 'Edit',
210
+ icon: Edit,
211
+ onClick: onGrantEdit(row),
212
+ disabled: !row.actions.update
213
+ },
214
+ {
215
+ label: 'Delete',
216
+ icon: TrashCan,
217
+ onClick: onGrantDelete(row),
218
+ disabled: !row.actions.delete
219
+ }
220
+ ]}
221
+ />
222
+ </div>
223
+ </div>
224
+ {#if showGrantEdit && grantToEdit}
225
+ {@const controlGroup = controlGroupLookup[grantToEdit.controlGroup.name]}
226
+ <PanelFormDialog open title="Edit {grantToEdit.allow ? 'Grant' : 'Exception'}" submit={onEditSubmit} validate={onEditValidate} on:cancel={onCloseEdit} on:saved={onSaveGrant} preload={grantToEditInput} let:data>
227
+ <FieldHidden path="allow" value={grantToEdit.allow} />
228
+ <FieldHidden path="controlGroup" value={grantToEdit.controlGroup.name} />
229
+ <FieldCheckboxList
230
+ path="controls"
231
+ legendText="Controls"
232
+ items={controlGroup.controls.map(c => ({ value: c.name }))}
233
+ >
234
+ <svelte:fragment slot="labelText" let:item>
235
+ {@const control = controlGroup.controls.find(c => c.name === item.value)}
236
+ {#if control}
237
+ <TooltipDefinition tooltipText={control.description} direction="bottom" align="start">
238
+ {control.name}
239
+ </TooltipDefinition>
240
+ {:else}
241
+ {item.value}
242
+ {/if}
243
+ </svelte:fragment>
244
+ </FieldCheckboxList>
245
+ {#each controlGroup.tags as category (category.category)}
246
+ <div class="tag-multiselect">
247
+ <FieldMultiselect
248
+ path="tags.{category.category}"
249
+ titleText={category.label}
250
+ helperText={category.description}
251
+ label="Restrict to {category.label}"
252
+ items={tagItems(category.tags)}
253
+ />
254
+ </div>
255
+ {/each}
256
+ </PanelFormDialog>
257
+ {:else if showGrantCreate}
258
+ <PanelFormDialog open title="New {grantCreateAllow ? 'Grant' : 'Exception'}" submit={onCreateSubmit} validate={onCreateValidate} on:cancel={onCloseEdit} on:saved={onSaveGrant} let:data>
259
+ <FieldHidden path="allow" value={grantCreateAllow} />
260
+ {@const controlGroup = data.controlGroup ? controlGroupLookup[data.controlGroup] : undefined}
261
+ <FieldSelect
262
+ path="controlGroup"
263
+ labelText="Control Group"
264
+ items={controlGroups.map(st => ({ value: st.name, label: st.title }))}
265
+ helperText={controlGroup?.description}
266
+ />
267
+ {#if controlGroup}
268
+ <FieldCheckboxList
269
+ path="controls"
270
+ legendText="Controls"
271
+ items={controlGroup.controls.map(c => ({ value: c.name }))}
272
+ >
273
+ <svelte:fragment slot="labelText" let:item>
274
+ {@const control = controlGroup.controls.find(c => c.name === item.value)}
275
+ {#if control}
276
+ <TooltipDefinition tooltipText={control.description} direction="bottom" align="start">
277
+ {control.name}
278
+ </TooltipDefinition>
279
+ {:else}
280
+ {item.value}
281
+ {/if}
282
+ </svelte:fragment>
283
+ </FieldCheckboxList>
284
+
285
+ {#each controlGroup.tags as category (category.category)}
286
+ <div class="tag-multiselect">
287
+ <FieldMultiselect
288
+ path="tags.{category.category}"
289
+ titleText={category.label}
290
+ helperText={category.description}
291
+ label="Restrict to {category.label}"
292
+ items={tagItems(category.tags)}
293
+ filterable
294
+ />
295
+ </div>
296
+ {/each}
297
+ {/if}
298
+ </PanelFormDialog>
299
+ {:else if grantToDelete}
300
+ <Modal
301
+ open
302
+ modalHeading="Delete {grantToDelete.allow ? 'Grant' : 'Exception'}"
303
+ primaryButtonText="Delete"
304
+ secondaryButtonText="Cancel"
305
+ danger
306
+ size="sm"
307
+ on:click:button--primary={executeDelete}
308
+ on:click:button--secondary={closeDeleteConfirmation}
309
+ on:submit={executeDelete}
310
+ on:close={closeDeleteConfirmation}
311
+ >
312
+ <p>Are you sure you want to delete this {grantToDelete.allow ? 'grant' : 'exception'}?<br><em>{controlGroupLookup[grantToDelete.controlGroup.name].title}</em></p>
313
+ <p>This action cannot be undone.</p>
314
+ </Modal>
315
+ {/if}
316
+
317
+ <style>
318
+ .role-list :global(.column-list-head) {
319
+ background: var(--cds-ui-03);
320
+ }
321
+ /* options carrying a description are two lines - the title, then the description as helper text.
322
+ carbon-svelte's multiselect already lets unvirtualized options grow, but sets white-space: normal on
323
+ the label with a five-class selector, so these need a sixth to keep the line break */
324
+ .tag-multiselect :global(.bx--list-box .bx--list-box__menu-item .bx--list-box__menu-item__option .bx--checkbox-label-text) {
325
+ white-space: pre-line;
326
+ font-size: 0.75rem;
327
+ line-height: 1rem;
328
+ color: var(--cds-text-secondary);
329
+ }
330
+ .tag-multiselect :global(.bx--list-box .bx--list-box__menu-item .bx--list-box__menu-item__option .bx--checkbox-label-text::first-line) {
331
+ font-size: 0.875rem;
332
+ line-height: 1.125rem;
333
+ color: var(--cds-text-primary);
334
+ }
335
+ </style>
@@ -0,0 +1,10 @@
1
+ import { api } from '$internal'
2
+ import { error } from '@sveltejs/kit'
3
+ import type { PageLoad } from './$types'
4
+
5
+ export const load: PageLoad = async ({ params, depends }) => {
6
+ const role = await api.getRoleDetails(params.id)
7
+ if (!role) throw error(404, 'Role not found')
8
+ depends('api:getRoleDetails')
9
+ return { role }
10
+ }
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import { TooltipDefinition } from 'carbon-components-svelte'
3
+ import { getContext } from 'svelte'
4
+
5
+ export let row: { controlGroup: { name: string }, controls: string[] }
6
+ const controlGroupLookup = getContext<() => Record<string, { controls: { name: string, description: string }[] }>>('controlGroupLookup')()
7
+ $: controlGroup = controlGroupLookup[row.controlGroup.name]
8
+ </script>
9
+
10
+ {#each row.controls as control, controlIndex}
11
+ {@const controlInfo = controlGroup.controls.find(c => c.name === control)}
12
+ <TooltipDefinition tooltipText={controlInfo?.description}>
13
+ {controlInfo?.name ?? control}
14
+ </TooltipDefinition>{#if controlIndex < row.controls.length - 1},{' '}{/if}
15
+ {/each}
@@ -0,0 +1,42 @@
1
+ <script lang="ts">
2
+ import { TooltipDefinition } from 'carbon-components-svelte'
3
+ import { groupby } from 'txstate-utils'
4
+
5
+ export let row: { tags: { categoryLabel: string, label: string, description?: string | null }[] }
6
+
7
+ // past this many tags the restrictions read better as a list per category than inline
8
+ const THRESHOLD = 5
9
+ $: categories = Object.entries(groupby(row.tags, 'categoryLabel'))
10
+ </script>
11
+
12
+ {#if row.tags.length > THRESHOLD}
13
+ {#each categories as [categoryLabel, tags], i (categoryLabel)}
14
+ {#if i > 0}<br />{/if}
15
+ <div>
16
+ <strong>{categoryLabel}</strong>
17
+ <ul>
18
+ {#each tags as tag, tagIndex (tagIndex)}
19
+ <li>
20
+ {#if tag.description}
21
+ <TooltipDefinition tooltipText={tag.description}>{tag.label}</TooltipDefinition>
22
+ {:else}
23
+ {tag.label}
24
+ {/if}
25
+ </li>
26
+ {/each}
27
+ </ul>
28
+ </div>
29
+ {/each}
30
+ {:else}
31
+ {#each categories as [categoryLabel, tags], i (categoryLabel)}
32
+ {#if i > 0}<br />{/if}
33
+ <strong>{categoryLabel}</strong>:
34
+ {#each tags as tag, tagIndex (tagIndex)}
35
+ {#if tag.description}
36
+ <TooltipDefinition tooltipText={tag.description}>{tag.label}</TooltipDefinition>
37
+ {:else}
38
+ {tag.label}
39
+ {/if}{#if tagIndex < tags.length - 1},{' '}{/if}
40
+ {/each}
41
+ {/each}
42
+ {/if}
@@ -0,0 +1,13 @@
1
+ import { api } from '$internal'
2
+ import { extractMergedFilters, type ComboMenuItem } from '@txstate-mws/carbon-svelte'
3
+ import type { LayoutLoad } from '../$types'
4
+
5
+ export const load: LayoutLoad = async ({ url, depends }) => {
6
+ const { search, institutionalRoles, applicationRoles } = extractMergedFilters(url)
7
+ // Get access data
8
+ const availableInstitutionalRoles: ComboMenuItem[] = [{ value: 'Faculty' }, { value: 'Staff' }, { value: 'Student' }]
9
+ const appRoles = (await api.getRoleList()) ?? []
10
+ const availableApplicationRoles: ComboMenuItem[] = appRoles.map(role => ({ value: role.name }))
11
+ depends('api:getRoleList')
12
+ return { availableApplicationRoles, availableInstitutionalRoles }
13
+ }
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import { ColumnList, FieldMultiselect, FilterUI, Pagination } from '@txstate-mws/carbon-svelte'
3
+ import type { PageData } from './$types'
4
+ import { IntroPanel } from '$internal'
5
+ import { uiRegistry } from '$local'
6
+
7
+ export let data: PageData
8
+ $: ({ columns, rows, filters, page, totalItems, availableApplicationRoles, roleUsersAttributeDescription } = data)
9
+
10
+ function handlePagination (event: CustomEvent<{ page: number, pageSize: number, totalItems: number }>) {
11
+ // event: {"isTrusted": false}
12
+ console.debug(`handlePagination event: ${JSON.stringify(event)}`)
13
+ }
14
+
15
+ // TODO: Dynamically add groupings
16
+ interface UsersSearchForm {
17
+ search?: string
18
+ applicationRoles?: string[]
19
+ // institutionalRoles?: string[]
20
+ [key: string]: string | string[] | undefined
21
+ }
22
+ let usersSearchFormData: UsersSearchForm | undefined
23
+
24
+ </script>
25
+ <IntroPanel
26
+ title="User Lookup"
27
+ subtitle={roleUsersAttributeDescription}
28
+ />
29
+ <FilterUI
30
+ search
31
+ on:apply={e => { usersSearchFormData = e.detail }}
32
+ on:mount={e => { usersSearchFormData = e.detail }}>
33
+ <svelte:fragment slot="quickfilters">
34
+ <FieldMultiselect path="applicationRoles" label={`${uiRegistry.getWord('appRequest')} Roles`} items={availableApplicationRoles} />
35
+ {#each filters as filter (filter.id)}
36
+ <FieldMultiselect path={filter.id} label={filter.label} items={filter.items} />
37
+ {/each}
38
+ </svelte:fragment>
39
+ </FilterUI>
40
+ <ColumnList
41
+ title='Users'
42
+ columns={columns}
43
+ rows={rows}
44
+ >
45
+ </ColumnList>
46
+ <Pagination {page} {totalItems} on:update={handlePagination} on:mount={handlePagination} noun="user" nounPlural="users" />
@@ -0,0 +1,90 @@
1
+ import { api } from '$internal'
2
+ import { extractMergedFilters, extractPaginationParams, type ColumnDefinition, type ComboMenuItem } from '@txstate-mws/carbon-svelte'
3
+ import type { PageLoad } from './$types'
4
+ import type { AccessUserFilter, Pagination } from '@reqquest/ui'
5
+ import { omit } from 'txstate-utils'
6
+ import { uiRegistry } from '$local'
7
+
8
+ export const load: PageLoad = async ({ url, depends }) => {
9
+ const roleUsersAttributeDescription = uiRegistry.config.roleUsersAttributeDescription ?? ''
10
+ const query = extractMergedFilters(url)
11
+ const { search, applicationRoles } = query
12
+ const groupingsQuery = omit(query, 'search', 'applicationRoles')
13
+ const { page, pagesize } = extractPaginationParams(url)
14
+ const pageFilter: Pagination = { page, perPage: pagesize }
15
+ // Get access data
16
+ const accessUsersFilter: AccessUserFilter = {}
17
+ if (search) accessUsersFilter.search = search
18
+ // groupings is a map<label, ids>
19
+ if (Array.isArray(applicationRoles) && applicationRoles.length > 0) {
20
+ accessUsersFilter.roles = applicationRoles
21
+ }
22
+ // Dynamically add groupings
23
+ const groupings = Object.keys(groupingsQuery)
24
+ for (const grouping of groupings) {
25
+ if (groupingsQuery[grouping] != null) {
26
+ accessUsersFilter.otherCategoriesByLabel ??= []
27
+ accessUsersFilter.otherCategoriesByLabel.push({ category: grouping, tags: groupingsQuery[grouping] })
28
+ }
29
+ }
30
+ const { users, pageInfo } = await api.getAccessUsers(accessUsersFilter, pageFilter)
31
+
32
+ interface User {
33
+ // Local data
34
+ id: string
35
+ // ids = id + otherId
36
+ ids: string
37
+ // contact = fullname + email
38
+ contact: string
39
+ applicationRoles: string
40
+ groups: string
41
+ // Remote data
42
+ // otherInfo?: any
43
+ // institutionalRoles?: string[]
44
+ // lastLogin?: string
45
+ [key: string]: string | undefined
46
+ }
47
+ const rows = users.map(u => {
48
+ const user: User = {
49
+ id: u.login,
50
+ ids: [u.login, u.otherInfo?.otherId].filter(id => id != null).join('\n'),
51
+ contact: u.otherInfo?.email ? u.fullname + '\n' + u.otherInfo.email : u.fullname,
52
+ applicationRoles: u.roles.map(r => r.name).join(', '),
53
+ groups: u.groups.join(', ')
54
+ // otherInfo: u.otherInfo
55
+ // institutionalRoles: u.otherInfo?.institutionalRoles ?? [],
56
+ // lastLogin: u.otherInfo?.lastLogin ? DateTime.fromISO(u.otherInfo?.lastLogin).toFormat('MM/dd/yyyy') : 'unknown'
57
+ }
58
+ if (u.otherInfo?.categories) {
59
+ for (const group of pageInfo?.categories ?? []) {
60
+ const category = group.category
61
+ if (group.useInList) user[category] = Array.isArray(u.otherInfo.categories[category]) ? u.otherInfo.categories[category].map(tl => tl.label ?? tl.tag).join(', ') : undefined
62
+ }
63
+ }
64
+ return user
65
+ })
66
+
67
+ interface Filter {
68
+ id: string
69
+ label: string
70
+ items: ComboMenuItem[]
71
+ }
72
+ const filters: Filter[] = []
73
+ const columns: ColumnDefinition<User>[] = [
74
+ { id: 'contact', label: 'Name', get: 'contact' },
75
+ { id: 'id', label: 'IDs', get: 'ids' },
76
+ { id: 'applicationRoles', label: `${uiRegistry.getWord('appRequest')} Roles`, get: 'applicationRoles' },
77
+ { id: 'groups', label: 'Groups', get: 'groups' }
78
+ // TODO: Dynamically pull extra grouping columns
79
+ // { id: 'institutionalRoles', label: 'Institutional Roles', render: user => user['institutionalRoles'] ? user['institutionalRoles'].join(', ') : '' },
80
+ // { id: 'lastLogin', label: 'Last Login', get: 'lastLogin' }
81
+ ]
82
+ for (const group of pageInfo?.categories ?? []) {
83
+ const category = group.category
84
+ if (group.useInList) columns.push({ id: category, label: group.label ?? category, get: category })
85
+ if (group.useInFilters) filters.push({ id: category, label: group.label ?? category, items: group.tags.map(tl => ({ value: tl.tag, label: tl.label ?? tl.tag })) })
86
+ }
87
+
88
+ depends('api:getAccessUsers')
89
+ return { columns, rows, filters, page: pageInfo?.currentPage, totalItems: pageInfo?.totalItems, roleUsersAttributeDescription }
90
+ }
Binary file
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Builds and runs a downstream ReqQuest UI. The project needs only @reqquest/ui installed and a
4
+ * src/local/index.ts exporting `uiRegistry`; svelte, SvelteKit, Vite and their configuration all come
5
+ * from this package. See docs/downstream-ui.md.
6
+ *
7
+ * reqquest-ui sync [--strict] [--accept] assemble .reqquest/ (see cli/sync.js)
8
+ * reqquest-ui dev [vite args...] sync, then the Vite dev server; re-syncs on changes
9
+ * reqquest-ui build sync --strict, registry check, production build
10
+ * -> build/ (static site) and build-runtime/ (nginx)
11
+ * reqquest-ui check [svelte-check args] type-check everything, including src/local
12
+ * reqquest-ui check-registry fail if src/local/index.ts has type errors
13
+ *
14
+ * Run it from the project root (the directory holding package.json and src/local).
15
+ */
16
+ import { spawnSync, spawn } from 'node:child_process'
17
+ import { cpSync, existsSync, mkdirSync, watch } from 'node:fs'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { sync, SyncError } from '../cli/sync.js'
21
+ import { packageBin } from '../cli/util.js'
22
+
23
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
24
+ const projectRoot = process.cwd()
25
+ const appRoot = join(projectRoot, '.reqquest')
26
+ const [command, ...args] = process.argv.slice(2)
27
+
28
+ function run (pkg, bin, binArgs) {
29
+ const result = spawnSync(process.execPath, [packageBin(pkg, PACKAGE_ROOT, bin), ...binArgs], { cwd: appRoot, stdio: 'inherit' })
30
+ if (result.status !== 0) process.exit(result.status ?? 1)
31
+ }
32
+
33
+ function svelteKitSync () {
34
+ run('@sveltejs/kit', 'svelte-kit', ['sync'])
35
+ }
36
+
37
+ /** Ported from ui/check-registry.js, which explains why the gate is narrowed to one file. */
38
+ function checkRegistry () {
39
+ const REGISTRY = 'src/local/index.ts'
40
+ const result = spawnSync(process.execPath, [packageBin('svelte-check', PACKAGE_ROOT), '--tsconfig', './tsconfig.json', '--output', 'machine'], { cwd: appRoot, encoding: 'utf8' })
41
+ const errors = (result.stdout ?? '').split('\n').filter(line => line.includes(' ERROR ') && line.includes(`${REGISTRY}"`))
42
+ if (errors.length > 0) {
43
+ console.error(`${REGISTRY} has ${errors.length} type error(s).\n`)
44
+ for (const line of errors) console.error(line)
45
+ console.error('\nA missing prompt means the API defines a prompt this UI has no component for.')
46
+ console.error(`Add it to the prompts object in ${REGISTRY}, or regenerate the key declaration if the API's definitions changed.`)
47
+ process.exit(1)
48
+ }
49
+ console.log(`${REGISTRY} is in step with the generated key declaration.`)
50
+ }
51
+
52
+ function dev () {
53
+ sync(projectRoot)
54
+ svelteKitSync()
55
+ const vite = spawn(process.execPath, [packageBin('vite', PACKAGE_ROOT), 'dev', ...args], { cwd: appRoot, stdio: 'inherit' })
56
+ vite.on('exit', code => process.exit(code ?? 0))
57
+
58
+ // src/local is aliased in place, so Vite already watches it; routes and static are copied
59
+ let timer
60
+ const resync = () => {
61
+ clearTimeout(timer)
62
+ timer = setTimeout(() => {
63
+ try {
64
+ sync(projectRoot)
65
+ } catch (e) {
66
+ if (!(e instanceof SyncError)) throw e
67
+ console.error(`reqquest-ui: ${e.message}`)
68
+ }
69
+ }, 100)
70
+ }
71
+ for (const dir of ['src/routes', 'static']) {
72
+ if (existsSync(join(projectRoot, dir))) watch(join(projectRoot, dir), { recursive: true }, resync)
73
+ }
74
+ watch(join(projectRoot, 'package.json'), resync)
75
+ }
76
+
77
+ function build () {
78
+ sync(projectRoot, { strict: true })
79
+ svelteKitSync()
80
+ checkRegistry()
81
+ run('vite', 'vite', ['build'])
82
+ const runtime = join(projectRoot, 'build-runtime')
83
+ mkdirSync(runtime, { recursive: true })
84
+ cpSync(join(PACKAGE_ROOT, 'app/runtime'), runtime, { recursive: true })
85
+ console.log('reqquest-ui: site written to build/, nginx.conf and apply-env.sh to build-runtime/')
86
+ }
87
+
88
+ try {
89
+ switch (command) {
90
+ case 'sync':
91
+ sync(projectRoot, { strict: args.includes('--strict'), accept: args.includes('--accept') })
92
+ break
93
+ case 'dev':
94
+ dev()
95
+ break
96
+ case 'build':
97
+ build()
98
+ break
99
+ case 'check':
100
+ sync(projectRoot)
101
+ svelteKitSync()
102
+ run('svelte-check', 'svelte-check', ['--tsconfig', './tsconfig.json', ...args])
103
+ break
104
+ case 'check-registry':
105
+ sync(projectRoot)
106
+ svelteKitSync()
107
+ checkRegistry()
108
+ break
109
+ default:
110
+ console.error('usage: reqquest-ui <sync [--strict] [--accept] | dev | build | check | check-registry>')
111
+ process.exit(command ? 1 : 0)
112
+ }
113
+ } catch (e) {
114
+ if (!(e instanceof SyncError)) throw e
115
+ console.error(`reqquest-ui: ${e.message}`)
116
+ process.exit(1)
117
+ }