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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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