Skip to content

Commit fcf1fc7

Browse files
feat(samples/action-center-app): act on review feedback
- Folder + Assignee filters in toolbar (header chip removed) - Folder picker via /odata/Folders raw fetch (new useFolders hook) - Create Task: folder dropdown instead of numeric ID - My Tasks: row click → Complete dialog + info icon → detail sheet - Manage Tasks: multi-select + bulk Assign / Reassign / Unassign, gated by selection state + completion - Assignee filter derived from loaded tasks (no folder needed) - 'Group' label for tasks assigned to a directory group - TaskDetail Sheet mirrors Action Center's Task Summary - Surface server messages on Create/Complete/Assign/Unassign errors; bulk batch errors use compact 'first + N more' format - Various UI polish (info icon placement, fixed-width pickers, truncation, header column dividers)
1 parent 4c3ef4c commit fcf1fc7

6 files changed

Lines changed: 1119 additions & 251 deletions

File tree

samples/action-center-app/README.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ Every method on [`TaskServiceModel`](https://uipath.github.io/uipath-typescript/
2323
| `task.complete(options)` | `components/TaskDetail.tsx` (+ `taskUtils.ts` builds the discriminated-union options) |
2424

2525
By default the app lists tasks across **all folders** you can view/edit. The
26-
header's **Folder ID** field is an optional filter — leave it blank for all
27-
folders, or set it to scope the list (persisted to `localStorage`). Per-task
28-
actions (detail, assign, complete) use each task's own folder, propagated from
29-
the row; creating a task asks for a target folder.
26+
toolbar's **Folder** dropdown (populated from `/odata/Folders`) lets you scope
27+
the list to one folder; the choice is persisted to `localStorage`. Per-task
28+
actions (assign, complete) use each task's own folder, propagated from the
29+
row; creating a task picks the target folder from the same dropdown.
3030

3131
## Files
3232

@@ -42,9 +42,12 @@ src/
4242
## Prerequisites
4343

4444
- Node.js 20+
45-
- A UiPath OAuth External Application (client ID) with the **`OR.Tasks`** scope
46-
- An Orchestrator folder ID that contains Action Center tasks (required only
47-
when creating tasks)
45+
- A UiPath OAuth External Application (client ID) with **`OR.Tasks`** and
46+
**`OR.Folders.Read`** scopes (the latter powers the folder dropdown — the
47+
SDK doesn't expose a Folders service yet, so the app calls
48+
`/odata/Folders` directly with the SDK's access token)
49+
- An Orchestrator folder that contains Action Center tasks (only needed for
50+
testing — leaving the filter on "All folders" still works)
4851

4952
## Setup
5053

@@ -71,13 +74,18 @@ uip codedapp deploy -n action-center-app --folder-key <FOLDER_KEY>
7174

7275
## Notes
7376

74-
- **My Tasks / Manage Tasks tabs:** these map to `getAll`'s `asTaskAdmin` flag,
75-
mirroring Action Center. "Manage Tasks" (`asTaskAdmin: true`) lists tasks
76-
across folders where you can assign them — the context where assigning to a
77-
**group** applies. (The SDK doesn't expose a strict "assigned only to me"
78-
view or a task `delete`, so those parts of the product aren't replicated.)
77+
- **My Tasks vs Manage Tasks:** these map to `getAll`'s `asTaskAdmin` flag.
78+
- **My Tasks** — clicking a row opens a quick-complete dialog (Approve /
79+
Reject / custom action). An info icon in that dialog opens the full
80+
detail sheet on demand. The streamlined flow is the daily worker view.
81+
- **Manage Tasks** (`asTaskAdmin: true`) — clicking rows selects them
82+
(multi-select), and bulk **Assign** / **Unassign** appear in the
83+
toolbar. Surfaces `Tasks.assign(payload[])` and `Tasks.unassign(ids)`
84+
in their array forms. When status is filtered to *Pending*, an
85+
additional **Assigned to** filter (powered by `Tasks.getUsers`) lets
86+
an admin see who's overloaded.
7987
- **Pagination:** the list uses server-side page navigation (`jumpToPage` +
8088
`totalCount`); the user picker loops the cursor. No list call assumes one
8189
response holds every row.
82-
- **Creation** through the API only supports **external** tasks — form and app
83-
tasks are created by the system through workflows.
90+
- **Creation** through the API only supports **external** tasks — form and
91+
app tasks are created by the system through workflows.

samples/action-center-app/src/App.tsx

Lines changed: 4 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { useEffect, useState } from 'react'
2-
import { Folder, ListChecks, LogOut, ShieldCheck, X } from 'lucide-react'
3-
import type { UiPathSDKConfig } from '@uipath/uipath-typescript/core'
1+
import { ListChecks, LogOut, ShieldCheck } from 'lucide-react'
42
import { Alert, AlertDescription } from '@uipath/apollo-wind/components/ui/alert'
53
import { Button } from '@uipath/apollo-wind/components/ui/button'
64
import {
@@ -10,41 +8,15 @@ import {
108
CardHeader,
119
CardTitle,
1210
} from '@uipath/apollo-wind/components/ui/card'
13-
import { Input } from '@uipath/apollo-wind/components/ui/input'
1411
import { Spinner } from '@uipath/apollo-wind/components/ui/spinner'
1512
import { Toaster } from '@uipath/apollo-wind/components/ui/sonner'
16-
import { AuthProvider, useAuth } from './hooks/useAuth'
13+
import { AuthProvider, useAuth } from './context/AuthContext'
1714
import { TaskList } from './components/TaskList'
1815
import { ThemeToggle } from './components/Theme'
1916

20-
// Coded App pattern: the SDK fills config from `<meta name="uipath:*">` tags
21-
// (injected locally by coded-apps-dev or by the platform), so we pass {}.
22-
const authConfig = {} as UiPathSDKConfig
23-
24-
const FOLDER_STORAGE_KEY = 'action-center-app.folderId'
25-
2617
function AppContent() {
2718
const { isAuthenticated, isLoading, login, logout, error } = useAuth()
2819

29-
// Persist the folder filter across refresh/OAuth; blank = all folders.
30-
const [folderId, setFolderId] = useState<number | null>(() => {
31-
const stored = localStorage.getItem(FOLDER_STORAGE_KEY)
32-
const parsed = stored ? Number(stored) : NaN
33-
return Number.isFinite(parsed) ? parsed : null
34-
})
35-
const [draft, setDraft] = useState(folderId?.toString() ?? '')
36-
37-
useEffect(() => {
38-
if (folderId == null) localStorage.removeItem(FOLDER_STORAGE_KEY)
39-
else localStorage.setItem(FOLDER_STORAGE_KEY, String(folderId))
40-
}, [folderId])
41-
42-
const commitFolder = () => {
43-
const trimmed = draft.trim()
44-
const parsed = trimmed === '' ? null : Number(trimmed)
45-
setFolderId(parsed != null && Number.isFinite(parsed) ? parsed : null)
46-
}
47-
4820
if (isLoading) {
4921
return (
5022
<div className="flex h-screen items-center justify-center">
@@ -89,50 +61,6 @@ function AppContent() {
8961
<h1 className="truncate text-base font-semibold">Action Center Manager</h1>
9062
</div>
9163
<div className="flex items-center gap-2">
92-
<div className="relative">
93-
<Folder
94-
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
95-
aria-hidden
96-
/>
97-
<Input
98-
id="folder-id"
99-
// type=text + inputMode=numeric: numeric keypad on mobile, no
100-
// desktop spinner arrows (incrementing an ID isn't meaningful).
101-
type="text"
102-
inputMode="numeric"
103-
pattern="[0-9]*"
104-
autoComplete="off"
105-
value={draft}
106-
onChange={(e) => setDraft(e.target.value.replace(/[^0-9]/g, ''))}
107-
onBlur={commitFolder}
108-
onKeyDown={(e) => {
109-
if (e.key === 'Enter') commitFolder()
110-
if (e.key === 'Escape' && draft) {
111-
setDraft('')
112-
setFolderId(null)
113-
}
114-
}}
115-
placeholder="All folders"
116-
aria-label="Folder ID"
117-
title="Optional — leave blank to list tasks across all your folders"
118-
className="w-44 pl-8 pr-8"
119-
/>
120-
{draft && (
121-
<Button
122-
variant="ghost"
123-
size="icon"
124-
onClick={() => {
125-
setDraft('')
126-
setFolderId(null)
127-
}}
128-
aria-label="Clear folder filter"
129-
title="Clear folder filter"
130-
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2"
131-
>
132-
<X className="h-3.5 w-3.5" />
133-
</Button>
134-
)}
135-
</div>
13664
<ThemeToggle />
13765
<Button variant="ghost" size="sm" onClick={logout}>
13866
<LogOut className="h-4 w-4" />
@@ -142,7 +70,7 @@ function AppContent() {
14270
</header>
14371

14472
<main className="min-h-0 flex-1">
145-
<TaskList folderId={folderId} />
73+
<TaskList />
14674
</main>
14775
<Toaster />
14876
</div>
@@ -151,7 +79,7 @@ function AppContent() {
15179

15280
export default function App() {
15381
return (
154-
<AuthProvider config={authConfig}>
82+
<AuthProvider>
15583
<AppContent />
15684
</AuthProvider>
15785
)

samples/action-center-app/src/components/TaskDetail.tsx

Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
SelectValue,
3131
} from '@uipath/apollo-wind/components/ui/select'
3232
import { Spinner } from '@uipath/apollo-wind/components/ui/spinner'
33-
import { useTask, useTaskUsers } from '../hooks/useTasks'
33+
import { useFolders, useTask, useTaskUsers } from '../hooks/useTasks'
3434
import {
3535
statusBadge,
3636
priorityBadge,
@@ -55,10 +55,15 @@ type Sub = 'assign' | 'reassign' | 'complete' | null
5555
/** Task detail panel (Tasks.getById) with lifecycle actions via task-attached methods. */
5656
export function TaskDetail({ taskId, folderId, isManage, onClose, onChanged }: Props) {
5757
const { task, loading, error, reload } = useTask(taskId, folderId)
58+
const { folders } = useFolders()
5859
const [sub, setSub] = useState<Sub>(null)
5960
const [busy, setBusy] = useState(false)
6061
const [actionError, setActionError] = useState<string | null>(null)
6162

63+
// Resolve folder display name; fall back to id.
64+
const folderPath =
65+
folders.find((f) => f.id === folderId)?.fullyQualifiedName ?? String(folderId)
66+
6267
const afterChange = () => {
6368
reload()
6469
onChanged()
@@ -73,7 +78,7 @@ export function TaskDetail({ taskId, folderId, isManage, onClose, onChanged }: P
7378
if (result.success) afterChange()
7479
else setActionError('Unassign did not complete successfully')
7580
} catch (err) {
76-
setActionError(err instanceof UiPathError ? err.message : 'Unassign failed')
81+
setActionError(extractServerMessage(err) ?? 'Unassign failed')
7782
} finally {
7883
setBusy(false)
7984
}
@@ -119,28 +124,50 @@ export function TaskDetail({ taskId, folderId, isManage, onClose, onChanged }: P
119124
)}
120125
</div>
121126

122-
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
123-
<Field
124-
label="Assignee"
125-
value={task.assignedToUser?.displayName ?? 'Unassigned'}
126-
/>
127+
{/* Task Summary — mirrors Action Center's right pane. */}
128+
<dl className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-1.5">
129+
<Field label="Task ID" value={`#${task.id}`} mono />
127130
{task.taskSource?.sourceName && (
128131
<Field label="Source" value={task.taskSource.sourceName} />
129132
)}
130-
<Field label="Created" value={formatDateTime(task.createdTime)} />
133+
<Field label="Folder path" value={folderPath} />
134+
<Field label="Created on" value={formatDateTime(task.createdTime)} />
131135
{task.completedTime && (
132136
<Field label="Completed" value={formatDateTime(task.completedTime)} />
133137
)}
134138
{task.completedByUser?.displayName && (
135139
<Field label="Completed by" value={task.completedByUser.displayName} />
136140
)}
141+
{task.taskAssignmentCriteria && (
142+
<Field label="Assignment criteria" value={task.taskAssignmentCriteria} />
143+
)}
144+
<Field
145+
label="Assigned to"
146+
value={
147+
task.assignedToUser?.displayName ??
148+
(assignedToGroupKey(task) ? 'Directory group' : 'Unassigned')
149+
}
150+
/>
137151
{(task.actionLabel || task.action) && (
138152
<Field label="Action" value={task.actionLabel ?? task.action ?? ''} />
139153
)}
140154
{task.externalTag && <Field label="External tag" value={task.externalTag} mono />}
141155
<Field label="Key" value={task.key} mono />
142156
</dl>
143157

158+
{task.tags && task.tags.length > 0 && (
159+
<Section title="Labels">
160+
<div className="flex flex-wrap gap-1.5">
161+
{task.tags.map((t) => (
162+
<Badge key={t.name} variant="secondary">
163+
{t.displayName || t.name}
164+
{t.displayValue ? `: ${t.displayValue}` : ''}
165+
</Badge>
166+
))}
167+
</div>
168+
</Section>
169+
)}
170+
144171
{task.data && Object.keys(task.data).length > 0 && (
145172
<Section title="Data">
146173
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-1 rounded-lg border bg-muted/40 p-3">
@@ -156,24 +183,6 @@ export function TaskDetail({ taskId, folderId, isManage, onClose, onChanged }: P
156183
</Section>
157184
)}
158185

159-
{task.activities && task.activities.length > 0 && (
160-
<Section title="Activity">
161-
<ul className="space-y-1.5">
162-
{task.activities.map((a, i) => (
163-
<li
164-
key={`${a.taskId}-${i}`}
165-
className="flex items-center justify-between gap-2"
166-
>
167-
<span>{a.activityType}</span>
168-
<span className="shrink-0 text-xs text-muted-foreground">
169-
{formatDateTime(a.createdTime)}
170-
</span>
171-
</li>
172-
))}
173-
</ul>
174-
</Section>
175-
)}
176-
177186
{actionError && (
178187
<Alert variant="destructive">
179188
<AlertDescription>{actionError}</AlertDescription>
@@ -243,6 +252,42 @@ function Section({ title, children }: { title: string; children: React.ReactNode
243252
)
244253
}
245254

255+
/** Assigned to a directory group (no user object, but a key). */
256+
function assignedToGroupKey(task: TaskGetResponse): boolean {
257+
if (task.assignedToUser) return false
258+
const key = (task as unknown as { assignedToUserKey?: string | null }).assignedToUserKey
259+
return !!key
260+
}
261+
262+
/** First server message from a failed assign payload. */
263+
function extractAssignError(data: unknown): string {
264+
if (Array.isArray(data)) {
265+
for (const item of data) {
266+
if (item && typeof item === 'object' && 'errorMessage' in item) {
267+
const msg = String((item as { errorMessage?: string }).errorMessage ?? '').trim()
268+
if (msg) return msg
269+
}
270+
}
271+
}
272+
return 'The assignment did not complete successfully'
273+
}
274+
275+
/** Server error string from a thrown UiPath/HTTP error. */
276+
function extractServerMessage(err: unknown): string | null {
277+
if (!err) return null
278+
if (err instanceof UiPathError && err.message) return err.message
279+
const e = err as Record<string, unknown>
280+
const candidates = [
281+
e.message,
282+
(e.body as Record<string, unknown> | undefined)?.message,
283+
((e.response as Record<string, unknown> | undefined)?.data as Record<string, unknown> | undefined)?.message,
284+
]
285+
for (const c of candidates) {
286+
if (typeof c === 'string' && c.trim()) return c.trim()
287+
}
288+
return null
289+
}
290+
246291
function renderValue(value: unknown): string {
247292
if (value === null || value === undefined) return '—'
248293
if (typeof value === 'object') {
@@ -292,10 +337,11 @@ function AssignForm({
292337
onDone()
293338
onClose()
294339
} else {
295-
setSubmitError('The assignment did not complete successfully')
340+
// Server-supplied error (e.g. "Already assigned to another user").
341+
setSubmitError(extractAssignError(result.data))
296342
}
297343
} catch (err) {
298-
setSubmitError(err instanceof UiPathError ? err.message : 'Assignment failed')
344+
setSubmitError(extractServerMessage(err) ?? 'Assignment failed')
299345
} finally {
300346
setBusy(false)
301347
}
@@ -387,7 +433,7 @@ function CompleteForm({
387433
setSubmitError('The task did not complete successfully')
388434
}
389435
} catch (err) {
390-
setSubmitError(err instanceof UiPathError ? err.message : 'Completion failed')
436+
setSubmitError(extractServerMessage(err) ?? 'Completion failed')
391437
} finally {
392438
setBusy(false)
393439
}
@@ -399,8 +445,7 @@ function CompleteForm({
399445
<DialogHeader>
400446
<DialogTitle>Complete task</DialogTitle>
401447
<DialogDescription>
402-
The action maps to the outcome the workflow expects. Use Reject, or type a custom
403-
action and choose Complete.
448+
Type the action your workflow expects (e.g. <span className="font-medium">Approve</span> or <span className="font-medium">Reject</span>) and confirm.
404449
</DialogDescription>
405450
</DialogHeader>
406451

@@ -426,11 +471,8 @@ function CompleteForm({
426471
<Button variant="outline" onClick={onClose} disabled={busy}>
427472
Cancel
428473
</Button>
429-
<Button variant="destructive" onClick={() => complete('Reject')} disabled={busy}>
430-
Reject
431-
</Button>
432474
<Button onClick={() => complete(action)} disabled={busy || !action.trim()}>
433-
{busy ? 'Completing…' : `Complete (${action.trim() || '—'})`}
475+
{busy ? 'Completing…' : 'Complete'}
434476
</Button>
435477
</DialogFooter>
436478
</DialogContent>

0 commit comments

Comments
 (0)