Card de curso en resultados de búsqueda
- Impacto — Tema:
classes/output/core/course_renderer.php(override decoursecat_coursebox()ysearch_courses()), templatescourse_search_card.mustache,course_search_header.mustacheycourse_search_hero.mustache(compartido con/course/index.php), SCSSscss/coursesearch.scss+scss/coursecategories.scss(hero compartido) + extensión de.cdigital-coursecard*enscss/preset/cdigital.scss, iconospix/Icon/lock-blue.svg,pix/coursesearch-icon.pngypix/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()):
| Campo | Origen |
|---|---|
| Portada | course_summary_exporter::get_course_image(), con fallback a $OUTPUT->get_generated_image_for_id() |
| Categoría | core_course_category::get($course->category, IGNORE_MISSING)->get_formatted_name() |
| Profesores | core_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.mustacherenderiza siempre.cdigital-coursecard__teachers(con el span de nombres vacío si el curso no tiene contactos), igual quecore_course/coursecard.mustache. Es lo que hace que elmin-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-starten__body+margin-top: autoen__actions. La card compartida usajustify-content: space-betweenentre sus dos únicos hijos (__top/__actions) para anclar el botón abajo; aquí__bodytiene tres hijos (__tag,__text,__actions) y ese modo repartiría el hueco también entre__tagy__text, separándolos. Se desactiva y el anclaje al fondo lo hacemargin-top: autoen__actions, dejando tag+título+profesor agrupados arriba con su propiogap.height: autoen la card, noheight: 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.coluna vez estirado. En esta retícula la card es el flex item directo de.courses.course-search-result, y un flex item conheighten porcentaje no se estira conalign-items: stretch(el spec solo aplica el estirado cuando el alto computado es la palabra claveauto; 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 alalign-items: stretchdel contenedor.scss/coursesearch.scssrestauraheight: autoen 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
- 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: 3heredado de la card de "Mis cursos" y crece — uniformemente en toda la fila, según el mecanismo anterior. - 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". - Resumen del curso: core lo muestra en los resultados de búsqueda; el diseño no lo contempla, así que se omite.
- Curso del sitio:
core_course_category::search_courses()puede devolver el cursoSITEIDsegún los criterios de búsqueda (se observa en el entorno local conmodulelist=forum). No se filtra: es comportamiento de core y queda fuera del alcance de este cambio.
Archivos clave
| Archivo | Descripción |
|---|---|
theme/cdigital/classes/output/core/course_renderer.php | Override 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.mustache | Markup de la card |
theme/cdigital/templates/course_search_header.mustache | Markup del encabezado "Resultados de la búsqueda" |
theme/cdigital/templates/course_search_hero.mustache | Markup del hero "Explora todos los cursos"; envuelve el HTML ya renderizado de course_search_form() |
theme/cdigital/scss/coursesearch.scss | Ancho 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.scss | Extiende el selector de .cdigital-coursecard* (media/imagen/tag/título/profesores) a #page-course-search |
theme/cdigital/pix/Icon/lock-blue.svg | Candado 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.png | Icono ilustrado (lupa) del encabezado de resultados |
theme/cdigital/pix/coursesearch-hero.png | Ilustración (libro abierto) del hero |
theme/cdigital/lang/{es,en}/theme_cdigital.php | Strings 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.