Saltar al contenido principal

Card de curso en resultados de búsqueda

Ficha técnica
  • Impacto — Tema: classes/output/core/course_renderer.php (override de coursecat_coursebox() y search_courses()), templates course_search_card.mustache, course_search_header.mustache y course_search_hero.mustache (compartido con /course/index.php), SCSS scss/coursesearch.scss + scss/coursecategories.scss (hero compartido) + extensión de .cdigital-coursecard* en scss/preset/cdigital.scss, iconos pix/Icon/lock-blue.svg, pix/coursesearch-icon.png y pix/coursesearch-hero.png
  • Impacto — Plugin: —
  • Requisito: Diseño (Figma) · Figma: 19631-1968 (retícula de cards), 19631-1956 (encabezado "Resultados de la búsqueda"), 19793-3712 (hero "Explora todos los cursos") · Estado: Implementado (hero sin selector de categoría — ver Hero "Explora todos los cursos")

Propósito​

Sustituye la lista vertical de resultados que renderiza core en /course/search.php (<h3> + resumen HTML + <ul class="teachers"> + categoría como línea de texto) por una retícula de cards, siguiendo el diseño Figma "Card_curso_busqueda". Alcanza únicamente esta página: el resto de listados de cursos de core (portada, resultados por etiqueta) conserva el markup original. /course/index.php tiene su propio rediseño, con su propio markup de card — ver Exploración de cursos por categorías.

  • Retícula de hasta 4 columnas (317×412 px por card, huecos de 34px/14px), ancho de página ampliado a 1312px para acomodarla.
  • Portada, chip de categoría, título y línea "Profesor:" — misma anatomía visual que la card de "Mis cursos", reutilizada tal cual.
  • Etiqueta verde "Curso" sobre la portada, con esquinas redondeadas solo a la izquierda.
  • Candado azul en la esquina superior derecha de la portada cuando el usuario actual no puede acceder al curso (core_course_list_element::can_access()).
  • CTA "Ver curso" como enlace subrayado con ícono de flecha circular, no el botón píldora de "Mis cursos".
  • Altura uniforme entre cards de una misma fila, con el CTA "Ver curso" siempre anclado al final de la card — mismo objetivo que la card de "Mis cursos", con un mecanismo de estirado propio (ver Altura fija y anclaje inferior).
  • Encabezado "Resultados de la búsqueda" con icono ilustrado, título con el texto buscado entre comillas y conteo de resultados, reemplazando el <h2> plano de core (ver Encabezado de resultados).
  • Hero "Explora todos los cursos" con ilustración, título, subtítulo y el buscador de core restilizado, reemplazando el <h1>{$sitename}</h1> genérico de la página (ver Hero "Explora todos los cursos").

Por qué un renderer y no solo CSS​

El markup que emite core (core_course_renderer::coursecat_coursebox(), course/renderer.php) no contiene los elementos del diseño — no hay CTA "Ver curso" propio, ni la portada como bloque separado de 180px, ni etiqueta de tipo — y el orden visual difiere del de core (resumen HTML, lista de contactos, categoría como texto). Por eso se sobreescribe coursecat_coursebox(), el único punto por el que pasa cada resultado individual, y se deja intacto coursecat_courses() (la lista contenedora, con su barra de paginación) — la retícula y el ajuste de la paginación se resuelven con CSS, no reimplementando esa lógica.

El pagetype es lo que acota cada rediseño a su página: coursecat_coursebox() distingue course-search (esta card) de los pagetypes de /course/index.php (la card de Exploración de cursos por categorías); cualquier otra llamada (frontpage, etiquetas) cae al comportamiento heredado sin modificar.

Dónde vive el renderer nuevo​

El tema ya tenía un renderer de cursos legacy, theme_cdigital_core_course_renderer (lib.php), que aporta el layout de cards de la portada (frontpage_available_courses()/frontpage_part()). theme_overridden_renderer_factory resuelve las clases de renderer namespaced (\output\core\course_renderer) antes que las legacy con el nombre completo (renderer_factory_base::standard_renderer_classnames()), así que la clase nueva extiende esa legacy en vez de core_course_renderer directamente — si extendiera core_course_renderer, reemplazaría en silencio al renderer legacy y la portada perdería su layout de cards. lib.php se incluye durante la carga de theme_config, antes de que cualquier renderer se resuelva, así que la clase legacy ya existe cuando el autoloader busca la namespaced.

