feat(frontend): streamline task creation and preview workflows
- remove prompt and preview generation from task creation - create tasks as inactive and route directly to edit page - add generated message history UX to edit task - update entity/task views and related test coverage
This commit is contained in:
@@ -229,6 +229,7 @@ TASK DETAILS
|
|||||||
|
|
||||||
INSTRUCTIONS
|
INSTRUCTIONS
|
||||||
- Write exactly one email message.
|
- Write exactly one email message.
|
||||||
|
- The message must be written by Milton Fiscal (Director of Ceremonial Logistics) as the sender persona.
|
||||||
- Use an extremely formal corporate tone.
|
- Use an extremely formal corporate tone.
|
||||||
- Keep the content casual, mundane, and slightly nonsensical.
|
- Keep the content casual, mundane, and slightly nonsensical.
|
||||||
- Reflect the entity personality and task prompt faithfully.
|
- Reflect the entity personality and task prompt faithfully.
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ const mockEntity = {
|
|||||||
|
|
||||||
describe('CreateTaskPage', () => {
|
describe('CreateTaskPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
|
||||||
vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation(
|
|
||||||
(entity, task) =>
|
|
||||||
`PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}`
|
|
||||||
)
|
|
||||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
||||||
mockNavigate.mockClear()
|
mockNavigate.mockClear()
|
||||||
})
|
})
|
||||||
@@ -54,25 +49,36 @@ describe('CreateTaskPage', () => {
|
|||||||
await screen.findByRole('link', { name: /back to entity a/i })
|
await screen.findByRole('link', { name: /back to entity a/i })
|
||||||
|
|
||||||
expect(screen.getByLabelText(/task name/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/task name/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/task prompt/i)).toBeInTheDocument()
|
expect(screen.queryByLabelText(/task prompt/i)).not.toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^Email Period$/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^Email Period$/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^Minute$/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^Hour$/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^Day of Month$/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^Month$/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^Day of Week$/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/^Day of Week$/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /generate test message/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Final Prompt/i)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should_generatePreviewAndCreateTask_when_formSubmitted', async () => {
|
it('should_createInactiveTaskAndNavigateToEditTask_when_formSubmitted', async () => {
|
||||||
vi.mocked(tasksApi.generateTaskPreview).mockResolvedValue('SUBJECT: Preview\nBODY:\nFormal nonsense')
|
|
||||||
vi.mocked(tasksApi.createTask).mockResolvedValue({
|
vi.mocked(tasksApi.createTask).mockResolvedValue({
|
||||||
id: 'task-2',
|
id: 'task-2',
|
||||||
entityId: 'entity-1',
|
entityId: 'entity-1',
|
||||||
name: 'Morning Blast',
|
name: 'Morning Blast',
|
||||||
prompt: 'Talk about coffee',
|
prompt: '',
|
||||||
scheduleCron: '0 8 * * 1-5',
|
scheduleCron: '0 8 * * 1-5',
|
||||||
emailLookback: 'last_week',
|
emailLookback: 'last_week',
|
||||||
active: true,
|
active: false,
|
||||||
|
createdAt: '2026-03-26T10:00:00Z',
|
||||||
|
})
|
||||||
|
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({
|
||||||
|
id: 'task-2',
|
||||||
|
entityId: 'entity-1',
|
||||||
|
name: 'Morning Blast',
|
||||||
|
prompt: '',
|
||||||
|
scheduleCron: '0 8 * * 1-5',
|
||||||
|
emailLookback: 'last_week',
|
||||||
|
active: false,
|
||||||
createdAt: '2026-03-26T10:00:00Z',
|
createdAt: '2026-03-26T10:00:00Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,45 +103,10 @@ describe('CreateTaskPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /Monthly/i })).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } })
|
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } })
|
||||||
fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } })
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Weekdays/i }))
|
||||||
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } })
|
||||||
|
|
||||||
expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument()
|
|
||||||
expect(
|
|
||||||
screen.getByText('PROMPT FOR Entity A: Morning Blast | Talk about coffee | 0 8 * * 1-5 | last_week')
|
|
||||||
).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(tasksApi.generateTaskPreview).toHaveBeenCalled()
|
|
||||||
expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith(
|
|
||||||
mockEntity,
|
|
||||||
expect.objectContaining({
|
|
||||||
entityId: 'entity-1',
|
|
||||||
name: 'Morning Blast',
|
|
||||||
prompt: 'Talk about coffee',
|
|
||||||
scheduleCron: '0 8 * * 1-5',
|
|
||||||
emailLookback: 'last_week',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
entity: mockEntity,
|
|
||||||
task: expect.objectContaining({
|
|
||||||
entityId: 'entity-1',
|
|
||||||
name: 'Morning Blast',
|
|
||||||
prompt: 'Talk about coffee',
|
|
||||||
scheduleCron: '0 8 * * 1-5',
|
|
||||||
emailLookback: 'last_week',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /create task/i }))
|
fireEvent.click(screen.getByRole('button', { name: /create task/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -144,32 +115,13 @@ describe('CreateTaskPage', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityId: 'entity-1',
|
entityId: 'entity-1',
|
||||||
name: 'Morning Blast',
|
name: 'Morning Blast',
|
||||||
prompt: 'Talk about coffee',
|
prompt: '',
|
||||||
scheduleCron: '0 8 * * 1-5',
|
scheduleCron: '0 8 * * 1-5',
|
||||||
emailLookback: 'last_week',
|
emailLookback: 'last_week',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1')
|
expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-2')
|
||||||
})
|
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1/tasks/task-2')
|
||||||
})
|
|
||||||
|
|
||||||
it('should_showReadablePreviewError_when_generationFails', async () => {
|
|
||||||
vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue(
|
|
||||||
new Error('Unable to generate a test message from the local model. Connection refused')
|
|
||||||
)
|
|
||||||
|
|
||||||
render(<CreateTaskPage />, { wrapper })
|
|
||||||
await screen.findByRole('link', { name: /back to entity a/i })
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } })
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate test message/i }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Unable to generate a test message from the local model. Connection refused/i)
|
|
||||||
).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,59 @@ describe('EditTaskPage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should_renderGeneratedMessagePanels_when_pageLoads', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await screen.findByRole('link', { name: /back to entity a/i })
|
||||||
|
|
||||||
|
expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/^Generated Message$/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/^Generated Message History$/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Generate a message and it will appear here./i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('list', { name: /generated message history/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/No generated messages yet./i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_showAndManageGeneratedMessageHistory_when_multipleMessagesGenerated', async () => {
|
||||||
|
vi.mocked(tasksApi.generateTaskPreview)
|
||||||
|
.mockResolvedValueOnce('SUBJECT: First\nBODY:\nFirst output')
|
||||||
|
.mockResolvedValueOnce('SUBJECT: Second\nBODY:\nSecond output')
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await screen.findByRole('link', { name: /back to entity a/i })
|
||||||
|
|
||||||
|
const generateButton = screen.getByRole('button', { name: /generate test message/i })
|
||||||
|
|
||||||
|
fireEvent.click(generateButton)
|
||||||
|
await screen.findByText(/First output/i)
|
||||||
|
|
||||||
|
fireEvent.click(generateButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Second output/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const history = screen.getByRole('list', { name: /generated message history/i })
|
||||||
|
expect(history).toBeInTheDocument()
|
||||||
|
|
||||||
|
const firstMessageHistoryItem = screen.getByRole('button', { name: /^message #1$/i })
|
||||||
|
const secondMessageHistoryItem = screen.getByRole('button', { name: /^message #2$/i })
|
||||||
|
|
||||||
|
fireEvent.click(firstMessageHistoryItem)
|
||||||
|
expect(screen.getByText(/First output/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: /delete message #1/i,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(firstMessageHistoryItem).not.toBeInTheDocument()
|
||||||
|
expect(secondMessageHistoryItem).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
||||||
renderPage({ task: { ...mockTask, active: false } })
|
renderPage({ task: { ...mockTask, active: false } })
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,37 @@ describe('EntitiesPage', () => {
|
|||||||
fireEvent.click(addButton)
|
fireEvent.click(addButton)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText(/default email context window/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_submitDefaultContextWindow_when_createEntitySubmitted', async () => {
|
||||||
|
vi.mocked(entitiesApi.getEntities).mockResolvedValue([])
|
||||||
|
vi.mocked(entitiesApi.createEntity).mockResolvedValue(mockEntity)
|
||||||
|
|
||||||
|
render(<EntitiesPage />, { wrapper })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add|create|new/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('dialog', { name: /create entity/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/entity name/i), { target: { value: 'Test Entity' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/sender email/i), { target: { value: 'test@condado.com' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/job title/i), { target: { value: 'Tester' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/personality notes/i), { target: { value: 'Formal' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(entitiesApi.createEntity).toHaveBeenCalled()
|
||||||
|
expect(vi.mocked(entitiesApi.createEntity).mock.calls[0]?.[0]).toEqual({
|
||||||
|
name: 'Test Entity',
|
||||||
|
email: 'test@condado.com',
|
||||||
|
jobTitle: 'Tester',
|
||||||
|
personality: 'Formal',
|
||||||
|
contextWindowDays: 3,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
@@ -20,6 +20,84 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe('EntityDetailPage', () => {
|
describe('EntityDetailPage', () => {
|
||||||
|
it('should_updateEntity_when_editSubmittedFromDetailPage', async () => {
|
||||||
|
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||||
|
|
||||||
|
vi.mocked(entitiesApi.getEntity)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'entity-1',
|
||||||
|
name: 'Entity A',
|
||||||
|
email: 'a@a.com',
|
||||||
|
jobTitle: 'Ops',
|
||||||
|
personality: 'Formal',
|
||||||
|
scheduleCron: '0 9 * * 1',
|
||||||
|
contextWindowDays: 3,
|
||||||
|
active: true,
|
||||||
|
createdAt: '',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'entity-1',
|
||||||
|
name: 'Entity A Updated',
|
||||||
|
email: 'updated@a.com',
|
||||||
|
jobTitle: 'Operations Lead',
|
||||||
|
personality: 'Still formal',
|
||||||
|
scheduleCron: '0 9 * * 1',
|
||||||
|
contextWindowDays: 5,
|
||||||
|
active: true,
|
||||||
|
createdAt: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(entitiesApi.updateEntity).mockResolvedValue({
|
||||||
|
id: 'entity-1',
|
||||||
|
name: 'Entity A Updated',
|
||||||
|
email: 'updated@a.com',
|
||||||
|
jobTitle: 'Operations Lead',
|
||||||
|
personality: 'Still formal',
|
||||||
|
scheduleCron: '0 9 * * 1',
|
||||||
|
contextWindowDays: 5,
|
||||||
|
active: true,
|
||||||
|
createdAt: '',
|
||||||
|
})
|
||||||
|
vi.mocked(tasksApi.getTasksByEntity).mockResolvedValue([])
|
||||||
|
|
||||||
|
render(<EntityDetailPage />, { wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Entity A/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /edit entity/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('dialog', { name: /edit entity/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/entity name/i)).toHaveValue('Entity A')
|
||||||
|
expect(screen.getByLabelText(/sender email/i)).toHaveValue('a@a.com')
|
||||||
|
expect(screen.getByLabelText(/job title/i)).toHaveValue('Ops')
|
||||||
|
expect(screen.getByLabelText(/personality notes/i)).toHaveValue('Formal')
|
||||||
|
expect(screen.queryByLabelText(/default email context window/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/entity name/i), { target: { value: 'Entity A Updated' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/sender email/i), { target: { value: 'updated@a.com' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/job title/i), { target: { value: 'Operations Lead' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/personality notes/i), { target: { value: 'Still formal' } })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(entitiesApi.updateEntity).toHaveBeenCalledWith('entity-1', {
|
||||||
|
name: 'Entity A Updated',
|
||||||
|
email: 'updated@a.com',
|
||||||
|
jobTitle: 'Operations Lead',
|
||||||
|
personality: 'Still formal',
|
||||||
|
contextWindowDays: 3,
|
||||||
|
})
|
||||||
|
expect(screen.queryByRole('dialog', { name: /edit entity/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: /Entity A Updated/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Operations Lead - updated@a.com/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should_renderEntityAndTasks_when_pageLoads', async () => {
|
it('should_renderEntityAndTasks_when_pageLoads', async () => {
|
||||||
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week')
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function buildTaskPreviewPrompt(
|
|||||||
'',
|
'',
|
||||||
'INSTRUCTIONS',
|
'INSTRUCTIONS',
|
||||||
'- Write exactly one email message.',
|
'- Write exactly one email message.',
|
||||||
|
`- The message must be written by ${entity.name} (${entity.jobTitle}) as the sender persona.`,
|
||||||
'- Use an extremely formal corporate tone.',
|
'- Use an extremely formal corporate tone.',
|
||||||
'- Keep the content casual, mundane, and slightly nonsensical.',
|
'- Keep the content casual, mundane, and slightly nonsensical.',
|
||||||
'- Reflect the entity personality and task prompt faithfully.',
|
'- Reflect the entity personality and task prompt faithfully.',
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity } from '../api/entitiesApi'
|
||||||
import {
|
import {
|
||||||
buildTaskPreviewPrompt,
|
|
||||||
createTask,
|
createTask,
|
||||||
generateTaskPreview,
|
inactivateTask,
|
||||||
type EmailLookback,
|
type EmailLookback,
|
||||||
} from '../api/tasksApi'
|
} from '../api/tasksApi'
|
||||||
|
|
||||||
interface TaskFormState {
|
interface TaskFormState {
|
||||||
name: string
|
name: string
|
||||||
prompt: string
|
|
||||||
scheduleCron: string
|
scheduleCron: string
|
||||||
emailLookback: EmailLookback
|
emailLookback: EmailLookback
|
||||||
}
|
}
|
||||||
@@ -72,7 +70,6 @@ function buildCron(parts: CronParts): string {
|
|||||||
|
|
||||||
const DEFAULT_TASK_FORM: TaskFormState = {
|
const DEFAULT_TASK_FORM: TaskFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
prompt: '',
|
|
||||||
scheduleCron: buildCron(DEFAULT_CRON_PARTS),
|
scheduleCron: buildCron(DEFAULT_CRON_PARTS),
|
||||||
emailLookback: 'last_week',
|
emailLookback: 'last_week',
|
||||||
}
|
}
|
||||||
@@ -83,8 +80,6 @@ export default function CreateTaskPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||||
const [preview, setPreview] = useState('')
|
|
||||||
const [previewError, setPreviewError] = useState('')
|
|
||||||
|
|
||||||
const { data: entity, isLoading } = useQuery({
|
const { data: entity, isLoading } = useQuery({
|
||||||
queryKey: ['entity', entityId],
|
queryKey: ['entity', entityId],
|
||||||
@@ -94,51 +89,18 @@ export default function CreateTaskPage() {
|
|||||||
|
|
||||||
const createTaskMutation = useMutation({
|
const createTaskMutation = useMutation({
|
||||||
mutationFn: createTask,
|
mutationFn: createTask,
|
||||||
onSuccess: async () => {
|
onSuccess: async (createdTask) => {
|
||||||
|
await inactivateTask(createdTask.id)
|
||||||
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
|
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
|
||||||
navigate(`/entities/${entityId}`)
|
await queryClient.invalidateQueries({ queryKey: ['entity-task', createdTask.id] })
|
||||||
},
|
navigate(`/entities/${entityId}/tasks/${createdTask.id}`)
|
||||||
})
|
|
||||||
|
|
||||||
const previewMutation = useMutation({
|
|
||||||
mutationFn: generateTaskPreview,
|
|
||||||
onMutate: () => {
|
|
||||||
setPreview('')
|
|
||||||
setPreviewError('')
|
|
||||||
},
|
|
||||||
onSuccess: (value) => setPreview(value),
|
|
||||||
onError: (error) => {
|
|
||||||
setPreviewError(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Unable to generate a test message from the local model.'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0)
|
const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0)
|
||||||
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
return Boolean(taskForm.name.trim() && hasFilledCronParts)
|
||||||
}, [cronParts, taskForm.name, taskForm.prompt])
|
}, [cronParts, taskForm.name])
|
||||||
|
|
||||||
const currentTask = useMemo(
|
|
||||||
() => ({
|
|
||||||
entityId,
|
|
||||||
name: taskForm.name,
|
|
||||||
prompt: taskForm.prompt,
|
|
||||||
scheduleCron: taskForm.scheduleCron,
|
|
||||||
emailLookback: taskForm.emailLookback,
|
|
||||||
}),
|
|
||||||
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
|
|
||||||
)
|
|
||||||
|
|
||||||
const finalPrompt = useMemo(() => {
|
|
||||||
if (!entity) {
|
|
||||||
return 'Entity details unavailable.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildTaskPreviewPrompt(entity, currentTask)
|
|
||||||
}, [currentTask, entity])
|
|
||||||
|
|
||||||
const applyCronParts = (nextCronParts: CronParts) => {
|
const applyCronParts = (nextCronParts: CronParts) => {
|
||||||
setCronParts(nextCronParts)
|
setCronParts(nextCronParts)
|
||||||
@@ -186,7 +148,7 @@ export default function CreateTaskPage() {
|
|||||||
createTaskMutation.mutate({
|
createTaskMutation.mutate({
|
||||||
entityId,
|
entityId,
|
||||||
name: taskForm.name,
|
name: taskForm.name,
|
||||||
prompt: taskForm.prompt,
|
prompt: '',
|
||||||
scheduleCron: taskForm.scheduleCron,
|
scheduleCron: taskForm.scheduleCron,
|
||||||
emailLookback: taskForm.emailLookback,
|
emailLookback: taskForm.emailLookback,
|
||||||
})
|
})
|
||||||
@@ -205,21 +167,6 @@ export default function CreateTaskPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="task-prompt" className="text-sm font-medium text-slate-200">
|
|
||||||
Task Prompt
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="task-prompt"
|
|
||||||
value={taskForm.prompt}
|
|
||||||
onChange={(event) =>
|
|
||||||
setTaskForm((prev) => ({ ...prev, prompt: event.target.value }))
|
|
||||||
}
|
|
||||||
className="mt-1 min-h-36 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
|
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
|
||||||
Email Period
|
Email Period
|
||||||
@@ -340,47 +287,6 @@ export default function CreateTaskPage() {
|
|||||||
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
|
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-slate-800 bg-slate-900 p-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!canSubmit) return
|
|
||||||
if (!entity) return
|
|
||||||
previewMutation.mutate({ entity, task: currentTask })
|
|
||||||
}}
|
|
||||||
disabled={!canSubmit || previewMutation.isPending}
|
|
||||||
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
||||||
Final Prompt
|
|
||||||
</p>
|
|
||||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
||||||
{finalPrompt}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewError && (
|
|
||||||
<p role="alert" className="mt-4 text-sm text-red-300">
|
|
||||||
{previewError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{preview && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
||||||
Generated Message
|
|
||||||
</p>
|
|
||||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
||||||
{preview}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pb-8">
|
<div className="flex justify-end gap-3 pb-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity } from '../api/entitiesApi'
|
||||||
@@ -35,6 +35,12 @@ interface RegularitySuggestion {
|
|||||||
cronParts: CronParts
|
cronParts: CronParts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeneratedMessageItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CRON_PARTS: CronParts = {
|
const DEFAULT_CRON_PARTS: CronParts = {
|
||||||
minute: '0',
|
minute: '0',
|
||||||
hour: '9',
|
hour: '9',
|
||||||
@@ -115,8 +121,10 @@ export default function EditTaskPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
||||||
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
||||||
const [preview, setPreview] = useState('')
|
const [generatedMessages, setGeneratedMessages] = useState<GeneratedMessageItem[]>([])
|
||||||
|
const [selectedMessageId, setSelectedMessageId] = useState('')
|
||||||
const [previewError, setPreviewError] = useState('')
|
const [previewError, setPreviewError] = useState('')
|
||||||
|
const generatedMessageCounter = useRef(0)
|
||||||
|
|
||||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||||
queryKey: ['entity', entityId],
|
queryKey: ['entity', entityId],
|
||||||
@@ -187,10 +195,19 @@ export default function EditTaskPage() {
|
|||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: generateTaskPreview,
|
mutationFn: generateTaskPreview,
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
setPreview('')
|
|
||||||
setPreviewError('')
|
setPreviewError('')
|
||||||
},
|
},
|
||||||
onSuccess: (value) => setPreview(value),
|
onSuccess: (value) => {
|
||||||
|
generatedMessageCounter.current += 1
|
||||||
|
const nextMessage: GeneratedMessageItem = {
|
||||||
|
id: `message-${generatedMessageCounter.current}`,
|
||||||
|
label: `Message #${generatedMessageCounter.current}`,
|
||||||
|
content: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneratedMessages((prev) => [nextMessage, ...prev])
|
||||||
|
setSelectedMessageId(nextMessage.id)
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setPreviewError(
|
setPreviewError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@@ -224,6 +241,11 @@ export default function EditTaskPage() {
|
|||||||
return buildTaskPreviewPrompt(entity, currentTask)
|
return buildTaskPreviewPrompt(entity, currentTask)
|
||||||
}, [currentTask, entity])
|
}, [currentTask, entity])
|
||||||
|
|
||||||
|
const selectedMessage = useMemo(
|
||||||
|
() => generatedMessages.find((message) => message.id === selectedMessageId),
|
||||||
|
[generatedMessages, selectedMessageId]
|
||||||
|
)
|
||||||
|
|
||||||
const applyCronParts = (nextCronParts: CronParts) => {
|
const applyCronParts = (nextCronParts: CronParts) => {
|
||||||
setCronParts(nextCronParts)
|
setCronParts(nextCronParts)
|
||||||
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
||||||
@@ -256,7 +278,7 @@ export default function EditTaskPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
|
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
|
||||||
<div className="mx-auto max-w-2xl px-4 sm:px-6">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||||
<nav className="mb-6">
|
<nav className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
to={`/entities/${entityId}`}
|
to={`/entities/${entityId}`}
|
||||||
@@ -443,31 +465,80 @@ export default function EditTaskPage() {
|
|||||||
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
Final Prompt
|
Final Prompt
|
||||||
</p>
|
</p>
|
||||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
<pre className="mt-2 h-full min-h-56 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
{finalPrompt}
|
{finalPrompt}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Generated Message
|
||||||
|
</p>
|
||||||
|
<pre className="mt-2 min-h-56 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
||||||
|
{selectedMessage?.content ?? 'Generate a message and it will appear here.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Generated Message History
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
aria-label="Generated message history"
|
||||||
|
className="mt-2 space-y-2 rounded-md border border-slate-800 bg-slate-950 p-3"
|
||||||
|
>
|
||||||
|
{generatedMessages.length === 0 && (
|
||||||
|
<li className="text-xs text-slate-400">No generated messages yet.</li>
|
||||||
|
)}
|
||||||
|
{generatedMessages.map((message) => (
|
||||||
|
<li
|
||||||
|
key={message.id}
|
||||||
|
className="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900 p-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedMessageId(message.id)}
|
||||||
|
className="flex-1 text-left text-xs text-slate-200 hover:text-cyan-300"
|
||||||
|
aria-label={message.label}
|
||||||
|
>
|
||||||
|
<span className="block font-medium">{message.label}</span>
|
||||||
|
<span className="mt-1 block line-clamp-2 text-slate-400">
|
||||||
|
{message.content.split('\n')[0]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setGeneratedMessages((prev) => {
|
||||||
|
const nextMessages = prev.filter((item) => item.id !== message.id)
|
||||||
|
if (selectedMessageId === message.id) {
|
||||||
|
setSelectedMessageId(nextMessages[0]?.id ?? '')
|
||||||
|
}
|
||||||
|
return nextMessages
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="rounded border border-red-500/40 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-red-300 hover:bg-red-500/10"
|
||||||
|
aria-label={`Delete ${message.label.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{previewError && (
|
{previewError && (
|
||||||
<p role="alert" className="mt-4 text-sm text-red-300">
|
<p role="alert" className="mt-4 text-sm text-red-300">
|
||||||
{previewError}
|
{previewError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{preview && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
||||||
Generated Message
|
|
||||||
</p>
|
|
||||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
||||||
{preview}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pb-8">
|
<div className="flex justify-end gap-3 pb-8">
|
||||||
|
|||||||
@@ -8,25 +8,29 @@ import {
|
|||||||
VirtualEntityCreateDto,
|
VirtualEntityCreateDto,
|
||||||
} from '../api/entitiesApi'
|
} from '../api/entitiesApi'
|
||||||
|
|
||||||
|
const DEFAULT_CONTEXT_WINDOW_DAYS = 3
|
||||||
|
|
||||||
|
const initialFormState: VirtualEntityCreateDto = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
jobTitle: '',
|
||||||
|
personality: '',
|
||||||
|
contextWindowDays: DEFAULT_CONTEXT_WINDOW_DAYS,
|
||||||
|
}
|
||||||
|
|
||||||
export default function EntitiesPage() {
|
export default function EntitiesPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
|
const { data: entities = [] } = useQuery({ queryKey: ['entities'], queryFn: getEntities })
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [form, setForm] = useState<VirtualEntityCreateDto>({
|
const [form, setForm] = useState<VirtualEntityCreateDto>(initialFormState)
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
jobTitle: '',
|
|
||||||
personality: '',
|
|
||||||
contextWindowDays: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: createEntity,
|
mutationFn: createEntity,
|
||||||
onSuccess: (createdEntity) => {
|
onSuccess: (createdEntity) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['entities'] })
|
queryClient.invalidateQueries({ queryKey: ['entities'] })
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
setForm({ name: '', email: '', jobTitle: '', personality: '', contextWindowDays: 3 })
|
setForm(initialFormState)
|
||||||
navigate(`/entities/${createdEntity.id}`)
|
navigate(`/entities/${createdEntity.id}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -140,20 +144,6 @@ export default function EntitiesPage() {
|
|||||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label htmlFor="entity-context-window" className="mb-1 block text-sm font-medium text-slate-200">
|
|
||||||
Default Email Context Window (days)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="entity-context-window"
|
|
||||||
type="number"
|
|
||||||
value={form.contextWindowDays}
|
|
||||||
onChange={(e) => setForm({ ...form, contextWindowDays: Number(e.target.value) })}
|
|
||||||
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
||||||
min={1}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity, updateEntity, VirtualEntityCreateDto, VirtualEntityResponse } from '../api/entitiesApi'
|
||||||
import { getEmailLookbackLabel, getTasksByEntity } from '../api/tasksApi'
|
import { getEmailLookbackLabel, getTasksByEntity } from '../api/tasksApi'
|
||||||
|
|
||||||
|
function toEntityForm(entity: VirtualEntityResponse): VirtualEntityCreateDto {
|
||||||
|
return {
|
||||||
|
name: entity.name,
|
||||||
|
email: entity.email,
|
||||||
|
jobTitle: entity.jobTitle,
|
||||||
|
personality: entity.personality ?? '',
|
||||||
|
contextWindowDays: entity.contextWindowDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function EntityDetailPage() {
|
export default function EntityDetailPage() {
|
||||||
const { entityId = '' } = useParams()
|
const { entityId = '' } = useParams()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<VirtualEntityCreateDto | null>(null)
|
||||||
|
|
||||||
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
const { data: entity, isLoading: isLoadingEntity } = useQuery({
|
||||||
queryKey: ['entity', entityId],
|
queryKey: ['entity', entityId],
|
||||||
@@ -18,6 +32,18 @@ export default function EntityDetailPage() {
|
|||||||
enabled: Boolean(entityId),
|
enabled: Boolean(entityId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: VirtualEntityCreateDto) => updateEntity(entityId, data),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['entity', entityId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['entities'] }),
|
||||||
|
])
|
||||||
|
setDialogOpen(false)
|
||||||
|
setForm(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (!entityId) {
|
if (!entityId) {
|
||||||
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
||||||
}
|
}
|
||||||
@@ -49,6 +75,17 @@ export default function EntityDetailPage() {
|
|||||||
{entity.jobTitle} - {entity.email}
|
{entity.jobTitle} - {entity.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(toEntityForm(entity))
|
||||||
|
setDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-slate-700 px-4 py-2 text-sm font-semibold text-slate-100 hover:border-cyan-500 hover:text-cyan-300"
|
||||||
|
>
|
||||||
|
Edit Entity
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to={`/entities/${entityId}/tasks/new`}
|
to={`/entities/${entityId}/tasks/new`}
|
||||||
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"
|
||||||
@@ -56,6 +93,7 @@ export default function EntityDetailPage() {
|
|||||||
New Task
|
New Task
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold text-slate-100">Scheduled Tasks</h2>
|
<h2 className="text-lg font-semibold text-slate-100">Scheduled Tasks</h2>
|
||||||
@@ -100,6 +138,93 @@ export default function EntityDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{dialogOpen && form && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Edit Entity"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-slate-800 bg-slate-950 p-6 shadow-lg">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-slate-100">Edit Entity</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
updateMutation.mutate(form)
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="entity-name" className="mb-1 block text-sm font-medium text-slate-200">
|
||||||
|
Entity Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entity-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="entity-email" className="mb-1 block text-sm font-medium text-slate-200">
|
||||||
|
Sender Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entity-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="entity-job-title" className="mb-1 block text-sm font-medium text-slate-200">
|
||||||
|
Job Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="entity-job-title"
|
||||||
|
value={form.jobTitle}
|
||||||
|
onChange={(e) => setForm({ ...form, jobTitle: e.target.value })}
|
||||||
|
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="entity-personality" className="mb-1 block text-sm font-medium text-slate-200">
|
||||||
|
Personality Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="entity-personality"
|
||||||
|
value={form.personality}
|
||||||
|
onChange={(e) => setForm({ ...form, personality: e.target.value })}
|
||||||
|
className="block w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setForm(null)
|
||||||
|
}}
|
||||||
|
className="rounded border border-slate-700 px-4 py-2 text-sm text-slate-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-cyan-500 px-4 py-2 text-sm font-medium text-slate-950 hover:bg-cyan-400"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user