fix: allow editing empty draft trails
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ArrowDown, ArrowLeft, ArrowUp, BookOpen, ExternalLink, Loader2, Pencil, Plus, Save, Trash2, Upload, X } from 'lucide-react';
|
import { ArrowDown, ArrowLeft, ArrowUp, BookOpen, ExternalLink, Loader2, Pencil, Plus, Save, Trash2, Upload, X } from 'lucide-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { adminApi, instructorApi, LearningPath, LearningPathInput } from '../services/api';
|
import { adminApi, instructorApi, LearningPath, LearningPathInput, TrailCourseOption } from '../services/api';
|
||||||
import { deleteCourse } from '../services/db';
|
import { deleteCourse } from '../services/db';
|
||||||
import { CourseCoverImage } from '../components/CourseCoverImage';
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
|
|
||||||
type CourseOption = { id: string; title: string; status: string; category: string; coverImageUrl: string | null };
|
type CourseOption = TrailCourseOption;
|
||||||
const emptyInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] });
|
const emptyInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] });
|
||||||
const toInput = (trail: LearningPath): LearningPathInput => ({ title: trail.title, description: trail.description, coverImageUrl: trail.coverImageUrl, coverImageZoom: trail.coverImageZoom, coverImagePositionX: trail.coverImagePositionX, coverImagePositionY: trail.coverImagePositionY, status: trail.status === 'published' ? 'published' : 'draft', courseIds: trail.courses.map((course) => course.id) });
|
const toInput = (trail: LearningPath): LearningPathInput => ({ title: trail.title, description: trail.description, coverImageUrl: trail.coverImageUrl, coverImageZoom: trail.coverImageZoom, coverImagePositionX: trail.coverImagePositionX, coverImagePositionY: trail.coverImagePositionY, status: trail.status === 'published' ? 'published' : 'draft', courseIds: trail.courses.map((course) => course.id) });
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ export const TrailDetail: React.FC = () => {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [pathsResponse, homeResponse] = await Promise.all([adminApi.listPaths(), adminApi.homeConfiguration()]);
|
const [pathsResponse, coursesResponse] = await Promise.all([adminApi.listPaths(), adminApi.listPathCourses()]);
|
||||||
setCourses(homeResponse.data.courses);
|
setCourses(coursesResponse.data);
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
const found = pathsResponse.data.find((item) => item.id === trailId);
|
const found = pathsResponse.data.find((item) => item.id === trailId);
|
||||||
if (!found) { showToast('Trilha não encontrada.', 'error'); navigate('/admin'); return; }
|
if (!found) { showToast('Trilha não encontrada.', 'error'); navigate('/admin'); return; }
|
||||||
@@ -66,7 +66,7 @@ export const TrailDetail: React.FC = () => {
|
|||||||
|
|
||||||
const save = async (event: React.FormEvent) => {
|
const save = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (input.courseIds.length === 0) { showToast('Adicione pelo menos um curso à trilha.', 'error'); return; }
|
if (input.status === 'published' && input.courseIds.length === 0) { showToast('Adicione pelo menos um curso antes de publicar a trilha.', 'error'); return; }
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = isNew ? await adminApi.createPath(input) : await adminApi.updatePath(trail!.id, input);
|
const response = isNew ? await adminApi.createPath(input) : await adminApi.updatePath(trail!.id, input);
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ const learningPathSchema = z.object({
|
|||||||
coverImagePositionX: z.number().min(0).max(100).default(50),
|
coverImagePositionX: z.number().min(0).max(100).default(50),
|
||||||
coverImagePositionY: z.number().min(0).max(100).default(50),
|
coverImagePositionY: z.number().min(0).max(100).default(50),
|
||||||
status: z.enum(['draft', 'published']).default('draft'),
|
status: z.enum(['draft', 'published']).default('draft'),
|
||||||
courseIds: z.array(z.string().uuid()).min(1).max(100),
|
courseIds: z.array(z.string().uuid()).max(100),
|
||||||
|
}).superRefine((input, context) => {
|
||||||
|
if (input.status === 'published' && input.courseIds.length === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['courseIds'],
|
||||||
|
message: 'Adicione pelo menos um curso antes de publicar a trilha.',
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
||||||
@@ -143,6 +151,17 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
||||||
|
|
||||||
|
app.get('/path-courses', adminAccess, async () => {
|
||||||
|
const result = await pool.query<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }>(
|
||||||
|
`select id, title, status, category,
|
||||||
|
coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl"
|
||||||
|
from courses
|
||||||
|
where status <> 'archived'
|
||||||
|
order by category, title`,
|
||||||
|
);
|
||||||
|
return { data: result.rows };
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
|
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
|
||||||
|
|
||||||
app.put('/home-banners', adminAccess, async (request) => {
|
app.put('/home-banners', adminAccess, async (request) => {
|
||||||
@@ -189,7 +208,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -223,7 +242,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { data: paths.find((item) => item.id === pathId) };
|
return { data: paths.find((item) => item.id === pathId) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ export interface LearningPathInput {
|
|||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
courseIds: string[];
|
courseIds: string[];
|
||||||
}
|
}
|
||||||
|
export interface TrailCourseOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
category: string;
|
||||||
|
coverImageUrl: string | null;
|
||||||
|
}
|
||||||
export interface HomeBannerInput {
|
export interface HomeBannerInput {
|
||||||
kind: 'course' | 'custom';
|
kind: 'course' | 'custom';
|
||||||
courseId: string | null;
|
courseId: string | null;
|
||||||
@@ -197,6 +204,7 @@ export const adminApi = {
|
|||||||
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
|
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
|
||||||
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
|
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
|
||||||
async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
|
async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
|
||||||
|
async listPathCourses() { return apiRequest<{ data: TrailCourseOption[] }>('/admin/path-courses'); },
|
||||||
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
|
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
|
||||||
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
|
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
|
||||||
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
||||||
|
|||||||
Reference in New Issue
Block a user