diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 0ced03b..a49837e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -5,6 +5,7 @@ const preview: Preview = { parameters: { options: { storySort: { + method: 'alphabetical', order: ['Welcome', 'Components', 'Patterns', 'Pages'], }, }, diff --git a/src/components/Chat/TaskPlan.stories.tsx b/src/components/Chat/TaskPlan.stories.tsx new file mode 100644 index 0000000..a84f71f --- /dev/null +++ b/src/components/Chat/TaskPlan.stories.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TaskPlan } from './TaskPlan' +import type { TaskPlanItem } from './TaskPlan' +import { ButtonWidget } from '../Button/ButtonWidget' + +const meta = { + title: 'Components/Chat/TaskPlan', + component: TaskPlan, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const sampleTasks: TaskPlanItem[] = [ + { + id: '1', + taskName: 'Create Group for Independence Team', + objectType: 'group', + objectName: 'RSM Independence Team', + notes: + 'Create a group to represent Independence Team members. Use this group for assigning read-only access and role-based visibility. Add the group as a member of the application\'s viewers group.', + }, + { + id: '2', + taskName: 'Update Submissions record type with user filters and search', + objectType: 'recordType', + objectName: 'RSM Board Committee Submission', + notes: + 'Update the submission record type to support user filters and search on partner name, submission date, and organization name to enable efficient querying in dashboards and record lists.', + }, + { + id: '3', + taskName: 'Create Independence Team Dashboard Interface', + objectType: 'interface', + objectName: 'RSM_BC_IndependenceTeamDashboard', + notes: + 'Build a landing dashboard for Independence Team members with key KPIs (total submissions, active memberships, submissions this month, total partners), a filterable grid of recent submissions across all partners, navigation links, and an export-to-Excel option.', + }, + { + id: '4', + taskName: 'Add Independence Team Dashboard Page to Site', + objectType: 'site', + objectName: 'Boards & Committees site', + notes: + 'Add the Independence Team dashboard interface as a page in the site. Configure it as the default landing page and restrict visibility to Independence Team members only.', + }, +] + +export const Default: Story = { + args: { + tasks: sampleTasks, + }, +} + +export const Editing: Story = { + args: { + tasks: sampleTasks, + editing: true, + onTasksChange: () => {}, + }, +} + +export const SingleTask: Story = { + args: { + tasks: [sampleTasks[0]], + }, +} + +export const InChatContext: Story = { + render: function InChatContextRender() { + const [editing, setEditing] = useState(false) + const [tasks, setTasks] = useState(sampleTasks) + + return ( +
+

+ Here's the plan for adding the Independence Team dashboard. + What do you think? +

+ +
+ {editing ? ( + setEditing(false)} + /> + ) : ( + <> + {}} + /> + setEditing(true)} + /> + + )} +
+
+ ) + }, + args: { + tasks: sampleTasks, + }, +} diff --git a/src/components/Chat/TaskPlan.tsx b/src/components/Chat/TaskPlan.tsx new file mode 100644 index 0000000..d52dd25 --- /dev/null +++ b/src/components/Chat/TaskPlan.tsx @@ -0,0 +1,243 @@ +import * as React from 'react' +import { useState } from 'react' +import { ChevronDown, FileText, Workflow, Database, HardDrive, Layout, Ruler, Calculator, Variable, Users, Globe } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { mergeClasses } from '../../utils/classNames' +import { TextField } from '../TextField/TextField' +import { ParagraphField } from '../ParagraphField/ParagraphField' +import { DropdownField } from '../Dropdown/DropdownField' + +/** + * Object types supported by the icon set. + */ +export type ObjectTypeKey = + | "document" + | "processModel" + | "recordType" + | "dataStore" + | "interface" + | "rule" + | "expression" + | "constant" + | "group" + | "site" + +interface IconConfig { + icon: LucideIcon + bg: string + fg: string +} + +const OBJECT_TYPE_ICONS: Record = { + document: { icon: FileText, bg: 'bg-green-50', fg: 'text-green-700' }, + processModel: { icon: Workflow, bg: 'bg-purple-50', fg: 'text-purple-700' }, + recordType: { icon: Database, bg: 'bg-orange-50', fg: 'text-orange-700' }, + dataStore: { icon: HardDrive, bg: 'bg-sky-50', fg: 'text-sky-700' }, + interface: { icon: Layout, bg: 'bg-teal-50', fg: 'text-teal-700' }, + rule: { icon: Ruler, bg: 'bg-blue-50', fg: 'text-blue-700' }, + expression: { icon: Calculator, bg: 'bg-pink-50', fg: 'text-pink-700' }, + constant: { icon: Variable, bg: 'bg-gray-100', fg: 'text-gray-700' }, + group: { icon: Users, bg: 'bg-blue-50', fg: 'text-blue-700' }, + site: { icon: Globe, bg: 'bg-purple-50', fg: 'text-purple-700' }, +} + +const OBJECT_TYPE_OPTIONS: { value: ObjectTypeKey; label: string }[] = [ + { value: "recordType", label: "Record Type" }, + { value: "processModel", label: "Process Model" }, + { value: "interface", label: "Interface" }, + { value: "rule", label: "Rule" }, + { value: "expression", label: "Expression" }, + { value: "constant", label: "Constant" }, + { value: "document", label: "Document" }, + { value: "dataStore", label: "Data Store" }, + { value: "group", label: "Group" }, + { value: "site", label: "Site" }, +] + +function getObjectTypeLabel(objectType: ObjectTypeKey): string { + return OBJECT_TYPE_OPTIONS.find((o) => o.value === objectType)?.label ?? objectType +} + +export interface TaskPlanItem { + /** Unique identifier */ + id: string + /** Task name / title */ + taskName: string + /** Object type for icon display */ + objectType: ObjectTypeKey + /** Name of the object being created/modified */ + objectName: string + /** Implementation notes */ + notes: string +} + +export interface TaskPlanProps { + /** The list of task plan items */ + tasks: TaskPlanItem[] + /** Whether the plan is in edit mode */ + editing?: boolean + /** Callback when tasks are updated in edit mode */ + onTasksChange?: (tasks: TaskPlanItem[]) => void + /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ + className?: string +} + +/** + * TaskPlan Component + * Displays a collapsible list of planned tasks with object type icons and details. + * Supports read-only and editable modes for plan review workflows. + */ +export const TaskPlan: React.FC = ({ + tasks, + editing = false, + onTasksChange, + className, +}) => { + const [openItems, setOpenItems] = useState>( + new Set(tasks.map((t) => t.id)) + ) + + const toggleItem = (id: string) => { + setOpenItems((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const updateTask = (id: string, updates: Partial) => { + if (!onTasksChange) return + onTasksChange( + tasks.map((t) => (t.id === id ? { ...t, ...updates } : t)) + ) + } + + const sailClasses = 'flex flex-col gap-2' + + return ( +
+ {tasks.map((task, index) => { + const isOpen = openItems.has(task.id) + const iconConfig = OBJECT_TYPE_ICONS[task.objectType] + const Icon = iconConfig.icon + + return ( +
+ + + {isOpen && ( +
+ {editing ? ( + updateTask(task.id, updates)} + /> + ) : ( + + )} +
+ )} +
+ ) + })} +
+ ) +} + +interface ReadOnlyTaskContentProps { + task: TaskPlanItem +} + +const ReadOnlyTaskContent: React.FC = ({ task }) => { + return ( +
+
+ + Object Type + + {getObjectTypeLabel(task.objectType)} +
+
+ + Object Name + + {task.objectName} +
+
+ + Implementation Notes + +

+ {task.notes} +

+
+
+ ) +} + +interface EditableTaskContentProps { + task: TaskPlanItem + onUpdate: (updates: Partial) => void +} + +const EditableTaskContent: React.FC = ({ task, onUpdate }) => { + return ( +
+ onUpdate({ taskName: val })} + marginBelow="LESS" + /> + o.label)} + choiceValues={OBJECT_TYPE_OPTIONS.map((o) => o.value)} + value={task.objectType} + saveInto={(val) => onUpdate({ objectType: val as ObjectTypeKey })} + marginBelow="LESS" + /> + onUpdate({ objectName: val })} + marginBelow="LESS" + /> + onUpdate({ notes: val })} + height="SHORT" + marginBelow="NONE" + /> +
+ ) +} diff --git a/src/components/Chat/TaskProgress.stories.tsx b/src/components/Chat/TaskProgress.stories.tsx new file mode 100644 index 0000000..9644f2f --- /dev/null +++ b/src/components/Chat/TaskProgress.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TaskProgress } from './TaskProgress' +import type { Task } from './TaskProgress' + +const meta = { + title: 'Components/Chat/TaskProgress', + component: TaskProgress, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const defaultTasks: Task[] = [ + { id: '1', label: 'Create loan application record type', status: 'COMPLETED' }, + { id: '2', label: 'Create document record type', status: 'COMPLETED' }, + { id: '3', label: 'Create brand selection interface', status: 'ACTIVE' }, + { id: '4', label: 'Build loan application form interface', status: 'TODO' }, + { id: '5', label: 'Create document upload component', status: 'TODO' }, +] + +export const Default: Story = { + args: { + title: 'Task Progress', + tasks: defaultTasks, + defaultOpen: true, + }, +} + +export const Collapsed: Story = { + args: { + title: 'Task Progress', + tasks: defaultTasks, + defaultOpen: false, + }, +} + +export const AllCompleted: Story = { + args: { + title: 'Completed Tasks', + tasks: [ + { id: '1', label: 'Setup project structure', status: 'COMPLETED' }, + { id: '2', label: 'Install dependencies', status: 'COMPLETED' }, + { id: '3', label: 'Create components', status: 'COMPLETED' }, + { id: '4', label: 'Write documentation', status: 'COMPLETED' }, + ], + defaultOpen: true, + }, +} + +export const MostlyTodo: Story = { + args: { + title: 'Upcoming Tasks', + tasks: [ + { id: '1', label: 'Setup project', status: 'COMPLETED' }, + { id: '2', label: 'Design components', status: 'ACTIVE' }, + { id: '3', label: 'Implement authentication', status: 'TODO' }, + { id: '4', label: 'Add database integration', status: 'TODO' }, + { id: '5', label: 'Write tests', status: 'TODO' }, + { id: '6', label: 'Deploy to production', status: 'TODO' }, + ], + defaultOpen: true, + }, +} + +export const CustomTitle: Story = { + args: { + title: 'Sprint 3 Progress', + tasks: [ + { id: '1', label: 'User authentication module', status: 'COMPLETED' }, + { id: '2', label: 'Dashboard layout', status: 'COMPLETED' }, + { id: '3', label: 'API integration', status: 'ACTIVE' }, + ], + defaultOpen: true, + }, +} diff --git a/src/components/Chat/TaskProgress.tsx b/src/components/Chat/TaskProgress.tsx new file mode 100644 index 0000000..d7aa19c --- /dev/null +++ b/src/components/Chat/TaskProgress.tsx @@ -0,0 +1,142 @@ +import * as React from 'react' +import { useState } from 'react' +import { ChevronDown, Check, Circle } from 'lucide-react' +import { mergeClasses } from '../../utils/classNames' +import { ProgressBar } from '../ProgressBar/ProgressBar' + +type TaskStatus = "COMPLETED" | "ACTIVE" | "TODO" + +export interface Task { + /** Unique identifier for the task */ + id: string + /** Task description */ + label: string + /** Task status */ + status: TaskStatus +} + +export interface TaskProgressProps { + /** Section title displayed in the collapsible header */ + title?: string + /** Array of tasks to display */ + tasks: Task[] + /** Whether the task list starts expanded */ + defaultOpen?: boolean + /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ + className?: string +} + +/** + * TaskProgress Component + * Displays a collapsible list of tasks with a progress bar summarizing completion. + * Used in chat interfaces to visualize multi-step task execution state. + */ +export const TaskProgress: React.FC = ({ + title = "Task Progress", + tasks, + defaultOpen = true, + className, +}) => { + const [open, setOpen] = useState(defaultOpen) + + const completedCount = tasks.filter((t) => t.status === "COMPLETED").length + const totalCount = tasks.length + const progressValue = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + + const sailClasses = 'border border-gray-200 rounded-md overflow-hidden bg-white' + + return ( +
+ + + {open && ( +
+
    + {tasks.map((task) => ( + + ))} +
+
+ )} +
+ ) +} + +interface TaskItemProps { + task: Task +} + +const TaskItem: React.FC = ({ task }) => { + const statusIcon = () => { + if (task.status === "COMPLETED") { + return