Fix test failures and workload/websocket behavior

This commit is contained in:
beabigegg
2026-01-11 08:37:21 +08:00
parent 3bdc6ff1c9
commit f5f870da56
49 changed files with 3006 additions and 1132 deletions

View File

@@ -0,0 +1,252 @@
import path from 'path'
import { fileURLToPath } from 'url'
import { expect, test, Page } from 'playwright/test'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const runId = new Date().toISOString().replace(/[-:.TZ]/g, '')
const spaceName = `E2E Space ${runId}`
const projectName = `E2E Project ${runId}`
const taskOneTitle = `E2E Task One ${runId}`
const taskTwoTitle = `E2E Task Two ${runId}`
const subtaskTitle = `E2E Subtask ${runId}`
const commentText = `E2E Comment ${runId}`
const customNumberField = `sp_${runId}`
const customFormulaField = `double_sp_${runId}`
const attachmentPath = path.join(__dirname, 'fixtures', 'attachment.txt')
const formatDateInput = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const setEnglishLocale = async (page: Page) => {
await page.addInitScript(() => {
localStorage.setItem('i18nextLng', 'en')
})
}
const openProjectFromSpaces = async (page: Page) => {
await page.goto('/spaces')
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
await page.getByRole('button', { name: `Projects: ${projectName}` }).click()
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible()
}
test.describe.serial('admin e2e flow', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
test('login and dashboard', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: /Welcome back/i })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Quick Actions' })).toBeVisible()
})
test('create space and project', async ({ page }) => {
await page.goto('/spaces')
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
await page.getByRole('button', { name: /Create Space/i }).click()
const spaceModal = page.getByRole('dialog', { name: 'Create Space' })
await spaceModal.getByLabel('Name').fill(spaceName)
await spaceModal.getByLabel('Description').fill('E2E space description')
await spaceModal.getByRole('button', { name: 'Create' }).click()
await expect(page.getByRole('button', { name: `Spaces: ${spaceName}` })).toBeVisible()
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
await page.getByRole('button', { name: /New Project/i }).click()
const projectModal = page.getByRole('dialog', { name: 'Create Project' })
await projectModal.getByText('Blank Project', { exact: true }).click()
await projectModal.locator('#project-title').fill(projectName)
await projectModal.locator('#project-description').fill('E2E project description')
await projectModal.getByRole('button', { name: 'Create' }).click()
await expect(page.getByRole('button', { name: `Projects: ${projectName}` })).toBeVisible()
await page.getByRole('button', { name: `Projects: ${projectName}` }).click()
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible()
})
test('project settings: members and custom fields', async ({ page }) => {
await openProjectFromSpaces(page)
await page.getByRole('button', { name: 'Settings' }).click()
await expect(page.getByRole('heading', { name: 'Project Settings' })).toBeVisible()
await page.getByRole('button', { name: 'Members' }).click()
await expect(page.getByText('Member Management')).toBeVisible()
await expect(page.getByText('User')).toBeVisible()
await page.getByRole('button', { name: 'Custom Fields' }).click()
await expect(page.getByRole('button', { name: /Add Field/i })).toBeVisible()
await page.getByRole('button', { name: /Add Field/i }).click()
const customModal = page.getByRole('dialog', { name: 'Create Field' })
await customModal.getByPlaceholder('e.g., Story Points, Sprint Number').fill(customNumberField)
await customModal.getByText('Number', { exact: true }).click()
await customModal.getByRole('button', { name: 'Create Field' }).click()
await expect(page.getByText(customNumberField)).toBeVisible()
await page.getByRole('button', { name: /Add Field/i }).click()
const formulaModal = page.getByRole('dialog', { name: 'Create Field' })
await formulaModal.getByPlaceholder('e.g., Story Points, Sprint Number').fill(customFormulaField)
await formulaModal.getByText('Formula', { exact: true }).click()
await formulaModal.getByPlaceholder('e.g., {time_spent} / {original_estimate} * 100')
.fill(`{${customNumberField}} * 2`)
await formulaModal.getByRole('button', { name: 'Create Field' }).click()
await expect(page.getByText(customFormulaField)).toBeVisible()
})
test('tasks flow: create, views, detail, attachments, comments, subtasks, dependencies', async ({ page }) => {
await openProjectFromSpaces(page)
const today = new Date()
const startDate = formatDateInput(today)
const dueDate = formatDateInput(new Date(today.getTime() + 3 * 24 * 60 * 60 * 1000))
await page.getByRole('button', { name: /Create Task/i }).click()
const createModal = page.getByRole('dialog', { name: 'Create Task' })
await createModal.locator('#task-title').fill(taskOneTitle)
await createModal.locator('#task-description').fill('E2E task description')
await createModal.locator('select').first().selectOption('low')
const dateInputs = createModal.locator('input[type="date"]')
await dateInputs.nth(0).fill(startDate)
await dateInputs.nth(1).fill(dueDate)
const customFieldContainer = createModal.locator('label', { hasText: customNumberField }).locator('..')
await customFieldContainer.locator('input[type="number"]').fill('5')
await createModal.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText(taskOneTitle)).toBeVisible()
await page.getByRole('button', { name: /Create Task/i }).click()
const createModalTwo = page.getByRole('dialog', { name: 'Create Task' })
await createModalTwo.locator('#task-title').fill(taskTwoTitle)
await createModalTwo.locator('#task-description').fill('E2E task description 2')
const dateInputsTwo = createModalTwo.locator('input[type="date"]')
await dateInputsTwo.nth(0).fill(startDate)
await dateInputsTwo.nth(1).fill(dueDate)
await createModalTwo.getByRole('button', { name: 'Create' }).click()
await expect(page.getByText(taskTwoTitle)).toBeVisible()
await expect(page.getByText(`${customNumberField}: 5`, { exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Kanban' }).click()
await expect(page.getByText(taskOneTitle)).toBeVisible()
await page.getByRole('button', { name: 'Calendar' }).click()
await expect(page.getByText(new RegExp(taskOneTitle))).toBeVisible()
await page.getByRole('button', { name: 'Gantt' }).click()
await expect(page.getByText('Task Dependencies')).toBeVisible()
await page.getByRole('button', { name: 'Manage Dependencies' }).first().click()
const depsHeading = page.getByRole('heading', {
name: /Manage Dependencies for/i,
})
await expect(depsHeading).toBeVisible()
const depsDialog = depsHeading.locator('..')
const dependencySelect = depsDialog.locator('select').first()
await dependencySelect.selectOption({ index: 1 })
await depsDialog.getByRole('button', { name: 'Add Dependency' }).click()
await expect(page.locator('text=Depends on:').first()).toBeVisible()
await page.getByRole('button', { name: 'List' }).click()
await page.getByText(taskOneTitle).first().click()
const taskModal = page.getByRole('dialog', { name: taskOneTitle })
await expect(taskModal.getByRole('heading', { name: taskOneTitle })).toBeVisible()
await page.getByRole('button', { name: 'Edit' }).click()
const descriptionField = page.locator('label', { hasText: 'Description' }).locator('..').locator('textarea')
await descriptionField.fill('Updated task description')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Updated task description')).toBeVisible()
await page.getByPlaceholder('Add a comment... Use @name to mention someone').fill(commentText)
await page.getByRole('button', { name: 'Post Comment' }).click()
await expect(page.getByText(commentText)).toBeVisible()
await page.locator('input[type="file"]').setInputFiles(attachmentPath)
await expect(page.getByText('attachment.txt')).toBeVisible()
await page.getByRole('button', { name: /Add Subtask/i }).click()
await page.locator('#new-subtask-title').fill(subtaskTitle)
await page.getByRole('button', { name: 'Add' }).click()
await expect(page.getByText(subtaskTitle)).toBeVisible()
await page.getByLabel('Close').click()
})
test('notifications and my settings', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: 'Notifications' }).click()
await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible()
await page.goto('/my-settings')
await expect(page.getByRole('heading', { name: 'My Settings' })).toBeVisible()
const capacityInput = page.locator('input[type="number"]').first()
const currentCapacity = await capacityInput.inputValue()
const nextCapacity = String((Number(currentCapacity) || 40) + 1)
await capacityInput.fill(nextCapacity)
await page.getByRole('button', { name: 'Save' }).click()
await capacityInput.fill(currentCapacity || '40')
await page.getByRole('button', { name: 'Save' }).click()
const weeklyCard = page.getByRole('heading', { name: 'Weekly Report' }).locator('..')
const subscriptionToggle = weeklyCard.locator('input[type="checkbox"]')
await expect(subscriptionToggle).toBeEnabled()
const isChecked = await subscriptionToggle.isChecked()
await subscriptionToggle.click()
if (isChecked) {
await expect(subscriptionToggle).not.toBeChecked()
} else {
await expect(subscriptionToggle).toBeChecked()
}
await subscriptionToggle.click()
})
test('workload, project health, audit pages', async ({ page }) => {
await page.goto('/workload')
await expect(page.getByRole('heading', { name: 'Workload' })).toBeVisible()
await page.getByRole('button', { name: 'Next week' }).click()
await page.goto('/project-health')
await expect(page.getByRole('heading', { name: 'Project Health Dashboard' })).toBeVisible()
await page.getByLabel('Sort by:').selectOption('name')
await page.goto('/audit')
await expect(page.getByRole('heading', { name: 'Audit Log' })).toBeVisible()
})
test('cleanup: delete project and space', async ({ page }) => {
await page.goto('/spaces')
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
const projectCard = page.getByRole('button', { name: `Projects: ${projectName}` })
await projectCard.getByRole('button', { name: 'Delete Project' }).click()
const deleteProjectModal = page.getByRole('dialog', { name: 'Delete Project' })
await deleteProjectModal.getByRole('button', { name: 'Delete' }).click()
await expect(page.getByRole('button', { name: `Projects: ${projectName}` })).toHaveCount(0)
await page.goto('/spaces')
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
const spaceCard = page.getByRole('button', { name: `Spaces: ${spaceName}` })
await spaceCard.getByRole('button', { name: 'Delete Space' }).click()
const deleteSpaceModal = page.getByRole('dialog', { name: 'Delete Space' })
await deleteSpaceModal.getByRole('button', { name: 'Delete' }).click()
await expect(page.getByRole('button', { name: `Spaces: ${spaceName}` })).toHaveCount(0)
})
})