Datos de la card​

Toda la información se resuelve server-side, reutilizando patrones ya probados en el tema (mismo fallback de imagen y mismo patrón contacts → username que theme_cdigital\output\core_renderer::course_hero_row_context()):

CampoOrigen
Portadacourse_summary_exporter::get_course_image(), con fallback a $OUTPUT->get_generated_image_for_id()
Categoríacore_course_category::get($course->category, IGNORE_MISSING)->get_formatted_name()
Profesorescore_course_list_element::get_course_contacts() → username de cada contacto
Candado!core_course_list_element::can_access()

Los contactos llegan ya precargados: search_courses() construye el coursecat_helper con COURSECAT_SHOW_COURSES_EXPANDED_WITH_CAT, y coursecat_helper::set_show_courses() activa la opción coursecontacts en search_courses() a partir de ese nivel — a diferencia de la card de "Mis cursos", aquí no hace falta ningún WS ni módulo AMD para resolver los nombres de los docentes.

Altura fija y anclaje inferior​

Mismo objetivo que la card de "Mis cursos" (ver su sección equivalente), con un ajuste propio porque esta retícula no comparte su estructura de dos niveles:

  • Párrafo "Profesor:" incondicional. course_search_card.mustache renderiza siempre .cdigital-coursecard__teachers (con el span de nombres vacío si el curso no tiene contactos), igual que core_course/coursecard.mustache. Es lo que hace que el min-height: calc(14px * 1.5 * 2) de esa clase (regla compartida, scss/preset/cdigital.scss) reserve siempre las 2 líneas de espacio — si el bloque se omitiera condicionalmente ({{#hasteachers}}), las cards sin docentes quedarían más bajas que sus vecinas de fila.
  • justify-content: flex-start en __body + margin-top: auto en __actions. La card compartida usa justify-content: space-between entre sus dos únicos hijos (__top/__actions) para anclar el botón abajo; aquí __body tiene tres hijos (__tag, __text, __actions) y ese modo repartiría el hueco también entre __tag y __text, separándolos. Se desactiva y el anclaje al fondo lo hace margin-top: auto en __actions, dejando tag+título+profesor agrupados arriba con su propio gap.
  • height: auto en la card, no height: 100%. La regla compartida fija .cdigital-coursecard { height: 100% }, pensada para "Mis cursos": ahí la card no es el flex item que se estira, es un hijo normal de .col (Bootstrap .row.row-cols-lg-3), que sí lo es — height:100% resuelve correctamente contra el alto ya definido de .col una vez estirado. En esta retícula la card es el flex item directo de .courses.course-search-result, y un flex item con height en porcentaje no se estira con align-items: stretch (el spec solo aplica el estirado cuando el alto computado es la palabra clave auto; un porcentaje cuenta como valor explícito aunque el contenedor no tenga alto definido). Verificado en vivo: sin el override, una card sin docentes quedaba visiblemente más baja que sus vecinas pese al align-items: stretch del contenedor. scss/coursesearch.scss restaura height: auto en el selector de la card dentro de la retícula.

Con los tres puntos anteriores, las 4 cards de una fila terminan con el mismo alto y el mismo CTA anclado al fondo, verificado con getBoundingClientRect() (mismo cardHeight y mismo ctaTop en las 4 cards).

Encabezado de resultados​

core_course_renderer::search_courses() (course/renderer.php) renderiza, cuando hay resultados, un <h2> plano: $this->heading(get_string('searchresults'). ": $totalcount"). El Figma (19631-1956) lo reemplaza por una tarjeta tipo "glass card": icono circular ilustrado (63px, fondo #f3f5fd) + dos líneas de texto (título en negrita #0943b5 con el texto buscado entre comillas, conteo en #4c4c4c debajo), border-radius:12px, backdrop-filter:blur(6px) y una sombra suave.

Por qué otro override de método completo​

A diferencia de coursecat_coursebox() (compartido por varias páginas, de ahí su guarda por pagetype), search_courses() solo se invoca desde course/search.php — verificado por grep: el resto de coincidencias de "search_courses(" en el código son la función estática no relacionada core_course_category::search_courses(). No necesita guarda.

El método no delega la construcción del heading a ningún punto overridable — el $this->heading(...) está inline — así que la única forma de sustituirlo es sobreescribir el método completo. La implementación del tema copia literal el bloque de opciones de paginación/orden de core (para no reimplementar esa lógica de otra forma) y solo cambia la rama con resultados, que ahora renderiza course_search_header.mustache en vez de llamar a $this->heading(). Las dos ramas sin resultados (nocoursesfound, novalidcourses) quedan intactas: no hay spec de Figma para esos estados.

Búsqueda sin texto libre​

El Figma solo cubre el estado con texto buscado (modulelist/blocklist/tagid sin search no tienen equivalente visual en el archivo). Cuando $searchcriteria['search'] está vacío, el título cae a get_string('searchresults') solo, sin los dos puntos ni las comillas — la línea de conteo no cambia. Es la extrapolación usada, por ejemplo, al probar con ?modulelist=forum&perpage=all (el mismo caso ya usado para verificar la retícula).

Relleno del contenedor "glass"​

get_design_context no devolvió un color de relleno explícito para la capa "Overlay+Shadow+OverlayBlur" del Figma (solo backdrop-blur + sombra). Sobre el fondo blanco de esta página, un blanco translúcido (rgba(255,255,255,.6)) es visualmente equivalente al original — desviación menor, de bajo riesgo visual en este contexto concreto.

Hero "Explora todos los cursos"​

El <h1> de esta página no lo controla el renderer de curso: search.php llama a $PAGE->set_heading($site->fullname) y core_renderer::context_header() (usado en todo el sitio, template core/full_header) lo convierte en <div class="page-context-header">…<h1>{$sitename}</h1>…</div>, chrome estándar de Boost fuera de search_courses(). El Figma (19793-3712) sustituye esa zona por una tarjeta ilustrada: libro abierto a la izquierda, y a la derecha un <h1> propio ("Explora todos los cursos", 34px negrita #0943b5), un subtítulo (18px #222) y el buscador de curso debajo.

Cómo se suprime el heading nativo. No es modificable desde el renderer de curso (vive en context_header(), que no es específico de esta página) ni desde search.php (core), así que se oculta con una regla CSS acotada, #page-course-search .page-context-header { display: none !important; }. El !important es obligatorio, no cosmético: el markup de core trae la utilidad Bootstrap .d-flex en el mismo elemento (class="page-context-header d-flex ..."), y .d-flex { display: flex !important; } gana sobre un display: none normal sin importar la especificidad del selector — se verificó en vivo (getComputedStyle seguía devolviendo flex sin el !important). El <h1> nuevo del hero queda como el único visible de la página (el nativo sigue en el DOM pero con display:none, fuera del árbol de accesibilidad) — el encabezado de resultados de más abajo ya usa <h2> — correcto para la jerarquía de encabezados (WCAG 1.3.1).

El buscador no se reimplementa. course_search_hero.mustache envuelve el HTML que ya produce core_course_renderer::course_search_form() ({{{searchform}}}, HTML de confianza generado por PHP antes de renderizar la plantilla) sin tocar su plantilla core (core/search_input, compartida con la búsqueda global de otras páginas) ni su lógica de envío — sigue siendo el mismo campo q que search.php ya mapea a $search. Solo se restilizan por CSS las clases que ese HTML ya trae (.simplesearchform, .input-group, .form-control, .btn.search-icon) para que la caja de texto se vea como la mitad derecha de la barra del Figma.

Selector de categoría descartado en esta página, implementado en /course/index.php. El Figma combina el buscador con un selector "Todas las categorías" en una sola barra partida. Aquí se decidió no implementarlo: core_course_category::search_courses() no admite combinar un filtro de categoría con la rama de búsqueda de texto libre (esa rama no aplica ningún filtro de categoría; solo la búsqueda por etiqueta tiene un filtro de categoría parcial, atado a tagid, no reutilizable aquí). Un selector funcional de verdad requeriría lógica nueva en el tema — traer todos los resultados de search_courses() sin paginar, filtrar por categoría en PHP y re-paginar ahí, ya que el filtrado no puede resolverse dentro de la función core sin tocarla — y uno decorativo sin conectar sería una UI que aparenta funcionar sin hacerlo.

course_search_hero.mustache sí terminó ganando ese selector, pero servido desde /course/index.php, no desde esta página: ahí el selector no filtra una búsqueda de texto, solo navega directo a /course/index.php?categoryid=N, algo que core_course_category ya soporta sin tocarla — ver Exploración de cursos por categorías. El bloque {{#categoryselect}} de la plantilla queda sin usar en esta página (search_courses() no lo pasa), así que el buscador sigue ocupando aquí el ancho completo que el Figma reparte entre las dos mitades de la barra combinada, sin cambios respecto a antes.

Desviaciones conscientes respecto al Figma​

  1. Altura de card: el Figma fija 412px con un título de una sola línea; con títulos reales la card conserva el -webkit-line-clamp: 3 heredado de la card de "Mis cursos" y crece — uniformemente en toda la fila, según el mecanismo anterior.
  2. Categoría no enlazada: core la renderiza como enlace a /course/index.php?categoryid=…; el diseño la dibuja como chip plano sin interacción, y así se implementa — mismo criterio ya aplicado en la card de "Mis cursos".
  3. Resumen del curso: core lo muestra en los resultados de búsqueda; el diseño no lo contempla, así que se omite.
  4. Curso del sitio: core_course_category::search_courses() puede devolver el curso SITEID según los criterios de búsqueda (se observa en el entorno local con modulelist=forum). No se filtra: es comportamiento de core y queda fuera del alcance de este cambio.

Archivos clave​

ArchivoDescripción
theme/cdigital/classes/output/core/course_renderer.phpOverride de coursecat_coursebox() (acotado a pagetype === 'course-search') y de search_courses() (sin guarda, solo se invoca desde esta página)
theme/cdigital/templates/course_search_card.mustacheMarkup de la card
theme/cdigital/templates/course_search_header.mustacheMarkup del encabezado "Resultados de la búsqueda"
theme/cdigital/templates/course_search_hero.mustacheMarkup del hero "Explora todos los cursos"; envuelve el HTML ya renderizado de course_search_form()
theme/cdigital/scss/coursesearch.scssAncho de página (1312px), retícula flex, tag "Curso", candado, CTA, encabezado de resultados. El bloque del hero (incluye ocultar .page-context-header) vive ahora en scss/coursecategories.scss, compartido con /course/index.php
theme/cdigital/scss/preset/cdigital.scssExtiende el selector de .cdigital-coursecard* (media/imagen/tag/título/profesores) a #page-course-search
theme/cdigital/pix/Icon/lock-blue.svgCandado en #0943b5 (variante de color de Icon/lock.svg, ya usado en gris por el drawer de índice de curso)
theme/cdigital/pix/coursesearch-icon.pngIcono ilustrado (lupa) del encabezado de resultados
theme/cdigital/pix/coursesearch-hero.pngIlustración (libro abierto) del hero
theme/cdigital/lang/{es,en}/theme_cdigital.phpStrings searchcard_typecourse, searchcard_lockedtitle, coursesearch_foundcount(one), coursesearch_herotitle, coursesearch_herosubtitle (reutiliza coursecard_viewcourse, coursecard_teacherlabel y el string core searchresults)

Build y despliegue​

No hay módulos AMD nuevos: la lógica es 100% server-side (PHP + Mustache) y el SCSS lo compila Moodle en runtime, así que no hace falta grunt amd. Para el despliegue completo (copia de código, upgrade, purga de cachés), ver Procedimiento de despliegue.