690503:0135 Update workflow #01
This commit is contained in:
@@ -23,6 +23,7 @@ export default function WorkflowEditPage() {
|
||||
const router = useRouter();
|
||||
const id = params?.id === 'new' ? null : (params?.id as string);
|
||||
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const [workflowData, setWorkflowData] = useState<Partial<Workflow>>({
|
||||
workflowName: '',
|
||||
description: '',
|
||||
@@ -102,7 +103,7 @@ export default function WorkflowEditPage() {
|
||||
<Link href="/admin/doc-control/workflows">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</Link>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button onClick={handleSave} disabled={saving || hasValidationErrors}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{id ? 'Save Changes' : 'Create Workflow'}
|
||||
@@ -177,6 +178,7 @@ export default function WorkflowEditPage() {
|
||||
<DSLEditor
|
||||
initialValue={workflowData.dslDefinition}
|
||||
onChange={(value) => setWorkflowData({ ...workflowData, dslDefinition: value })}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// T043: Vitest component test for FilePreviewModal
|
||||
// ตรวจสอบ: PDF → iframe, Image → img, unsupported → download link, onClose callback
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import type { WorkflowAttachmentSummary } from '@/types/workflow';
|
||||
|
||||
// Mock useTranslations — คืน key เป็น fallback สำหรับ test
|
||||
vi.mock('@/hooks/use-translations', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// apiClient.get ถูก mock ใน vitest.setup.ts แล้ว
|
||||
const mockApiGet = vi.mocked(apiClient.get);
|
||||
|
||||
// Mock URL.createObjectURL / revokeObjectURL
|
||||
const mockObjectUrl = 'blob:http://localhost/mock-blob-url';
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn().mockReturnValue(mockObjectUrl),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
|
||||
const makeAttachment = (
|
||||
overrides: Partial<WorkflowAttachmentSummary> = {}
|
||||
): WorkflowAttachmentSummary => ({
|
||||
publicId: 'att-preview-001',
|
||||
originalFilename: 'test-file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: 102400,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('FilePreviewModal', () => {
|
||||
const onClose = vi.fn();
|
||||
const onUnavailable = vi.fn();
|
||||
const mockBlob = new Blob(['%PDF-1.4'], { type: 'application/pdf' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApiGet.mockResolvedValue({ data: mockBlob });
|
||||
});
|
||||
|
||||
it('renders iframe for PDF MIME type', async () => {
|
||||
const attachment = makeAttachment({ mimeType: 'application/pdf' });
|
||||
|
||||
render(<FilePreviewModal attachment={attachment} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('test-file.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const iframe = screen.getByTitle('test-file.pdf') as HTMLIFrameElement;
|
||||
expect(iframe.tagName).toBe('IFRAME');
|
||||
expect(iframe.src).toContain('blob:');
|
||||
});
|
||||
|
||||
it('renders img for image MIME type', async () => {
|
||||
const imageBlob = new Blob(['fake-image'], { type: 'image/png' });
|
||||
mockApiGet.mockResolvedValue({ data: imageBlob });
|
||||
|
||||
const attachment = makeAttachment({
|
||||
mimeType: 'image/png',
|
||||
originalFilename: 'photo.png',
|
||||
});
|
||||
|
||||
render(<FilePreviewModal attachment={attachment} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('photo.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const img = screen.getByAltText('photo.png') as HTMLImageElement;
|
||||
expect(img.tagName).toBe('IMG');
|
||||
});
|
||||
|
||||
it('shows download link for unsupported MIME type (no iframe or img)', async () => {
|
||||
const docxBlob = new Blob(['PK...'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
mockApiGet.mockResolvedValue({ data: docxBlob });
|
||||
|
||||
const attachment = makeAttachment({
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
originalFilename: 'report.docx',
|
||||
});
|
||||
|
||||
render(<FilePreviewModal attachment={attachment} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// download link ต้องมี href = blobUrl
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', mockObjectUrl);
|
||||
expect(link).toHaveAttribute('download', 'report.docx');
|
||||
});
|
||||
|
||||
// ต้องไม่มี iframe หรือ img
|
||||
expect(screen.queryByTitle('report.docx')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const attachment = makeAttachment();
|
||||
|
||||
render(<FilePreviewModal attachment={attachment} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /filepreview.close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /filepreview.close/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onUnavailable when API returns 404', async () => {
|
||||
const notFoundError = Object.assign(new Error('Not Found'), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
mockApiGet.mockRejectedValue(notFoundError);
|
||||
|
||||
const attachment = makeAttachment({ publicId: 'missing-att-001' });
|
||||
|
||||
render(
|
||||
<FilePreviewModal
|
||||
attachment={attachment}
|
||||
onClose={onClose}
|
||||
onUnavailable={onUnavailable}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUnavailable).toHaveBeenCalledWith('missing-att-001');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render when attachment is null (dialog closed)', () => {
|
||||
render(<FilePreviewModal attachment={null} onClose={onClose} />);
|
||||
|
||||
// Dialog should not be visible
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// T054: Vitest test for DSLEditor — validates onValidationChange callback and Save button disable logic
|
||||
// ตรวจสอบ: Validate กดแล้ว workflowApi.validateDSL ถูกเรียก; errors → onValidationChange(true); valid → onValidationChange(false)
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DSLEditor } from '../dsl-editor';
|
||||
import { workflowApi } from '@/lib/api/workflows';
|
||||
|
||||
// Mock Monaco editor — ไม่มี DOM environment สำหรับ Monaco
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({ onChange }: { onChange?: (v: string) => void }) => (
|
||||
<textarea
|
||||
data-testid="monaco-editor"
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next-themes
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}));
|
||||
|
||||
// Mock workflowApi.validateDSL
|
||||
vi.mock('@/lib/api/workflows', () => ({
|
||||
workflowApi: {
|
||||
validateDSL: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockValidateDSL = vi.mocked(workflowApi.validateDSL);
|
||||
|
||||
describe('DSLEditor (T054)', () => {
|
||||
const onValidationChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls workflowApi.validateDSL when Validate button is clicked', async () => {
|
||||
mockValidateDSL.mockResolvedValue({ valid: true });
|
||||
|
||||
render(<DSLEditor initialValue="workflow: test" onValidationChange={onValidationChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockValidateDSL).toHaveBeenCalledWith('workflow: test');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onValidationChange(true) when validation returns errors', async () => {
|
||||
mockValidateDSL.mockResolvedValue({
|
||||
valid: false,
|
||||
errors: ['DSL must have at least one state'],
|
||||
});
|
||||
|
||||
render(<DSLEditor initialValue="bad: dsl" onValidationChange={onValidationChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onValidationChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
// แสดง error message ใน UI
|
||||
expect(
|
||||
screen.getByText('DSL must have at least one state')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onValidationChange(false) when validation returns valid', async () => {
|
||||
mockValidateDSL.mockResolvedValue({ valid: true });
|
||||
|
||||
render(<DSLEditor initialValue="workflow: rfa" onValidationChange={onValidationChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// แสดง success message
|
||||
expect(screen.getByText(/valid and ready/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onValidationChange(true) on server error', async () => {
|
||||
mockValidateDSL.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<DSLEditor initialValue="workflow: test" onValidationChange={onValidationChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onValidationChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onValidationChange when prop is not provided', async () => {
|
||||
mockValidateDSL.mockResolvedValue({ valid: true });
|
||||
|
||||
// ไม่ส่ง onValidationChange — ต้องไม่ throw
|
||||
render(<DSLEditor initialValue="workflow: test" />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /validate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockValidateDSL).toHaveBeenCalled();
|
||||
});
|
||||
// ไม่ throw error
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,11 @@ interface DSLEditorProps {
|
||||
initialValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
// FR-025: callback เมื่อผล validate เปลี่ยน — parent ใช้ disable Save button
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
}
|
||||
|
||||
export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSLEditorProps) {
|
||||
export function DSLEditor({ initialValue = '', onChange, readOnly = false, onValidationChange }: DSLEditorProps) {
|
||||
const [dsl, setDsl] = useState(initialValue);
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
@@ -47,9 +49,12 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
|
||||
try {
|
||||
const result = await workflowApi.validateDSL(dsl);
|
||||
setValidationResult(result);
|
||||
// FR-025: แจ้ง parent ว่ามี validation errors หรือไม่
|
||||
onValidationChange?.(!result.valid);
|
||||
} catch (_error) {
|
||||
// Validation failed - error state shown in UI
|
||||
setValidationResult({ valid: false, errors: ['Validation failed due to server error'] });
|
||||
onValidationChange?.(true);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
|
||||
@@ -67,10 +67,18 @@ export function useWorkflowAction(instanceId: string | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด)
|
||||
// Clarify Q1: 409 Conflict (state violation หรือ optimistic lock conflict)
|
||||
if (statusCode === 409) {
|
||||
// M3: reset idempotency key — user intent กับ state เดิมใช้ไม่ได้แล้ว
|
||||
setIdempotencyKey(uuidv4());
|
||||
// FR-002: Optimistic lock conflict — แสดง message เฉพาะเพื่อบอก user ให้ refresh
|
||||
const isVersionConflict = error.error.code === 'WORKFLOW_VERSION_CONFLICT';
|
||||
if (isVersionConflict) {
|
||||
toast.error('เอกสารถูกอนุมัติโดยผู้อื่นแล้ว กรุณารีเฟรช', {
|
||||
description: 'ข้อมูลที่คุณกำลังดูอาจล้าสมัย กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง',
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', {
|
||||
description: recoveryActions?.[0],
|
||||
});
|
||||
|
||||
@@ -77,3 +77,11 @@ export const useGetAvailableActions = () => {
|
||||
mutationFn: (data: GetAvailableActionsDto) => workflowEngineService.getAvailableActions(data),
|
||||
});
|
||||
};
|
||||
|
||||
// FR-025: Inline DSL validation (POST /workflow-engine/definitions/validate)
|
||||
export const useValidateDsl = () => {
|
||||
return useMutation({
|
||||
mutationFn: (dsl: Record<string, unknown>) =>
|
||||
workflowEngineService.validateDsl(dsl),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -178,6 +178,23 @@ export const workflowEngineService = {
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* FR-025: ตรวจสอบ DSL โดยไม่บันทึก
|
||||
* POST /workflow-engine/definitions/validate
|
||||
*/
|
||||
validateDsl: async (
|
||||
dsl: Record<string, unknown>
|
||||
): Promise<
|
||||
| { valid: true }
|
||||
| { valid: false; errors: { path: string; message: string }[] }
|
||||
> => {
|
||||
const response = await apiClient.post(
|
||||
'/workflow-engine/definitions/validate',
|
||||
{ dsl }
|
||||
);
|
||||
return (response.data as { data?: unknown })?.data ?? response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* ลบ Workflow Definition
|
||||
* DELETE /workflow-engine/definitions/:id
|
||||
|
||||
@@ -75,4 +75,7 @@ export interface WorkflowTransitionWithAttachmentsDto {
|
||||
|
||||
/** รายการ publicId ของไฟล์แนบประจำ Step นี้ (max 20, ADR-016 Two-Phase upload) */
|
||||
attachmentPublicIds?: string[];
|
||||
|
||||
/** FR-002: Client-side optimistic lock version — ส่งพร้อมทุก transition เพื่อตรวจ conflict (HTTP 409) */
|
||||
versionNo?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user