From c61e0e03ac2c6b3c1bbc3e7a2da6552e810595e6 Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:05:50 -0400 Subject: [PATCH 01/20] error de ruta --- frontend/angular/src/app/app.routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts index 8d38e40..2b0da4f 100644 --- a/frontend/angular/src/app/app.routes.ts +++ b/frontend/angular/src/app/app.routes.ts @@ -12,6 +12,6 @@ export const routes: Routes = [ // listas {path: "lista-videos", component: VideosListComponent}, {path: "lista-ejercicios", component: ExercisesListComponent}, - {path: "progreso-aprendizaje", component: LearningProgressComponent} + {path: "progreso-aprendizaje", component: LearningProgressComponent}, {path: "lista-cursos", component: CoursesListComponent} ]; \ No newline at end of file From ddbc4f62b68ec71c62f826b5620f8ecd7ce791d8 Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 18:43:22 -0400 Subject: [PATCH 02/20] redirije a cursos al completar o cancelar Crear curso --- .../src/app/pages/create-course/create-course.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.ts b/frontend/angular/src/app/pages/create-course/create-course.component.ts index 4c0c33f..a1c96eb 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.ts +++ b/frontend/angular/src/app/pages/create-course/create-course.component.ts @@ -261,7 +261,7 @@ export class CreateCourseComponent implements OnInit { this.addContent(); // Agregar contenido inicial // Opcional: Navegar a la lista de cursos o a ver el curso creado - // this.router.navigate(['/courses']); + this.router.navigate(['/cursos']); this.isSubmitting = false; }, @@ -283,6 +283,7 @@ export class CreateCourseComponent implements OnInit { this.courseForm.reset(); this.contents.clear(); this.addContent(); // Agregar contenido inicial + this.router.navigate(['/cursos']) } } From 2b6b2fafb77be3ed4da39ce07455ecb0cfae1324 Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:17:45 -0400 Subject: [PATCH 03/20] componente de formulario de curso separado --- .../form-course/form-course.component.html | 191 +++++++ .../form-course/form-course.component.scss | 511 ++++++++++++++++++ .../form-course/form-course.component.spec.ts | 0 .../form-course/form-course.component.ts | 336 ++++++++++++ .../create-course.component.html | 230 +------- .../create-course.component.scss | 404 -------------- .../create-course/create-course.component.ts | 314 +++-------- 7 files changed, 1131 insertions(+), 855 deletions(-) create mode 100644 frontend/angular/src/app/components/form-course/form-course.component.html create mode 100644 frontend/angular/src/app/components/form-course/form-course.component.scss create mode 100644 frontend/angular/src/app/components/form-course/form-course.component.spec.ts create mode 100644 frontend/angular/src/app/components/form-course/form-course.component.ts diff --git a/frontend/angular/src/app/components/form-course/form-course.component.html b/frontend/angular/src/app/components/form-course/form-course.component.html new file mode 100644 index 0000000..55d521d --- /dev/null +++ b/frontend/angular/src/app/components/form-course/form-course.component.html @@ -0,0 +1,191 @@ +
+ +
+

📚 Información del Curso

+
+ + +
+ El título es requerido +
+
+ +
+ + +
+ La descripción es requerida +
+
+
+ + +
+
+

📖 Contenidos del Curso

+ +
+ +
+
+ +
+
+ {{ i + 1 }} +

📄 Contenido {{ i + 1 }}

+
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+

📋 Subcontenidos

+ +
+ +
+
+ +
+
+ {{ i + 1 }}.{{ j + 1 }} + 📝 Subcontenido {{ j + 1 }} +
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+
💻 Ejemplos de Código
+ +
+ +
+
+
+ {{ i + 1 }}.{{ j + 1 }}.{{ k + 1 }} + + +
+ +
+
+
+
+
+
+
+
+
+
+
+ + +
+

Vista Previa del JSON

+
{{ getPreviewJson() }}
+
+
\ No newline at end of file diff --git a/frontend/angular/src/app/components/form-course/form-course.component.scss b/frontend/angular/src/app/components/form-course/form-course.component.scss new file mode 100644 index 0000000..99deedb --- /dev/null +++ b/frontend/angular/src/app/components/form-course/form-course.component.scss @@ -0,0 +1,511 @@ +@use "sass:color"; + +// Variables de color +$primary-color: #4346ff; +$secondary-color: #ff6a81; +$background-dark: #000000; +$background-light: #272149; +$text-light: #ffffff; +$text-muted: rgba(255, 255, 255, 0.8); +$card-bg: rgba(255, 255, 255, 0.05); +$card-border: rgba(255, 255, 255, 0.1); +$error-color: #f44336; +$success-color: #4caf50; + +.course-form { + max-width: 1200px; + margin: 0 auto; + padding: 0 120px; + + @media (max-width: 768px) { + padding: 0 20px; + } +} + +.form-section { + background: $card-bg; + border: 1px solid $card-border; + border-radius: 12px; + padding: 30px; + margin-bottom: 30px; + backdrop-filter: blur(10px); + + h2 { + font-size: 1.5rem; + margin-bottom: 25px; + font-weight: 600; + position: relative; + color: $text-light; + + &:after { + content: ""; + position: absolute; + bottom: -8px; + left: 0; + width: 60px; + height: 3px; + background: linear-gradient(90deg, $primary-color, $secondary-color); + border-radius: 2px; + } + } +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + + @media (max-width: 576px) { + flex-direction: column; + gap: 15px; + align-items: stretch; + } +} + +.form-group { + margin-bottom: 20px; + + label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: $text-light; + font-size: 0.95rem; + } +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid $card-border; + border-radius: 8px; + color: $text-light; + font-size: 0.95rem; + transition: all 0.3s ease; + + &::placeholder { + color: rgba(255, 255, 255, 0.5); + } + + &:focus { + outline: none; + border-color: $secondary-color; + box-shadow: 0 0 0 3px rgba(138, 99, 210, 0.1); + background: rgba(255, 255, 255, 0.15); + } + + &:invalid { + border-color: $error-color; + } +} + +.form-textarea { + resize: vertical; + min-height: 60px; +} + +.code-textarea { + width: 100%; + padding: 16px; + background: rgba(0, 0, 0, 0.4); + border: 1px solid $card-border; + border-radius: 8px; + color: $text-light; + font-family: "Courier New", monospace; + font-size: 0.9rem; + line-height: 1.4; + resize: vertical; + + &::placeholder { + color: rgba(255, 255, 255, 0.4); + } + + &:focus { + outline: none; + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1); + } +} + +.error-message { + color: $error-color; + font-size: 0.85rem; + margin-top: 5px; + display: block; +} + +// Botones +.add-button, +.add-sub-button, +.add-example-button { + background: linear-gradient(45deg, $primary-color, $secondary-color); + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(223, 136, 29, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.add-sub-button, +.add-example-button { + font-size: 0.85rem; + padding: 8px 16px; +} + +.remove-button, +.remove-sub-button, +.remove-example-button { + background: rgba(255, 82, 82, 0.1); + color: $error-color; + border: 1px solid rgba(244, 67, 54, 0.5); + padding: 8px 14px; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + background: rgba(244, 67, 54, 0.2); + border-color: rgba(244, 67, 54, 0.7); + transform: scale(1.05); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.2); + } +} + +// Contenedores de contenido con jerarquía visual mejorada +.contents-container { + display: flex; + flex-direction: column; + gap: 25px; +} + +.content-card { + background: rgba(255, 255, 255, 0.08); + border: 2px solid rgba(138, 99, 210, 0.3); + border-left: 4px solid $secondary-color; + border-radius: 10px; + margin-bottom: 2rem; + position: relative; + backdrop-filter: blur(10px); +} + +.content-header { + background: rgba(138, 99, 210, 0.15); + padding: 1rem; + border-bottom: 1px solid rgba(138, 99, 210, 0.2); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; + + h3 { + color: $text-light; + font-size: 1.2rem; + font-weight: 600; + margin: 0; + } +} + +.content-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.content-number { + background: $secondary-color; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 50%; + font-weight: bold; + font-size: 0.875rem; + min-width: 2rem; + text-align: center; +} + +.content-body { + padding: 1.5rem; +} + +.subcontent-section { + margin-top: 2rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 1.5rem; + + .subcontent-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + h4 { + color: $primary-color; + font-size: 1.1rem; + font-weight: 600; + margin: 0; + } + } +} + +.subcontent-container { + margin-left: 1rem; + border-left: 2px solid rgba(255, 255, 255, 0.1); + padding-left: 1rem; +} + +.subcontent-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.subcontent-header-item { + background: rgba(255, 255, 255, 0.08); + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.subcontent-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.subcontent-number { + background: $primary-color; + color: white; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-weight: bold; + font-size: 0.75rem; +} + +.subcontent-body { + padding: 1rem; +} + +.examples-section { + margin-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); + padding-top: 1rem; + + .examples-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + + h5 { + color: rgba(76, 175, 80, 0.9); + margin: 0; + font-size: 1rem; + } + } +} + +.examples-container { + margin-left: 1rem; + border-left: 2px solid rgba(255, 255, 255, 0.05); + padding-left: 1rem; +} + +.example-item { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + margin-bottom: 0.75rem; + overflow: hidden; + position: relative; +} + +.example-header { + background: rgba(76, 175, 80, 0.15); + padding: 0.5rem 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid rgba(76, 175, 80, 0.2); +} + +.example-number { + background: $success-color; + color: white; + padding: 0.15rem 0.3rem; + border-radius: 3px; + font-weight: bold; + font-size: 0.7rem; +} + +.example-label { + font-size: 0.875rem; + color: rgba(76, 175, 80, 0.9); + font-weight: 500; +} + +// Sobrescribimos el code-textarea dentro de examples para mantener coherencia +.example-item .code-textarea { + width: 100%; + border: none; + background: rgba(0, 0, 0, 0.6); + font-family: "Courier New", monospace; + padding: 0.75rem; + resize: vertical; + min-height: 120px; + color: $text-light; + border-radius: 0; + + &::placeholder { + color: rgba(255, 255, 255, 0.4); + } + + &:focus { + outline: none; + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1); + } +} + +.remove-example-button { + margin-left: auto; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +// Acciones del formulario +.form-actions { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 40px; + padding: 0 120px; + + @media (max-width: 768px) { + padding: 0 20px; + flex-direction: column; + } +} + +.cancel-button { + background: transparent; + color: $text-muted; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 14px 32px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: $text-light; + } +} + +.submit-button { + background: linear-gradient(45deg, $primary-color, $secondary-color); + color: white; + border: none; + padding: 14px 32px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + min-width: 150px; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(223, 136, 29, 0.4); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + } + + .fa-spinner { + margin-right: 8px; + } +} + +// Vista previa +.preview-section { + max-width: 1200px; + margin: 40px auto 0; + padding: 0 120px; + + @media (max-width: 768px) { + padding: 0 20px; + } + + h3 { + color: $text-light; + margin-bottom: 15px; + font-size: 1.2rem; + } + + .json-preview { + background: rgba(0, 0, 0, 0.6); + border: 1px solid $card-border; + border-radius: 8px; + padding: 20px; + color: $text-light; + font-family: "Courier New", monospace; + font-size: 0.85rem; + line-height: 1.4; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + } +} + +// Responsive +@media (max-width: 576px) { + .form-actions { + flex-direction: column; + + .cancel-button, + .submit-button { + width: 100%; + } + } + + .section-header { + .add-button { + width: 100%; + justify-content: center; + } + } +} diff --git a/frontend/angular/src/app/components/form-course/form-course.component.spec.ts b/frontend/angular/src/app/components/form-course/form-course.component.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/angular/src/app/components/form-course/form-course.component.ts b/frontend/angular/src/app/components/form-course/form-course.component.ts new file mode 100644 index 0000000..6ca0134 --- /dev/null +++ b/frontend/angular/src/app/components/form-course/form-course.component.ts @@ -0,0 +1,336 @@ +import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core'; +import { FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +interface CourseData { + course: { + title: string; + description: string; + goto: string; + }; + contents: ContentData[]; +} + +interface ContentData { + title: string; + paragraph: string[]; + subcontent: SubcontentData[]; + next: string | null; +} + +interface SubcontentData { + subtitle: string; + subparagraph: string[]; + example: string[]; +} + +interface FormContentValue { + title: string; + paragraph: string; + subcontent: FormSubcontentValue[]; +} + +interface FormSubcontentValue { + subtitle: string; + subparagraph: string; + example: string[]; +} + +interface FormValue { + title: string; + description: string; + contents: FormContentValue[]; +} + +@Component({ + selector: 'app-form-course', + standalone: true, + templateUrl: './form-course.component.html', + styleUrls: ['./form-course.component.scss'], + imports: [CommonModule, ReactiveFormsModule] +}) +export class FormCourseComponent implements OnInit, OnChanges { + @Input() initialData?: CourseData; // Para modo edición + @Input() showPreview = false; + @Output() formDataChange = new EventEmitter(); + @Output() formValidChange = new EventEmitter(); + + courseForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.courseForm = this.createCourseForm(); + } + + ngOnInit(): void { + // Agregar el primer contenido por defecto si no hay datos iniciales + if (this.contents.length === 0 && !this.initialData) { + this.addContent(); + } + + // Suscribirse a cambios del formulario + this.courseForm.valueChanges.subscribe(() => { + this.emitFormData(); + }); + + this.courseForm.statusChanges.subscribe(() => { + this.emitFormValid(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['initialData'] && changes['initialData'].currentValue) { + this.loadInitialData(changes['initialData'].currentValue); + } + } + + private createCourseForm(): FormGroup { + return this.fb.group({ + title: ['', [Validators.required, Validators.minLength(3)]], + description: ['', [Validators.required, Validators.minLength(10)]], + contents: this.fb.array([]) + }); + } + + private loadInitialData(data: CourseData): void { + // Limpiar contenidos existentes + this.contents.clear(); + + // Cargar datos del curso + this.courseForm.patchValue({ + title: data.course.title, + description: data.course.description + }); + + // Cargar contenidos + data.contents.forEach(content => { + const contentGroup = this.fb.group({ + title: [content.title, Validators.required], + paragraph: [content.paragraph.join('\n'), Validators.required], + subcontent: this.fb.array([]) + }); + + // Cargar subcontenidos + content.subcontent.forEach(subcontent => { + const subcontentGroup = this.fb.group({ + subtitle: [subcontent.subtitle, Validators.required], + subparagraph: [subcontent.subparagraph.join('\n'), Validators.required], + example: this.fb.array( + subcontent.example.map(example => this.fb.control(example, Validators.required)) + ) + }); + + (contentGroup.get('subcontent') as FormArray).push(subcontentGroup); + }); + + this.contents.push(contentGroup); + }); + + this.emitFormData(); + this.emitFormValid(); + } + + // Getters para FormArrays + get contents(): FormArray { + return this.courseForm.get('contents') as FormArray; + } + + getSubcontents(contentIndex: number): FormArray { + return this.contents.at(contentIndex).get('subcontent') as FormArray; + } + + getExamples(contentIndex: number, subcontentIndex: number): FormArray { + return this.getSubcontents(contentIndex).at(subcontentIndex).get('example') as FormArray; + } + + // Métodos para manejar contenidos + addContent(): void { + if (this.contents.length < 20) { + const contentGroup = this.fb.group({ + title: ['', Validators.required], + paragraph: ['', Validators.required], + subcontent: this.fb.array([]) + }); + + this.contents.push(contentGroup); + + // Agregar el primer subcontenido automáticamente + this.addSubcontent(this.contents.length - 1); + + // Actualizar las referencias "next" automáticamente + this.updateNextOptions(); + } + } + + removeContent(index: number): void { + if (this.contents.length > 1) { + this.contents.removeAt(index); + this.updateNextOptions(); + } + } + + // Métodos para manejar subcontenidos + addSubcontent(contentIndex: number): void { + const subcontentGroup = this.fb.group({ + subtitle: ['', Validators.required], + subparagraph: ['', Validators.required], + example: this.fb.array([this.fb.control('', Validators.required)]) + }); + + this.getSubcontents(contentIndex).push(subcontentGroup); + } + + removeSubcontent(contentIndex: number, subcontentIndex: number): void { + const subcontents = this.getSubcontents(contentIndex); + if (subcontents.length > 1) { + subcontents.removeAt(subcontentIndex); + } + } + + // Métodos para manejar ejemplos + addExample(contentIndex: number, subcontentIndex: number): void { + this.getExamples(contentIndex, subcontentIndex).push( + this.fb.control('', Validators.required) + ); + } + + removeExample(contentIndex: number, subcontentIndex: number, exampleIndex: number): void { + const examples = this.getExamples(contentIndex, subcontentIndex); + if (examples.length > 1) { + examples.removeAt(exampleIndex); + } + } + + // Actualizar opciones de "siguiente contenido" automáticamente + updateNextOptions(): void { + // Esta función ahora solo actualiza los valores "next" internamente + // No se muestra en el formulario, pero se usa en el JSON final + } + + // Generar ruta de acceso automáticamente desde el título + private generateGoto(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') // Eliminar caracteres especiales + .replace(/\s+/g, '-') // Reemplazar espacios con guiones + .trim(); + } + + // Formatear datos para el JSON final + private formatCourseData(): CourseData { + const formValue = this.courseForm.value as FormValue; + + const contents: ContentData[] = formValue.contents.map((content: FormContentValue, index: number) => { + const contentData: ContentData = { + title: content.title, + paragraph: [content.paragraph], + subcontent: content.subcontent.map((sub: FormSubcontentValue) => ({ + subtitle: sub.subtitle, + subparagraph: [sub.subparagraph], + example: sub.example.filter((ex: string) => ex.trim() !== '') + })), + next: null // Inicializar next como null por defecto + }; + + // Agregar "next" automáticamente si no es el último contenido + if (index < formValue.contents.length - 1) { + const nextContent = formValue.contents[index + 1]; + // Verificar que el siguiente contenido existe y tiene título + if (nextContent && nextContent.title && nextContent.title.trim() !== '') { + contentData.next = nextContent.title.trim(); + } + } + + return contentData; + }); + + return { + course: { + title: formValue.title, + description: formValue.description, + goto: this.generateGoto(formValue.title) + }, + contents + }; + } + + // Vista previa del JSON + getPreviewJson(): string { + if (this.courseForm.valid) { + return JSON.stringify(this.formatCourseData(), null, 2); + } + return 'Completa todos los campos requeridos para ver la vista previa'; + } + + // Validación personalizada + validateCourse(): boolean { + // Validar que cada contenido tenga al menos un subcontenido + for (let i = 0; i < this.contents.length; i++) { + const subcontents = this.getSubcontents(i); + if (subcontents.length === 0) { + alert(`El contenido ${i + 1} debe tener al menos un subcontenido`); + return false; + } + + // Validar que cada subcontenido tenga al menos un ejemplo + for (let j = 0; j < subcontents.length; j++) { + const examples = this.getExamples(i, j); + const validExamples = examples.controls.filter(ex => ex.value.trim() !== ''); + if (validExamples.length === 0) { + alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo`); + return false; + } + } + } + + return true; + } + + // Marcar todos los campos como tocados para mostrar errores + markFormGroupTouched(): void { + this.markFormGroupTouchedRecursive(this.courseForm); + } + + private markFormGroupTouchedRecursive(formGroup: FormGroup | FormArray): void { + Object.keys(formGroup.controls).forEach(key => { + const control = formGroup.get(key); + if (control instanceof FormGroup || control instanceof FormArray) { + this.markFormGroupTouchedRecursive(control); + } else { + control?.markAsTouched(); + } + }); + } + + // Método para actualizar ruta automáticamente cuando cambia el título + onTitleChange(): void { + // Este método se mantiene para compatibilidad con el template + // La generación de goto ahora es automática en formatCourseData() + } + + // Métodos para emitir cambios al componente padre + private emitFormData(): void { + if (this.courseForm.valid) { + this.formDataChange.emit(this.formatCourseData()); + } + } + + private emitFormValid(): void { + this.formValidChange.emit(this.courseForm.valid && this.validateCourse()); + } + + // Método para obtener los datos del formulario (para uso externo) + getFormData(): CourseData | null { + if (this.courseForm.valid && this.validateCourse()) { + return this.formatCourseData(); + } + return null; + } + + // Método para resetear el formulario + resetForm(): void { + this.courseForm.reset(); + this.contents.clear(); + this.addContent(); + } +} \ No newline at end of file diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.html b/frontend/angular/src/app/pages/create-course/create-course.component.html index 87fb10a..ab93853 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.html +++ b/frontend/angular/src/app/pages/create-course/create-course.component.html @@ -6,208 +6,32 @@

Crear Nuevo Curso

-
- -
-

📚 Información del Curso

-
- - -
- El título es requerido -
-
- -
- - -
- La descripción es requerida -
-
-
- - -
-
-

📖 Contenidos del Curso

- -
- -
-
- -
-
- {{ i + 1 }} -

📄 Contenido {{ i + 1 }}

-
- -
- -
-
- - -
- -
- - -
- - -
-
-

📋 Subcontenidos

- -
- -
-
- -
-
- {{ i + 1 }}.{{ j + 1 }} - 📝 Subcontenido {{ j + 1 }} -
- -
- -
-
- - -
- -
- - -
- - -
-
-
💻 Ejemplos de Código
- -
- -
-
-
- {{ i + 1 }}.{{ j + 1 }}.{{ k + 1 }} - - -
- -
-
-
-
-
-
-
-
-
-
-
- - -
- - -
-
- - -
-

Vista Previa del JSON

-
{{ getPreviewJson() }}
+ + + +
+ + +
\ No newline at end of file diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.scss b/frontend/angular/src/app/pages/create-course/create-course.component.scss index fc61aed..d059200 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.scss +++ b/frontend/angular/src/app/pages/create-course/create-course.component.scss @@ -50,410 +50,6 @@ $success-color: #4caf50; } } -.course-form { - max-width: 1200px; - margin: 0 auto; - padding: 0 120px; - - @media (max-width: 768px) { - padding: 0 20px; - } -} - -.form-section { - background: $card-bg; - border: 1px solid $card-border; - border-radius: 12px; - padding: 30px; - margin-bottom: 30px; - backdrop-filter: blur(10px); - - h2 { - font-size: 1.5rem; - margin-bottom: 25px; - font-weight: 600; - position: relative; - color: $text-light; - - &:after { - content: ""; - position: absolute; - bottom: -8px; - left: 0; - width: 60px; - height: 3px; - background: linear-gradient(90deg, $primary-color, $secondary-color); - border-radius: 2px; - } - } -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 25px; - - @media (max-width: 576px) { - flex-direction: column; - gap: 15px; - align-items: stretch; - } -} - -.form-group { - margin-bottom: 20px; - - label { - display: block; - margin-bottom: 8px; - font-weight: 500; - color: $text-light; - font-size: 0.95rem; - } -} - -.form-input, -.form-textarea, -.form-select { - width: 100%; - padding: 12px 16px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid $card-border; - border-radius: 8px; - color: $text-light; - font-size: 0.95rem; - transition: all 0.3s ease; - - &::placeholder { - color: rgba(255, 255, 255, 0.5); - } - - &:focus { - outline: none; - border-color: $secondary-color; - box-shadow: 0 0 0 3px rgba(138, 99, 210, 0.1); - background: rgba(255, 255, 255, 0.15); - } - - &:invalid { - border-color: $error-color; - } -} - -.form-textarea { - resize: vertical; - min-height: 60px; -} - -.code-textarea { - width: 100%; - padding: 16px; - background: rgba(0, 0, 0, 0.4); - border: 1px solid $card-border; - border-radius: 8px; - color: $text-light; - font-family: "Courier New", monospace; - font-size: 0.9rem; - line-height: 1.4; - resize: vertical; - - &::placeholder { - color: rgba(255, 255, 255, 0.4); - } - - &:focus { - outline: none; - border-color: $primary-color; - box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1); - } -} - -.error-message { - color: $error-color; - font-size: 0.85rem; - margin-top: 5px; - display: block; -} - -// Botones -.add-button, -.add-sub-button, -.add-example-button { - background: linear-gradient(45deg, $primary-color, $secondary-color); - color: white; - border: none; - padding: 10px 20px; - border-radius: 6px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 8px; - - &:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(223, 136, 29, 0.3); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - i { - font-size: 0.9rem; - } -} - -.add-sub-button, -.add-example-button { - font-size: 0.85rem; - padding: 8px 16px; -} - -.remove-button, -.remove-sub-button, -.remove-example-button { - background: rgba(255, 82, 82, 0.1); // rojo translúcido sobre fondo oscuro - color: $error-color; - border: 1px solid rgba(244, 67, 54, 0.5); - padding: 8px 14px; - border-radius: 6px; - font-weight: 500; - cursor: pointer; - font-size: 0.9rem; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 8px; - - i { - font-size: 0.9rem; - } - - &:hover { - background: rgba(244, 67, 54, 0.2); - border-color: rgba(244, 67, 54, 0.7); - transform: scale(1.05); - } - - &:focus { - outline: none; - box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.2); - } -} -// Contenedores de contenido con jerarquía visual mejorada -.contents-container { - display: flex; - flex-direction: column; - gap: 25px; -} - -.content-card { - background: rgba(255, 255, 255, 0.08); - border: 2px solid rgba(138, 99, 210, 0.3); - border-left: 4px solid $secondary-color; - border-radius: 10px; - margin-bottom: 2rem; - position: relative; - backdrop-filter: blur(10px); -} - -.content-header { - background: rgba(138, 99, 210, 0.15); - padding: 1rem; - border-bottom: 1px solid rgba(138, 99, 210, 0.2); - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0; - - h3 { - color: $text-light; - font-size: 1.2rem; - font-weight: 600; - margin: 0; - } -} - -.content-title { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.content-number { - background: $secondary-color; - color: white; - padding: 0.25rem 0.5rem; - border-radius: 50%; - font-weight: bold; - font-size: 0.875rem; - min-width: 2rem; - text-align: center; -} - -.content-body { - padding: 1.5rem; -} - -.subcontent-section { - margin-top: 2rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); - padding-top: 1.5rem; - - .subcontent-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - - h4 { - color: $primary-color; - font-size: 1.1rem; - font-weight: 600; - margin: 0; - } - } -} - -.subcontent-container { - margin-left: 1rem; - border-left: 2px solid rgba(255, 255, 255, 0.1); - padding-left: 1rem; -} - -.subcontent-card { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - margin-bottom: 1rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.subcontent-header-item { - background: rgba(255, 255, 255, 0.08); - padding: 0.75rem 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - justify-content: space-between; - align-items: center; -} - -.subcontent-title { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.subcontent-number { - background: $primary-color; - color: white; - padding: 0.2rem 0.4rem; - border-radius: 4px; - font-weight: bold; - font-size: 0.75rem; -} - -.subcontent-body { - padding: 1rem; -} - -.examples-section { - margin-top: 1rem; - border-top: 1px solid rgba(255, 255, 255, 0.05); - padding-top: 1rem; - - .examples-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - - label { - margin-bottom: 0; - font-size: 0.9rem; - color: $text-muted; - } - - h5 { - color: rgba(76, 175, 80, 0.9); - margin: 0; - font-size: 1rem; - } - } -} - -.examples-container { - margin-left: 1rem; - border-left: 2px solid rgba(255, 255, 255, 0.05); - padding-left: 1rem; -} - -.example-item { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - margin-bottom: 0.75rem; - overflow: hidden; - position: relative; -} - -.example-header { - background: rgba(76, 175, 80, 0.15); - padding: 0.5rem 0.75rem; - display: flex; - align-items: center; - gap: 0.5rem; - border-bottom: 1px solid rgba(76, 175, 80, 0.2); -} - -.example-number { - background: $success-color; - color: white; - padding: 0.15rem 0.3rem; - border-radius: 3px; - font-weight: bold; - font-size: 0.7rem; -} - -.example-label { - font-size: 0.875rem; - color: rgba(76, 175, 80, 0.9); - font-weight: 500; -} - -// Sobrescribimos el code-textarea dentro de examples para mantener coherencia -.example-item .code-textarea { - width: 100%; - border: none; - background: rgba(0, 0, 0, 0.6); - font-family: "Courier New", monospace; - padding: 0.75rem; - resize: vertical; - min-height: 120px; - color: $text-light; - border-radius: 0; - - &::placeholder { - color: rgba(255, 255, 255, 0.4); - } - - &:focus { - outline: none; - border-color: $primary-color; - box-shadow: 0 0 0 3px rgba(223, 136, 29, 0.1); - } -} - -.remove-example-button { - margin-left: auto; - padding: 0.25rem 0.5rem; - font-size: 0.75rem; -} - // Acciones del formulario .form-actions { display: flex; diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.ts b/frontend/angular/src/app/pages/create-course/create-course.component.ts index a1c96eb..ca33328 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.ts +++ b/frontend/angular/src/app/pages/create-course/create-course.component.ts @@ -1,10 +1,9 @@ -import { Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms'; +import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { CommonModule } from '@angular/common'; -import { ReactiveFormsModule } from '@angular/forms'; -import { CoursesService } from '../../services/courses.service'; // Ajusta la ruta según tu estructura +import { FormCourseComponent } from '../../components/form-course/form-course.component'; +// Interfaces para el tipado de datos interface CourseData { course: { title: string; @@ -27,281 +26,100 @@ interface SubcontentData { example: string[]; } -interface FormContentValue { - title: string; - paragraph: string; - subcontent: FormSubcontentValue[]; -} - -interface FormSubcontentValue { - subtitle: string; - subparagraph: string; - example: string[]; -} - -interface FormValue { - title: string; - description: string; - contents: FormContentValue[]; -} - @Component({ selector: 'app-create-course', standalone: true, templateUrl: './create-course.component.html', styleUrls: ['./create-course.component.scss'], - imports: [CommonModule, ReactiveFormsModule] + imports: [CommonModule, FormCourseComponent] }) -export class CreateCourseComponent implements OnInit { - courseForm: FormGroup; +export class CreateCourseComponent { + // Estados del componente isSubmitting = false; + isFormValid = false; showPreview = false; + currentCourseData: CourseData | null = null; - constructor( - private fb: FormBuilder, - private router: Router, - private coursesService: CoursesService - ) { - this.courseForm = this.createCourseForm(); - } - - ngOnInit(): void { - // Agregar el primer contenido por defecto - if (this.contents.length === 0) { - this.addContent(); - } - } - - private createCourseForm(): FormGroup { - return this.fb.group({ - title: ['', [Validators.required, Validators.minLength(3)]], - description: ['', [Validators.required, Validators.minLength(10)]], - contents: this.fb.array([]) - }); - } - - // Getters para FormArrays - get contents(): FormArray { - return this.courseForm.get('contents') as FormArray; - } - - getSubcontents(contentIndex: number): FormArray { - return this.contents.at(contentIndex).get('subcontent') as FormArray; - } - - getExamples(contentIndex: number, subcontentIndex: number): FormArray { - return this.getSubcontents(contentIndex).at(subcontentIndex).get('example') as FormArray; - } - - // Métodos para manejar contenidos - addContent(): void { - if (this.contents.length < 20) { - const contentGroup = this.fb.group({ - title: ['', Validators.required], - paragraph: ['', Validators.required], - subcontent: this.fb.array([]) - }); - - this.contents.push(contentGroup); - - // Agregar el primer subcontenido automáticamente - this.addSubcontent(this.contents.length - 1); - - // Actualizar las referencias "next" automáticamente - this.updateNextOptions(); - } - } + constructor(private router: Router) {} - removeContent(index: number): void { - if (this.contents.length > 1) { - this.contents.removeAt(index); - this.updateNextOptions(); - } + // Manejar cambios en los datos del formulario + onFormDataChange(courseData: CourseData): void { + this.currentCourseData = courseData; + console.log('Datos del curso actualizados:', courseData); } - // Métodos para manejar subcontenidos - addSubcontent(contentIndex: number): void { - const subcontentGroup = this.fb.group({ - subtitle: ['', Validators.required], - subparagraph: ['', Validators.required], - example: this.fb.array([this.fb.control('', Validators.required)]) - }); - - this.getSubcontents(contentIndex).push(subcontentGroup); - } - - removeSubcontent(contentIndex: number, subcontentIndex: number): void { - const subcontents = this.getSubcontents(contentIndex); - if (subcontents.length > 1) { - subcontents.removeAt(subcontentIndex); - } - } - - // Métodos para manejar ejemplos - addExample(contentIndex: number, subcontentIndex: number): void { - this.getExamples(contentIndex, subcontentIndex).push( - this.fb.control('', Validators.required) - ); - } - - removeExample(contentIndex: number, subcontentIndex: number, exampleIndex: number): void { - const examples = this.getExamples(contentIndex, subcontentIndex); - if (examples.length > 1) { - examples.removeAt(exampleIndex); - } - } - - // Actualizar opciones de "siguiente contenido" automáticamente - updateNextOptions(): void { - // Esta función ahora solo actualiza los valores "next" internamente - // No se muestra en el formulario, pero se usa en el JSON final - } - - // Generar ruta de acceso automáticamente desde el título - private generateGoto(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9\s]/g, '') // Eliminar caracteres especiales - .replace(/\s+/g, '-') // Reemplazar espacios con guiones - .trim(); - } - - // Formatear datos para el JSON final - private formatCourseData(): CourseData { - const formValue = this.courseForm.value as FormValue; - - const contents: ContentData[] = formValue.contents.map((content: FormContentValue, index: number) => { - const contentData: ContentData = { - title: content.title, - paragraph: [content.paragraph], - subcontent: content.subcontent.map((sub: FormSubcontentValue) => ({ - subtitle: sub.subtitle, - subparagraph: [sub.subparagraph], - example: sub.example.filter((ex: string) => ex.trim() !== '') - })), - next: null // Inicializar next como null por defecto - }; - - // Agregar "next" automáticamente si no es el último contenido - if (index < formValue.contents.length - 1) { - const nextContent = formValue.contents[index + 1]; - // Verificar que el siguiente contenido existe y tiene título - if (nextContent && nextContent.title && nextContent.title.trim() !== '') { - contentData.next = nextContent.title.trim(); - } - } - - return contentData; - }); - - return { - course: { - title: formValue.title, - description: formValue.description, - goto: this.generateGoto(formValue.title) - }, - contents - }; - } - - // Vista previa del JSON - getPreviewJson(): string { - if (this.courseForm.valid) { - return JSON.stringify(this.formatCourseData(), null, 2); - } - return 'Completa todos los campos requeridos para ver la vista previa'; + // Manejar cambios en la validez del formulario + onFormValidChange(isValid: boolean): void { + this.isFormValid = isValid; + console.log('Estado de validez del formulario:', isValid); } + // Alternar vista previa del JSON togglePreview(): void { this.showPreview = !this.showPreview; } - // Validación personalizada - private validateCourse(): boolean { - // Validar que cada contenido tenga al menos un subcontenido - for (let i = 0; i < this.contents.length; i++) { - const subcontents = this.getSubcontents(i); - if (subcontents.length === 0) { - alert(`El contenido ${i + 1} debe tener al menos un subcontenido`); - return false; - } - - // Validar que cada subcontenido tenga al menos un ejemplo - for (let j = 0; j < subcontents.length; j++) { - const examples = this.getExamples(i, j); - const validExamples = examples.controls.filter(ex => ex.value.trim() !== ''); - if (validExamples.length === 0) { - alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo`); - return false; - } - } + // Enviar formulario + async onSubmit(): Promise { + if (!this.isFormValid || !this.currentCourseData) { + console.error('Formulario inválido o sin datos'); + return; } - return true; - } + this.isSubmitting = true; - // MÉTODO ACTUALIZADO: Envío del formulario con integración a la API - onSubmit(): void { - if (this.courseForm.valid && this.validateCourse()) { - this.isSubmitting = true; + try { + // Aquí puedes implementar tu lógica de envío + // Por ejemplo, llamar a un servicio para guardar el curso + console.log('Enviando curso:', this.currentCourseData); - const courseData = this.formatCourseData(); + // Simular una llamada asíncrona + await this.saveCourse(this.currentCourseData); - // Enviar el curso a la API - this.coursesService.createCourse(courseData).subscribe({ - next: (response) => { - console.log('Curso creado exitosamente:', response); - - // Mostrar mensaje de éxito - alert(`¡Curso creado exitosamente! ID: ${response.id}`); - - // Opcional: Resetear el formulario o navegar a otra página - this.courseForm.reset(); - this.contents.clear(); - this.addContent(); // Agregar contenido inicial - - // Opcional: Navegar a la lista de cursos o a ver el curso creado - this.router.navigate(['/cursos']); - - this.isSubmitting = false; - }, - error: (error) => { - console.error('Error al crear el curso:', error); - alert('Error al crear el curso: ' + error.message); - this.isSubmitting = false; - } - }); + // Mostrar mensaje de éxito y redirigir + alert('¡Curso creado exitosamente!'); + this.router.navigate(['/courses']); // Ajusta la ruta según tu aplicación - } else { - this.markFormGroupTouched(this.courseForm); - alert('Por favor, completa todos los campos requeridos'); + } catch (error) { + console.error('Error al crear el curso:', error); + alert('Error al crear el curso. Por favor, inténtalo de nuevo.'); + } finally { + this.isSubmitting = false; } } - onCancel(): void { - if (confirm('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios.')) { - this.courseForm.reset(); - this.contents.clear(); - this.addContent(); // Agregar contenido inicial - this.router.navigate(['/cursos']) - } + // Simular guardado del curso (reemplaza con tu lógica real) + private async saveCourse(courseData: CourseData): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simular éxito o error + const success = Math.random() > 0.1; // 90% de probabilidad de éxito + + if (success) { + resolve(); + } else { + reject(new Error('Error simulado en el servidor')); + } + }, 2000); // Simular 2 segundos de carga + }); } - // Marcar todos los campos como tocados para mostrar errores - private markFormGroupTouched(formGroup: FormGroup | FormArray): void { - Object.keys(formGroup.controls).forEach(key => { - const control = formGroup.get(key); - if (control instanceof FormGroup || control instanceof FormArray) { - this.markFormGroupTouched(control); - } else { - control?.markAsTouched(); + // Cancelar creación + onCancel(): void { + const hasChanges = this.currentCourseData !== null; + + if (hasChanges) { + const confirmLeave = confirm('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios.'); + if (!confirmLeave) { + return; } - }); + } + + this.router.navigate(['/courses']); // Ajusta la ruta según tu aplicación } - // Método para actualizar ruta automáticamente cuando cambia el título - onTitleChange(): void { - // Este método se mantiene para compatibilidad con el template - // La generación de goto ahora es automática en formatCourseData() + // Método para obtener datos del curso (útil para debugging) + getCourseData(): CourseData | null { + return this.currentCourseData; } } \ No newline at end of file From 74adeb96c755c9d10d77aaef8db6c52166b23373 Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:31:51 -0400 Subject: [PATCH 04/20] campos de tiempo maximo y recursos maximos incluidos en el formulario de curso --- .../form-course/form-course.component.html | 71 ++++++++++-- .../form-course/form-course.component.ts | 108 +++++++++++++++--- 2 files changed, 152 insertions(+), 27 deletions(-) diff --git a/frontend/angular/src/app/components/form-course/form-course.component.html b/frontend/angular/src/app/components/form-course/form-course.component.html index 55d521d..9a0f3fd 100644 --- a/frontend/angular/src/app/components/form-course/form-course.component.html +++ b/frontend/angular/src/app/components/form-course/form-course.component.html @@ -39,7 +39,7 @@

📖 Contenidos del Curso

class="add-button" (click)="addContent()" [disabled]="contents.length >= 20"> - ➕ Agregar Contenido + ➕ Agregar Contenido ({{ contents.length }}/20) @@ -92,8 +92,9 @@

📋 Subcontenidos

@@ -145,18 +146,21 @@
💻 Ejemplos de Código
+
{{ i + 1 }}.{{ j + 1 }}.{{ k + 1 }} - +
- + +
+ +
+ + +
+ El código del ejemplo es requerido +
+
+ + +
+
+ + +
+ {{ getResourceConsumptionError(i, j, k) }} +
+
+ +
+ + +
+ {{ getProcessingTimeError(i, j, k) }} +
+
+
+
diff --git a/frontend/angular/src/app/components/form-course/form-course.component.ts b/frontend/angular/src/app/components/form-course/form-course.component.ts index 6ca0134..7d495bb 100644 --- a/frontend/angular/src/app/components/form-course/form-course.component.ts +++ b/frontend/angular/src/app/components/form-course/form-course.component.ts @@ -22,7 +22,13 @@ interface ContentData { interface SubcontentData { subtitle: string; subparagraph: string[]; - example: string[]; + example: ExampleData[]; +} + +interface ExampleData { + code: string; + maxResourceConsumption: number; + maxProcessingTime: number; } interface FormContentValue { @@ -34,7 +40,13 @@ interface FormContentValue { interface FormSubcontentValue { subtitle: string; subparagraph: string; - example: string[]; + example: FormExampleValue[]; +} + +interface FormExampleValue { + code: string; + maxResourceConsumption: number; + maxProcessingTime: number; } interface FormValue { @@ -43,6 +55,21 @@ interface FormValue { contents: FormContentValue[]; } +// Custom validators +function positiveIntegerValidator(control: any) { + const value = control.value; + if (value === null || value === undefined || value === '') { + return null; // Let required validator handle empty values + } + + const numValue = Number(value); + if (!Number.isInteger(numValue) || numValue <= 0) { + return { positiveInteger: true }; + } + + return null; +} + @Component({ selector: 'app-form-course', standalone: true, @@ -92,6 +119,20 @@ export class FormCourseComponent implements OnInit, OnChanges { }); } + private createExampleFormGroup(example?: ExampleData): FormGroup { + return this.fb.group({ + code: [example?.code || '', Validators.required], + maxResourceConsumption: [ + example?.maxResourceConsumption || '', + [Validators.required, positiveIntegerValidator] + ], + maxProcessingTime: [ + example?.maxProcessingTime || '', + [Validators.required, positiveIntegerValidator] + ] + }); + } + private loadInitialData(data: CourseData): void { // Limpiar contenidos existentes this.contents.clear(); @@ -116,7 +157,7 @@ export class FormCourseComponent implements OnInit, OnChanges { subtitle: [subcontent.subtitle, Validators.required], subparagraph: [subcontent.subparagraph.join('\n'), Validators.required], example: this.fb.array( - subcontent.example.map(example => this.fb.control(example, Validators.required)) + subcontent.example.map(example => this.createExampleFormGroup(example)) ) }); @@ -171,13 +212,16 @@ export class FormCourseComponent implements OnInit, OnChanges { // Métodos para manejar subcontenidos addSubcontent(contentIndex: number): void { - const subcontentGroup = this.fb.group({ - subtitle: ['', Validators.required], - subparagraph: ['', Validators.required], - example: this.fb.array([this.fb.control('', Validators.required)]) - }); + const subcontents = this.getSubcontents(contentIndex); + if (subcontents.length < 10) { + const subcontentGroup = this.fb.group({ + subtitle: ['', Validators.required], + subparagraph: ['', Validators.required], + example: this.fb.array([this.createExampleFormGroup()]) + }); - this.getSubcontents(contentIndex).push(subcontentGroup); + subcontents.push(subcontentGroup); + } } removeSubcontent(contentIndex: number, subcontentIndex: number): void { @@ -189,9 +233,10 @@ export class FormCourseComponent implements OnInit, OnChanges { // Métodos para manejar ejemplos addExample(contentIndex: number, subcontentIndex: number): void { - this.getExamples(contentIndex, subcontentIndex).push( - this.fb.control('', Validators.required) - ); + const examples = this.getExamples(contentIndex, subcontentIndex); + if (examples.length < 5) { + examples.push(this.createExampleFormGroup()); + } } removeExample(contentIndex: number, subcontentIndex: number, exampleIndex: number): void { @@ -227,7 +272,13 @@ export class FormCourseComponent implements OnInit, OnChanges { subcontent: content.subcontent.map((sub: FormSubcontentValue) => ({ subtitle: sub.subtitle, subparagraph: [sub.subparagraph], - example: sub.example.filter((ex: string) => ex.trim() !== '') + example: sub.example + .filter((ex: FormExampleValue) => ex.code.trim() !== '') + .map((ex: FormExampleValue) => ({ + code: ex.code, + maxResourceConsumption: Number(ex.maxResourceConsumption), + maxProcessingTime: Number(ex.maxProcessingTime) + })) })), next: null // Inicializar next como null por defecto }; @@ -275,9 +326,13 @@ export class FormCourseComponent implements OnInit, OnChanges { // Validar que cada subcontenido tenga al menos un ejemplo for (let j = 0; j < subcontents.length; j++) { const examples = this.getExamples(i, j); - const validExamples = examples.controls.filter(ex => ex.value.trim() !== ''); + const validExamples = examples.controls.filter(ex => + ex.get('code')?.value.trim() !== '' && + ex.get('maxResourceConsumption')?.valid && + ex.get('maxProcessingTime')?.valid + ); if (validExamples.length === 0) { - alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo`); + alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo válido`); return false; } } @@ -333,4 +388,27 @@ export class FormCourseComponent implements OnInit, OnChanges { this.contents.clear(); this.addContent(); } + + // Métodos auxiliares para obtener mensajes de error + getResourceConsumptionError(contentIndex: number, subcontentIndex: number, exampleIndex: number): string { + const control = this.getExamples(contentIndex, subcontentIndex).at(exampleIndex).get('maxResourceConsumption'); + if (control?.hasError('required')) { + return 'El consumo máximo de recursos es requerido'; + } + if (control?.hasError('positiveInteger')) { + return 'Debe ser un número entero positivo mayor a 0'; + } + return ''; + } + + getProcessingTimeError(contentIndex: number, subcontentIndex: number, exampleIndex: number): string { + const control = this.getExamples(contentIndex, subcontentIndex).at(exampleIndex).get('maxProcessingTime'); + if (control?.hasError('required')) { + return 'El tiempo máximo de procesamiento es requerido'; + } + if (control?.hasError('positiveInteger')) { + return 'Debe ser un número entero positivo mayor a 0'; + } + return ''; + } } \ No newline at end of file From 6c2c13082024b3109c9c8f060acbcf4bd775848c Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:50:00 -0400 Subject: [PATCH 05/20] crear curso en el navbar con traducciones --- frontend/angular/public/i18n/en.json | 1 + frontend/angular/public/i18n/es.json | 1 + frontend/angular/public/i18n/qu.json | 1 + frontend/angular/src/app/shared/navbar/navbar.component.html | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/angular/public/i18n/en.json b/frontend/angular/public/i18n/en.json index e43a1ce..96a3a5d 100644 --- a/frontend/angular/public/i18n/en.json +++ b/frontend/angular/public/i18n/en.json @@ -8,6 +8,7 @@ "terminal": "Terminal", "chat": "Chat", "myLearning": "My Learning", + "createCourse": "Create Course", "language": "Language" }, "HOME": { diff --git a/frontend/angular/public/i18n/es.json b/frontend/angular/public/i18n/es.json index 0826429..cd73ec7 100644 --- a/frontend/angular/public/i18n/es.json +++ b/frontend/angular/public/i18n/es.json @@ -8,6 +8,7 @@ "terminal": "Terminal", "chat": "Chat", "myLearning": "Mi aprendizaje", + "createCourse": "Crear curso", "language": "Idioma" }, "HOME": { diff --git a/frontend/angular/public/i18n/qu.json b/frontend/angular/public/i18n/qu.json index 3496df3..e8b6592 100644 --- a/frontend/angular/public/i18n/qu.json +++ b/frontend/angular/public/i18n/qu.json @@ -8,6 +8,7 @@ "terminal": "Terminal", "chat": "Rimanakuy", "myLearning": "Ñuqa yachachiyki", + "createCourse": "Cursota Paqarichiy", "language": "Rimay" }, "HOME": { diff --git a/frontend/angular/src/app/shared/navbar/navbar.component.html b/frontend/angular/src/app/shared/navbar/navbar.component.html index 43db559..baf4a62 100644 --- a/frontend/angular/src/app/shared/navbar/navbar.component.html +++ b/frontend/angular/src/app/shared/navbar/navbar.component.html @@ -62,7 +62,7 @@

PyBloom

routerLink="/crear-curso" routerLinkActive="active" (click)="closeMenu()" - >{{ "Crear cursos" | translate }}{{ "navbar.createCourse" | translate }}
  • From ff2bf78a81c635b3b1aaeced789e85c3dd93ff0a Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 20:28:03 -0400 Subject: [PATCH 06/20] editar curso front, carga el JSON en el form --- frontend/angular/src/app/app.routes.ts | 2 + .../edit-course/edit-course.component.html | 38 +++ .../edit-course/edit-course.component.scss | 217 +++++++++++++ .../edit-course/edit-course.component.spec.ts | 0 .../edit-course/edit-course.component.ts | 302 ++++++++++++++++++ 5 files changed, 559 insertions(+) create mode 100644 frontend/angular/src/app/pages/edit-course/edit-course.component.html create mode 100644 frontend/angular/src/app/pages/edit-course/edit-course.component.scss create mode 100644 frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts create mode 100644 frontend/angular/src/app/pages/edit-course/edit-course.component.ts diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts index a680a0f..1e7dadc 100644 --- a/frontend/angular/src/app/app.routes.ts +++ b/frontend/angular/src/app/app.routes.ts @@ -13,6 +13,7 @@ import { CursosComponent } from './pages/cursos/cursos.component'; import { IntroductionComponent } from './pages/introduction/introduction.component'; import { ChatComponent } from './pages/chat/chat.component'; import { CreateCourseComponent } from './pages/create-course/create-course.component'; +import { EditCourseComponent } from './pages/edit-course/edit-course.component'; export const routes: Routes = [ { path: '', component: HomeComponent }, @@ -29,6 +30,7 @@ export const routes: Routes = [ { path: 'lista-cursos', component: CoursesListComponent }, { path: 'terminal', component: TerminalComponent }, { path: 'cursos/:id', component: IntroductionComponent }, + { path: 'editar-curso/:id', component: EditCourseComponent }, { path: 'chat', component: ChatComponent }, { path: 'crear-curso', component: CreateCourseComponent }, diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.html b/frontend/angular/src/app/pages/edit-course/edit-course.component.html new file mode 100644 index 0000000..78e5605 --- /dev/null +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.html @@ -0,0 +1,38 @@ +
    +
    +
    +

    Editar Curso

    +

    Modifica y perfecciona tu curso de programación

    +
    +
    + + + + +
    + + + +
    +
    \ No newline at end of file diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.scss b/frontend/angular/src/app/pages/edit-course/edit-course.component.scss new file mode 100644 index 0000000..f8b163b --- /dev/null +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.scss @@ -0,0 +1,217 @@ +@use "sass:color"; + +// Variables de color +$primary-color: #4346ff; // azul vibrante +$secondary-color: #ff6a81; // rosa coral +$background-dark: #000000; // fondo negro total +$background-light: #272149; // morado oscuro +$text-light: #ffffff; +$text-muted: rgba(255, 255, 255, 0.8); +$card-bg: rgba(255, 255, 255, 0.05); +$card-border: rgba(255, 255, 255, 0.1); +$error-color: #f44336; +$success-color: #4caf50; +$warning-color: #ff9800; + +.edit-course-container { + min-height: 100vh; + color: $text-light; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + padding-bottom: 60px; +} + +.hero-section { + padding: 20px; + + @media (max-width: 768px) { + padding: 30px 20px 15px; + } + + .hero-content { + text-align: center; + align-items: center; + display: flex; + flex-direction: column; + + h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + font-weight: 700; + color: $text-light; + background: linear-gradient(45deg, $primary-color, $secondary-color); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + width: fit-content; + } + + .welcome-message { + font-size: 1.1rem; + opacity: 0.9; + color: $text-muted; + } + } +} + +// Acciones del formulario +.form-actions { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 40px; + padding: 0 120px; + + @media (max-width: 768px) { + padding: 0 20px; + flex-direction: column; + } +} + +.cancel-button { + background: transparent; + color: $text-muted; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 14px 32px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: $text-light; + border-color: rgba(255, 255, 255, 0.5); + } +} + +.preview-button { + background: rgba(255, 255, 255, 0.1); + color: $text-light; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 14px 32px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.5); + } + + &.active { + background: linear-gradient(45deg, rgba($primary-color, 0.2), rgba($secondary-color, 0.2)); + border-color: $primary-color; + color: $primary-color; + } +} + +.submit-button { + background: linear-gradient(45deg, $primary-color, $secondary-color); + color: white; + border: none; + padding: 14px 32px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + min-width: 180px; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba($primary-color, 0.4); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + background: rgba(255, 255, 255, 0.1); + color: $text-muted; + } +} + +// Loading state +.loading-state { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + color: $text-muted; + font-size: 1.1rem; + + .spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: $primary-color; + animation: spin 1s ease-in-out infinite; + margin-right: 10px; + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +// Error state +.error-state { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 200px; + color: $error-color; + text-align: center; + padding: 20px; + + .error-icon { + font-size: 3rem; + margin-bottom: 1rem; + } + + .error-title { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + .error-message { + color: $text-muted; + margin-bottom: 1.5rem; + } + + .retry-button { + background: $error-color; + color: white; + border: none; + padding: 12px 24px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: color.adjust($error-color, $lightness: -10%); + } + } +} + +// Responsive +@media (max-width: 576px) { + .form-actions { + flex-direction: column; + + .cancel-button, + .preview-button, + .submit-button { + width: 100%; + } + } + + .hero-section .hero-content h1 { + font-size: 2rem; + } +} \ No newline at end of file diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts new file mode 100644 index 0000000..36f69b8 --- /dev/null +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts @@ -0,0 +1,302 @@ +import { Component, OnInit } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormCourseComponent } from '../../components/form-course/form-course.component'; + +// Interfaces para el tipado de datos +interface CourseData { + course: { + title: string; + description: string; + goto: string; + }; + contents: ContentData[]; +} + +interface ContentData { + title: string; + paragraph: string[]; + subcontent: SubcontentData[]; + next: string | null; +} + +interface SubcontentData { + subtitle: string; + subparagraph: string[]; + example: ExampleData[]; +} + +interface ExampleData { + code: string; + maxResourceConsumption: number; + maxProcessingTime: number; +} + +@Component({ + selector: 'app-edit-course', + standalone: true, + templateUrl: './edit-course.component.html', + styleUrls: ['./edit-course.component.scss'], + imports: [CommonModule, FormCourseComponent] +}) +export class EditCourseComponent implements OnInit { + // Estados del componente + isSubmitting = false; + isFormValid = false; + showPreview = false; + isLoading = true; + hasError = false; + errorMessage = ''; + hasChanges = false; + + // Datos del curso + courseId: string | null = null; + initialCourseData: CourseData | undefined = undefined; + currentCourseData: CourseData | undefined = undefined; + + constructor( + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit(): void { + this.loadCourseData(); + } + + // Cargar datos del curso desde la ruta + private async loadCourseData(): Promise { + try { + this.isLoading = true; + this.hasError = false; + + // Obtener ID del curso desde la ruta + this.courseId = this.route.snapshot.paramMap.get('id'); + + if (!this.courseId) { + throw new Error('ID del curso no encontrado'); + } + + // AQUI CARGAR LOS DATOS DEL CURSO + this.initialCourseData = await this.fetchCourseData(this.courseId); + this.currentCourseData = JSON.parse(JSON.stringify(this.initialCourseData)); // Deep copy + + console.log('Datos del curso cargados:', this.initialCourseData); + + } catch (error) { + console.error('Error al cargar el curso:', error); + this.hasError = true; + this.errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + } finally { + this.isLoading = false; + } + } + + // Simular carga de datos del curso (reemplaza con tu lógica real) + private async fetchCourseData(courseId: string): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simular éxito o error + const success = Math.random() > 0.1; // 90% de probabilidad de éxito + + if (success) { + // Mock data basado en el formato JSON que proporcionaste + const mockCourseData: CourseData = { + course: { + title: "askfjsafkj", + description: "kkjafsfakjkjfskjsfkj", + goto: "askfjsafkj" + }, + contents: [ + { + title: "puebads", + paragraph: [ + "ndjkgandga" + ], + subcontent: [ + { + subtitle: "vkjadkjvnkjad", + subparagraph: [ + "kjadavknjajdknj" + ], + example: [ + { + code: "djvanjkdvnkja", + maxResourceConsumption: 124, + maxProcessingTime: 12531 + }, + { + code: "scas", + maxResourceConsumption: 124, + maxProcessingTime: 214 + }, + { + code: "39519", + maxResourceConsumption: 8462, + maxProcessingTime: 1358 + } + ] + }, + { + subtitle: "adkjakjdgkj", + subparagraph: [ + "kdgkjkjag" + ], + example: [ + { + code: "akgjkajgkj", + maxResourceConsumption: 853, + maxProcessingTime: 8135 + } + ] + } + ], + next: "asgn" + }, + { + title: "asgn", + paragraph: [ + "kdkjkjgnenkj" + ], + subcontent: [ + { + subtitle: "agkjganjk", + subparagraph: [ + "kjasgsag" + ], + example: [ + { + code: "15", + maxResourceConsumption: 215, + maxProcessingTime: 4624 + } + ] + } + ], + next: null + } + ] + }; + resolve(mockCourseData); + } else { + reject(new Error('No se pudo cargar el curso')); + } + }, 1500); // Simular 1.5 segundos de carga + }); + } + + // Manejar cambios en los datos del formulario + onFormDataChange(courseData: CourseData): void { + this.currentCourseData = courseData; + this.checkForChanges(); + console.log('Datos del curso actualizados:', courseData); + } + + // Manejar cambios en la validez del formulario + onFormValidChange(isValid: boolean): void { + this.isFormValid = isValid; + console.log('Estado de validez del formulario:', isValid); + } + + // Verificar si hay cambios en el formulario + private checkForChanges(): void { + if (!this.initialCourseData || !this.currentCourseData) { + this.hasChanges = false; + return; + } + + const initialJson = JSON.stringify(this.initialCourseData); + const currentJson = JSON.stringify(this.currentCourseData); + this.hasChanges = initialJson !== currentJson; + } + + // Alternar vista previa del JSON + togglePreview(): void { + this.showPreview = !this.showPreview; + } + + // Enviar formulario + async onSubmit(): Promise { + if (!this.isFormValid || !this.currentCourseData || !this.hasChanges) { + console.error('Formulario inválido, sin datos o sin cambios'); + return; + } + + this.isSubmitting = true; + + try { + console.log('Guardando cambios del curso:', this.currentCourseData); + + // Simular una llamada asíncrona + await this.updateCourse(this.courseId!, this.currentCourseData); + + // Actualizar datos iniciales después del guardado exitoso + this.initialCourseData = JSON.parse(JSON.stringify(this.currentCourseData)); + this.hasChanges = false; + + // Mostrar mensaje de éxito + alert('¡Curso actualizado exitosamente!'); + + // Opcionalmente redirigir + // this.router.navigate(['/courses']); + + } catch (error) { + console.error('Error al actualizar el curso:', error); + alert('Error al actualizar el curso. Por favor, inténtalo de nuevo.'); + } finally { + this.isSubmitting = false; + } + } + + // Simular actualización del curso (reemplaza con tu lógica real) + private async updateCourse(courseId: string, courseData: CourseData): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simular éxito o error + const success = Math.random() > 0.1; // 90% de probabilidad de éxito + + if (success) { + resolve(); + } else { + reject(new Error('Error simulado en el servidor')); + } + }, 2000); // Simular 2 segundos de carga + }); + } + + // Cancelar edición + onCancel(): void { + if (this.hasChanges) { + const confirmLeave = confirm('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios no guardados.'); + if (!confirmLeave) { + return; + } + } + + this.router.navigate(['/courses']); // Ajusta la ruta según tu aplicación + } + + // Reintentar carga de datos + onRetry(): void { + this.loadCourseData(); + } + + // Método para obtener datos del curso (útil para debugging) + getCourseData(): CourseData | undefined { + return this.currentCourseData; + } + + // Verificar si hay cambios pendientes + get hasPendingChanges(): boolean { + return this.hasChanges; + } + + // Obtener estado de carga + get isLoadingData(): boolean { + return this.isLoading; + } + + // Obtener estado de error + get hasLoadingError(): boolean { + return this.hasError; + } +} \ No newline at end of file From d4b59af9c12fe6fc5229aa67e80d4c98a3b11578 Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:01:57 -0400 Subject: [PATCH 07/20] campos de espacio y tiempo maximo cambiado --- .../form-course/form-course.component.html | 66 ++++----- .../form-course/form-course.component.ts | 61 ++++---- .../create-course/create-course.component.ts | 14 +- .../edit-course/edit-course.component.ts | 132 ++++++++---------- 4 files changed, 128 insertions(+), 145 deletions(-) diff --git a/frontend/angular/src/app/components/form-course/form-course.component.html b/frontend/angular/src/app/components/form-course/form-course.component.html index 9a0f3fd..cc7b5cf 100644 --- a/frontend/angular/src/app/components/form-course/form-course.component.html +++ b/frontend/angular/src/app/components/form-course/form-course.component.html @@ -85,6 +85,39 @@

    📄 Contenido {{ i + 1 }}

    rows="2"> + +
    +
    + + +
    + {{ getResourceConsumptionError(i) }} +
    +
    + +
    + + +
    + {{ getProcessingTimeError(i) }} +
    +
    +
    +
    @@ -184,39 +217,6 @@
    💻 Ejemplos de Código
    El código del ejemplo es requerido
    - - -
    -
    - - -
    - {{ getResourceConsumptionError(i, j, k) }} -
    -
    - -
    - - -
    - {{ getProcessingTimeError(i, j, k) }} -
    -
    -
    diff --git a/frontend/angular/src/app/components/form-course/form-course.component.ts b/frontend/angular/src/app/components/form-course/form-course.component.ts index 7d495bb..960ef13 100644 --- a/frontend/angular/src/app/components/form-course/form-course.component.ts +++ b/frontend/angular/src/app/components/form-course/form-course.component.ts @@ -16,7 +16,9 @@ interface ContentData { title: string; paragraph: string[]; subcontent: SubcontentData[]; - next: string | null; + next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; } interface SubcontentData { @@ -27,14 +29,14 @@ interface SubcontentData { interface ExampleData { code: string; - maxResourceConsumption: number; - maxProcessingTime: number; } interface FormContentValue { title: string; paragraph: string; subcontent: FormSubcontentValue[]; + maxResourceConsumption: number; + maxProcessingTime: number; } interface FormSubcontentValue { @@ -45,8 +47,6 @@ interface FormSubcontentValue { interface FormExampleValue { code: string; - maxResourceConsumption: number; - maxProcessingTime: number; } interface FormValue { @@ -119,17 +119,25 @@ export class FormCourseComponent implements OnInit, OnChanges { }); } - private createExampleFormGroup(example?: ExampleData): FormGroup { + private createContentFormGroup(content?: ContentData): FormGroup { return this.fb.group({ - code: [example?.code || '', Validators.required], + title: [content?.title || '', Validators.required], + paragraph: [content?.paragraph?.join('\n') || '', Validators.required], maxResourceConsumption: [ - example?.maxResourceConsumption || '', + content?.maxResourceConsumption || '', [Validators.required, positiveIntegerValidator] ], maxProcessingTime: [ - example?.maxProcessingTime || '', + content?.maxProcessingTime || '', [Validators.required, positiveIntegerValidator] - ] + ], + subcontent: this.fb.array([]) + }); + } + + private createExampleFormGroup(example?: ExampleData): FormGroup { + return this.fb.group({ + code: [example?.code || '', Validators.required] }); } @@ -145,11 +153,7 @@ export class FormCourseComponent implements OnInit, OnChanges { // Cargar contenidos data.contents.forEach(content => { - const contentGroup = this.fb.group({ - title: [content.title, Validators.required], - paragraph: [content.paragraph.join('\n'), Validators.required], - subcontent: this.fb.array([]) - }); + const contentGroup = this.createContentFormGroup(content); // Cargar subcontenidos content.subcontent.forEach(subcontent => { @@ -187,12 +191,7 @@ export class FormCourseComponent implements OnInit, OnChanges { // Métodos para manejar contenidos addContent(): void { if (this.contents.length < 20) { - const contentGroup = this.fb.group({ - title: ['', Validators.required], - paragraph: ['', Validators.required], - subcontent: this.fb.array([]) - }); - + const contentGroup = this.createContentFormGroup(); this.contents.push(contentGroup); // Agregar el primer subcontenido automáticamente @@ -275,12 +274,12 @@ export class FormCourseComponent implements OnInit, OnChanges { example: sub.example .filter((ex: FormExampleValue) => ex.code.trim() !== '') .map((ex: FormExampleValue) => ({ - code: ex.code, - maxResourceConsumption: Number(ex.maxResourceConsumption), - maxProcessingTime: Number(ex.maxProcessingTime) + code: ex.code })) })), - next: null // Inicializar next como null por defecto + next: null, // Inicializar next como null por defecto + maxResourceConsumption: Number(content.maxResourceConsumption), + maxProcessingTime: Number(content.maxProcessingTime) }; // Agregar "next" automáticamente si no es el último contenido @@ -327,9 +326,7 @@ export class FormCourseComponent implements OnInit, OnChanges { for (let j = 0; j < subcontents.length; j++) { const examples = this.getExamples(i, j); const validExamples = examples.controls.filter(ex => - ex.get('code')?.value.trim() !== '' && - ex.get('maxResourceConsumption')?.valid && - ex.get('maxProcessingTime')?.valid + ex.get('code')?.value.trim() !== '' ); if (validExamples.length === 0) { alert(`El subcontenido ${j + 1} del contenido ${i + 1} debe tener al menos un ejemplo válido`); @@ -390,8 +387,8 @@ export class FormCourseComponent implements OnInit, OnChanges { } // Métodos auxiliares para obtener mensajes de error - getResourceConsumptionError(contentIndex: number, subcontentIndex: number, exampleIndex: number): string { - const control = this.getExamples(contentIndex, subcontentIndex).at(exampleIndex).get('maxResourceConsumption'); + getResourceConsumptionError(contentIndex: number): string { + const control = this.contents.at(contentIndex).get('maxResourceConsumption'); if (control?.hasError('required')) { return 'El consumo máximo de recursos es requerido'; } @@ -401,8 +398,8 @@ export class FormCourseComponent implements OnInit, OnChanges { return ''; } - getProcessingTimeError(contentIndex: number, subcontentIndex: number, exampleIndex: number): string { - const control = this.getExamples(contentIndex, subcontentIndex).at(exampleIndex).get('maxProcessingTime'); + getProcessingTimeError(contentIndex: number): string { + const control = this.contents.at(contentIndex).get('maxProcessingTime'); if (control?.hasError('required')) { return 'El tiempo máximo de procesamiento es requerido'; } diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.ts b/frontend/angular/src/app/pages/create-course/create-course.component.ts index ca33328..a3d59c8 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.ts +++ b/frontend/angular/src/app/pages/create-course/create-course.component.ts @@ -17,13 +17,19 @@ interface ContentData { title: string; paragraph: string[]; subcontent: SubcontentData[]; - next: string | null; + next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; } interface SubcontentData { subtitle: string; subparagraph: string[]; - example: string[]; + example: ExampleData[]; +} + +interface ExampleData { + code: string; } @Component({ @@ -78,7 +84,7 @@ export class CreateCourseComponent { // Mostrar mensaje de éxito y redirigir alert('¡Curso creado exitosamente!'); - this.router.navigate(['/courses']); // Ajusta la ruta según tu aplicación + this.router.navigate(['/cursos']); // Ajusta la ruta según tu aplicación } catch (error) { console.error('Error al crear el curso:', error); @@ -115,7 +121,7 @@ export class CreateCourseComponent { } } - this.router.navigate(['/courses']); // Ajusta la ruta según tu aplicación + this.router.navigate(['/cursos']); // Ajusta la ruta según tu aplicación } // Método para obtener datos del curso (útil para debugging) diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts index 36f69b8..7ed7025 100644 --- a/frontend/angular/src/app/pages/edit-course/edit-course.component.ts +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts @@ -17,7 +17,9 @@ interface ContentData { title: string; paragraph: string[]; subcontent: SubcontentData[]; - next: string | null; + next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; } interface SubcontentData { @@ -28,8 +30,6 @@ interface SubcontentData { interface ExampleData { code: string; - maxResourceConsumption: number; - maxProcessingTime: number; } @Component({ @@ -76,7 +76,7 @@ export class EditCourseComponent implements OnInit { throw new Error('ID del curso no encontrado'); } - // AQUI CARGAR LOS DATOS DEL CURSO + // Cargar datos del curso this.initialCourseData = await this.fetchCourseData(this.courseId); this.currentCourseData = JSON.parse(JSON.stringify(this.initialCourseData)); // Deep copy @@ -101,81 +101,61 @@ export class EditCourseComponent implements OnInit { if (success) { // Mock data basado en el formato JSON que proporcionaste const mockCourseData: CourseData = { - course: { - title: "askfjsafkj", - description: "kkjafsfakjkjfskjsfkj", - goto: "askfjsafkj" - }, - contents: [ + course: { + title: "afsafasfa", + description: "safagsgsgsaggas", + goto: "afsafasfa" + }, + contents: [ + { + title: "agsgasagsgsag", + paragraph: [ + "asgasgaasg" + ], + subcontent: [ + { + subtitle: "afavvqeqveqve", + subparagraph: [ + "qvevqevqeqveqve" + ], + example: [ { - title: "puebads", - paragraph: [ - "ndjkgandga" - ], - subcontent: [ - { - subtitle: "vkjadkjvnkjad", - subparagraph: [ - "kjadavknjajdknj" - ], - example: [ - { - code: "djvanjkdvnkja", - maxResourceConsumption: 124, - maxProcessingTime: 12531 - }, - { - code: "scas", - maxResourceConsumption: 124, - maxProcessingTime: 214 - }, - { - code: "39519", - maxResourceConsumption: 8462, - maxProcessingTime: 1358 - } - ] - }, - { - subtitle: "adkjakjdgkj", - subparagraph: [ - "kdgkjkjag" - ], - example: [ - { - code: "akgjkajgkj", - maxResourceConsumption: 853, - maxProcessingTime: 8135 - } - ] - } - ], - next: "asgn" + code: "142124dv" }, { - title: "asgn", - paragraph: [ - "kdkjkjgnenkj" - ], - subcontent: [ - { - subtitle: "agkjganjk", - subparagraph: [ - "kjasgsag" - ], - example: [ - { - code: "15", - maxResourceConsumption: 215, - maxProcessingTime: 4624 - } - ] - } - ], - next: null + code: "dsvsdvdvsd112215" + } + ] + } + ], + next: "fqefqffqw", + maxResourceConsumption: 123, + maxProcessingTime: 421 + }, + { + title: "fqefqffqw", + paragraph: [ + "12wfqfeveqvqqve" + ], + subcontent: [ + { + subtitle: "qwrwqrqwr", + subparagraph: [ + "qwrwrqqwr" + ], + example: [ + { + code: "qwrqw" } ] - }; + } + ], + next: null, + maxResourceConsumption: 124, + maxProcessingTime: 531 + } + ] + }; resolve(mockCourseData); } else { reject(new Error('No se pudo cargar el curso')); @@ -237,7 +217,7 @@ export class EditCourseComponent implements OnInit { alert('¡Curso actualizado exitosamente!'); // Opcionalmente redirigir - // this.router.navigate(['/courses']); + this.router.navigate(['/cursos']); } catch (error) { console.error('Error al actualizar el curso:', error); @@ -272,7 +252,7 @@ export class EditCourseComponent implements OnInit { } } - this.router.navigate(['/courses']); // Ajusta la ruta según tu aplicación + this.router.navigate(['/cursos']); // Ajusta la ruta según tu aplicación } // Reintentar carga de datos From 5be251f77f92ef7484cfe100421039309cb7255d Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:03:57 -0400 Subject: [PATCH 08/20] Vista previa del JSON eliminado, solo era pa probar --- .../app/pages/create-course/create-course.component.html | 7 ------- .../src/app/pages/edit-course/edit-course.component.html | 7 ------- 2 files changed, 14 deletions(-) diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.html b/frontend/angular/src/app/pages/create-course/create-course.component.html index ab93853..750dcf4 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.html +++ b/frontend/angular/src/app/pages/create-course/create-course.component.html @@ -16,13 +16,6 @@

    Crear Nuevo Curso

    - - + +

    {{ content.description }}

    diff --git a/frontend/angular/src/app/components/card-curso/card-curso.component.scss b/frontend/angular/src/app/components/card-curso/card-curso.component.scss index 8270932..75e4421 100644 --- a/frontend/angular/src/app/components/card-curso/card-curso.component.scss +++ b/frontend/angular/src/app/components/card-curso/card-curso.component.scss @@ -1,25 +1,69 @@ +@use "sass:color"; // solo necesario si usás funciones como color.adjust + +// Variables locales de color (copiadas de tu diseño original) +$primary-color: #4346ff; +$secondary-color: #ff6a81; +$background-dark: #000000; +$background-light: #272149; +$text-light: #ffffff; +$text-muted: rgba(255, 255, 255, 0.8); +$card-bg: rgba(255, 255, 255, 0.05); +$card-border: rgba(255, 255, 255, 0.1); +$error-color: #f44336; + .card { - background-color: #222; + background-color: $card-bg; + border: 1px solid $card-border; border-radius: 12px; - color: #ccc; - padding: 1rem; - display: flex; - flex-direction: column; - justify-content: space-between; - height: 100%; - width: 100%; - box-shadow: 0 4px 6px rgba(0,0,0,0.2); -} + padding: 20px; + position: relative; + transition: all 0.3s ease; + color: $text-light; -.card-title { - font-size: 1.2rem; - margin: 0; -} + &:hover { + background-color: rgba($primary-color, 0.05); + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + } -.card-description { - font-size: 0.9rem; - margin: 0; + .card-title { + font-size: 1.5rem; + margin: 0; + background: linear-gradient(45deg, $primary-color, $secondary-color); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + .card-description { + margin-top: 12px; + font-size: 1rem; + color: $text-muted; + } + + .edit-button { + background: transparent; + border: none; + color: $primary-color; + font-size: 1.2rem; + cursor: pointer; + padding: 6px; + border-radius: 50%; + transition: background 0.3s; + + &:hover { + background: rgba($primary-color, 0.15); + } + + i { + pointer-events: none; + } + } } + //poner buenos estilos :v \ No newline at end of file diff --git a/frontend/angular/src/app/components/card-curso/card-curso.component.ts b/frontend/angular/src/app/components/card-curso/card-curso.component.ts index 70c5031..69581f7 100644 --- a/frontend/angular/src/app/components/card-curso/card-curso.component.ts +++ b/frontend/angular/src/app/components/card-curso/card-curso.component.ts @@ -1,16 +1,24 @@ import { Component, Input } from '@angular/core'; +import { Router } from '@angular/router'; +import { ICardCurso } from '../../shared/interfaces/interfaces'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; -import { ICardCurso } from '../../shared/interfaces/interfaces'; import { RouterModule } from '@angular/router'; @Component({ selector: 'app-card-curso', + standalone: true, imports: [MatCardModule, MatButtonModule, RouterModule], templateUrl: './card-curso.component.html', - standalone: true, styleUrl: './card-curso.component.scss', }) export class CardCursoComponent { @Input() content!: ICardCurso; + + constructor(private router: Router) {} + + onEdit(event: MouseEvent) { + event.stopPropagation(); // Evita que también se dispare el routerLink general + this.router.navigate(['/editar-curso', this.content.id]); + } } From 20cde4006ae5622306ae55a520b5a1c3b31dccea Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:43:51 -0400 Subject: [PATCH 14/20] grid de cursos mejorao, cambienlo si no convence xd --- .../src/app/pages/cursos/cursos.component.html | 4 +--- .../src/app/pages/cursos/cursos.component.scss | 17 ++++++----------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/frontend/angular/src/app/pages/cursos/cursos.component.html b/frontend/angular/src/app/pages/cursos/cursos.component.html index 349d7dc..ce7b4b2 100644 --- a/frontend/angular/src/app/pages/cursos/cursos.component.html +++ b/frontend/angular/src/app/pages/cursos/cursos.component.html @@ -1,9 +1,7 @@
    -
    +
    @for (curso of cursos; track curso.id) { -
    -
    }
    diff --git a/frontend/angular/src/app/pages/cursos/cursos.component.scss b/frontend/angular/src/app/pages/cursos/cursos.component.scss index 026160b..fb51d4c 100644 --- a/frontend/angular/src/app/pages/cursos/cursos.component.scss +++ b/frontend/angular/src/app/pages/cursos/cursos.component.scss @@ -1,18 +1,13 @@ .cursos-container { - height: 100%; - width: 100%; - display: flex; - justify-content: center; - align-items: center; + padding: 40px 20px; + max-width: 1200px; + margin: 0 auto; } -.cards-container { - width: 50%; +.cards-grid { display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-auto-rows: 150px; - row-gap: 1rem; - column-gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 24px; } .single-card :hover { From 5159ae8fa39ef3c58599e114f37aeff0b0b85a77 Mon Sep 17 00:00:00 2001 From: jeremiasVA <150405774+jeremiasVA@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:56:39 -0400 Subject: [PATCH 15/20] cambio spec.ts --- .../form-course/form-course.component.spec.ts | 533 +-------------- .../create-course.component.spec.ts | 357 +--------- .../edit-course/edit-course.component.spec.ts | 634 +----------------- 3 files changed, 10 insertions(+), 1514 deletions(-) diff --git a/frontend/angular/src/app/components/form-course/form-course.component.spec.ts b/frontend/angular/src/app/components/form-course/form-course.component.spec.ts index 183ad64..96c0a12 100644 --- a/frontend/angular/src/app/components/form-course/form-course.component.spec.ts +++ b/frontend/angular/src/app/components/form-course/form-course.component.spec.ts @@ -1,549 +1,24 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; -import { CommonModule } from '@angular/common'; -import { DebugElement } from '@angular/core'; -import { By } from '@angular/platform-browser'; +import { ReactiveFormsModule } from '@angular/forms'; import { FormCourseComponent } from './form-course.component'; describe('FormCourseComponent', () => { let component: FormCourseComponent; let fixture: ComponentFixture; - let formBuilder: FormBuilder; - - const mockCourseData = { - course: { - title: 'Test Course', - description: 'Test Description', - goto: 'test-course' - }, - contents: [ - { - title: 'Test Content 1', - paragraph: ['Test paragraph content'], - subcontent: [ - { - subtitle: 'Test Subtitle', - subparagraph: ['Test subparagraph content'], - example: [ - { - code: 'console.log("test");' - } - ] - } - ], - next: null, - maxResourceConsumption: 256, - maxProcessingTime: 5000 - } - ] - }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - FormCourseComponent, - CommonModule, - ReactiveFormsModule - ], - providers: [FormBuilder] + imports: [ReactiveFormsModule], + declarations: [FormCourseComponent] }).compileComponents(); fixture = TestBed.createComponent(FormCourseComponent); component = fixture.componentInstance; - formBuilder = TestBed.inject(FormBuilder); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); - - describe('Component Initialization', () => { - it('should initialize with empty form', () => { - expect(component.courseForm).toBeDefined(); - expect(component.contents.length).toBe(1); // Should have one content by default - }); - - it('should create form with required validators', () => { - const titleControl = component.courseForm.get('title'); - const descriptionControl = component.courseForm.get('description'); - - expect(titleControl?.hasError('required')).toBeTruthy(); - expect(descriptionControl?.hasError('required')).toBeTruthy(); - }); - - it('should add first content automatically on init', () => { - expect(component.contents.length).toBe(1); - const firstContent = component.contents.at(0); - expect(firstContent.get('title')).toBeDefined(); - expect(firstContent.get('paragraph')).toBeDefined(); - expect(firstContent.get('maxResourceConsumption')).toBeDefined(); - expect(firstContent.get('maxProcessingTime')).toBeDefined(); - }); - }); - - describe('Form Validation', () => { - it('should validate title with minimum length', () => { - const titleControl = component.courseForm.get('title'); - titleControl?.setValue('ab'); - expect(titleControl?.hasError('minlength')).toBeTruthy(); - - titleControl?.setValue('abc'); - expect(titleControl?.hasError('minlength')).toBeFalsy(); - }); - - it('should validate description with minimum length', () => { - const descriptionControl = component.courseForm.get('description'); - descriptionControl?.setValue('short'); - expect(descriptionControl?.hasError('minlength')).toBeTruthy(); - - descriptionControl?.setValue('this is a longer description'); - expect(descriptionControl?.hasError('minlength')).toBeFalsy(); - }); - - it('should validate positive integers for resource consumption', () => { - const content = component.contents.at(0); - const resourceControl = content.get('maxResourceConsumption'); - - resourceControl?.setValue(-1); - expect(resourceControl?.hasError('positiveInteger')).toBeTruthy(); - - resourceControl?.setValue(0); - expect(resourceControl?.hasError('positiveInteger')).toBeTruthy(); - - resourceControl?.setValue(1.5); - expect(resourceControl?.hasError('positiveInteger')).toBeTruthy(); - - resourceControl?.setValue(256); - expect(resourceControl?.hasError('positiveInteger')).toBeFalsy(); - }); - - it('should validate positive integers for processing time', () => { - const content = component.contents.at(0); - const timeControl = content.get('maxProcessingTime'); - - timeControl?.setValue(-1); - expect(timeControl?.hasError('positiveInteger')).toBeTruthy(); - - timeControl?.setValue(0); - expect(timeControl?.hasError('positiveInteger')).toBeTruthy(); - - timeControl?.setValue(1.5); - expect(timeControl?.hasError('positiveInteger')).toBeTruthy(); - - timeControl?.setValue(5000); - expect(timeControl?.hasError('positiveInteger')).toBeFalsy(); - }); - }); - - describe('Content Management', () => { - it('should add content', () => { - const initialLength = component.contents.length; - component.addContent(); - expect(component.contents.length).toBe(initialLength + 1); - }); - - it('should not add more than 20 contents', () => { - // Add contents up to the limit - for (let i = component.contents.length; i < 20; i++) { - component.addContent(); - } - expect(component.contents.length).toBe(20); - - // Try to add one more - component.addContent(); - expect(component.contents.length).toBe(20); - }); - - it('should remove content when more than one exists', () => { - component.addContent(); - const initialLength = component.contents.length; - component.removeContent(0); - expect(component.contents.length).toBe(initialLength - 1); - }); - - it('should not remove content when only one exists', () => { - component.removeContent(0); - expect(component.contents.length).toBe(1); - }); - }); - - describe('Subcontent Management', () => { - it('should add subcontent', () => { - const initialLength = component.getSubcontents(0).length; - component.addSubcontent(0); - expect(component.getSubcontents(0).length).toBe(initialLength + 1); - }); - - it('should not add more than 10 subcontents', () => { - // Add subcontents up to the limit - for (let i = component.getSubcontents(0).length; i < 10; i++) { - component.addSubcontent(0); - } - expect(component.getSubcontents(0).length).toBe(10); - - // Try to add one more - component.addSubcontent(0); - expect(component.getSubcontents(0).length).toBe(10); - }); - - it('should remove subcontent when more than one exists', () => { - component.addSubcontent(0); - const initialLength = component.getSubcontents(0).length; - component.removeSubcontent(0, 0); - expect(component.getSubcontents(0).length).toBe(initialLength - 1); - }); - - it('should not remove subcontent when only one exists', () => { - component.removeSubcontent(0, 0); - expect(component.getSubcontents(0).length).toBe(1); - }); - }); - - describe('Example Management', () => { - it('should add example', () => { - const initialLength = component.getExamples(0, 0).length; - component.addExample(0, 0); - expect(component.getExamples(0, 0).length).toBe(initialLength + 1); - }); - - it('should not add more than 5 examples', () => { - // Add examples up to the limit - for (let i = component.getExamples(0, 0).length; i < 5; i++) { - component.addExample(0, 0); - } - expect(component.getExamples(0, 0).length).toBe(5); - - // Try to add one more - component.addExample(0, 0); - expect(component.getExamples(0, 0).length).toBe(5); - }); - - it('should remove example when more than one exists', () => { - component.addExample(0, 0); - const initialLength = component.getExamples(0, 0).length; - component.removeExample(0, 0, 0); - expect(component.getExamples(0, 0).length).toBe(initialLength - 1); - }); - - it('should not remove example when only one exists', () => { - component.removeExample(0, 0, 0); - expect(component.getExamples(0, 0).length).toBe(1); - }); - }); - - describe('Data Loading', () => { - it('should load initial data correctly', () => { - component.initialData = mockCourseData; - component.ngOnChanges({ - initialData: { - currentValue: mockCourseData, - previousValue: undefined, - firstChange: true, - isFirstChange: () => true - } - }); - - expect(component.courseForm.get('title')?.value).toBe('Test Course'); - expect(component.courseForm.get('description')?.value).toBe('Test Description'); - expect(component.contents.length).toBe(1); - - const firstContent = component.contents.at(0); - expect(firstContent.get('title')?.value).toBe('Test Content 1'); - expect(firstContent.get('maxResourceConsumption')?.value).toBe(256); - expect(firstContent.get('maxProcessingTime')?.value).toBe(5000); - }); - }); - - describe('Data Formatting', () => { - beforeEach(() => { - // Set up a valid form - component.courseForm.patchValue({ - title: 'Test Course', - description: 'Test Description for the course' - }); - - const content = component.contents.at(0); - content.patchValue({ - title: 'Test Content', - paragraph: 'Test paragraph content', - maxResourceConsumption: 256, - maxProcessingTime: 5000 - }); - - const subcontent = component.getSubcontents(0).at(0); - subcontent.patchValue({ - subtitle: 'Test Subtitle', - subparagraph: 'Test subparagraph content' - }); - - const example = component.getExamples(0, 0).at(0); - example.patchValue({ - code: 'console.log("test");' - }); - }); - - it('should format course data correctly', () => { - const formData = component.getFormData(); - - expect(formData).toBeTruthy(); - expect(formData?.course.title).toBe('Test Course'); - expect(formData?.course.description).toBe('Test Description for the course'); - expect(formData?.course.goto).toBe('test-course'); - expect(formData?.contents.length).toBe(1); - - const content = formData?.contents[0]; - expect(content?.title).toBe('Test Content'); - expect(content?.maxResourceConsumption).toBe(256); - expect(content?.maxProcessingTime).toBe(5000); - expect(content?.paragraph).toEqual(['Test paragraph content']); - expect(content?.subcontent.length).toBe(1); - - const subcontent = content?.subcontent[0]; - expect(subcontent?.subtitle).toBe('Test Subtitle'); - expect(subcontent?.subparagraph).toEqual(['Test subparagraph content']); - expect(subcontent?.example.length).toBe(1); - expect(subcontent?.example[0].code).toBe('console.log("test");'); - }); - - it('should generate goto field correctly', () => { - component.courseForm.patchValue({ - title: 'Test Course With Special Characters!' - }); - - const formData = component.getFormData(); - expect(formData?.course.goto).toBe('test-course-with-special-characters'); - }); - - it('should set next field correctly', () => { - // Add a second content - component.addContent(); - const secondContent = component.contents.at(1); - secondContent.patchValue({ - title: 'Second Content', - paragraph: 'Second paragraph', - maxResourceConsumption: 512, - maxProcessingTime: 10000 - }); - - // Add subcontent and example to second content - const secondSubcontent = component.getSubcontents(1).at(0); - secondSubcontent.patchValue({ - subtitle: 'Second Subtitle', - subparagraph: 'Second subparagraph' - }); - - const secondExample = component.getExamples(1, 0).at(0); - secondExample.patchValue({ - code: 'console.log("second");' - }); - - const formData = component.getFormData(); - expect(formData?.contents[0].next).toBe('Second Content'); - expect(formData?.contents[1].next).toBeNull(); - }); - }); - - describe('Form Events', () => { - it('should emit form data changes', () => { - spyOn(component.formDataChange, 'emit'); - - component.courseForm.patchValue({ - title: 'Test Course', - description: 'Test Description for the course' - }); - - const content = component.contents.at(0); - content.patchValue({ - title: 'Test Content', - paragraph: 'Test paragraph', - maxResourceConsumption: 256, - maxProcessingTime: 5000 - }); - - const subcontent = component.getSubcontents(0).at(0); - subcontent.patchValue({ - subtitle: 'Test Subtitle', - subparagraph: 'Test subparagraph' - }); - - const example = component.getExamples(0, 0).at(0); - example.patchValue({ - code: 'console.log("test");' - }); - - // Trigger change detection - fixture.detectChanges(); - - expect(component.formDataChange.emit).toHaveBeenCalled(); - }); - - it('should emit form validity changes', () => { - spyOn(component.formValidChange, 'emit'); - - component.courseForm.patchValue({ - title: 'Test Course' - }); - - // Trigger change detection - fixture.detectChanges(); - - expect(component.formValidChange.emit).toHaveBeenCalled(); - }); - }); - - describe('Error Messages', () => { - it('should return correct resource consumption error messages', () => { - const content = component.contents.at(0); - const resourceControl = content.get('maxResourceConsumption'); - - resourceControl?.markAsTouched(); - resourceControl?.setValue(''); - expect(component.getResourceConsumptionError(0)).toBe('El consumo máximo de recursos es requerido'); - - resourceControl?.setValue(-1); - expect(component.getResourceConsumptionError(0)).toBe('Debe ser un número entero positivo mayor a 0'); - - resourceControl?.setValue(256); - expect(component.getResourceConsumptionError(0)).toBe(''); - }); - - it('should return correct processing time error messages', () => { - const content = component.contents.at(0); - const timeControl = content.get('maxProcessingTime'); - - timeControl?.markAsTouched(); - timeControl?.setValue(''); - expect(component.getProcessingTimeError(0)).toBe('El tiempo máximo de procesamiento es requerido'); - - timeControl?.setValue(-1); - expect(component.getProcessingTimeError(0)).toBe('Debe ser un número entero positivo mayor a 0'); - - timeControl?.setValue(5000); - expect(component.getProcessingTimeError(0)).toBe(''); - }); - }); - - describe('Form Reset', () => { - it('should reset form correctly', () => { - // Fill form with data - component.courseForm.patchValue({ - title: 'Test Course', - description: 'Test Description' - }); - - component.addContent(); - - // Reset form - component.resetForm(); - - expect(component.courseForm.get('title')?.value).toBeFalsy(); - expect(component.courseForm.get('description')?.value).toBeFalsy(); - expect(component.contents.length).toBe(1); - }); - }); - - describe('Custom Validation', () => { - it('should validate that course has at least one content with subcontent and example', () => { - // Create a valid form - component.courseForm.patchValue({ - title: 'Test Course', - description: 'Test Description for the course' - }); - - const content = component.contents.at(0); - content.patchValue({ - title: 'Test Content', - paragraph: 'Test paragraph', - maxResourceConsumption: 256, - maxProcessingTime: 5000 - }); - - const subcontent = component.getSubcontents(0).at(0); - subcontent.patchValue({ - subtitle: 'Test Subtitle', - subparagraph: 'Test subparagraph' - }); - - const example = component.getExamples(0, 0).at(0); - example.patchValue({ - code: 'console.log("test");' - }); - - expect(component.validateCourse()).toBeTruthy(); - }); - - it('should return false when example code is empty', () => { - // Create form with empty example - component.courseForm.patchValue({ - title: 'Test Course', - description: 'Test Description for the course' - }); - - const content = component.contents.at(0); - content.patchValue({ - title: 'Test Content', - paragraph: 'Test paragraph', - maxResourceConsumption: 256, - maxProcessingTime: 5000 - }); - - const subcontent = component.getSubcontents(0).at(0); - subcontent.patchValue({ - subtitle: 'Test Subtitle', - subparagraph: 'Test subparagraph' - }); - - const example = component.getExamples(0, 0).at(0); - example.patchValue({ - code: '' - }); - - spyOn(window, 'alert'); - expect(component.validateCourse()).toBeFalsy(); - expect(window.alert).toHaveBeenCalled(); - }); - }); - - describe('Template Integration', () => { - it('should render course title input', () => { - const titleInput = fixture.debugElement.query(By.css('#title')); - expect(titleInput).toBeTruthy(); - }); - - it('should render course description textarea', () => { - const descriptionTextarea = fixture.debugElement.query(By.css('#description')); - expect(descriptionTextarea).toBeTruthy(); - }); - - it('should render add content button', () => { - const addButton = fixture.debugElement.query(By.css('.add-button')); - expect(addButton).toBeTruthy(); - }); - - it('should render content fields', () => { - const contentTitle = fixture.debugElement.query(By.css('input[formControlName="title"]')); - const contentParagraph = fixture.debugElement.query(By.css('textarea[formControlName="paragraph"]')); - const resourceConsumption = fixture.debugElement.query(By.css('input[formControlName="maxResourceConsumption"]')); - const processingTime = fixture.debugElement.query(By.css('input[formControlName="maxProcessingTime"]')); - - expect(contentTitle).toBeTruthy(); - expect(contentParagraph).toBeTruthy(); - expect(resourceConsumption).toBeTruthy(); - expect(processingTime).toBeTruthy(); - }); - - it('should disable add content button when limit reached', () => { - // Add contents up to the limit - for (let i = component.contents.length; i < 20; i++) { - component.addContent(); - } - - fixture.detectChanges(); - - const addButton = fixture.debugElement.query(By.css('.add-button')); - expect(addButton.nativeElement.disabled).toBeTruthy(); - }); - }); -}); \ No newline at end of file +}); diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts b/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts index d4d1f87..bc88af5 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts +++ b/frontend/angular/src/app/pages/create-course/create-course.component.spec.ts @@ -1,53 +1,11 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { Component, Input, Output, EventEmitter } from '@angular/core'; -import { CreateCourseComponent } from './create-course.component'; - -// Mock del componente FormCourseComponent -@Component({ - selector: 'app-form-course', - template: '
    Mock Form Course Component
    ' -}) -class MockFormCourseComponent { - @Input() showPreview = false; - @Output() formDataChange = new EventEmitter(); - @Output() formValidChange = new EventEmitter(); -} - -// Interfaces para las pruebas -interface CourseData { - course: { - title: string; - description: string; - goto: string; - }; - contents: ContentData[]; -} - -interface ContentData { - title: string; - paragraph: string[]; - subcontent: SubcontentData[]; - next: string | null; - maxResourceConsumption: number; - maxProcessingTime: number; -} - -interface SubcontentData { - subtitle: string; - subparagraph: string[]; - example: ExampleData[]; -} -interface ExampleData { - code: string; -} +import { CreateCourseComponent } from './create-course.component'; describe('CreateCourseComponent', () => { let component: CreateCourseComponent; let fixture: ComponentFixture; - let router: jasmine.SpyObj; - let mockCourseData: CourseData; beforeEach(async () => { const routerSpy = jasmine.createSpyObj('Router', ['navigate']); @@ -58,325 +16,14 @@ describe('CreateCourseComponent', () => { { provide: Router, useValue: routerSpy } ] }) - .overrideComponent(CreateCourseComponent, { - remove: { - imports: [MockFormCourseComponent] - }, - add: { - imports: [MockFormCourseComponent] - } - }) .compileComponents(); fixture = TestBed.createComponent(CreateCourseComponent); component = fixture.componentInstance; - router = TestBed.inject(Router) as jasmine.SpyObj; - - // Datos de prueba - mockCourseData = { - course: { - title: 'Test Course', - description: 'Test Description', - goto: 'test-goto' - }, - contents: [ - { - title: 'Test Content', - paragraph: ['Test paragraph'], - subcontent: [ - { - subtitle: 'Test Subtitle', - subparagraph: ['Test subparagraph'], - example: [ - { code: 'console.log("test");' } - ] - } - ], - next: null, - maxResourceConsumption: 100, - maxProcessingTime: 5000 - } - ] - }; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); - - describe('Initial State', () => { - it('should initialize with default values', () => { - expect(component.isSubmitting).toBeFalse(); - expect(component.isFormValid).toBeFalse(); - expect(component.showPreview).toBeFalse(); - expect(component.currentCourseData).toBeNull(); - }); - }); - - describe('Form Data Handling', () => { - it('should update course data when form data changes', () => { - component.onFormDataChange(mockCourseData); - - expect(component.currentCourseData).toEqual(mockCourseData); - }); - - it('should update form validity when form valid state changes', () => { - component.onFormValidChange(true); - - expect(component.isFormValid).toBeTrue(); - }); - - it('should handle form invalid state', () => { - component.onFormValidChange(false); - - expect(component.isFormValid).toBeFalse(); - }); - }); - - describe('Preview Functionality', () => { - it('should toggle preview state', () => { - expect(component.showPreview).toBeFalse(); - - component.togglePreview(); - expect(component.showPreview).toBeTrue(); - - component.togglePreview(); - expect(component.showPreview).toBeFalse(); - }); - }); - - describe('Form Submission', () => { - beforeEach(() => { - spyOn(window, 'alert'); - spyOn(console, 'error'); - }); - - it('should not submit if form is invalid', async () => { - component.isFormValid = false; - component.currentCourseData = mockCourseData; - - await component.onSubmit(); - - expect(console.error).toHaveBeenCalledWith('Formulario inválido o sin datos'); - expect(component.isSubmitting).toBeFalse(); - }); - - it('should not submit if no course data', async () => { - component.isFormValid = true; - component.currentCourseData = null; - - await component.onSubmit(); - - expect(console.error).toHaveBeenCalledWith('Formulario inválido o sin datos'); - expect(component.isSubmitting).toBeFalse(); - }); - - it('should submit successfully with valid data', fakeAsync(() => { - component.isFormValid = true; - component.currentCourseData = mockCourseData; - - // Mock Math.random para simular éxito - spyOn(Math, 'random').and.returnValue(0.5); - - component.onSubmit(); - expect(component.isSubmitting).toBeTrue(); - - tick(2000); - - expect(window.alert).toHaveBeenCalledWith('¡Curso creado exitosamente!'); - expect(router.navigate).toHaveBeenCalledWith(['/cursos']); - expect(component.isSubmitting).toBeFalse(); - })); - - it('should handle submission error', fakeAsync(() => { - component.isFormValid = true; - component.currentCourseData = mockCourseData; - - // Mock Math.random para simular error - spyOn(Math, 'random').and.returnValue(0.05); - - component.onSubmit(); - expect(component.isSubmitting).toBeTrue(); - - tick(2000); - - expect(console.error).toHaveBeenCalledWith('Error al crear el curso:', jasmine.any(Error)); - expect(window.alert).toHaveBeenCalledWith('Error al crear el curso. Por favor, inténtalo de nuevo.'); - expect(component.isSubmitting).toBeFalse(); - })); - }); - - describe('Cancellation', () => { - beforeEach(() => { - spyOn(window, 'confirm'); - }); - - it('should navigate to courses without confirmation when no changes', () => { - component.currentCourseData = null; - - component.onCancel(); - - expect(window.confirm).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/cursos']); - }); - - it('should show confirmation dialog when there are changes', () => { - component.currentCourseData = mockCourseData; - (window.confirm as jasmine.Spy).and.returnValue(true); - - component.onCancel(); - - expect(window.confirm).toHaveBeenCalledWith('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios.'); - expect(router.navigate).toHaveBeenCalledWith(['/cursos']); - }); - - it('should not navigate when user cancels confirmation', () => { - component.currentCourseData = mockCourseData; - (window.confirm as jasmine.Spy).and.returnValue(false); - - component.onCancel(); - - expect(window.confirm).toHaveBeenCalledWith('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios.'); - expect(router.navigate).not.toHaveBeenCalled(); - }); - }); - - describe('Utility Methods', () => { - it('should return current course data', () => { - component.currentCourseData = mockCourseData; - - const result = component.getCourseData(); - - expect(result).toEqual(mockCourseData); - }); - - it('should return null when no course data', () => { - component.currentCourseData = null; - - const result = component.getCourseData(); - - expect(result).toBeNull(); - }); - }); - - describe('Template Integration', () => { - it('should render hero section with correct content', () => { - const compiled = fixture.nativeElement; - - expect(compiled.querySelector('h1').textContent).toContain('Crear Nuevo Curso'); - expect(compiled.querySelector('.welcome-message').textContent).toContain('Diseña tu curso de programación paso a paso'); - }); - - it('should render form course component', () => { - const compiled = fixture.nativeElement; - - expect(compiled.querySelector('app-form-course')).toBeTruthy(); - }); - - it('should render form actions buttons', () => { - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('.form-actions button'); - - expect(buttons.length).toBe(2); - expect(buttons[0].textContent.trim()).toBe('Cancelar'); - expect(buttons[1].textContent.trim()).toBe('Crear Curso'); - }); - - it('should disable submit button when form is invalid', () => { - component.isFormValid = false; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.disabled).toBeTrue(); - }); - - it('should enable submit button when form is valid', () => { - component.isFormValid = true; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.disabled).toBeFalse(); - }); - - it('should show loading state when submitting', () => { - component.isSubmitting = true; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.textContent.trim()).toContain('⏳ Creando...'); - expect(submitButton.disabled).toBeTrue(); - }); - - it('should call onCancel when cancel button is clicked', () => { - spyOn(component, 'onCancel'); - - const cancelButton = fixture.nativeElement.querySelector('.cancel-button'); - cancelButton.click(); - - expect(component.onCancel).toHaveBeenCalled(); - }); - - it('should call onSubmit when submit button is clicked', () => { - spyOn(component, 'onSubmit'); - component.isFormValid = true; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - submitButton.click(); - - expect(component.onSubmit).toHaveBeenCalled(); - }); - }); - - describe('Event Handlers', () => { - it('should handle form data change event', () => { - spyOn(component, 'onFormDataChange'); - - // Simular evento del componente hijo - const formCourse = fixture.debugElement.query(sel => sel.name === 'app-form-course'); - formCourse.triggerEventHandler('formDataChange', mockCourseData); - - expect(component.onFormDataChange).toHaveBeenCalledWith(mockCourseData); - }); - - it('should handle form valid change event', () => { - spyOn(component, 'onFormValidChange'); - - // Simular evento del componente hijo - const formCourse = fixture.debugElement.query(sel => sel.name === 'app-form-course'); - formCourse.triggerEventHandler('formValidChange', true); - - expect(component.onFormValidChange).toHaveBeenCalledWith(true); - }); - }); - - describe('Private Methods', () => { - it('should resolve saveCourse promise on success', fakeAsync(() => { - spyOn(Math, 'random').and.returnValue(0.5); - let resolved = false; - - component['saveCourse'](mockCourseData).then(() => { - resolved = true; - }); - - tick(2000); - - expect(resolved).toBeTrue(); - })); - - it('should reject saveCourse promise on error', fakeAsync(() => { - spyOn(Math, 'random').and.returnValue(0.05); - let rejected = false; - - component['saveCourse'](mockCourseData).catch(() => { - rejected = true; - }); - - tick(2000); - - expect(rejected).toBeTrue(); - })); - }); }); \ No newline at end of file diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts index 8a646e8..35e928b 100644 --- a/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.spec.ts @@ -1,64 +1,18 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router, ActivatedRoute } from '@angular/router'; -import { Component, Input, Output, EventEmitter } from '@angular/core'; -import { of } from 'rxjs'; -import { EditCourseComponent } from './edit-course.component'; - -// Mock del componente FormCourseComponent -@Component({ - selector: 'app-form-course', - template: '
    Mock Form Course Component
    ' -}) -class MockFormCourseComponent { - @Input() showPreview = false; - @Input() initialData: any; - @Output() formDataChange = new EventEmitter(); - @Output() formValidChange = new EventEmitter(); -} - -// Interfaces para las pruebas -interface CourseData { - course: { - title: string; - description: string; - goto: string; - }; - contents: ContentData[]; -} - -interface ContentData { - title: string; - paragraph: string[]; - subcontent: SubcontentData[]; - next: string | null; - maxResourceConsumption: number; - maxProcessingTime: number; -} - -interface SubcontentData { - subtitle: string; - subparagraph: string[]; - example: ExampleData[]; -} -interface ExampleData { - code: string; -} +import { EditCourseComponent } from './edit-course.component'; describe('EditCourseComponent', () => { let component: EditCourseComponent; let fixture: ComponentFixture; - let router: jasmine.SpyObj; - let activatedRoute: any; - let mockCourseData: CourseData; - let modifiedCourseData: CourseData; beforeEach(async () => { const routerSpy = jasmine.createSpyObj('Router', ['navigate']); const activatedRouteSpy = { snapshot: { paramMap: { - get: jasmine.createSpy('get') + get: jasmine.createSpy('get').and.returnValue('123') } } }; @@ -70,594 +24,14 @@ describe('EditCourseComponent', () => { { provide: ActivatedRoute, useValue: activatedRouteSpy } ] }) - .overrideComponent(EditCourseComponent, { - remove: { - imports: [MockFormCourseComponent] - }, - add: { - imports: [MockFormCourseComponent] - } - }) .compileComponents(); fixture = TestBed.createComponent(EditCourseComponent); component = fixture.componentInstance; - router = TestBed.inject(Router) as jasmine.SpyObj; - activatedRoute = TestBed.inject(ActivatedRoute); - - // Datos de prueba originales - mockCourseData = { - course: { - title: "Test Course", - description: "Test Description", - goto: "test-goto" - }, - contents: [ - { - title: "Test Content", - paragraph: ["Test paragraph"], - subcontent: [ - { - subtitle: "Test Subtitle", - subparagraph: ["Test subparagraph"], - example: [{ code: "console.log('test');" }] - } - ], - next: null, - maxResourceConsumption: 100, - maxProcessingTime: 5000 - } - ] - }; - - // Datos modificados para pruebas - modifiedCourseData = { - course: { - title: "Modified Test Course", - description: "Modified Test Description", - goto: "modified-test-goto" - }, - contents: [ - { - title: "Modified Test Content", - paragraph: ["Modified Test paragraph"], - subcontent: [ - { - subtitle: "Modified Test Subtitle", - subparagraph: ["Modified Test subparagraph"], - example: [{ code: "console.log('modified test');" }] - } - ], - next: null, - maxResourceConsumption: 150, - maxProcessingTime: 6000 - } - ] - }; + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); - - describe('Initial State', () => { - it('should initialize with default values', () => { - expect(component.isSubmitting).toBeFalse(); - expect(component.isFormValid).toBeFalse(); - expect(component.showPreview).toBeFalse(); - expect(component.isLoading).toBeTrue(); - expect(component.hasError).toBeFalse(); - expect(component.errorMessage).toBe(''); - expect(component.hasChanges).toBeFalse(); - expect(component.courseId).toBeNull(); - expect(component.initialCourseData).toBeUndefined(); - expect(component.currentCourseData).toBeUndefined(); - }); - }); - - describe('ngOnInit', () => { - it('should call loadCourseData on initialization', () => { - spyOn(component, 'loadCourseData' as any); - - component.ngOnInit(); - - expect(component['loadCourseData']).toHaveBeenCalled(); - }); - }); - - describe('loadCourseData', () => { - beforeEach(() => { - spyOn(console, 'error'); - }); - - it('should load course data successfully', fakeAsync(() => { - activatedRoute.snapshot.paramMap.get.and.returnValue('123'); - spyOn(Math, 'random').and.returnValue(0.5); - - component['loadCourseData'](); - expect(component.isLoading).toBeTrue(); - expect(component.hasError).toBeFalse(); - - tick(1500); - - expect(component.isLoading).toBeFalse(); - expect(component.hasError).toBeFalse(); - expect(component.courseId).toBe('123'); - expect(component.initialCourseData).toBeDefined(); - expect(component.currentCourseData).toBeDefined(); - })); - - it('should handle error when course ID is not found', fakeAsync(() => { - activatedRoute.snapshot.paramMap.get.and.returnValue(null); - - component['loadCourseData'](); - - tick(0); - - expect(component.hasError).toBeTrue(); - expect(component.errorMessage).toBe('ID del curso no encontrado'); - expect(component.isLoading).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - })); - - it('should handle error when fetching course data fails', fakeAsync(() => { - activatedRoute.snapshot.paramMap.get.and.returnValue('123'); - spyOn(Math, 'random').and.returnValue(0.05); - - component['loadCourseData'](); - - tick(1500); - - expect(component.hasError).toBeTrue(); - expect(component.errorMessage).toBe('No se pudo cargar el curso'); - expect(component.isLoading).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - })); - }); - - describe('fetchCourseData', () => { - it('should resolve with mock data on success', fakeAsync(() => { - spyOn(Math, 'random').and.returnValue(0.5); - let result: CourseData | undefined; - - component['fetchCourseData']('123').then(data => { - result = data; - }); - - tick(1500); - - expect(result).toBeDefined(); - expect(result?.course.title).toBe('afsafasfa'); - })); - - it('should reject on error', fakeAsync(() => { - spyOn(Math, 'random').and.returnValue(0.05); - let errorThrown = false; - - component['fetchCourseData']('123').catch(() => { - errorThrown = true; - }); - - tick(1500); - - expect(errorThrown).toBeTrue(); - })); - }); - - describe('Form Data Handling', () => { - beforeEach(() => { - component.initialCourseData = mockCourseData; - component.currentCourseData = JSON.parse(JSON.stringify(mockCourseData)); - }); - - it('should update course data and check for changes', () => { - spyOn(component, 'checkForChanges' as any); - - component.onFormDataChange(modifiedCourseData); - - expect(component.currentCourseData).toEqual(modifiedCourseData); - expect(component['checkForChanges']).toHaveBeenCalled(); - }); - - it('should update form validity', () => { - component.onFormValidChange(true); - expect(component.isFormValid).toBeTrue(); - - component.onFormValidChange(false); - expect(component.isFormValid).toBeFalse(); - }); - }); - - describe('checkForChanges', () => { - it('should detect no changes when data is identical', () => { - component.initialCourseData = mockCourseData; - component.currentCourseData = JSON.parse(JSON.stringify(mockCourseData)); - - component['checkForChanges'](); - - expect(component.hasChanges).toBeFalse(); - }); - - it('should detect changes when data is different', () => { - component.initialCourseData = mockCourseData; - component.currentCourseData = modifiedCourseData; - - component['checkForChanges'](); - - expect(component.hasChanges).toBeTrue(); - }); - - it('should set hasChanges to false when data is undefined', () => { - component.initialCourseData = undefined; - component.currentCourseData = undefined; - - component['checkForChanges'](); - - expect(component.hasChanges).toBeFalse(); - }); - }); - - describe('togglePreview', () => { - it('should toggle preview state', () => { - expect(component.showPreview).toBeFalse(); - - component.togglePreview(); - expect(component.showPreview).toBeTrue(); - - component.togglePreview(); - expect(component.showPreview).toBeFalse(); - }); - }); - - describe('Form Submission', () => { - beforeEach(() => { - spyOn(window, 'alert'); - spyOn(console, 'error'); - component.courseId = '123'; - component.initialCourseData = mockCourseData; - component.currentCourseData = modifiedCourseData; - component.hasChanges = true; - }); - - it('should not submit if form is invalid', async () => { - component.isFormValid = false; - - await component.onSubmit(); - - expect(console.error).toHaveBeenCalledWith('Formulario inválido, sin datos o sin cambios'); - expect(component.isSubmitting).toBeFalse(); - }); - - it('should not submit if no current data', async () => { - component.isFormValid = true; - component.currentCourseData = undefined; - - await component.onSubmit(); - - expect(console.error).toHaveBeenCalledWith('Formulario inválido, sin datos o sin cambios'); - expect(component.isSubmitting).toBeFalse(); - }); - - it('should not submit if no changes', async () => { - component.isFormValid = true; - component.hasChanges = false; - - await component.onSubmit(); - - expect(console.error).toHaveBeenCalledWith('Formulario inválido, sin datos o sin cambios'); - expect(component.isSubmitting).toBeFalse(); - }); - - it('should submit successfully with valid data and changes', fakeAsync(() => { - component.isFormValid = true; - spyOn(Math, 'random').and.returnValue(0.5); - - component.onSubmit(); - expect(component.isSubmitting).toBeTrue(); - - tick(2000); - - expect(window.alert).toHaveBeenCalledWith('¡Curso actualizado exitosamente!'); - expect(router.navigate).toHaveBeenCalledWith(['/cursos']); - expect(component.isSubmitting).toBeFalse(); - expect(component.hasChanges).toBeFalse(); - expect(component.initialCourseData).toEqual(modifiedCourseData); - })); - - it('should handle submission error', fakeAsync(() => { - component.isFormValid = true; - spyOn(Math, 'random').and.returnValue(0.05); - - component.onSubmit(); - expect(component.isSubmitting).toBeTrue(); - - tick(2000); - - expect(console.error).toHaveBeenCalledWith('Error al actualizar el curso:', jasmine.any(Error)); - expect(window.alert).toHaveBeenCalledWith('Error al actualizar el curso. Por favor, inténtalo de nuevo.'); - expect(component.isSubmitting).toBeFalse(); - })); - }); - - describe('updateCourse', () => { - it('should resolve on success', fakeAsync(() => { - spyOn(Math, 'random').and.returnValue(0.5); - let resolved = false; - - component['updateCourse']('123', mockCourseData).then(() => { - resolved = true; - }); - - tick(2000); - - expect(resolved).toBeTrue(); - })); - - it('should reject on error', fakeAsync(() => { - spyOn(Math, 'random').and.returnValue(0.05); - let rejected = false; - - component['updateCourse']('123', mockCourseData).catch(() => { - rejected = true; - }); - - tick(2000); - - expect(rejected).toBeTrue(); - })); - }); - - describe('Cancellation', () => { - beforeEach(() => { - spyOn(window, 'confirm'); - }); - - it('should navigate without confirmation when no changes', () => { - component.hasChanges = false; - - component.onCancel(); - - expect(window.confirm).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/cursos']); - }); - - it('should show confirmation dialog when there are changes', () => { - component.hasChanges = true; - (window.confirm as jasmine.Spy).and.returnValue(true); - - component.onCancel(); - - expect(window.confirm).toHaveBeenCalledWith('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios no guardados.'); - expect(router.navigate).toHaveBeenCalledWith(['/cursos']); - }); - - it('should not navigate when user cancels confirmation', () => { - component.hasChanges = true; - (window.confirm as jasmine.Spy).and.returnValue(false); - - component.onCancel(); - - expect(window.confirm).toHaveBeenCalledWith('¿Estás seguro de que quieres cancelar? Se perderán todos los cambios no guardados.'); - expect(router.navigate).not.toHaveBeenCalled(); - }); - }); - - describe('onRetry', () => { - it('should call loadCourseData', () => { - spyOn(component, 'loadCourseData' as any); - - component.onRetry(); - - expect(component['loadCourseData']).toHaveBeenCalled(); - }); - }); - - describe('Utility Methods and Getters', () => { - it('should return current course data', () => { - component.currentCourseData = mockCourseData; - - const result = component.getCourseData(); - - expect(result).toEqual(mockCourseData); - }); - - it('should return undefined when no course data', () => { - component.currentCourseData = undefined; - - const result = component.getCourseData(); - - expect(result).toBeUndefined(); - }); - - it('should return correct hasPendingChanges value', () => { - component.hasChanges = true; - expect(component.hasPendingChanges).toBeTrue(); - - component.hasChanges = false; - expect(component.hasPendingChanges).toBeFalse(); - }); - - it('should return correct isLoadingData value', () => { - component.isLoading = true; - expect(component.isLoadingData).toBeTrue(); - - component.isLoading = false; - expect(component.isLoadingData).toBeFalse(); - }); - - it('should return correct hasLoadingError value', () => { - component.hasError = true; - expect(component.hasLoadingError).toBeTrue(); - - component.hasError = false; - expect(component.hasLoadingError).toBeFalse(); - }); - }); - - describe('Template Integration', () => { - beforeEach(() => { - // Simular que ya se cargaron los datos - component.isLoading = false; - component.hasError = false; - component.initialCourseData = mockCourseData; - component.currentCourseData = mockCourseData; - fixture.detectChanges(); - }); - - it('should render hero section with correct content', () => { - const compiled = fixture.nativeElement; - - expect(compiled.querySelector('h1').textContent).toContain('Editar Curso'); - expect(compiled.querySelector('.welcome-message').textContent).toContain('Modifica y perfecciona tu curso de programación'); - }); - - it('should render form course component with initial data', () => { - const compiled = fixture.nativeElement; - - expect(compiled.querySelector('app-form-course')).toBeTruthy(); - }); - - it('should render form actions buttons', () => { - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('.form-actions button'); - - expect(buttons.length).toBe(2); - expect(buttons[0].textContent.trim()).toBe('Cancelar'); - expect(buttons[1].textContent.trim()).toBe('Guardar Cambios'); - }); - - it('should disable submit button when form is invalid', () => { - component.isFormValid = false; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.disabled).toBeTrue(); - }); - - it('should disable submit button when no changes', () => { - component.isFormValid = true; - component.hasChanges = false; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.disabled).toBeTrue(); - }); - - it('should enable submit button when form is valid and has changes', () => { - component.isFormValid = true; - component.hasChanges = true; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.disabled).toBeFalse(); - }); - - it('should show loading state when submitting', () => { - component.isSubmitting = true; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - expect(submitButton.textContent.trim()).toContain('⏳ Guardando...'); - expect(submitButton.disabled).toBeTrue(); - }); - - it('should call onCancel when cancel button is clicked', () => { - spyOn(component, 'onCancel'); - - const cancelButton = fixture.nativeElement.querySelector('.cancel-button'); - cancelButton.click(); - - expect(component.onCancel).toHaveBeenCalled(); - }); - - it('should call onSubmit when submit button is clicked', () => { - spyOn(component, 'onSubmit'); - component.isFormValid = true; - component.hasChanges = true; - fixture.detectChanges(); - - const submitButton = fixture.nativeElement.querySelector('.submit-button'); - submitButton.click(); - - expect(component.onSubmit).toHaveBeenCalled(); - }); - }); - - describe('Event Handlers', () => { - beforeEach(() => { - component.isLoading = false; - component.hasError = false; - component.initialCourseData = mockCourseData; - fixture.detectChanges(); - }); - - it('should handle form data change event', () => { - spyOn(component, 'onFormDataChange'); - - const formCourse = fixture.debugElement.query(sel => sel.name === 'app-form-course'); - formCourse.triggerEventHandler('formDataChange', modifiedCourseData); - - expect(component.onFormDataChange).toHaveBeenCalledWith(modifiedCourseData); - }); - - it('should handle form valid change event', () => { - spyOn(component, 'onFormValidChange'); - - const formCourse = fixture.debugElement.query(sel => sel.name === 'app-form-course'); - formCourse.triggerEventHandler('formValidChange', true); - - expect(component.onFormValidChange).toHaveBeenCalledWith(true); - }); - - it('should pass showPreview to form component', () => { - component.showPreview = true; - fixture.detectChanges(); - - const formCourse = fixture.debugElement.query(sel => sel.name === 'app-form-course'); - expect(formCourse.attributes['ng-reflect-show-preview']).toBe('true'); - }); - - it('should pass initialData to form component', () => { - const formCourse = fixture.debugElement.query(sel => sel.name === 'app-form-course'); - expect(formCourse.componentInstance.initialData).toBe(mockCourseData); - }); - }); - - describe('Lifecycle and State Management', () => { - it('should handle complete loading cycle', fakeAsync(() => { - activatedRoute.snapshot.paramMap.get.and.returnValue('123'); - spyOn(Math, 'random').and.returnValue(0.5); - - component.ngOnInit(); - - expect(component.isLoading).toBeTrue(); - expect(component.hasError).toBeFalse(); - - tick(1500); - - expect(component.isLoading).toBeFalse(); - expect(component.hasError).toBeFalse(); - expect(component.courseId).toBe('123'); - expect(component.initialCourseData).toBeDefined(); - expect(component.currentCourseData).toBeDefined(); - })); - - it('should maintain state consistency after successful update', fakeAsync(() => { - component.courseId = '123'; - component.isFormValid = true; - component.hasChanges = true; - component.initialCourseData = mockCourseData; - component.currentCourseData = modifiedCourseData; - - spyOn(Math, 'random').and.returnValue(0.5); - spyOn(window, 'alert'); - - component.onSubmit(); - tick(2000); - - expect(component.hasChanges).toBeFalse(); - expect(component.initialCourseData).toEqual(modifiedCourseData); - expect(component.isSubmitting).toBeFalse(); - })); - }); }); \ No newline at end of file From 2b360ad2b41d1e9721c62126439ed63127886555 Mon Sep 17 00:00:00 2001 From: Josuex123 Date: Tue, 24 Jun 2025 18:40:54 -0400 Subject: [PATCH 16/20] fix: nuevas columnas agregadas a la db --- backend/main.go | 5 +- backend/models/Course.go | 2 + backend/routes/cors.go | 1 + backend/routes/courses.routes.go | 258 +++++++++- .../create-course/create-course.component.ts | 185 ++++++- .../edit-course/edit-course.component.ts | 450 ++++++++++++++---- .../introduction/introduction.component.html | 4 +- .../src/app/services/courses.service.ts | 194 +++++++- 8 files changed, 959 insertions(+), 140 deletions(-) diff --git a/backend/main.go b/backend/main.go index 9c03a43..9ff43a4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "log" "github.com/Frosmin/backend/db" + "github.com/Frosmin/backend/models" "github.com/Frosmin/backend/routes" "github.com/joho/godotenv" @@ -20,11 +21,11 @@ func main() { //db.DB.AutoMigrate(models.Exercise{}) //db.DB.AutoMigrate(models.Tutorial{}) //db.DB.AutoMigrate(models.Video{}) - /*db.DB.AutoMigrate( + db.DB.AutoMigrate( &models.Course{}, &models.Content{}, &models.Subcontent{}, - )*/ + ) // Obtener el router configurado con CORS y rutas r := routes.SetupRouter() diff --git a/backend/models/Course.go b/backend/models/Course.go index 5f29447..947509f 100644 --- a/backend/models/Course.go +++ b/backend/models/Course.go @@ -21,6 +21,8 @@ type Content struct { Paragraph GormStrings `json:"paragraph" gorm:"type:jsonb"` Subcontent []Subcontent `json:"subcontent" gorm:"foreignKey:ContentID"` Next string `json:"next,omitempty"` + MaxResourceConsumption int `json:"maxResourceConsumption"` + MaxProcessingTime int `json:"maxProcessingTime"` } type Subcontent struct { ID uint `json:"id" gorm:"primaryKey"` diff --git a/backend/routes/cors.go b/backend/routes/cors.go index 905f72b..2c1be6c 100644 --- a/backend/routes/cors.go +++ b/backend/routes/cors.go @@ -56,5 +56,6 @@ func SetupRouter() *gin.Engine { api.GET("/courses/:id", GetCourse) api.POST("/courses", CreateCourse) api.GET("/course/:goto", GetCourseIDByGoto) + api.PATCH("/courses/:id", UpdateCourse) return r } diff --git a/backend/routes/courses.routes.go b/backend/routes/courses.routes.go index 431baaf..2a7f976 100644 --- a/backend/routes/courses.routes.go +++ b/backend/routes/courses.routes.go @@ -25,17 +25,23 @@ type CourseInfo struct { } type ContentInfo struct { - ID uint `json:"id"` - Title string `json:"title"` - Paragraph []string `json:"paragraph"` - Subcontent []SubcontentInfo `json:"subcontent"` - Next string `json:"next,omitempty"` + ID uint `json:"id"` + Title string `json:"title"` + Paragraph []string `json:"paragraph"` + Subcontent []SubcontentInfo `json:"subcontent"` + Next *string `json:"next"` + MaxResourceConsumption int `json:"maxResourceConsumption"` + MaxProcessingTime int `json:"maxProcessingTime"` } type SubcontentInfo struct { - Subtitle string `json:"subtitle"` - Subparagraph []string `json:"subparagraph"` - Example []string `json:"example"` + Subtitle string `json:"subtitle"` + Subparagraph []string `json:"subparagraph"` + Example []ExampleInfo `json:"example"` +} + +type ExampleInfo struct { + Code string `json:"code"` } type BasicCourseResponse struct { @@ -45,6 +51,22 @@ type BasicCourseResponse struct { Goto string `json:"goto"` } +// Helper function to convert *string to string +func ptrStringToString(ptr *string) string { + if ptr == nil { + return "" + } + return *ptr +} + +// Helper function to convert string to *string +func stringToPtrString(s string) *string { + if s == "" { + return nil + } + return &s +} + func GetBasicCourses(c *gin.Context) { var courses []models.Course if err := db.DB.Select("id, title, description, goto").Find(&courses).Error; err != nil { @@ -84,15 +106,23 @@ func CreateCourse(c *gin.Context) { } for _, contentInfo := range courseRequest.Contents { content := models.Content{ - Title: contentInfo.Title, - Paragraph: models.GormStrings(contentInfo.Paragraph), - Next: contentInfo.Next, + Title: contentInfo.Title, + Paragraph: models.GormStrings(contentInfo.Paragraph), + Next: ptrStringToString(contentInfo.Next), // Fixed: Convert *string to string + MaxResourceConsumption: contentInfo.MaxResourceConsumption, + MaxProcessingTime: contentInfo.MaxProcessingTime, } for _, subcontentInfo := range contentInfo.Subcontent { + // Convert ExampleInfo slice to string slice for GORM + var exampleStrings []string + for _, example := range subcontentInfo.Example { + exampleStrings = append(exampleStrings, example.Code) + } + subcontent := models.Subcontent{ Subtitle: subcontentInfo.Subtitle, Subparagraph: models.GormStrings(subcontentInfo.Subparagraph), - Example: models.GormStrings(subcontentInfo.Example), + Example: models.GormStrings(exampleStrings), } content.Subcontent = append(content.Subcontent, subcontent) } @@ -146,17 +176,25 @@ func GetCourse(c *gin.Context) { } for i, content := range course.Contents { response.Contents[i] = ContentInfo{ - ID: content.ID, - Title: content.Title, - Paragraph: []string(content.Paragraph), - Subcontent: make([]SubcontentInfo, len(content.Subcontent)), - Next: content.Next, + ID: content.ID, + Title: content.Title, + Paragraph: []string(content.Paragraph), + Subcontent: make([]SubcontentInfo, len(content.Subcontent)), + Next: stringToPtrString(content.Next), // Fixed: Convert string to *string + MaxResourceConsumption: content.MaxResourceConsumption, + MaxProcessingTime: content.MaxProcessingTime, } for j, subcontent := range content.Subcontent { + // Convert string slice back to ExampleInfo slice + var examples []ExampleInfo + for _, exampleStr := range []string(subcontent.Example) { + examples = append(examples, ExampleInfo{Code: exampleStr}) + } + response.Contents[i].Subcontent[j] = SubcontentInfo{ Subtitle: subcontent.Subtitle, Subparagraph: []string(subcontent.Subparagraph), - Example: []string(subcontent.Example), + Example: examples, } } } @@ -185,17 +223,25 @@ func GetAllCourses(c *gin.Context) { } for i, content := range course.Contents { response.Contents[i] = ContentInfo{ - ID: content.ID, - Title: content.Title, - Paragraph: []string(content.Paragraph), - Subcontent: make([]SubcontentInfo, len(content.Subcontent)), - Next: content.Next, + ID: content.ID, + Title: content.Title, + Paragraph: []string(content.Paragraph), + Subcontent: make([]SubcontentInfo, len(content.Subcontent)), + Next: stringToPtrString(content.Next), // Fixed: Convert string to *string + MaxResourceConsumption: content.MaxResourceConsumption, + MaxProcessingTime: content.MaxProcessingTime, } for j, subcontent := range content.Subcontent { + // Convert string slice back to ExampleInfo slice + var examples []ExampleInfo + for _, exampleStr := range []string(subcontent.Example) { + examples = append(examples, ExampleInfo{Code: exampleStr}) + } + response.Contents[i].Subcontent[j] = SubcontentInfo{ Subtitle: subcontent.Subtitle, Subparagraph: []string(subcontent.Subparagraph), - Example: []string(subcontent.Example), + Example: examples, } } } @@ -203,6 +249,168 @@ func GetAllCourses(c *gin.Context) { } c.JSON(http.StatusOK, responses) } + +func UpdateCourse(c *gin.Context) { + courseID := c.Param("id") + id, err := strconv.ParseUint(courseID, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid course ID", + }) + return + } + + var courseRequest CourseRequest + if err := c.ShouldBindJSON(&courseRequest); err != nil { + log.Printf("Error parsing JSON: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid JSON format", + "details": err.Error(), + }) + return + } + + // Verificar que el curso existe + var existingCourse models.Course + if err := db.DB.Preload("Contents.Subcontent").First(&existingCourse, uint(id)).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Course not found", + }) + return + } + log.Printf("Error fetching course: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to fetch course", + }) + return + } + + // Usar transacción para asegurar consistencia + tx := db.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Actualizar información básica del curso + existingCourse.Title = courseRequest.Course.Title + existingCourse.Description = courseRequest.Course.Description + existingCourse.Goto = courseRequest.Course.Goto + + if err := tx.Save(&existingCourse).Error; err != nil { + tx.Rollback() + log.Printf("Error updating course: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to update course", + "details": err.Error(), + }) + return + } + + // Eliminar todos los contenidos existentes y sus subcontenidos + if err := tx.Where("course_id = ?", existingCourse.ID).Delete(&models.Content{}).Error; err != nil { + tx.Rollback() + log.Printf("Error deleting existing contents: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to update course contents", + }) + return + } + + // Crear los nuevos contenidos + for _, contentInfo := range courseRequest.Contents { + content := models.Content{ + Title: contentInfo.Title, + Paragraph: models.GormStrings(contentInfo.Paragraph), + Next: ptrStringToString(contentInfo.Next), // Fixed: Convert *string to string + MaxResourceConsumption: contentInfo.MaxResourceConsumption, + MaxProcessingTime: contentInfo.MaxProcessingTime, + CourseID: existingCourse.ID, + } + + for _, subcontentInfo := range contentInfo.Subcontent { + // Convert ExampleInfo slice to string slice for GORM + var exampleStrings []string + for _, example := range subcontentInfo.Example { + exampleStrings = append(exampleStrings, example.Code) + } + + subcontent := models.Subcontent{ + Subtitle: subcontentInfo.Subtitle, + Subparagraph: models.GormStrings(subcontentInfo.Subparagraph), + Example: models.GormStrings(exampleStrings), + } + content.Subcontent = append(content.Subcontent, subcontent) + } + + if err := tx.Create(&content).Error; err != nil { + tx.Rollback() + log.Printf("Error creating content: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to create course content", + "details": err.Error(), + }) + return + } + } + + // Confirmar transacción + if err := tx.Commit().Error; err != nil { + log.Printf("Error committing transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to update course", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Course updated successfully", + "course_id": existingCourse.ID, + }) +} + +func DeleteCourse(c *gin.Context) { + courseID := c.Param("id") + id, err := strconv.ParseUint(courseID, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid course ID", + }) + return + } + + // Verificar que el curso existe + var course models.Course + if err := db.DB.First(&course, uint(id)).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Course not found", + }) + return + } + log.Printf("Error fetching course: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to fetch course", + }) + return + } + + // Eliminar el curso (las relaciones se eliminarán en cascada si está configurado) + if err := db.DB.Delete(&course).Error; err != nil { + log.Printf("Error deleting course: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete course", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Course deleted successfully", + }) +} + func GetCourseIDByGoto(c *gin.Context) { gotoParam := c.Param("goto") @@ -224,4 +432,4 @@ func GetCourseIDByGoto(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "id": course.ID, }) -} +} \ No newline at end of file diff --git a/frontend/angular/src/app/pages/create-course/create-course.component.ts b/frontend/angular/src/app/pages/create-course/create-course.component.ts index fa3cd40..6c3c863 100644 --- a/frontend/angular/src/app/pages/create-course/create-course.component.ts +++ b/frontend/angular/src/app/pages/create-course/create-course.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { CommonModule } from '@angular/common'; import { FormCourseComponent } from '../../components/form-course/form-course.component'; +import { CoursesService } from '../../services/courses.service'; // Interfaces para el tipado de datos interface CourseData { @@ -32,6 +33,35 @@ interface ExampleData { code: string; } +// Interface para la creación de curso (compatible con el servicio) +interface CreateCourseRequest { + course: { + title: string; + description: string; + goto: string; + }; + contents: CreateCourseContent[]; +} + +interface CreateCourseContent { + title: string; + paragraph: string[]; + subcontent: CreateSubContent[]; + next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; +} + +interface CreateSubContent { + subtitle: string; + subparagraph: string[]; + example: CreateExampleContent[]; +} + +interface CreateExampleContent { + code: string; +} + @Component({ selector: 'app-create-course', standalone: true, @@ -44,13 +74,19 @@ export class CreateCourseComponent { isSubmitting = false; isFormValid = false; showPreview = false; + hasError = false; + errorMessage = ''; currentCourseData: CourseData | null = null; - constructor(private router: Router) {} + constructor( + private router: Router, + private coursesService: CoursesService + ) {} // Manejar cambios en los datos del formulario onFormDataChange(courseData: CourseData): void { this.currentCourseData = courseData; + this.hasError = false; // Limpiar errores cuando cambian los datos } // Manejar cambios en la validez del formulario @@ -66,39 +102,136 @@ export class CreateCourseComponent { // Enviar formulario async onSubmit(): Promise { if (!this.isFormValid || !this.currentCourseData) { - console.error('Formulario inválido o sin datos'); + console.error('Formulario inválido o sin datos', { + isFormValid: this.isFormValid, + hasData: !!this.currentCourseData + }); return; } this.isSubmitting = true; + this.hasError = false; - try { - // Simular una llamada asíncrona - await this.saveCourse(this.currentCourseData); + try { + // Validar datos antes de enviar + if (!this.validateCourseData(this.currentCourseData)) { + throw new Error('Datos del curso inválidos'); + } + + // Transformar datos al formato esperado por la API + const createRequest = this.transformCourseDataForCreation(this.currentCourseData); + + console.log('Datos que se enviarán al servidor:', JSON.stringify(createRequest, null, 2)); + + // Llamar al servicio para crear el curso + await this.createCourse(createRequest); alert('¡Curso creado exitosamente!'); this.router.navigate(['/cursos']); } catch (error) { console.error('Error al crear el curso:', error); - alert('Error al crear el curso. Por favor, inténtalo de nuevo.'); + this.hasError = true; + this.errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + alert(`Error al crear el curso: ${this.errorMessage}`); } finally { this.isSubmitting = false; } } - private async saveCourse(courseData: CourseData): Promise { + // Validar datos del curso + private validateCourseData(courseData: CourseData): boolean { + if (!courseData.course) { + console.error('Datos del curso faltantes'); + return false; + } + + if (!courseData.course.title || !courseData.course.description || !courseData.course.goto) { + console.error('Campos requeridos del curso faltantes'); + return false; + } + + if (!Array.isArray(courseData.contents) || courseData.contents.length === 0) { + console.error('Contenidos del curso faltantes o vacíos'); + return false; + } + + // Validar cada contenido + for (const content of courseData.contents) { + if (!content.title) { + console.error('Título del contenido faltante'); + return false; + } + + if (!Array.isArray(content.paragraph)) { + console.error('Párrafos del contenido deben ser un array'); + return false; + } + + if (!Array.isArray(content.subcontent)) { + console.error('Subcontenidos deben ser un array'); + return false; + } + + // Validar subcontenidos + for (const sub of content.subcontent) { + if (!sub.subtitle || !Array.isArray(sub.subparagraph) || !Array.isArray(sub.example)) { + console.error('Estructura de subcontenido inválida'); + return false; + } + } + } + + return true; + } + + // Transformar datos para la creación + private transformCourseDataForCreation(courseData: CourseData): CreateCourseRequest { + return { + course: { + title: courseData.course.title.trim(), + description: courseData.course.description.trim(), + goto: courseData.course.goto.trim() + }, + contents: courseData.contents.map(content => ({ + title: content.title.trim(), + paragraph: content.paragraph.filter(p => p.trim() !== ''), + subcontent: content.subcontent.map(sub => ({ + subtitle: sub.subtitle.trim(), + subparagraph: sub.subparagraph.filter(sp => sp.trim() !== ''), + example: sub.example.map(ex => ({ + code: ex.code.trim() + })) + })), + next: content.next?.trim() || null, + maxResourceConsumption: Number(content.maxResourceConsumption) || 100, + maxProcessingTime: Number(content.maxProcessingTime) || 5000 + })) + }; + } + + // Crear curso usando el coursesService + private async createCourse(courseData: CreateCourseRequest): Promise { return new Promise((resolve, reject) => { - setTimeout(() => { - // Simular éxito o error - const success = Math.random() > 0.1; - - if (success) { + this.coursesService.createCourse(courseData).subscribe({ + next: (response) => { + console.log('Curso creado exitosamente:', response); resolve(); - } else { - reject(new Error('Error simulado en el servidor')); + }, + error: (error) => { + console.error('Error detallado del servidor:', error); + + // Proporcionar más información sobre el error + let errorMessage = 'Error interno del servidor'; + if (error.error && typeof error.error === 'object') { + errorMessage = error.error.message || error.error.error || errorMessage; + } else if (error.message) { + errorMessage = error.message; + } + + reject(new Error(errorMessage)); } - }, 2000); // Simular 2 segundos de carga + }); }); } @@ -116,8 +249,28 @@ export class CreateCourseComponent { this.router.navigate(['/cursos']); } - // Método para obtener datos del curso pa debugging + // Reintentar en caso de error + onRetry(): void { + this.hasError = false; + this.errorMessage = ''; + this.onSubmit(); + } + + // Método para obtener datos del curso para debugging getCourseData(): CourseData | null { return this.currentCourseData; } + + // Getters para el template + get hasFormData(): boolean { + return this.currentCourseData !== null; + } + + get canSubmit(): boolean { + return this.isFormValid && this.hasFormData && !this.isSubmitting; + } + + get hasFormError(): boolean { + return this.hasError; + } } \ No newline at end of file diff --git a/frontend/angular/src/app/pages/edit-course/edit-course.component.ts b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts index fb7ab38..0e5e3f3 100644 --- a/frontend/angular/src/app/pages/edit-course/edit-course.component.ts +++ b/frontend/angular/src/app/pages/edit-course/edit-course.component.ts @@ -2,10 +2,12 @@ import { Component, OnInit } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; import { FormCourseComponent } from '../../components/form-course/form-course.component'; +import { CoursesService } from '../../services/courses.service'; // Interfaces para el tipado de datos interface CourseData { course: { + id?: number; title: string; description: string; goto: string; @@ -14,6 +16,7 @@ interface CourseData { } interface ContentData { + id?: number; title: string; paragraph: string[]; subcontent: SubcontentData[]; @@ -32,6 +35,64 @@ interface ExampleData { code: string; } +// Interface para actualización parcial (campos opcionales) +interface PartialUpdateCourseRequest { + course?: { + title?: string; + description?: string; + goto?: string; + }; + contents?: PartialUpdateContentData[]; +} + +interface PartialUpdateContentData { + title?: string; + paragraph?: string[]; + subcontent?: PartialUpdateSubcontentData[]; + next?: string | null; + maxResourceConsumption?: number; + maxProcessingTime?: number; +} + +interface PartialUpdateSubcontentData { + subtitle?: string; + subparagraph?: string[]; + example?: PartialUpdateExampleData[]; +} + +interface PartialUpdateExampleData { + code?: string; +} + +// Interface completa (para compatibilidad con el servicio actual) +interface UpdateCourseRequest { + course: { + title: string; + description: string; + goto: string; + }; + contents: UpdateContentData[]; +} + +interface UpdateContentData { + title: string; + paragraph: string[]; + subcontent: UpdateSubcontentData[]; + next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; +} + +interface UpdateSubcontentData { + subtitle: string; + subparagraph: string[]; + example: UpdateExampleData[]; +} + +interface UpdateExampleData { + code: string; +} + @Component({ selector: 'app-edit-course', standalone: true, @@ -54,9 +115,13 @@ export class EditCourseComponent implements OnInit { initialCourseData: CourseData | undefined = undefined; currentCourseData: CourseData | undefined = undefined; + // Configuración para actualizaciones parciales + enablePartialUpdates = true; + constructor( private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private coursesService: CoursesService ) {} ngOnInit(): void { @@ -76,11 +141,16 @@ export class EditCourseComponent implements OnInit { throw new Error('ID del curso no encontrado'); } - // Cargar datos del curso - this.initialCourseData = await this.fetchCourseData(this.courseId); + // Validar que el ID sea un número válido + const numericId = parseInt(this.courseId, 10); + if (isNaN(numericId)) { + throw new Error('ID del curso inválido'); + } + + // Cargar datos del curso desde la API + this.initialCourseData = await this.fetchCourseData(numericId); this.currentCourseData = JSON.parse(JSON.stringify(this.initialCourseData)); // Deep copy - } catch (error) { console.error('Error al cargar el curso:', error); this.hasError = true; @@ -90,75 +160,48 @@ export class EditCourseComponent implements OnInit { } } - private async fetchCourseData(courseId: string): Promise { + private async fetchCourseData(courseId: number): Promise { return new Promise((resolve, reject) => { - setTimeout(() => { - // Simular éxito o error - const success = Math.random() > 0.1; - - if (success) { - // Mock data basado en el formato JSON - const mockCourseData: CourseData = { - course: { - title: "afsafasfa", - description: "safagsgsgsaggas", - goto: "afsafasfa" - }, - contents: [ - { - title: "agsgasagsgsag", - paragraph: [ - "asgasgaasg" - ], - subcontent: [ - { - subtitle: "afavvqeqveqve", - subparagraph: [ - "qvevqevqeqveqve" - ], - example: [ - { - code: "142124dv" - }, - { - code: "dsvsdvdvsd112215" - } - ] - } - ], - next: "fqefqffqw", - maxResourceConsumption: 123, - maxProcessingTime: 421 + this.coursesService.getCourseById(courseId).subscribe({ + next: (courseData) => { + const transformedData: CourseData = { + course: { + id: courseData.course.id, + title: courseData.course.title, + description: courseData.course.description, + goto: courseData.course.goto }, - { - title: "fqefqffqw", - paragraph: [ - "12wfqfeveqvqqve" - ], - subcontent: [ - { - subtitle: "qwrwqrqwr", - subparagraph: [ - "qwrwrqqwr" - ], - example: [ - { - code: "qwrqw" - } - ] - } - ], - next: null, - maxResourceConsumption: 124, - maxProcessingTime: 531 - } - ] - }; - resolve(mockCourseData); - } else { - reject(new Error('No se pudo cargar el curso')); + contents: courseData.contents.map(content => ({ + id: content.id, + title: content.title, + paragraph: Array.isArray(content.paragraph) ? content.paragraph : [], + subcontent: Array.isArray(content.subcontent) ? content.subcontent.map(sub => ({ + subtitle: sub.subtitle || '', + subparagraph: Array.isArray(sub.subparagraph) ? sub.subparagraph : [], + example: Array.isArray(sub.example) ? sub.example.map((ex: any) => { + if (typeof ex === 'string') { + return { code: ex }; + } else if (typeof ex === 'object' && ex !== null) { + return { code: ex.code || String(ex) }; + } else { + return { code: String(ex) }; + } + }) : [] + })) : [], + next: content.next || null, + maxResourceConsumption: content.maxResourceConsumption || 100, + maxProcessingTime: content.maxProcessingTime || 5000 + })) + }; + + console.log('Datos transformados:', transformedData); + resolve(transformedData); + }, + error: (error) => { + console.error('Error al obtener el curso:', error); + reject(new Error('No se pudo cargar el curso: ' + error.message)); } - }, 1500); // Simular 1.5 segundos de carga + }); }); } @@ -185,23 +228,84 @@ export class EditCourseComponent implements OnInit { this.hasChanges = initialJson !== currentJson; } + // Detectar cambios específicos para actualizaciones parciales + private detectChanges(): PartialUpdateCourseRequest { + if (!this.initialCourseData || !this.currentCourseData) { + return {}; + } + + const changes: PartialUpdateCourseRequest = {}; + + // Verificar cambios en el curso + const courseChanges: any = {}; + if (this.initialCourseData.course.title !== this.currentCourseData.course.title) { + courseChanges.title = this.currentCourseData.course.title.trim(); + } + if (this.initialCourseData.course.description !== this.currentCourseData.course.description) { + courseChanges.description = this.currentCourseData.course.description.trim(); + } + if (this.initialCourseData.course.goto !== this.currentCourseData.course.goto) { + courseChanges.goto = this.currentCourseData.course.goto.trim(); + } + + if (Object.keys(courseChanges).length > 0) { + changes.course = courseChanges; + } + + // Verificar cambios en contenidos + const initialContentsJson = JSON.stringify(this.initialCourseData.contents); + const currentContentsJson = JSON.stringify(this.currentCourseData.contents); + + if (initialContentsJson !== currentContentsJson) { + // Para simplificar, si hay cambios en contenidos, enviamos todos + // En una implementación más avanzada, podrías detectar cambios específicos por contenido + changes.contents = this.transformContentsForUpdate(this.currentCourseData.contents); + } + + return changes; + } + // Alternar vista previa del JSON togglePreview(): void { this.showPreview = !this.showPreview; } - // Enviar formulario + // Enviar formulario con actualizaciones parciales o completas async onSubmit(): Promise { if (!this.isFormValid || !this.currentCourseData || !this.hasChanges) { - console.error('Formulario inválido, sin datos o sin cambios'); + console.error('Formulario inválido, sin datos o sin cambios', { + isFormValid: this.isFormValid, + hasData: !!this.currentCourseData, + hasChanges: this.hasChanges + }); return; } this.isSubmitting = true; try { - // Simular una llamada asíncrona - await this.updateCourse(this.courseId!, this.currentCourseData); + let updateRequest: UpdateCourseRequest; + + if (this.enablePartialUpdates) { + // Detectar solo los cambios y crear un request parcial + const partialChanges = this.detectChanges(); + console.log('Cambios detectados:', JSON.stringify(partialChanges, null, 2)); + + // Convertir a formato completo para el servicio actual + updateRequest = this.convertPartialToFull(partialChanges); + } else { + // Enviar todos los datos (comportamiento anterior) + updateRequest = this.transformCourseDataForUpdate(this.currentCourseData); + } + + // Validar datos antes de enviar + if (!this.validateCourseData(this.currentCourseData)) { + throw new Error('Datos del curso inválidos'); + } + + console.log('Datos que se enviarán al servidor:', JSON.stringify(updateRequest, null, 2)); + + await this.updateCourse(parseInt(this.courseId!, 10), updateRequest); // Actualizar datos iniciales después del guardado exitoso this.initialCourseData = JSON.parse(JSON.stringify(this.currentCourseData)); @@ -212,28 +316,192 @@ export class EditCourseComponent implements OnInit { } catch (error) { console.error('Error al actualizar el curso:', error); - alert('Error al actualizar el curso. Por favor, inténtalo de nuevo.'); + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + alert(`Error al actualizar el curso: ${errorMessage}`); } finally { this.isSubmitting = false; } } - // Simular actualización del curso - private async updateCourse(courseId: string, courseData: CourseData): Promise { + // Convertir cambios parciales a formato completo (para compatibilidad con API actual) + private convertPartialToFull(partialChanges: PartialUpdateCourseRequest): UpdateCourseRequest { + if (!this.currentCourseData) { + throw new Error('No hay datos del curso actual'); + } + + return { + course: { + title: partialChanges.course?.title ?? this.currentCourseData.course.title, + description: partialChanges.course?.description ?? this.currentCourseData.course.description, + goto: partialChanges.course?.goto ?? this.currentCourseData.course.goto + }, + contents: partialChanges.contents + ? this.convertPartialContentsToFull(partialChanges.contents) + : this.transformContentsForUpdate(this.currentCourseData.contents) + }; +} + +// Helper method to convert partial contents to full contents +private convertPartialContentsToFull(partialContents: PartialUpdateContentData[]): UpdateContentData[] { + if (!this.currentCourseData) { + throw new Error('No hay datos del curso actual'); + } + + return partialContents.map((partialContent, index) => { + const currentContent = this.currentCourseData!.contents[index]; + + if (!currentContent) { + throw new Error(`Contenido en índice ${index} no encontrado`); + } + + return { + title: partialContent.title ?? currentContent.title, + paragraph: partialContent.paragraph ?? currentContent.paragraph, + subcontent: partialContent.subcontent + ? this.convertPartialSubcontentsToFull(partialContent.subcontent, currentContent.subcontent) + : currentContent.subcontent.map(sub => ({ + subtitle: sub.subtitle, + subparagraph: sub.subparagraph, + example: sub.example.map(ex => ({ code: ex.code })) + })), + next: partialContent.next !== undefined ? partialContent.next : currentContent.next, + maxResourceConsumption: partialContent.maxResourceConsumption ?? currentContent.maxResourceConsumption, + maxProcessingTime: partialContent.maxProcessingTime ?? currentContent.maxProcessingTime + }; + }); +} + +// Helper method to convert partial subcontents to full subcontents +private convertPartialSubcontentsToFull( + partialSubcontents: PartialUpdateSubcontentData[], + currentSubcontents: SubcontentData[] +): UpdateSubcontentData[] { + return partialSubcontents.map((partialSub, index) => { + const currentSub = currentSubcontents[index]; + + if (!currentSub) { + throw new Error(`Subcontenido en índice ${index} no encontrado`); + } + + return { + subtitle: partialSub.subtitle ?? currentSub.subtitle, + subparagraph: partialSub.subparagraph ?? currentSub.subparagraph, + example: partialSub.example + ? partialSub.example.map((partialEx, exIndex) => ({ + code: partialEx.code ?? currentSub.example[exIndex]?.code ?? '' + })) + : currentSub.example.map(ex => ({ code: ex.code })) + }; + }); +} + + // Transformar contenidos para actualización + private transformContentsForUpdate(contents: ContentData[]): UpdateContentData[] { + return contents.map(content => ({ + title: content.title.trim(), + paragraph: content.paragraph.filter(p => p.trim() !== ''), + subcontent: content.subcontent.map(sub => ({ + subtitle: sub.subtitle.trim(), + subparagraph: sub.subparagraph.filter(sp => sp.trim() !== ''), + example: sub.example.map(ex => ({ + code: ex.code.trim() + })) + })), + next: content.next?.trim() || null, + maxResourceConsumption: Number(content.maxResourceConsumption) || 100, + maxProcessingTime: Number(content.maxProcessingTime) || 5000 + })); + } + + // Validar datos del curso + private validateCourseData(courseData: CourseData): boolean { + if (!courseData.course) { + console.error('Datos del curso faltantes'); + return false; + } + + if (!courseData.course.title || !courseData.course.description || !courseData.course.goto) { + console.error('Campos requeridos del curso faltantes'); + return false; + } + + if (!Array.isArray(courseData.contents) || courseData.contents.length === 0) { + console.error('Contenidos del curso faltantes o vacíos'); + return false; + } + + // Validar cada contenido + for (const content of courseData.contents) { + if (!content.title) { + console.error('Título del contenido faltante'); + return false; + } + + if (!Array.isArray(content.paragraph)) { + console.error('Párrafos del contenido deben ser un array'); + return false; + } + + if (!Array.isArray(content.subcontent)) { + console.error('Subcontenidos deben ser un array'); + return false; + } + + // Validar subcontenidos + for (const sub of content.subcontent) { + if (!sub.subtitle || !Array.isArray(sub.subparagraph) || !Array.isArray(sub.example)) { + console.error('Estructura de subcontenido inválida'); + return false; + } + } + } + + return true; + } + + // Transformar datos para la actualización (método legacy) + private transformCourseDataForUpdate(courseData: CourseData): UpdateCourseRequest { + return { + course: { + title: courseData.course.title.trim(), + description: courseData.course.description.trim(), + goto: courseData.course.goto.trim() + }, + contents: this.transformContentsForUpdate(courseData.contents) + }; + } + + // Actualizar curso usando el coursesService + private async updateCourse(courseId: number, courseData: UpdateCourseRequest): Promise { return new Promise((resolve, reject) => { - setTimeout(() => { - // Simular éxito o error - const success = Math.random() > 0.1; - - if (success) { + this.coursesService.updateCourse(courseId, courseData).subscribe({ + next: (response) => { + console.log('Curso actualizado exitosamente:', response); resolve(); - } else { - reject(new Error('Error simulado en el servidor')); + }, + error: (error) => { + console.error('Error detallado del servidor:', error); + + // Proporcionar más información sobre el error + let errorMessage = 'Error interno del servidor'; + if (error.error && typeof error.error === 'object') { + errorMessage = error.error.message || error.error.error || errorMessage; + } else if (error.message) { + errorMessage = error.message; + } + + reject(new Error(errorMessage)); } - }, 2000); // Simular 2 segundos de carga + }); }); } + // Alternar modo de actualización + togglePartialUpdates(): void { + this.enablePartialUpdates = !this.enablePartialUpdates; + console.log('Actualizaciones parciales:', this.enablePartialUpdates ? 'Habilitadas' : 'Deshabilitadas'); + } + // Cancelar edición onCancel(): void { if (this.hasChanges) { @@ -251,11 +519,16 @@ export class EditCourseComponent implements OnInit { this.loadCourseData(); } - // Método para obtener datos del curso pa debugging + // Método para obtener datos del curso para debugging getCourseData(): CourseData | undefined { return this.currentCourseData; } + // Obtener cambios detectados para debugging + getDetectedChanges(): PartialUpdateCourseRequest { + return this.detectChanges(); + } + // Verificar si hay cambios pendientes get hasPendingChanges(): boolean { return this.hasChanges; @@ -270,4 +543,9 @@ export class EditCourseComponent implements OnInit { get hasLoadingError(): boolean { return this.hasError; } + + // Obtener modo de actualización + get isPartialUpdateMode(): boolean { + return this.enablePartialUpdates; + } } \ No newline at end of file diff --git a/frontend/angular/src/app/pages/introduction/introduction.component.html b/frontend/angular/src/app/pages/introduction/introduction.component.html index f82e75b..ac8bc5e 100644 --- a/frontend/angular/src/app/pages/introduction/introduction.component.html +++ b/frontend/angular/src/app/pages/introduction/introduction.component.html @@ -1,4 +1,4 @@ - + @if (loading) { diff --git a/frontend/angular/src/app/services/courses.service.ts b/frontend/angular/src/app/services/courses.service.ts index fb7056c..73a8d87 100644 --- a/frontend/angular/src/app/services/courses.service.ts +++ b/frontend/angular/src/app/services/courses.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { Observable, map, catchError, throwError, of, switchMap } from 'rxjs'; import { ICardCurso } from '../shared/interfaces/interfaces'; @@ -26,6 +26,8 @@ interface ContenidoCurso { paragraph: string[]; subcontent: SubContent[]; next?: string; + maxResourceConsumption?: number; + maxProcessingTime?: number; instrucciones?: string; codigo_incompleto?: string; solucion_correcta?: string; @@ -63,19 +65,53 @@ interface CreateCourseContent { paragraph: string[]; subcontent: CreateSubContent[]; next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; } interface CreateSubContent { subtitle: string; subparagraph: string[]; - example: string[]; + example: CreateExampleContent[]; } -// Interface para la respuesta de creación -interface CreateCourseResponse { - id: number; +interface CreateExampleContent { + code: string; +} + +// Interface para actualizar un curso +interface UpdateCourseRequest { + course: { + title: string; + description: string; + goto: string; + }; + contents: UpdateCourseContent[]; +} + +interface UpdateCourseContent { + title: string; + paragraph: string[]; + subcontent: UpdateSubContent[]; + next: string | null; + maxResourceConsumption: number; + maxProcessingTime: number; +} + +interface UpdateSubContent { + subtitle: string; + subparagraph: string[]; + example: UpdateExampleContent[]; +} + +interface UpdateExampleContent { + code: string; +} + +// Interface para la respuesta de creación/actualización +interface CourseResponse { message: string; - course?: CourseData; + course_id: number; } @Injectable({ @@ -94,9 +130,124 @@ export class CoursesService { constructor(private http: HttpClient) {} - // NUEVA FUNCIÓN: Crear un nuevo curso - createCourse(courseData: CreateCourseRequest): Observable { - return this.http.post(`${this.baseUrl}/courses`, courseData).pipe( + // Crear headers HTTP apropiados + private getHttpHeaders(): HttpHeaders { + return new HttpHeaders({ + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }); + } + + // Crear un nuevo curso + createCourse(courseData: CreateCourseRequest): Observable { + console.log('Enviando datos de creación de curso:', JSON.stringify(courseData, null, 2)); + + return this.http.post(`${this.baseUrl}/courses`, courseData, { + headers: this.getHttpHeaders() + }).pipe( + catchError(this.handleError) + ); + } + + // Actualizar un curso existente (usando PATCH para actualizaciones parciales) + updateCourse(courseId: number, courseData: UpdateCourseRequest): Observable { + console.log(`Actualizando curso ID ${courseId}:`, JSON.stringify(courseData, null, 2)); + + // Validar datos antes de enviar + if (!this.validateUpdateCourseData(courseData)) { + return throwError(() => new Error('Datos de curso inválidos para actualización')); + } + + return this.http.patch(`${this.baseUrl}/courses/${courseId}`, courseData, { + headers: this.getHttpHeaders() + }).pipe( + catchError(this.handleError) + ); + } + + // Validar datos de actualización de curso + private validateUpdateCourseData(courseData: UpdateCourseRequest): boolean { + if (!courseData || !courseData.course || !courseData.contents) { + console.error('Estructura de datos de curso inválida'); + return false; + } + + const { course, contents } = courseData; + + // Validar campos del curso + if (!course.title?.trim() || !course.description?.trim() || !course.goto?.trim()) { + console.error('Campos requeridos del curso faltantes o vacíos'); + return false; + } + + // Validar contenidos + if (!Array.isArray(contents) || contents.length === 0) { + console.error('Contenidos del curso deben ser un array no vacío'); + return false; + } + + // Validar cada contenido + for (const content of contents) { + if (!content.title?.trim()) { + console.error('Título del contenido requerido'); + return false; + } + + if (!Array.isArray(content.paragraph)) { + console.error('Los párrafos deben ser un array'); + return false; + } + + if (!Array.isArray(content.subcontent)) { + console.error('Los subcontenidos deben ser un array'); + return false; + } + + if (typeof content.maxResourceConsumption !== 'number' || content.maxResourceConsumption < 0) { + console.error('maxResourceConsumption debe ser un número no negativo'); + return false; + } + + if (typeof content.maxProcessingTime !== 'number' || content.maxProcessingTime < 0) { + console.error('maxProcessingTime debe ser un número no negativo'); + return false; + } + + // Validar subcontenidos + for (const sub of content.subcontent) { + if (!sub.subtitle?.trim()) { + console.error('Subtítulo del subcontenido requerido'); + return false; + } + + if (!Array.isArray(sub.subparagraph)) { + console.error('Los subpárrafos deben ser un array'); + return false; + } + + if (!Array.isArray(sub.example)) { + console.error('Los ejemplos deben ser un array'); + return false; + } + + // Validar ejemplos + for (const example of sub.example) { + if (!example.code || typeof example.code !== 'string') { + console.error('Cada ejemplo debe tener un código válido'); + return false; + } + } + } + } + + return true; + } + + // Eliminar un curso + deleteCourse(courseId: number): Observable<{ message: string }> { + return this.http.delete<{ message: string }>(`${this.baseUrl}/courses/${courseId}`, { + headers: this.getHttpHeaders() + }).pipe( catchError(this.handleError) ); } @@ -233,23 +384,48 @@ export class CoursesService { private handleError = (error: HttpErrorResponse): Observable => { let errorMessage = 'Ha ocurrido un error desconocido'; + + console.error('Error HTTP completo:', error); + if (error.error instanceof ErrorEvent) { + // Error del lado del cliente errorMessage = `Error: ${error.error.message}`; } else { + // Error del lado del servidor switch (error.status) { + case 400: + errorMessage = 'Datos inválidos enviados al servidor'; + if (error.error && error.error.message) { + errorMessage += `: ${error.error.message}`; + } + break; case 404: errorMessage = 'Curso no encontrado'; break; + case 422: + errorMessage = 'Datos no válidos o incompletos'; + if (error.error && error.error.message) { + errorMessage += `: ${error.error.message}`; + } + break; case 500: errorMessage = 'Error interno del servidor'; + if (error.error && error.error.message) { + errorMessage += `: ${error.error.message}`; + } + console.error('Detalles del error 500:', error.error); break; case 0: errorMessage = 'No se puede conectar con el servidor. Verifica tu conexión.'; break; default: errorMessage = `Error del servidor: ${error.status} - ${error.message}`; + if (error.error && error.error.message) { + errorMessage += ` (${error.error.message})`; + } } } + console.error('Error en CoursesService:', errorMessage, error); return throwError(() => new Error(errorMessage)); } From ca1ddcd70dc9ed77810f4c4b8aa2a31586ead7f2 Mon Sep 17 00:00:00 2001 From: Josuex123 Date: Tue, 24 Jun 2025 22:57:10 -0400 Subject: [PATCH 17/20] fix: ejemplos cursos ya muestra como deberia --- .../introduction/introduction.component.html | 2 +- .../introduction/introduction.component.ts | 21 ++- .../src/app/services/courses.service.ts | 135 +----------------- 3 files changed, 27 insertions(+), 131 deletions(-) diff --git a/frontend/angular/src/app/pages/introduction/introduction.component.html b/frontend/angular/src/app/pages/introduction/introduction.component.html index ac8bc5e..502efd9 100644 --- a/frontend/angular/src/app/pages/introduction/introduction.component.html +++ b/frontend/angular/src/app/pages/introduction/introduction.component.html @@ -46,7 +46,7 @@

    {{ subcontent.subtitle }}

    Ejemplo

    -
    {{ subcontent.example[0] }}
    +
    {{ getExampleCode(subcontent.example[0]) }}
    } diff --git a/frontend/angular/src/app/pages/introduction/introduction.component.ts b/frontend/angular/src/app/pages/introduction/introduction.component.ts index 6cff385..d0637b3 100644 --- a/frontend/angular/src/app/pages/introduction/introduction.component.ts +++ b/frontend/angular/src/app/pages/introduction/introduction.component.ts @@ -249,4 +249,23 @@ export class IntroductionComponent implements OnInit { getCurrentCourseInfo(): CursoDisponible | null { return this.coursesService.getCourseInfoByGoto(this.currentGoto); } -} \ No newline at end of file + getExampleCode(example: any): string { + // Si example es un string, devolverlo directamente + if (typeof example === 'string') { + return example; + } + + // Si example es un objeto con propiedad 'code' + if (example && typeof example === 'object' && example.code) { + return example.code; + } + + // Si example es un objeto, convertirlo a JSON formateado + if (example && typeof example === 'object') { + return JSON.stringify(example, null, 2); + } + + // Fallback + return 'Código no disponible'; +} +} diff --git a/frontend/angular/src/app/services/courses.service.ts b/frontend/angular/src/app/services/courses.service.ts index 73a8d87..bbb517e 100644 --- a/frontend/angular/src/app/services/courses.service.ts +++ b/frontend/angular/src/app/services/courses.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, map, catchError, throwError, of, switchMap } from 'rxjs'; import { ICardCurso } from '../shared/interfaces/interfaces'; @@ -130,124 +130,23 @@ export class CoursesService { constructor(private http: HttpClient) {} - // Crear headers HTTP apropiados - private getHttpHeaders(): HttpHeaders { - return new HttpHeaders({ - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }); - } - // Crear un nuevo curso createCourse(courseData: CreateCourseRequest): Observable { - console.log('Enviando datos de creación de curso:', JSON.stringify(courseData, null, 2)); - - return this.http.post(`${this.baseUrl}/courses`, courseData, { - headers: this.getHttpHeaders() - }).pipe( + return this.http.post(`${this.baseUrl}/courses`, courseData).pipe( catchError(this.handleError) ); } - // Actualizar un curso existente (usando PATCH para actualizaciones parciales) + // NUEVO: Actualizar un curso existente updateCourse(courseId: number, courseData: UpdateCourseRequest): Observable { - console.log(`Actualizando curso ID ${courseId}:`, JSON.stringify(courseData, null, 2)); - - // Validar datos antes de enviar - if (!this.validateUpdateCourseData(courseData)) { - return throwError(() => new Error('Datos de curso inválidos para actualización')); - } - - return this.http.patch(`${this.baseUrl}/courses/${courseId}`, courseData, { - headers: this.getHttpHeaders() - }).pipe( + return this.http.put(`${this.baseUrl}/courses/${courseId}`, courseData).pipe( catchError(this.handleError) ); } - // Validar datos de actualización de curso - private validateUpdateCourseData(courseData: UpdateCourseRequest): boolean { - if (!courseData || !courseData.course || !courseData.contents) { - console.error('Estructura de datos de curso inválida'); - return false; - } - - const { course, contents } = courseData; - - // Validar campos del curso - if (!course.title?.trim() || !course.description?.trim() || !course.goto?.trim()) { - console.error('Campos requeridos del curso faltantes o vacíos'); - return false; - } - - // Validar contenidos - if (!Array.isArray(contents) || contents.length === 0) { - console.error('Contenidos del curso deben ser un array no vacío'); - return false; - } - - // Validar cada contenido - for (const content of contents) { - if (!content.title?.trim()) { - console.error('Título del contenido requerido'); - return false; - } - - if (!Array.isArray(content.paragraph)) { - console.error('Los párrafos deben ser un array'); - return false; - } - - if (!Array.isArray(content.subcontent)) { - console.error('Los subcontenidos deben ser un array'); - return false; - } - - if (typeof content.maxResourceConsumption !== 'number' || content.maxResourceConsumption < 0) { - console.error('maxResourceConsumption debe ser un número no negativo'); - return false; - } - - if (typeof content.maxProcessingTime !== 'number' || content.maxProcessingTime < 0) { - console.error('maxProcessingTime debe ser un número no negativo'); - return false; - } - - // Validar subcontenidos - for (const sub of content.subcontent) { - if (!sub.subtitle?.trim()) { - console.error('Subtítulo del subcontenido requerido'); - return false; - } - - if (!Array.isArray(sub.subparagraph)) { - console.error('Los subpárrafos deben ser un array'); - return false; - } - - if (!Array.isArray(sub.example)) { - console.error('Los ejemplos deben ser un array'); - return false; - } - - // Validar ejemplos - for (const example of sub.example) { - if (!example.code || typeof example.code !== 'string') { - console.error('Cada ejemplo debe tener un código válido'); - return false; - } - } - } - } - - return true; - } - - // Eliminar un curso + // NUEVO: Eliminar un curso deleteCourse(courseId: number): Observable<{ message: string }> { - return this.http.delete<{ message: string }>(`${this.baseUrl}/courses/${courseId}`, { - headers: this.getHttpHeaders() - }).pipe( + return this.http.delete<{ message: string }>(`${this.baseUrl}/courses/${courseId}`).pipe( catchError(this.handleError) ); } @@ -384,48 +283,26 @@ export class CoursesService { private handleError = (error: HttpErrorResponse): Observable => { let errorMessage = 'Ha ocurrido un error desconocido'; - - console.error('Error HTTP completo:', error); - if (error.error instanceof ErrorEvent) { - // Error del lado del cliente errorMessage = `Error: ${error.error.message}`; } else { - // Error del lado del servidor switch (error.status) { case 400: errorMessage = 'Datos inválidos enviados al servidor'; - if (error.error && error.error.message) { - errorMessage += `: ${error.error.message}`; - } break; case 404: errorMessage = 'Curso no encontrado'; break; - case 422: - errorMessage = 'Datos no válidos o incompletos'; - if (error.error && error.error.message) { - errorMessage += `: ${error.error.message}`; - } - break; case 500: errorMessage = 'Error interno del servidor'; - if (error.error && error.error.message) { - errorMessage += `: ${error.error.message}`; - } - console.error('Detalles del error 500:', error.error); break; case 0: errorMessage = 'No se puede conectar con el servidor. Verifica tu conexión.'; break; default: errorMessage = `Error del servidor: ${error.status} - ${error.message}`; - if (error.error && error.error.message) { - errorMessage += ` (${error.error.message})`; - } } } - console.error('Error en CoursesService:', errorMessage, error); return throwError(() => new Error(errorMessage)); } From 09ebabf06f156006592b749c5438d6a3dd4e1de2 Mon Sep 17 00:00:00 2001 From: Josuex123 Date: Wed, 25 Jun 2025 01:43:27 -0400 Subject: [PATCH 18/20] fix: cursos editables --- backend/main.go | 5 +- backend/models/Course.go | 17 +- backend/routes/cors.go | 3 +- backend/routes/courses.routes.go | 96 ++- .../form-course/form-course.component.html | 5 +- .../form-course/form-course.component.spec.ts | 3 +- .../form-course/form-course.component.ts | 9 +- .../edit-course/edit-course.component.ts | 616 ++++++++---------- .../introduction/introduction.component.ts | 17 +- .../src/app/services/courses.service.ts | 29 +- 10 files changed, 396 insertions(+), 404 deletions(-) diff --git a/backend/main.go b/backend/main.go index 9ff43a4..9c03a43 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,7 +4,6 @@ import ( "log" "github.com/Frosmin/backend/db" - "github.com/Frosmin/backend/models" "github.com/Frosmin/backend/routes" "github.com/joho/godotenv" @@ -21,11 +20,11 @@ func main() { //db.DB.AutoMigrate(models.Exercise{}) //db.DB.AutoMigrate(models.Tutorial{}) //db.DB.AutoMigrate(models.Video{}) - db.DB.AutoMigrate( + /*db.DB.AutoMigrate( &models.Course{}, &models.Content{}, &models.Subcontent{}, - ) + )*/ // Obtener el router configurado con CORS y rutas r := routes.SetupRouter() diff --git a/backend/models/Course.go b/backend/models/Course.go index 947509f..e2e2fd5 100644 --- a/backend/models/Course.go +++ b/backend/models/Course.go @@ -1,32 +1,32 @@ package models import ( - //"gorm.io/gorm" "encoding/json" "database/sql/driver" ) type Course struct { - ID uint `json:"id" gorm:"primaryKey"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` Title string `json:"title"` Description string `json:"description"` Goto string `json:"goto"` - Contents []Content `json:"contents" gorm:"foreignKey:CourseID"` + Contents []Content `json:"contents" gorm:"foreignKey:CourseID;constraint:OnDelete:CASCADE"` } type Content struct { - ID uint `json:"id" gorm:"primaryKey"` - CourseID uint `json:"-"` + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + CourseID uint `json:"-" gorm:"not null"` Title string `json:"title"` Paragraph GormStrings `json:"paragraph" gorm:"type:jsonb"` - Subcontent []Subcontent `json:"subcontent" gorm:"foreignKey:ContentID"` + Subcontent []Subcontent `json:"subcontent" gorm:"foreignKey:ContentID;constraint:OnDelete:CASCADE"` Next string `json:"next,omitempty"` MaxResourceConsumption int `json:"maxResourceConsumption"` MaxProcessingTime int `json:"maxProcessingTime"` } + type Subcontent struct { - ID uint `json:"id" gorm:"primaryKey"` - ContentID uint `json:"-"` + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + ContentID uint `json:"-" gorm:"not null"` Subtitle string `json:"subtitle"` Subparagraph GormStrings `json:"subparagraph" gorm:"type:jsonb"` Example GormStrings `json:"example" gorm:"type:jsonb"` @@ -37,6 +37,7 @@ type GormStrings []string func (g GormStrings) Value() (driver.Value, error) { return json.Marshal(g) } + func (g *GormStrings) Scan(value interface{}) error { return json.Unmarshal(value.([]byte), g) } diff --git a/backend/routes/cors.go b/backend/routes/cors.go index 2c1be6c..f683f74 100644 --- a/backend/routes/cors.go +++ b/backend/routes/cors.go @@ -56,6 +56,7 @@ func SetupRouter() *gin.Engine { api.GET("/courses/:id", GetCourse) api.POST("/courses", CreateCourse) api.GET("/course/:goto", GetCourseIDByGoto) - api.PATCH("/courses/:id", UpdateCourse) + api.PUT("/courses/:id", UpdateCourse) + api.DELETE("/courses/:id", DeleteCourse) return r } diff --git a/backend/routes/courses.routes.go b/backend/routes/courses.routes.go index 2a7f976..0c8ca0e 100644 --- a/backend/routes/courses.routes.go +++ b/backend/routes/courses.routes.go @@ -99,26 +99,31 @@ func CreateCourse(c *gin.Context) { }) return } + + // Crear curso sin ID (para que GORM genere uno nuevo) course := models.Course{ Title: courseRequest.Course.Title, Description: courseRequest.Course.Description, Goto: courseRequest.Course.Goto, } + for _, contentInfo := range courseRequest.Contents { + // Crear contenido sin ID (para que GORM genere uno nuevo) content := models.Content{ Title: contentInfo.Title, Paragraph: models.GormStrings(contentInfo.Paragraph), - Next: ptrStringToString(contentInfo.Next), // Fixed: Convert *string to string + Next: ptrStringToString(contentInfo.Next), MaxResourceConsumption: contentInfo.MaxResourceConsumption, MaxProcessingTime: contentInfo.MaxProcessingTime, } + for _, subcontentInfo := range contentInfo.Subcontent { - // Convert ExampleInfo slice to string slice for GORM var exampleStrings []string for _, example := range subcontentInfo.Example { exampleStrings = append(exampleStrings, example.Code) } + // Crear subcontenido sin ID (para que GORM genere uno nuevo) subcontent := models.Subcontent{ Subtitle: subcontentInfo.Subtitle, Subparagraph: models.GormStrings(subcontentInfo.Subparagraph), @@ -128,6 +133,7 @@ func CreateCourse(c *gin.Context) { } course.Contents = append(course.Contents, content) } + if err := db.DB.Create(&course).Error; err != nil { log.Printf("Error creating course: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -136,6 +142,7 @@ func CreateCourse(c *gin.Context) { }) return } + c.JSON(http.StatusCreated, gin.H{ "message": "Course created successfully", "course_id": course.ID, @@ -180,12 +187,11 @@ func GetCourse(c *gin.Context) { Title: content.Title, Paragraph: []string(content.Paragraph), Subcontent: make([]SubcontentInfo, len(content.Subcontent)), - Next: stringToPtrString(content.Next), // Fixed: Convert string to *string + Next: stringToPtrString(content.Next), MaxResourceConsumption: content.MaxResourceConsumption, MaxProcessingTime: content.MaxProcessingTime, } for j, subcontent := range content.Subcontent { - // Convert string slice back to ExampleInfo slice var examples []ExampleInfo for _, exampleStr := range []string(subcontent.Example) { examples = append(examples, ExampleInfo{Code: exampleStr}) @@ -227,12 +233,11 @@ func GetAllCourses(c *gin.Context) { Title: content.Title, Paragraph: []string(content.Paragraph), Subcontent: make([]SubcontentInfo, len(content.Subcontent)), - Next: stringToPtrString(content.Next), // Fixed: Convert string to *string + Next: stringToPtrString(content.Next), MaxResourceConsumption: content.MaxResourceConsumption, MaxProcessingTime: content.MaxProcessingTime, } for j, subcontent := range content.Subcontent { - // Convert string slice back to ExampleInfo slice var examples []ExampleInfo for _, exampleStr := range []string(subcontent.Example) { examples = append(examples, ExampleInfo{Code: exampleStr}) @@ -250,6 +255,7 @@ func GetAllCourses(c *gin.Context) { c.JSON(http.StatusOK, responses) } +// FUNCIÓN UPDATECOURSE CORREGIDA func UpdateCourse(c *gin.Context) { courseID := c.Param("id") id, err := strconv.ParseUint(courseID, 10, 32) @@ -272,7 +278,7 @@ func UpdateCourse(c *gin.Context) { // Verificar que el curso existe var existingCourse models.Course - if err := db.DB.Preload("Contents.Subcontent").First(&existingCourse, uint(id)).Error; err != nil { + if err := db.DB.First(&existingCourse, uint(id)).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": "Course not found", @@ -309,35 +315,53 @@ func UpdateCourse(c *gin.Context) { return } - // Eliminar todos los contenidos existentes y sus subcontenidos + // PASO 1: Eliminar todos los subcontenidos existentes + if err := tx.Where("content_id IN (?)", + tx.Model(&models.Content{}).Select("id").Where("course_id = ?", existingCourse.ID), + ).Delete(&models.Subcontent{}).Error; err != nil { + tx.Rollback() + log.Printf("Error deleting subcontents: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete subcontents", + "details": err.Error(), + }) + return + } + + // PASO 2: Eliminar todos los contenidos existentes if err := tx.Where("course_id = ?", existingCourse.ID).Delete(&models.Content{}).Error; err != nil { tx.Rollback() - log.Printf("Error deleting existing contents: %v", err) + log.Printf("Error deleting contents: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to update course contents", + "error": "Failed to delete contents", + "details": err.Error(), }) return } - // Crear los nuevos contenidos + // PASO 3: Crear los nuevos contenidos (SIN especificar IDs) for _, contentInfo := range courseRequest.Contents { + // IMPORTANTE: No establecer ID para que GORM genere uno nuevo content := models.Content{ + // ID se omite intencionalmente para que GORM genere uno nuevo Title: contentInfo.Title, Paragraph: models.GormStrings(contentInfo.Paragraph), - Next: ptrStringToString(contentInfo.Next), // Fixed: Convert *string to string + Next: ptrStringToString(contentInfo.Next), MaxResourceConsumption: contentInfo.MaxResourceConsumption, MaxProcessingTime: contentInfo.MaxProcessingTime, CourseID: existingCourse.ID, } for _, subcontentInfo := range contentInfo.Subcontent { - // Convert ExampleInfo slice to string slice for GORM var exampleStrings []string for _, example := range subcontentInfo.Example { exampleStrings = append(exampleStrings, example.Code) } + // IMPORTANTE: No establecer ID para que GORM genere uno nuevo subcontent := models.Subcontent{ + // ID se omite intencionalmente para que GORM genere uno nuevo + // ContentID se establecerá automáticamente por GORM Subtitle: subcontentInfo.Subtitle, Subparagraph: models.GormStrings(subcontentInfo.Subparagraph), Example: models.GormStrings(exampleStrings), @@ -361,6 +385,7 @@ func UpdateCourse(c *gin.Context) { log.Printf("Error committing transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to update course", + "details": err.Error(), }) return } @@ -397,8 +422,39 @@ func DeleteCourse(c *gin.Context) { return } - // Eliminar el curso (las relaciones se eliminarán en cascada si está configurado) - if err := db.DB.Delete(&course).Error; err != nil { + // Usar transacción para eliminar en cascada + tx := db.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Eliminar subcontenidos + if err := tx.Where("content_id IN (?)", + tx.Model(&models.Content{}).Select("id").Where("course_id = ?", course.ID), + ).Delete(&models.Subcontent{}).Error; err != nil { + tx.Rollback() + log.Printf("Error deleting subcontents: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete subcontents", + }) + return + } + + // Eliminar contenidos + if err := tx.Where("course_id = ?", course.ID).Delete(&models.Content{}).Error; err != nil { + tx.Rollback() + log.Printf("Error deleting contents: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete contents", + }) + return + } + + // Eliminar curso + if err := tx.Delete(&course).Error; err != nil { + tx.Rollback() log.Printf("Error deleting course: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to delete course", @@ -406,6 +462,14 @@ func DeleteCourse(c *gin.Context) { return } + if err := tx.Commit().Error; err != nil { + log.Printf("Error committing delete transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete course", + }) + return + } + c.JSON(http.StatusOK, gin.H{ "message": "Course deleted successfully", }) @@ -432,4 +496,4 @@ func GetCourseIDByGoto(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "id": course.ID, }) -} \ No newline at end of file +} diff --git a/frontend/angular/src/app/components/form-course/form-course.component.html b/frontend/angular/src/app/components/form-course/form-course.component.html index cc7b5cf..d9f5d27 100644 --- a/frontend/angular/src/app/components/form-course/form-course.component.html +++ b/frontend/angular/src/app/components/form-course/form-course.component.html @@ -193,7 +193,10 @@
    💻 Ejemplos de Código
    {{ i + 1 }}.{{ j + 1 }}.{{ k + 1 }} - + + + +