Skip to content

Commit 2b9499a

Browse files
feat(samples/action-center-app): side-sheet task detail + UI fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 45dc166 commit 2b9499a

5 files changed

Lines changed: 76 additions & 80 deletions

File tree

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

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,16 @@ import { AuthProvider, useAuth } from './hooks/useAuth'
1717
import { TaskList } from './components/TaskList'
1818
import { ThemeToggle } from './components/Theme'
1919

20-
// Coded App pattern: the SDK fills required fields (clientId, orgName,
21-
// tenantName, baseUrl, scope, redirectUri) from `<meta name="uipath:*">` tags
22-
// injected at runtime — by `@uipath/coded-apps-dev` locally (from uipath.json)
23-
// or by the platform when deployed. The static type insists on those fields,
24-
// so we pass an empty object cast to satisfy it.
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 {}.
2522
const authConfig = {} as UiPathSDKConfig
2623

2724
const FOLDER_STORAGE_KEY = 'action-center-app.folderId'
2825

2926
function AppContent() {
3027
const { isAuthenticated, isLoading, login, logout, error } = useAuth()
3128

32-
// Action Center tasks are folder-scoped. Persist the choice so it survives a
33-
// refresh and the OAuth round-trip. Leaving it blank lists tasks across all
34-
// folders the user can view/edit.
29+
// Persist the folder filter across refresh/OAuth; blank = all folders.
3530
const [folderId, setFolderId] = useState<number | null>(() => {
3631
const stored = localStorage.getItem(FOLDER_STORAGE_KEY)
3732
const parsed = stored ? Number(stored) : NaN
@@ -101,10 +96,8 @@ function AppContent() {
10196
/>
10297
<Input
10398
id="folder-id"
104-
// Folder IDs are identifiers, not quantities — `type="text"` with
105-
// `inputMode="numeric"` keeps the on-screen numeric keypad on
106-
// mobile while dropping the desktop spinner arrows (which would
107-
// imply incrementing an ID is meaningful, which it isn't).
99+
// type=text + inputMode=numeric: numeric keypad on mobile, no
100+
// desktop spinner arrows (incrementing an ID isn't meaningful).
108101
type="text"
109102
inputMode="numeric"
110103
pattern="[0-9]*"

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

Lines changed: 43 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {
1313
DialogHeader,
1414
DialogTitle,
1515
} from '@uipath/apollo-wind/components/ui/dialog'
16+
import {
17+
Sheet,
18+
SheetContent,
19+
SheetFooter,
20+
SheetHeader,
21+
SheetTitle,
22+
} from '@uipath/apollo-wind/components/ui/sheet'
1623
import { Input } from '@uipath/apollo-wind/components/ui/input'
1724
import { Label } from '@uipath/apollo-wind/components/ui/label'
1825
import {
@@ -36,19 +43,17 @@ import {
3643
interface Props {
3744
taskId: number
3845
folderId: number
46+
/** Opened from Manage Tasks — gates the Assign/Reassign actions. */
47+
isManage: boolean
3948
onClose: () => void
4049
/** Called after a mutation so the parent list can refresh. */
4150
onChanged: () => void
4251
}
4352

4453
type Sub = 'assign' | 'reassign' | 'complete' | null
4554

46-
/**
47-
* Detail modal for one task (via `Tasks.getById`) plus the lifecycle actions.
48-
* Assign / reassign / complete open small inline forms; unassign runs inline.
49-
* All mutations use the task-attached methods on the fetched task.
50-
*/
51-
export function TaskDetail({ taskId, folderId, onClose, onChanged }: Props) {
55+
/** Task detail panel (Tasks.getById) with lifecycle actions via task-attached methods. */
56+
export function TaskDetail({ taskId, folderId, isManage, onClose, onChanged }: Props) {
5257
const { task, loading, error, reload } = useTask(taskId, folderId)
5358
const [sub, setSub] = useState<Sub>(null)
5459
const [busy, setBusy] = useState(false)
@@ -79,12 +84,16 @@ export function TaskDetail({ taskId, folderId, onClose, onChanged }: Props) {
7984

8085
return (
8186
<>
82-
<Dialog open onOpenChange={(open) => !open && onClose()}>
83-
<DialogContent className="sm:max-w-lg">
84-
<DialogHeader>
85-
<DialogTitle className="truncate">{task?.title ?? `Task #${taskId}`}</DialogTitle>
86-
</DialogHeader>
87+
<Sheet open onOpenChange={(open) => !open && onClose()}>
88+
<SheetContent side="right" className="flex w-full flex-col sm:max-w-lg">
89+
<SheetHeader>
90+
<SheetTitle className="min-w-0 break-words pr-8">
91+
{task?.title ?? `Task #${taskId}`}
92+
</SheetTitle>
93+
</SheetHeader>
8794

95+
{/* Scrollable body so the footer stays pinned regardless of content height. */}
96+
<div className="-mx-6 min-h-0 flex-1 overflow-y-auto px-6">
8897
{loading ? (
8998
<div className="flex justify-center py-6">
9099
<Spinner label="Loading task…" showLabel />
@@ -94,8 +103,8 @@ export function TaskDetail({ taskId, folderId, onClose, onChanged }: Props) {
94103
<AlertDescription>{error}</AlertDescription>
95104
</Alert>
96105
) : task ? (
97-
<div className="space-y-5 text-sm">
98-
<div className="flex flex-wrap items-center gap-2">
106+
<div className="space-y-4 text-sm">
107+
<div className="flex flex-wrap items-center gap-1.5">
99108
<Badge variant={statusBadge(task.status).variant}>
100109
{statusBadge(task.status).label}
101110
</Badge>
@@ -115,27 +124,23 @@ export function TaskDetail({ taskId, folderId, onClose, onChanged }: Props) {
115124
label="Assignee"
116125
value={task.assignedToUser?.displayName ?? 'Unassigned'}
117126
/>
127+
{task.taskSource?.sourceName && (
128+
<Field label="Source" value={task.taskSource.sourceName} />
129+
)}
118130
<Field label="Created" value={formatDateTime(task.createdTime)} />
119131
{task.completedTime && (
120132
<Field label="Completed" value={formatDateTime(task.completedTime)} />
121133
)}
134+
{task.completedByUser?.displayName && (
135+
<Field label="Completed by" value={task.completedByUser.displayName} />
136+
)}
137+
{(task.actionLabel || task.action) && (
138+
<Field label="Action" value={task.actionLabel ?? task.action ?? ''} />
139+
)}
122140
{task.externalTag && <Field label="External tag" value={task.externalTag} mono />}
123141
<Field label="Key" value={task.key} mono />
124142
</dl>
125143

126-
{task.tags && task.tags.length > 0 && (
127-
<Section title="Tags">
128-
<div className="flex flex-wrap gap-1.5">
129-
{task.tags.map((t) => (
130-
<Badge key={t.name} variant="secondary">
131-
{t.displayName || t.name}
132-
{t.displayValue ? `: ${t.displayValue}` : ''}
133-
</Badge>
134-
))}
135-
</div>
136-
</Section>
137-
)}
138-
139144
{task.data && Object.keys(task.data).length > 0 && (
140145
<Section title="Data">
141146
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-1 rounded-lg border bg-muted/40 p-3">
@@ -176,11 +181,15 @@ export function TaskDetail({ taskId, folderId, onClose, onChanged }: Props) {
176181
)}
177182
</div>
178183
) : null}
184+
</div>
179185

180186
{task && !isCompleted && (
181-
<DialogFooter className="flex-wrap">
182-
{!hasAssignee && <Button onClick={() => setSub('assign')}>Assign</Button>}
183-
{hasAssignee && (
187+
<SheetFooter className="flex-wrap gap-2">
188+
{/* Assign/Reassign to others is a Manage Tasks action only. */}
189+
{isManage && !hasAssignee && (
190+
<Button onClick={() => setSub('assign')}>Assign</Button>
191+
)}
192+
{isManage && hasAssignee && (
184193
<Button variant="outline" onClick={() => setSub('reassign')}>
185194
Reassign
186195
</Button>
@@ -193,10 +202,10 @@ export function TaskDetail({ taskId, folderId, onClose, onChanged }: Props) {
193202
<Button variant="outline" onClick={() => setSub('complete')}>
194203
Complete
195204
</Button>
196-
</DialogFooter>
205+
</SheetFooter>
197206
)}
198-
</DialogContent>
199-
</Dialog>
207+
</SheetContent>
208+
</Sheet>
200209

201210
{task && (sub === 'assign' || sub === 'reassign') && (
202211
<AssignForm
@@ -269,9 +278,8 @@ function AssignForm({
269278
const id = Number(userId)
270279
if (!Number.isFinite(id) || id <= 0) return
271280
const selected = users.find((u) => u.id === id)
272-
// Assigning to a directory group needs an assignment criteria so the
273-
// backend knows how to distribute the task across the group's members; a
274-
// direct (single-user) assignment omits it.
281+
// Directory-group assignment needs a criteria to distribute across members;
282+
// single-user assignment omits it.
275283
const opts: TaskAssignOptions =
276284
selected?.type === TaskUserType.DirectoryGroup
277285
? { userId: id, assignmentCriteria: TaskAssignmentCriteria.AllUsers }

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

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react'
2-
import { Inbox, Plus, RefreshCw, User, Users } from 'lucide-react'
2+
import { ChevronRight, Inbox, Plus, RefreshCw, User, Users } from 'lucide-react'
33
import { Tasks, TaskStatus, TaskPriority } from '@uipath/uipath-typescript/tasks'
44
import type { TaskGetResponse } from '@uipath/uipath-typescript/tasks'
55
import { UiPathError } from '@uipath/uipath-typescript/core'
@@ -74,9 +74,7 @@ interface Props {
7474
export function TaskList({ folderId }: Props) {
7575
const [status, setStatus] = useState<TaskStatus | 'all'>('all')
7676
const [priority, setPriority] = useState<TaskPriority | 'all'>('all')
77-
// Mirrors Action Center's My Tasks / Manage Tasks split via getAll's
78-
// `asTaskAdmin` flag — "Manage" lists tasks the user can assign (the context
79-
// where group assignment applies).
77+
// My Tasks / Manage Tasks split via getAll's `asTaskAdmin` flag.
8078
const [asTaskAdmin, setAsTaskAdmin] = useState(false)
8179
const [selected, setSelected] = useState<TaskGetResponse | null>(null)
8280
const [creating, setCreating] = useState(false)
@@ -178,7 +176,8 @@ export function TaskList({ folderId }: Props) {
178176
<TableHead className="w-[12%]">Status</TableHead>
179177
<TableHead className="w-[10%]">Priority</TableHead>
180178
<TableHead className="w-[14%]">Assignee</TableHead>
181-
<TableHead className="w-[18%]">Created</TableHead>
179+
<TableHead className="w-[14%]">Created</TableHead>
180+
<TableHead className="w-[4%]" aria-label="Open" />
182181
</TableRow>
183182
</TableHeader>
184183
<TableBody>
@@ -224,6 +223,9 @@ export function TaskList({ folderId }: Props) {
224223
<TableCell className="truncate text-muted-foreground">
225224
{formatDateTime(task.createdTime)}
226225
</TableCell>
226+
<TableCell className="text-right text-muted-foreground">
227+
<ChevronRight className="inline h-4 w-4" aria-hidden />
228+
</TableCell>
227229
</TableRow>
228230
)
229231
})}
@@ -268,6 +270,7 @@ export function TaskList({ folderId }: Props) {
268270
key={selected.id}
269271
taskId={selected.id}
270272
folderId={selected.folderId}
273+
isManage={asTaskAdmin}
271274
onClose={() => setSelected(null)}
272275
onChanged={refresh}
273276
/>
@@ -283,11 +286,7 @@ export function TaskList({ folderId }: Props) {
283286
)
284287
}
285288

286-
/**
287-
* Skeleton placeholder for the task table. Mirrors the real table's column
288-
* layout so the page doesn't jump on load — the industry-standard pattern
289-
* for data-grids (Linear, Notion, Vercel, Stripe all do this).
290-
*/
289+
/** Skeleton placeholder mirroring the table's columns so the page doesn't jump on load. */
291290
function TaskTableSkeleton({ rows = 8 }: { rows?: number }) {
292291
return (
293292
<div className="rounded-lg border">
@@ -299,7 +298,8 @@ function TaskTableSkeleton({ rows = 8 }: { rows?: number }) {
299298
<TableHead className="w-[12%]">Status</TableHead>
300299
<TableHead className="w-[10%]">Priority</TableHead>
301300
<TableHead className="w-[14%]">Assignee</TableHead>
302-
<TableHead className="w-[18%]">Created</TableHead>
301+
<TableHead className="w-[14%]">Created</TableHead>
302+
<TableHead className="w-[4%]" />
303303
</TableRow>
304304
</TableHeader>
305305
<TableBody>
@@ -323,6 +323,7 @@ function TaskTableSkeleton({ rows = 8 }: { rows?: number }) {
323323
<TableCell>
324324
<Skeleton className="h-4 w-32" />
325325
</TableCell>
326+
<TableCell />
326327
</TableRow>
327328
))}
328329
</TableBody>
@@ -359,11 +360,7 @@ function FilterSelect({
359360
)
360361
}
361362

362-
/**
363-
* Inline create form — `Tasks.create` only supports External tasks and needs a
364-
* target folder, so a Folder ID field is always shown (prefilled from the
365-
* header filter when one is set).
366-
*/
363+
/** Inline create form — Tasks.create makes External tasks and needs a target folder. */
367364
function CreateForm({
368365
defaultFolderId,
369366
onClose,

samples/action-center-app/src/hooks/useTasks.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@ import type { TaskFilters } from '../taskUtils'
1414
* to keep the sample small; each wraps one part of `TaskServiceModel`.
1515
*/
1616

17-
// Principal types a human task can be assigned to. `Tasks.getUsers` also
18-
// returns robot / external-application accounts (and a folder may contain only
19-
// groups, not individual users), so we keep people + directory users + groups
20-
// and drop the non-human accounts.
17+
// Human-assignable principals only — drop the robot / external-app accounts
18+
// that Tasks.getUsers also returns.
2119
const ASSIGNABLE_TYPES = new Set<TaskUserType>([
2220
TaskUserType.User,
2321
TaskUserType.DirectoryUser,
@@ -62,10 +60,8 @@ export function useTasks(
6260

6361
const filter = buildTaskFilter(filters)
6462

65-
// Reset to page 1 AND clear the previous result set when the folder,
66-
// filters, or admin scope change. Clearing `tasks` flips the table into its
67-
// loading state immediately instead of letting the stale rows linger until
68-
// the new fetch resolves.
63+
// On folder/filter/scope change, reset to page 1 and clear rows so the table
64+
// shows loading immediately instead of stale data.
6965
useEffect(() => {
7066
setPage(1)
7167
setTasks([])
@@ -81,10 +77,8 @@ export function useTasks(
8177
const result = await svc.getAll({
8278
pageSize,
8379
jumpToPage: page,
84-
// Use the raw server property name. The SDK renames `CreationTime` →
85-
// `createdTime` on the response, but OData $orderby/$filter run on the
86-
// server, which only knows the original `CreationTime` — passing the
87-
// SDK alias returns "Invalid OData query options" (400).
80+
// Raw server property name: $orderby runs server-side and only knows
81+
// `CreationTime` (the SDK alias `createdTime` would 400).
8882
orderby: 'CreationTime desc',
8983
...(folderId != null ? { folderId } : {}),
9084
...(filter ? { filter } : {}),
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/*
2-
* Apollo Vertex design system styles — bundles Tailwind v4 base + utilities,
3-
* Apollo's design tokens (semantic colours, typography, radii), and built-in
4-
* `.dark` overrides. `next-themes` toggles `class="dark"` on <html> and
5-
* everything else (apollo-wind components, our Tailwind utilities) reacts.
2+
* Import apollo-wind's Tailwind source (not the pre-compiled styles.css) so this
3+
* app compiles its own utility classes. Brings in Tailwind v4 base/utilities,
4+
* Apollo design tokens, and `.dark` overrides (toggled by next-themes).
65
*/
7-
@import '@uipath/apollo-wind/styles.css';
6+
@import '@uipath/apollo-wind/tailwind.css';
7+
8+
/* Tailwind skips node_modules — scan apollo-wind so its component classes
9+
(e.g. the Dialog's p-6/gap-4) regenerate; the second line scans this app. */
10+
@source '../node_modules/@uipath/apollo-wind/dist';
11+
@source './';

0 commit comments

Comments
 (0)