View File

@@ -0,0 +1 @@
Playwright attachment fixture for Project Control e2e tests.

View File

@@ -0,0 +1,43 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { chromium, FullConfig } from 'playwright/test'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const storageStatePath = path.join(__dirname, '.auth', 'admin.json')
const baseUrl = process.env.E2E_BASE_URL || 'http://localhost:3000'
const requireEnv = (name: string) => {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required env var: ${name}`)
}
return value
}
async function globalSetup(_config: FullConfig) {
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true })
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.addInitScript(() => {
localStorage.setItem('i18nextLng', 'en')
})
const email = requireEnv('E2E_EMAIL')
const password = requireEnv('E2E_PASSWORD')
await page.goto(`${baseUrl}/login`)
await page.getByLabel('Email').fill(email)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.getByRole('button', { name: 'Logout' }).waitFor()
await context.storageState({ path: storageStatePath })
await browser.close()
}
export default globalSetup

View File

@@ -0,0 +1,6 @@
import { expect, test } from 'playwright/test';
test('smoke: basic rendering works', async ({ page }) => {
await page.setContent('<main><h1>Smoke</h1><p>Playwright ready</p></main>');
await expect(page.getByRole('heading', { name: 'Smoke' })).toBeVisible();
});

View File

@@ -31,6 +31,7 @@
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^27.4.0",
"playwright": "^1.57.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^4.0.16"
@@ -2787,6 +2788,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -35,6 +35,7 @@
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^27.4.0",
"playwright": "^1.57.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^4.0.16"

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'playwright/test';
export default defineConfig({
testDir: 'e2e',
testMatch: '**/*.spec.ts',
outputDir: 'test-results/playwright',
globalSetup: './e2e/global-setup.ts',
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: [['list']],
workers: 1,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
storageState: 'e2e/.auth/admin.json',
headless: true,
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
});

View File

@@ -15,7 +15,91 @@
"capacityHelp": "Recommended: 40 hours (standard work week). Maximum: 168 hours (total hours in a week).",
"capacitySaved": "Capacity settings saved",
"capacityError": "Failed to save capacity settings",
"capacityInvalid": "Please enter a valid number of hours (0-168)"
"capacityInvalid": "Please enter a valid number of hours (0-168)",
"weeklyReportTitle": "Weekly Report",
"weeklyReportDescription": "Subscribe to receive a weekly in-app report for projects you own or are a member of.",
"weeklyReportSubscribe": "Weekly report subscription",
"weeklyReportEnabled": "Weekly report subscription enabled",
"weeklyReportDisabled": "Weekly report subscription disabled",
"weeklyReportError": "Failed to update weekly report subscription"
},
"triggers": {
"descriptionOptional": "Description (optional)",
"conditions": "Conditions",
"conditionsHint": "All conditions must match (AND).",
"field": "Field",
"operator": "Operator",
"value": "Value",
"addCondition": "Add condition",
"and": "AND",
"action": "Action",
"notify": "Notify",
"templateLabel": "Message template (optional)",
"templateHint": "Variables: {task_title}, {old_value}, {new_value}",
"selectCustomField": "Select custom field",
"saving": "Saving...",
"update": "Update",
"enable": "Enable",
"disable": "Disable",
"retry": "Retry",
"empty": "No triggers configured for this project.",
"when": "When",
"then": "Then",
"deleteTitle": "Delete Trigger",
"deleteMessage": "Are you sure you want to delete this trigger? This action cannot be undone.",
"fields": {
"status": "Status",
"assignee": "Assignee",
"priority": "Priority",
"startDate": "Start date",
"dueDate": "Due date",
"customField": "Custom field"
},
"operators": {
"equals": "equals",
"notEquals": "does not equal",
"changedTo": "changes to",
"changedFrom": "changes from",
"before": "before",
"after": "after",
"in": "in"
},
"targets": {
"assignee": "Task Assignee",
"creator": "Task Creator",
"projectOwner": "Project Owner",
"projectMembers": "Project Members",
"user": "Specific user",
"department": "Department",
"role": "Role"
},
"placeholders": {
"value": "Enter value",
"list": "Comma-separated values",
"user": "User ID",
"department": "Department ID",
"role": "Role name"
},
"range": {
"start": "Start",
"end": "End"
},
"toasts": {
"enabled": "Trigger enabled",
"disabled": "Trigger disabled",
"deleteSuccess": "Trigger deleted successfully"
},
"errors": {
"saveFailed": "Failed to save trigger",
"loadFailed": "Failed to load triggers",
"updateFailed": "Failed to update trigger",
"deleteFailed": "Failed to delete trigger",
"missingCustomField": "Please select a custom field",
"missingValue": "Please enter a value",
"missingDateRange": "Please select a start and end date",
"missingTargetValue": "Please enter a target value",
"missingConditions": "Please add at least one condition"
}
},
"tabs": {
"general": "General",

View File

@@ -15,7 +15,91 @@
"capacityHelp": "建議值40 小時標準工時。最大值168 小時(一週總時數)。",
"capacitySaved": "容量設定已儲存",
"capacityError": "儲存容量設定失敗",
"capacityInvalid": "請輸入有效的時數0-168"
"capacityInvalid": "請輸入有效的時數0-168",
"weeklyReportTitle": "每週報告",
"weeklyReportDescription": "訂閱後將收到你所擁有或參與專案的每週站內報告。",
"weeklyReportSubscribe": "每週報告訂閱",
"weeklyReportEnabled": "已開啟每週報告訂閱",
"weeklyReportDisabled": "已關閉每週報告訂閱",
"weeklyReportError": "更新每週報告訂閱失敗"
},
"triggers": {
"descriptionOptional": "描述(選填)",
"conditions": "觸發條件",
"conditionsHint": "所有條件需同時成立AND。",
"field": "欄位",
"operator": "運算子",
"value": "值",
"addCondition": "新增條件",
"and": "且",
"action": "動作",
"notify": "通知",
"templateLabel": "訊息模板(選填)",
"templateHint": "可用變數:{task_title}, {old_value}, {new_value}",
"selectCustomField": "選擇自訂欄位",
"saving": "儲存中...",
"update": "更新",
"enable": "啟用",
"disable": "停用",
"retry": "重試",
"empty": "此專案尚未設定觸發器。",
"when": "當",
"then": "則",
"deleteTitle": "刪除觸發器",
"deleteMessage": "確定要刪除此觸發器嗎?此操作無法復原。",
"fields": {
"status": "狀態",
"assignee": "負責人",
"priority": "優先度",
"startDate": "開始日期",
"dueDate": "截止日期",
"customField": "自訂欄位"
},
"operators": {
"equals": "等於",
"notEquals": "不等於",
"changedTo": "變更為",
"changedFrom": "變更自",
"before": "早於",
"after": "晚於",
"in": "包含於"
},
"targets": {
"assignee": "任務負責人",
"creator": "任務建立者",
"projectOwner": "專案負責人",
"projectMembers": "專案成員",
"user": "指定使用者",
"department": "部門",
"role": "角色"
},
"placeholders": {
"value": "輸入值",
"list": "以逗號分隔",
"user": "使用者 ID",
"department": "部門 ID",
"role": "角色名稱"
},
"range": {
"start": "開始",
"end": "結束"
},
"toasts": {
"enabled": "觸發器已啟用",
"disabled": "觸發器已停用",
"deleteSuccess": "觸發器已刪除"
},
"errors": {
"saveFailed": "儲存觸發器失敗",
"loadFailed": "載入觸發器失敗",
"updateFailed": "更新觸發器失敗",
"deleteFailed": "刪除觸發器失敗",
"missingCustomField": "請選擇自訂欄位",
"missingValue": "請輸入值",
"missingDateRange": "請選擇開始與結束日期",
"missingTargetValue": "請輸入通知對象",
"missingConditions": "請至少新增一個條件"
}
},
"tabs": {
"general": "一般",

View File

@@ -1,5 +1,14 @@
import { useState, useEffect } from 'react'
import { triggersApi, Trigger, TriggerCreate, TriggerCondition, TriggerAction } from '../services/triggers'
import { useTranslation } from 'react-i18next'
import {
triggersApi,
Trigger,
TriggerCreate,
TriggerCondition,
TriggerAction,
TriggerConditionRule,
} from '../services/triggers'
import { customFieldsApi, CustomField } from '../services/customFields'
interface TriggerFormProps {
projectId: string
@@ -8,40 +17,289 @@ interface TriggerFormProps {
onCancel: () => void
}
type RuleState = {
id: string
field: string
operator: string
value: string
field_id?: string
range_start?: string
range_end?: string
}
const createRuleId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
const createDefaultRule = (): RuleState => ({
id: createRuleId(),
field: 'status_id',
operator: 'changed_to',
value: '',
})
const parseRuleValue = (rule: TriggerConditionRule): Pick<RuleState, 'value' | 'range_start' | 'range_end'> => {
if (rule.operator === 'in') {
if (rule.value && typeof rule.value === 'object' && !Array.isArray(rule.value)) {
const range = rule.value as { start?: string; end?: string }
return {
value: '',
range_start: range.start ? String(range.start) : '',
range_end: range.end ? String(range.end) : '',
}
}
if (Array.isArray(rule.value)) {
return {
value: rule.value.map(item => String(item)).join(', '),
}
}
}
return {
value: rule.value != null ? String(rule.value) : '',
}
}
export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFormProps) {
const { t } = useTranslation(['settings', 'common'])
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [field, setField] = useState('status_id')
const [operator, setOperator] = useState('changed_to')
const [value, setValue] = useState('')
const [target, setTarget] = useState('assignee')
const [rules, setRules] = useState<RuleState[]>([createDefaultRule()])
const [targetType, setTargetType] = useState('assignee')
const [targetValue, setTargetValue] = useState('')
const [template, setTemplate] = useState('')
const [isActive, setIsActive] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [customFields, setCustomFields] = useState<CustomField[]>([])
useEffect(() => {
if (trigger) {
setName(trigger.name)
setDescription(trigger.description || '')
setField(trigger.conditions.field)
setOperator(trigger.conditions.operator)
setValue(trigger.conditions.value)
if (trigger.actions.length > 0) {
setTarget(trigger.actions[0].target)
setTemplate(trigger.actions[0].template || '')
const fetchCustomFields = async () => {
try {
const response = await customFieldsApi.getCustomFields(projectId)
setCustomFields(response.fields)
} catch {
setCustomFields([])
}
}
if (projectId) {
fetchCustomFields()
}
}, [projectId])
useEffect(() => {
if (!trigger) return
setName(trigger.name)
setDescription(trigger.description || '')
setIsActive(trigger.is_active)
const triggerConditions = trigger.conditions
const conditionRules: TriggerConditionRule[] = Array.isArray(triggerConditions?.rules) && triggerConditions.rules.length > 0
? triggerConditions.rules
: triggerConditions?.field
? [{
field: triggerConditions.field,
operator: triggerConditions.operator || 'changed_to',
value: triggerConditions.value,
field_id: triggerConditions.field_id,
}]
: []
setRules(
conditionRules.length > 0
? conditionRules.map(rule => ({
id: createRuleId(),
field: rule.field,
operator: rule.operator,
field_id: rule.field_id,
...parseRuleValue(rule),
}))
: [createDefaultRule()]
)
if (trigger.actions.length > 0) {
const target = trigger.actions[0].target
const templateValue = trigger.actions[0].template || ''
setTemplate(templateValue)
if (target.startsWith('user:')) {
setTargetType('user')
setTargetValue(target.split(':')[1] || '')
} else if (target.startsWith('department:')) {
setTargetType('department')
setTargetValue(target.split(':')[1] || '')
} else if (target.startsWith('role:')) {
setTargetType('role')
setTargetValue(target.split(':')[1] || '')
} else {
setTargetType(target)
setTargetValue('')
}
setIsActive(trigger.is_active)
}
}, [trigger])
useEffect(() => {
if (customFields.length === 0) return
setRules(prev => prev.map(rule => {
if (rule.field === 'custom_fields' && !rule.field_id) {
return { ...rule, field_id: customFields[0]?.id }
}
return rule
}))
}, [customFields])
const getCustomField = (fieldId?: string) => customFields.find(field => field.id === fieldId)
const getFieldType = (rule: RuleState) => {
if (rule.field === 'start_date' || rule.field === 'due_date') {
return 'date'
}
if (rule.field === 'custom_fields') {
const field = getCustomField(rule.field_id)
if (!field) return 'text'
if (field.field_type === 'formula') return 'number'
return field.field_type
}
return 'text'
}
const getOperatorOptions = (fieldType: string) => {
const operators = ['changed_to', 'changed_from', 'equals', 'not_equals', 'in']
if (fieldType === 'date' || fieldType === 'number') {
operators.push('before', 'after')
}
return operators
}
const updateRule = (ruleId: string, updates: Partial<RuleState>) => {
setRules(prev => prev.map(rule => rule.id === ruleId ? { ...rule, ...updates } : rule))
}
const handleFieldChange = (rule: RuleState, field: string) => {
const fieldId = field === 'custom_fields' ? (rule.field_id || customFields[0]?.id) : undefined
const updatedRule = {
...rule,
field,
field_id: fieldId,
value: '',
range_start: '',
range_end: '',
}
const fieldType = getFieldType(updatedRule)
const operators = getOperatorOptions(fieldType)
const operator = operators.includes(updatedRule.operator) ? updatedRule.operator : operators[0]
updateRule(rule.id, { ...updatedRule, operator })
}
const handleCustomFieldChange = (rule: RuleState, fieldId: string) => {
const updatedRule = { ...rule, field_id: fieldId }
const fieldType = getFieldType(updatedRule)
const operators = getOperatorOptions(fieldType)
const operator = operators.includes(updatedRule.operator) ? updatedRule.operator : operators[0]
updateRule(rule.id, { field_id: fieldId, operator })
}
const buildRulePayload = (rule: RuleState): TriggerConditionRule | null => {
const fieldType = getFieldType(rule)
let value: unknown = rule.value
if (rule.operator === 'in') {
if (fieldType === 'date') {
value = {
start: rule.range_start || '',
end: rule.range_end || '',
}
} else {
value = rule.value
.split(',')
.map(item => item.trim())
.filter(Boolean)
}
} else if (fieldType === 'number') {
const numericValue = Number(rule.value)
value = rule.value !== '' && !Number.isNaN(numericValue) ? numericValue : rule.value
}
return {
field: rule.field,
operator: rule.operator,
value,
field_id: rule.field === 'custom_fields' ? rule.field_id : undefined,
}
}
const resolveTarget = () => {
if (['user', 'department', 'role'].includes(targetType)) {
const trimmed = targetValue.trim()
if (!trimmed) {
return null
}
return `${targetType}:${trimmed}`
}
return targetType
}
const validateRules = () => {
if (rules.length === 0) {
setError(t('settings:triggers.errors.missingConditions'))
return false
}
for (const rule of rules) {
const fieldType = getFieldType(rule)
if (rule.field === 'custom_fields' && !rule.field_id) {
setError(t('settings:triggers.errors.missingCustomField'))
return false
}
if (rule.operator === 'in' && fieldType === 'date') {
if (!rule.range_start || !rule.range_end) {
setError(t('settings:triggers.errors.missingDateRange'))
return false
}
} else if (rule.operator === 'in') {
if (!rule.value.trim()) {
setError(t('settings:triggers.errors.missingValue'))
return false
}
} else if (!rule.value.trim()) {
setError(t('settings:triggers.errors.missingValue'))
return false
}
}
const resolvedTarget = resolveTarget()
if (!resolvedTarget) {
setError(t('settings:triggers.errors.missingTargetValue'))
return false
}
return true
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!validateRules()) {
return
}
setLoading(true)
const conditions: TriggerCondition = { field, operator, value }
const actions: TriggerAction[] = [{ type: 'notify', target, template: template || undefined }]
const rulePayloads = rules
.map(buildRulePayload)
.filter((rule): rule is TriggerConditionRule => rule !== null)
const conditions: TriggerCondition = rulePayloads.length > 1
? { logic: 'and', rules: rulePayloads }
: rulePayloads[0]
const resolvedTarget = resolveTarget() || 'assignee'
const actions: TriggerAction[] = [{ type: 'notify', target: resolvedTarget, template: template || undefined }]
try {
if (trigger) {
@@ -65,16 +323,96 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
}
onSave()
} catch {
setError('Failed to save trigger')
setError(t('settings:triggers.errors.saveFailed'))
} finally {
setLoading(false)
}
}
const operatorLabel = (operator: string) => {
const keyMap: Record<string, string> = {
equals: 'equals',
not_equals: 'notEquals',
changed_to: 'changedTo',
changed_from: 'changedFrom',
before: 'before',
after: 'after',
in: 'in',
}
return t(`settings:triggers.operators.${keyMap[operator] || operator}`)
}
const fieldLabel = (field: string) => {
const keyMap: Record<string, string> = {
status_id: 'status',
assignee_id: 'assignee',
priority: 'priority',
start_date: 'startDate',
due_date: 'dueDate',
custom_fields: 'customField',
}
return t(`settings:triggers.fields.${keyMap[field] || field}`)
}
const renderValueInput = (rule: RuleState) => {
const fieldType = getFieldType(rule)
if (rule.operator === 'in' && fieldType === 'date') {
return (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500">{t('settings:triggers.range.start')}</label>
<input
type="date"
value={rule.range_start || ''}
onChange={e => updateRule(rule.id, { range_start: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs text-gray-500">{t('settings:triggers.range.end')}</label>
<input
type="date"
value={rule.range_end || ''}
onChange={e => updateRule(rule.id, { range_end: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
)
}
if (fieldType === 'date') {
return (
<input
type="date"
value={rule.value}
onChange={e => updateRule(rule.id, { value: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
/>
)
}
const inputType = fieldType === 'number' ? 'number' : 'text'
const placeholder = rule.operator === 'in'
? t('settings:triggers.placeholders.list')
: t('settings:triggers.placeholders.value')
return (
<input
type={inputType}
value={rule.value}
onChange={e => updateRule(rule.id, { value: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder={placeholder}
/>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<label className="block text-sm font-medium text-gray-700">{t('common:labels.name')}</label>
<input
type="text"
value={name}
@@ -85,7 +423,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
<label className="block text-sm font-medium text-gray-700">{t('settings:triggers.descriptionOptional')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
@@ -95,70 +433,128 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
</div>
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Condition</legend>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm text-gray-600">Field</label>
<select
value={field}
onChange={e => setField(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="status_id">Status</option>
<option value="assignee_id">Assignee</option>
<option value="priority">Priority</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Operator</label>
<select
value={operator}
onChange={e => setOperator(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="changed_to">Changes to</option>
<option value="changed_from">Changes from</option>
<option value="equals">Equals</option>
<option value="not_equals">Not equals</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Value</label>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder="UUID or value"
required
/>
</div>
<legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.conditions')}</legend>
<p className="text-xs text-gray-500 mb-3">{t('settings:triggers.conditionsHint')}</p>
<div className="space-y-4">
{rules.map((rule, index) => {
const fieldType = getFieldType(rule)
const operatorOptions = getOperatorOptions(fieldType)
return (
<div key={rule.id} className="space-y-2">
<div className="grid grid-cols-12 gap-3 items-end">
<div className="col-span-3">
<label className="block text-sm text-gray-600">{t('settings:triggers.field')}</label>
<select
value={rule.field}
onChange={e => handleFieldChange(rule, e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="status_id">{fieldLabel('status_id')}</option>
<option value="assignee_id">{fieldLabel('assignee_id')}</option>
<option value="priority">{fieldLabel('priority')}</option>
<option value="start_date">{fieldLabel('start_date')}</option>
<option value="due_date">{fieldLabel('due_date')}</option>
<option value="custom_fields">{fieldLabel('custom_fields')}</option>
</select>
{rule.field === 'custom_fields' && (
<select
value={rule.field_id || ''}
onChange={e => handleCustomFieldChange(rule, e.target.value)}
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="">{t('settings:triggers.selectCustomField')}</option>
{customFields.map(field => (
<option key={field.id} value={field.id}>
{field.name}
</option>
))}
</select>
)}
</div>
<div className="col-span-3">
<label className="block text-sm text-gray-600">{t('settings:triggers.operator')}</label>
<select
value={rule.operator}
onChange={e => updateRule(rule.id, { operator: e.target.value })}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
{operatorOptions.map(option => (
<option key={option} value={option}>
{operatorLabel(option)}
</option>
))}
</select>
</div>
<div className="col-span-5">
<label className="block text-sm text-gray-600">{t('settings:triggers.value')}</label>
{renderValueInput(rule)}
</div>
<div className="col-span-1">
{rules.length > 1 && (
<button
type="button"
onClick={() => setRules(prev => prev.filter(item => item.id !== rule.id))}
className="text-sm text-red-600 hover:text-red-700"
>
{t('common:buttons.remove')}
</button>
)}
</div>
</div>
{index < rules.length - 1 && (
<div className="text-xs text-gray-500">{t('settings:triggers.and')}</div>
)}
</div>
)
})}
</div>
<button
type="button"
onClick={() => setRules(prev => [...prev, createDefaultRule()])}
className="mt-3 text-sm text-blue-600 hover:underline"
>
{t('settings:triggers.addCondition')}
</button>
</fieldset>
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Action</legend>
<legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.action')}</legend>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-600">Notify</label>
<label className="block text-sm text-gray-600">{t('settings:triggers.notify')}</label>
<select
value={target}
onChange={e => setTarget(e.target.value)}
value={targetType}
onChange={e => setTargetType(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="assignee">Task Assignee</option>
<option value="creator">Task Creator</option>
<option value="project_owner">Project Owner</option>
<option value="assignee">{t('settings:triggers.targets.assignee')}</option>
<option value="creator">{t('settings:triggers.targets.creator')}</option>
<option value="project_owner">{t('settings:triggers.targets.projectOwner')}</option>
<option value="project_members">{t('settings:triggers.targets.projectMembers')}</option>
<option value="user">{t('settings:triggers.targets.user')}</option>
<option value="department">{t('settings:triggers.targets.department')}</option>
<option value="role">{t('settings:triggers.targets.role')}</option>
</select>
{['user', 'department', 'role'].includes(targetType) && (
<input
type="text"
value={targetValue}
onChange={e => setTargetValue(e.target.value)}
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder={t(`settings:triggers.placeholders.${targetType}`)}
/>
)}
</div>
<div>
<label className="block text-sm text-gray-600">Message template (optional)</label>
<label className="block text-sm text-gray-600">{t('settings:triggers.templateLabel')}</label>
<input
type="text"
value={template}
onChange={e => setTemplate(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder="Variables: {task_title}, {old_value}, {new_value}"
placeholder={t('settings:triggers.templateHint')}
/>
</div>
</div>
@@ -172,7 +568,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
onChange={e => setIsActive(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
<label htmlFor="isActive" className="text-sm text-gray-700">{t('common:labels.active')}</label>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
@@ -183,14 +579,18 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
{t('common:buttons.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{loading ? 'Saving...' : trigger ? 'Update' : 'Create'}
{loading
? t('settings:triggers.saving')
: trigger
? t('settings:triggers.update')
: t('common:buttons.create')}
</button>
</div>
</form>

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { triggersApi, Trigger } from '../services/triggers'
import { useTranslation } from 'react-i18next'
import { triggersApi, Trigger, TriggerConditionRule } from '../services/triggers'
import { customFieldsApi } from '../services/customFields'
import { ConfirmModal } from './ConfirmModal'
import { useToast } from '../contexts/ToastContext'
import { SkeletonList } from './Skeleton'
@@ -10,11 +12,13 @@ interface TriggerListProps {
}
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
const { t } = useTranslation(['settings', 'common'])
const { showToast } = useToast()
const [triggers, setTriggers] = useState<Trigger[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [customFieldMap, setCustomFieldMap] = useState<Record<string, string>>({})
const fetchTriggers = useCallback(async () => {
try {
@@ -23,23 +27,42 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
setTriggers(response.triggers)
setError(null)
} catch {
setError('Failed to load triggers')
setError(t('settings:triggers.errors.loadFailed'))
} finally {
setLoading(false)
}
}, [projectId, t])
const fetchCustomFields = useCallback(async () => {
try {
const response = await customFieldsApi.getCustomFields(projectId)
const map: Record<string, string> = {}
response.fields.forEach(field => {
map[field.id] = field.name
})
setCustomFieldMap(map)
} catch {
setCustomFieldMap({})
}
}, [projectId])
useEffect(() => {
fetchTriggers()
}, [fetchTriggers])
fetchCustomFields()
}, [fetchTriggers, fetchCustomFields])
const handleToggleActive = async (trigger: Trigger) => {
try {
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
fetchTriggers()
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
showToast(
trigger.is_active
? t('settings:triggers.toasts.disabled')
: t('settings:triggers.toasts.enabled'),
'success'
)
} catch {
showToast('Failed to update trigger', 'error')
showToast(t('settings:triggers.errors.updateFailed'), 'error')
}
}
@@ -50,29 +73,70 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
try {
await triggersApi.deleteTrigger(triggerId)
fetchTriggers()
showToast('Trigger deleted successfully', 'success')
showToast(t('settings:triggers.toasts.deleteSuccess'), 'success')
} catch {
showToast('Failed to delete trigger', 'error')
showToast(t('settings:triggers.errors.deleteFailed'), 'error')
}
}
const getFieldLabel = (field: string) => {
switch (field) {
case 'status_id': return 'Status'
case 'assignee_id': return 'Assignee'
case 'priority': return 'Priority'
default: return field
const getRules = (conditions: Trigger['conditions']): TriggerConditionRule[] => {
if (Array.isArray(conditions?.rules) && conditions.rules.length > 0) {
return conditions.rules
}
if (conditions?.field) {
return [{
field: conditions.field,
operator: conditions.operator || 'equals',
value: conditions.value,
field_id: conditions.field_id,
}]
}
return []
}
const getFieldLabel = (field: string, fieldId?: string) => {
if (field === 'custom_fields') {
return customFieldMap[fieldId || '']
? `${t('settings:triggers.fields.customField')}: ${customFieldMap[fieldId || '']}`
: t('settings:triggers.fields.customField')
}
const keyMap: Record<string, string> = {
status_id: 'status',
assignee_id: 'assignee',
priority: 'priority',
start_date: 'startDate',
due_date: 'dueDate',
}
return t(`settings:triggers.fields.${keyMap[field] || field}`)
}
const getOperatorLabel = (operator: string) => {
switch (operator) {
case 'equals': return 'equals'
case 'not_equals': return 'does not equal'
case 'changed_to': return 'changes to'
case 'changed_from': return 'changes from'
default: return operator
const keyMap: Record<string, string> = {
equals: 'equals',
not_equals: 'notEquals',
changed_to: 'changedTo',
changed_from: 'changedFrom',
before: 'before',
after: 'after',
in: 'in',
}
return t(`settings:triggers.operators.${keyMap[operator] || operator}`)
}
const formatRuleValue = (rule: TriggerConditionRule) => {
if (rule.operator === 'in') {
if (rule.value && typeof rule.value === 'object' && !Array.isArray(rule.value)) {
const range = rule.value as { start?: string; end?: string }
const start = range.start ? String(range.start) : ''
const end = range.end ? String(range.end) : ''
return start && end ? `${start} ~ ${end}` : start || end
}
if (Array.isArray(rule.value)) {
return rule.value.map(item => String(item)).join(', ')
}
}
return rule.value != null ? String(rule.value) : ''
}
if (loading) {
@@ -84,7 +148,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline">
Retry
{t('settings:triggers.retry')}
</button>
</div>
)
@@ -93,79 +157,89 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
if (triggers.length === 0) {
return (
<div className="p-4 text-center text-gray-500">
No triggers configured for this project.
{t('settings:triggers.empty')}
</div>
)
}
return (
<div className="space-y-3">
{triggers.map(trigger => (
<div
key={trigger.id}
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{trigger.name}</h4>
<span className={`px-2 py-0.5 text-xs rounded ${
trigger.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{trigger.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{trigger.description && (
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
)}
<div className="text-sm text-gray-600 mt-2">
<span className="font-medium">When: </span>
{getFieldLabel(trigger.conditions.field)} {getOperatorLabel(trigger.conditions.operator)} {trigger.conditions.value}
</div>
<div className="text-sm text-gray-600 mt-1">
<span className="font-medium">Then: </span>
{trigger.actions.map((a, i) => (
<span key={i}>
{a.type === 'notify' ? `Notify ${a.target}` : a.type}
{i < trigger.actions.length - 1 && ', '}
{triggers.map(trigger => {
const rules = getRules(trigger.conditions)
return (
<div
key={trigger.id}
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{trigger.name}</h4>
<span className={`px-2 py-0.5 text-xs rounded ${
trigger.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{trigger.is_active ? t('common:labels.active') : t('common:labels.inactive')}
</span>
))}
</div>
{trigger.description && (
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
)}
<div className="text-sm text-gray-600 mt-2">
<span className="font-medium">{t('settings:triggers.when')}: </span>
{rules.map((rule, index) => (
<span key={`${trigger.id}-${index}`}>
{getFieldLabel(rule.field, rule.field_id)} {getOperatorLabel(rule.operator)} {formatRuleValue(rule)}
{index < rules.length - 1 && ` ${t('settings:triggers.and')} `}
</span>
))}
</div>
<div className="text-sm text-gray-600 mt-1">
<span className="font-medium">{t('settings:triggers.then')}: </span>
{trigger.actions.map((action, index) => (
<span key={index}>
{action.type === 'notify'
? `${t('settings:triggers.notify')} ${action.target}`
: action.type}
{index < trigger.actions.length - 1 && ', '}
</span>
))}
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleToggleActive(trigger)}
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
>
{trigger.is_active ? 'Disable' : 'Enable'}
</button>
{onEdit && (
<div className="flex gap-2">
<button
onClick={() => onEdit(trigger)}
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
onClick={() => handleToggleActive(trigger)}
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
>
Edit
{trigger.is_active ? t('settings:triggers.disable') : t('settings:triggers.enable')}
</button>
)}
<button
onClick={() => setDeleteConfirm(trigger.id)}
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
>
Delete
</button>
{onEdit && (
<button
onClick={() => onEdit(trigger)}
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
>
{t('common:buttons.edit')}
</button>
)}
<button
onClick={() => setDeleteConfirm(trigger.id)}
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
>
{t('common:buttons.delete')}
</button>
</div>
</div>
</div>
</div>
))}
)
})}
<ConfirmModal
isOpen={deleteConfirm !== null}
title="Delete Trigger"
message="Are you sure you want to delete this trigger? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
title={t('settings:triggers.deleteTitle')}
message={t('settings:triggers.deleteMessage')}
confirmText={t('common:buttons.delete')}
cancelText={t('common:buttons.cancel')}
confirmStyle="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}

View File

@@ -23,9 +23,13 @@ export default function MySettings() {
const [profile, setProfile] = useState<UserProfile | null>(null)
const [capacity, setCapacity] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [weeklySubscription, setWeeklySubscription] = useState(false)
const [subscriptionLoading, setSubscriptionLoading] = useState(true)
const [subscriptionSaving, setSubscriptionSaving] = useState(false)
useEffect(() => {
loadProfile()
loadWeeklySubscription()
}, [])
const loadProfile = async () => {
@@ -50,6 +54,19 @@ export default function MySettings() {
}
}
const loadWeeklySubscription = async () => {
setSubscriptionLoading(true)
try {
const response = await api.get('/reports/weekly/subscription')
setWeeklySubscription(Boolean(response.data?.is_active))
} catch (err) {
console.error('Failed to load weekly subscription:', err)
showToast(t('mySettings.weeklyReportError'), 'error')
} finally {
setSubscriptionLoading(false)
}
}
const handleSaveCapacity = async () => {
if (!profile) return
@@ -75,6 +92,23 @@ export default function MySettings() {
}
}
const handleToggleSubscription = async (enabled: boolean) => {
setSubscriptionSaving(true)
try {
await api.put('/reports/weekly/subscription', { is_active: enabled })
setWeeklySubscription(enabled)
showToast(
enabled ? t('mySettings.weeklyReportEnabled') : t('mySettings.weeklyReportDisabled'),
'success'
)
} catch (err) {
console.error('Failed to update weekly subscription:', err)
showToast(t('mySettings.weeklyReportError'), 'error')
} finally {
setSubscriptionSaving(false)
}
}
if (loading) {
return (
<div style={styles.container}>
@@ -160,6 +194,21 @@ export default function MySettings() {
{saving ? t('common:labels.loading') : t('common:buttons.save')}
</button>
</div>
<div style={styles.card}>
<h2 style={styles.cardTitle}>{t('mySettings.weeklyReportTitle')}</h2>
<p style={styles.cardDescription}>{t('mySettings.weeklyReportDescription')}</p>
<div style={styles.toggleRow}>
<label style={styles.toggleLabel}>{t('mySettings.weeklyReportSubscribe')}</label>
<input
type="checkbox"
checked={weeklySubscription}
disabled={subscriptionLoading || subscriptionSaving}
onChange={(e) => handleToggleSubscription(e.target.checked)}
style={styles.toggleInput}
/>
</div>
</div>
</div>
)
}
@@ -267,6 +316,21 @@ const styles: Record<string, React.CSSProperties> = {
backgroundColor: '#ccc',
cursor: 'not-allowed',
},
toggleRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
},
toggleLabel: {
fontSize: '14px',
color: '#333',
fontWeight: 500,
},
toggleInput: {
width: '40px',
height: '20px',
},
error: {
padding: '48px',
textAlign: 'center',

View File

@@ -1,9 +1,19 @@
import api from './api'
export interface TriggerCondition {
export interface TriggerConditionRule {
field: string
operator: string
value: string
value: unknown
field_id?: string
}
export interface TriggerCondition {
field?: string
operator?: string
value?: unknown
field_id?: string
logic?: 'and'
rules?: TriggerConditionRule[]
}
export interface TriggerAction {