Componentes de Vue
Casi toda la interfaz de usuario de omegaUp es Vue. La antigua migración de Smarty a Vue ya está completa: la aplicación actualmente incluye 257 componentes .vue de un solo archivo en una sola plantilla del lado del servidor (frontend/templates/template.tpl, un shell Twig 3 que simplemente envuelve un punto de entrada de Vue e inyecta su carga útil JSON). Entonces, cuando construyes una página hoy, casi siempre estás construyendo (o conectando) componentes de Vue, no escribiendo HTML renderizado por el servidor.
Estamos en Vue 2.7.16 + TypeScript 4.4.4, usando la API de Opciones a través de vue-property-decorator (componentes estilo clase con @Component / @Prop). Hay una migración separada y lenta a Vue 3 que se encuentra en los directorios vue-upgrade-tool/ y vue-js-tutorial/ de nivel raíz, pero todo lo que escriba hoy apunta a Vue 2.7, así que no busque <script setup> o la API de composición.
Dónde viven los componentes
Cada archivo .vue se encuentra en frontend/www/js/omegaup/, y 248 de los 257 se encuentran específicamente en frontend/www/js/omegaup/components/. No existe ningún frontend/www/js/components/; si buscas allí, no encontrarás nada.
Dentro de components/ el árbol está agrupado por la parte del producto a la que pertenece un componente, no por tipo. Un puñado de bloques de construcción verdaderamente genéricos se encuentran en el nivel superior (Markdown.vue, CountryFlag.vue, ToggleSwitch.vue, RadioSwitch.vue, Autocomplete.vue, DatePicker.vue), y todo lo relacionado con características específicas se encuentra en un subdirectorio con nombre: arena/, badge/, contest/, course/, group/, problem/, user/, homepage/, notification/, submissions/, common/ y alrededor de una docena más. Coloque un nuevo componente junto a sus hermanos (un nuevo widget de insignia pertenece a components/badge/, no a la raíz) para que la persona que lo sigue pueda encontrarlo por función.
Un componente básico
<template>
<div class="my-component">
<h1>{% raw %}{{ title }}{% endraw %}</h1>
<button @click="handleClick">{% raw %}{{ T.commonSave }}{% endraw %}</button>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
import T from '../../lang';
@Component
export default class MyComponent extends Vue {
@Prop({ required: true })
title!: string;
T = T; // expose the translation table to the template
handleClick(): void {
this.$emit('clicked');
}
}
</script>
Nunca codifique texto visible para el usuario. Todas las cadenas provienen de la tabla de traducción T (frontend/www/js/omegaup/lang), por lo que el mismo componente se representa en español, inglés y portugués sin necesidad de reescribirlo. <div>Hello</div> no pasa la revisión; Pases {% raw %}{{ T.helloWorld }}{% endraw %}. Y cuando una cadena tenga un valor de tiempo de ejecución, no la concatene ({% raw %}{{ T.greeting }} {{ userName }}{% endraw %} se rompe en idiomas donde el orden de las palabras difiere); use ui.formatString(T.greeting, { name: userName }) para que el marcador de posición acabe donde lo colocó el traductor.
Prefiere ranuras a indicadores de comportamiento. Un componente que voltea grandes porciones de su propio marcado basado en un accesorio mode o variant se convierte en una maraña que nadie quiere tocar. Exponga los <slot> con nombre y permita que la persona que llama proporcione las diferentes partes, de modo que un componente mantenga un trabajo:
<template>
<div>
<slot name="header"></slot>
<slot name="content"></slot>
</div>
</template>
var(--color-primary)) en lugar de un #ff0000 literal, de modo que un componente recoja los cambios de tema de forma gratuita en lugar de fijar un valor hexadecimal que alguien tendrá que buscar más tarde.
Libro de cuentos
Storybook es donde desarrollas y analizas un componente de forma aislada, sin iniciar toda la aplicación. Le brinda un taller interactivo: renderice un componente por sí solo, voltee sus accesorios desde una barra lateral y vea cada estado y variación uno al lado del otro. Ese desacoplamiento es el punto: puede crear y revisar un Badge o un ContestCard sin un backend en ejecución, un usuario que haya iniciado sesión o las filas correctas de la base de datos, y los revisores pueden obtener exactamente los estados que usted creó.
Estamos en Storybook 7.6 (storybook@^7.6.21), ejecutando Vue a través de @storybook/vue 7.4.6 en el constructor @storybook/vue-webpack5: la misma cadena de herramientas de Webpack 5 con la que se construye la aplicación real, por lo que un componente que se representa en Storybook se representa de la misma manera en producción.
Ejecutándolo
Hay un script dedicado y, a diferencia de la mayoría de omegaUp, no necesitas Docker up para usarlo:
yarn storybook
storybook dev -p 6006 (consulte la entrada storybook en package.json), que compila la colección de historias y proporciona un panel en http://localhost:6006. Déjalo funcionando; se recarga en caliente a medida que edita un componente o su historia.
Cómo está cableado
La configuración son dos archivos en .storybook/:
.storybook/main.tsseñala Storybook a las historias con el globo../frontend/www/js/omegaup/**/*.stories.@(js|jsx|ts|tsx), en cualquier lugar debajo defrontend/www/js/omegaup/, por lo que también captura historias que no soncomponents/, como las que están bajograderv2/. Registra tres complementos (addon-links,addon-essentials,addon-interactions), configurastaticDirs: ['../frontend/www']para que las rutas de recursos relativas se resuelvan exactamente como lo hacen en la aplicación, alias@afrontend/www/y enseña a la configuración del paquete web compartido cómo cargar.vue,.scss,.cssy archivos de imagen.docs.autodocsestá configurado en'tag', por lo que una historia solo obtiene una página de documentos generada automáticamente si opta por etiquetarla..storybook/preview.tscarga el CSS global que cada componente supone que está presente:third_party/bootstrap-4.5.0/css/bootstrap.min.css(estamos en Bootstrap 4, conbootstrap-vue, no Bootstrap 5) más FontAwesome 5.15.4 inyectado en el iframe<head>. También declara los comparadorescontrolsque hacen que Storybook seleccione automáticamente el widget correcto: cualquier argumento que termine encolor/backgroundobtiene un selector de color, cualquier cosa que termine enDateobtiene un selector de fecha y una expresión regularactions(^on[A-Z].*) que registra los controladores coincidentes en el panel Acciones.
Sin preview.ts cargando Bootstrap y FontAwesome, su componente se mostraría sin estilo y sin íconos en Storybook a pesar de que se ve bien en la aplicación; esa falta de coincidencia es exactamente lo que este archivo pretende evitar.
La realidad de la cobertura
Sea honesto consigo mismo sobre el estado de esto: actualmente solo hay 10 archivos .stories para 257 componentes. Storybook no es un lugar donde ya viven todos los componentes; es un lugar al que los estamos trasladando gradualmente. Si está tocando un componente y no tiene historia, agregar uno es una contribución realmente bienvenida y de bajo riesgo.
Escribir una historia
La convención es un archivo de historia por componente, con su nombre y ubicado justo al lado del archivo .vue: para Badge.vue, crea Badge.stories.ts en la misma carpeta. (Es por eso que el globo es una coincidencia recursiva de ** en lugar de un único directorio de historias: las historias viven dondequiera que vivan sus componentes).
Escribimos Formato de historia del componente 3 (CSF3): un objeto meta exportado de forma predeterminada que describe el componente, luego una exportación con nombre por estado que desea mostrar, cada uno de ellos un StoryObj. Aquí está el ToggleSwitch.stories.ts real, que es una buena plantilla mínima para copiar:
import { StoryObj, Meta } from '@storybook/vue';
import ToggleSwitch, { ToggleSwitchSize } from './ToggleSwitch.vue';
const meta: Meta<typeof ToggleSwitch> = {
component: ToggleSwitch,
title: 'Components/ToggleSwitch',
argTypes: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore FIXME: vue-property-decorator is deprecated, so we can't get prop types from the component
textDescription: {
control: 'text',
},
checkedValue: {
control: 'boolean',
},
size: {
control: 'select',
options: ToggleSwitchSize,
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
textDescription: 'Text for the check',
checkedValue: false,
size: ToggleSwitchSize.Large,
},
render: (args, { argTypes }) => ({
components: { ToggleSwitch },
props: Object.keys(argTypes),
template:
'<toggle-switch :text-description="$props.textDescription" :checked-value="$props.checkedValue" :size="$props.size" />',
}),
};
Default.storyName = 'ToggleSwitch';
title('Components/ToggleSwitch') es la ruta en la barra lateral de Storybook; la barra diagonal forma una carpeta. Siga la agrupación existente: los componentes genéricos y de nivel superior se encuentran enComponents/..., los widgets de arena enArena/...(consulteContestCardv2.stories.tstitulado'Arena/ContestCard'), y así sucesivamente.argTypesdeclara cada accesorio y, fundamentalmente, qué control lo representa en el tablero:control: 'text'proporciona un cuadro de texto,'boolean'un interruptor,'select'conoptionsun menú desplegable. Esto es lo que convierte un renderizado estático en un campo de juego controlado por perillas donde un revisor puede ejercitar cada estado manualmente.- Ese
@ts-ignore FIXMEno es un texto repetitivo que puedas eliminar: soporta carga. Debido a que creamos componentes con el obsoletovue-property-decorator, la escritura de Storybook 7 no puede inferir tipos de accesorios directamente a partir de la clase de componente, por lo que suprimimos el error resultante enargTypes. Copie el comentario tal cual; documenta por qué el ignorado está ahí para la siguiente persona. argsson los valores predeterminados concretos que se introducen en esos controles cuando se representa la historia por primera vez.rendercrea la instancia de Vue real. La líneaprops: Object.keys(argTypes)reenvía cada argumento declarado al componente contenedor para que los controles estén conectados a accesorios reales, y la cadenatemplatemonta el componente con esos accesorios vinculados. Solo necesitarendercuando el montaje predeterminado no es suficiente; para un componente simple, a menudo puede omitirlo y dejar que Storybook monte el componente directamente.storyNameanula la etiqueta que se muestra para esa historia individual (de lo contrario, se deriva del nombre de la exportación, por ejemplo,Default).
Algunos componentes toman un objeto completo en lugar de accesorios planos, y la función de renderizado es donde lo ensamblas. Badge.stories.ts recopila sus argumentos y los pasa como un único objeto vinculado, template: '<badge :badge="$props" />', con badge_alias expuesto como un control select sobre la lista completa de alias de insignias reales ('100solvedProblems', 'coderOfTheMonth', 'problemSetter',…) para que pueda hojear cada insignia visualmente.
Mostrando múltiples estados
El valor real aparece cuando un componente tiene estados significativamente diferentes: cree una exportación con nombre para cada uno. ContestCardv2.stories.ts es el modelo: define un Template reutilizable, luego exporta Default, Recommended, Current, Future y Past, cada uno de los cuales distribuye los argumentos base y anula solo los campos que difieren (un indicador recomendado, horas de inicio/finalización hace una hora versus un día fuera) para que los cinco estados del concurso se alineen uno al lado del otro en la barra lateral:
export const Future = Template.bind({});
Future.args = {
contest: {
...Default.args.contest,
title: 'Future Contest',
start_time: new Date(Date.now() + 86400000), // 1 day from now
finish_time: new Date(Date.now() + 172800000), // 2 days from now
active: false,
} as types.ContestListItem,
};
as types.ContestListItem: los datos simulados se escriben con los tipos de API generados en frontend/www/js/omegaup/api_types.ts (producido por frontend/server/cmd/APITool.php, marcado como DO NOT EDIT), para que sus dispositivos sean honestos con la forma que realmente envía el backend. Si falta un campo o el tipo es incorrecto, TypeScript se lo informa en la historia antes de llegar a una página.
ContestCardv2.stories.ts también está escrito en el estilo CSF2 anterior (Template.bind({}) con Story de @storybook/vue) en lugar de CSF3 (ambos funcionan y ambos están en el árbol), pero prefiera el formulario CSF3 StoryObj que se muestra arriba para cualquier cosa nueva; es en torno a lo que se construye Storybook 7 y hacia dónde se dirige el ecosistema.
Pruebas de componentes
Además de las historias, los componentes llevan pruebas unitarias de Jest llamadas Component.test.ts en la misma carpeta (verá Countdown.test.ts, Markdown.test.ts, ToggleSwitch.test.ts y amigos junto a sus archivos .vue). Utilice @vue/test-utils para montar y afirmar:
import { mount } from '@vue/test-utils';
import MyComponent from './MyComponent.vue';
describe('MyComponent', () => {
it('renders title', () => {
const wrapper = mount(MyComponent, {
propsData: { title: 'Test' },
});
expect(wrapper.text()).toContain('Test');
});
});
Documentación relacionada
- Pautas de codificación: las reglas completas de Vue/TypeScript (jQuery está prohibido,
Tpara todas las cadenas,ui.formatStringpara interpolación) - Guía de pruebas — Jest, Cypress y cómo ejecutar las suites
- Arquitectura de interfaz: cómo encajan el shell Twig, los puntos de entrada de Webpack y los componentes de Vue