Skip to content

Commit

Permalink
Merge pull request #23 from jameel-institute/jidea-57-non-country-inp…
Browse files Browse the repository at this point in the history
…uts-front-end

Convert parameter metadata into a front-end form
  • Loading branch information
david-mears-2 authored Sep 9, 2024
2 parents bee45e8 + 29a6f0f commit 45014aa
Show file tree
Hide file tree
Showing 28 changed files with 1,973 additions and 86 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ npx @eslint/config-inspector

In VSCode, [make sure](https://eslint.nuxt.com/packages/module#vs-code) your ESlint VS Code extension (vscode-eslint) is at least v3.0.10 (released June 2024). Turn on the 'Format on Save' setting.

# NPM dependency notes

To document why some of package.json is the way it is (since JSON doesn't support comments):

1. The Vue version is overridden because of the issue described in the 'tip' in the installation section of https://pinia.vuejs.org/ssr/nuxt.html
1. `@rollup/rollup-linux-x64-gnu` is an optional dependency as a fix for the issue that Rollup describes [here](https://github.com/rollup/rollup/blob/f83b3151e93253a45f5b8ccb9ccb2e04214bc490/native.js#L59) and which occurred for us when doing an installation with npm on Docker on CI. Their suggested fix does not work for our use case, because removing package-lock.json prevents the use of `npm ci`, so instead we use the solution suggested [here](https://github.com/vitejs/vite/discussions/15532#discussioncomment-10192839).

# CI

Playwright tests produce HTML reports when they run, whether on CI or not, showing visual snapshots at each timestep in each test. If you need to open these, follow the instructions [here](https://playwright.dev/docs/ci-intro#html-report), particularly '[Viewing the HTML report](https://playwright.dev/docs/ci-intro#viewing-the-html-report)'.
Expand Down
24 changes: 17 additions & 7 deletions assets/icons/index.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import {
cilArrowRight,
cilArrowThickToLeft,
cilBookmark,
cilBug,
cilChevronLeft,
cilCloudDownload,
cilGlobeAlt,
cilHistory,
cilIndustry,
cilLink,
cilMenu,
cilNoteAdd,
cilPlus,
cilShareAlt,
cilShieldAlt,
} from "@coreui/icons";

export const iconsSet = {
cilMenu,
cilCloudDownload,
cilPlus,
cilArrowRight,
cilArrowThickToLeft,
cilBookmark,
cilBug,
cilChevronLeft,
cilCloudDownload,
cilGlobeAlt,
cilHistory,
cilShareAlt,
cilIndustry,
cilLink,
cilMenu,
cilNoteAdd,
cilGlobeAlt,
cilArrowThickToLeft,
cilChevronLeft,
cilPlus,
cilShareAlt,
cilShieldAlt,
};
57 changes: 56 additions & 1 deletion assets/scss/_theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,59 @@ body {
}
}
}


.form-label {
color: rgba(37, 43, 54, 0.65);
}

.row > span.form-icon, .row > .form-label {
width: auto; // Avoids width: 100% being applied when inside a .row
padding: 0;
}

// Overriding '0' to add the !important flag
.btn-group > .btn:not(:last-child):not(.dropdown-toggle) {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}

.btn-group > label.btn {
border-radius: 0.75rem;
}

.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {
background-color: var(--cui-btn-color);
}

.btn-group-lg > label.btn {
border-radius: 1rem;
}

.form-select, .btn {
border-radius: 0.75rem;

&.form-select-lg, &.btn-lg {
border-radius: 1rem;
}
}

// Undoes styling for '.btn-check + .btn:hover' to (nearly) match that of '.btn:hover'
.btn-group {
.btn-check + .btn:hover {
color: var(--cui-btn-hover-color);
background-color: var(--cui-btn-hover-bg);
border-color: var(--cui-btn-hover-border-color);
}

.btn-check:not(:checked) + .btn:hover {
background-color: var(--cui-btn-hover-bg);
}
}

.button-group-container .btn-outline-primary {
background-color: var(--cui-tertiary-bg);
}

.btn:disabled, .btn.disabled, fieldset:disabled .btn {
opacity: 0.5;
}
3 changes: 3 additions & 0 deletions assets/scss/_variables.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
$app-header-height: 70px;
$app-header-margin-bottom: 1.5rem;
$min-wrapper-height: calc(100dvh - $app-header-height - $app-header-margin-bottom);
$container-padding: 1.5rem;
$sidebar-narrow-width: 4rem;

// Imperial Brand
// ==============
Expand Down Expand Up @@ -43,3 +45,4 @@ $grid-breakpoints: (
xl: 1200px,
xxl: 1400px
);
$cui-tertiary-bg: rgb(243, 244, 247)
1 change: 0 additions & 1 deletion components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ onBeforeUnmount(() => {
.header-toggler {
margin-inline-start: -14px;
}
$sidebar-narrow-width: 4rem;
.full-breadcrumb-container {
min-height: 2.5rem !important;
background-color: rgb(250, 250, 250);
Expand Down
162 changes: 162 additions & 0 deletions components/ParameterForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<template>
<div>
<CForm
v-if="props.metadata && formData"
class="inputs"
:data-test="JSON.stringify(formData)"
@submit.prevent="submitForm"
>
<div
v-for="(parameter) in props.metadata.parameters"
:key="parameter.id"
class="field-container"
>
<CCol v-if="renderAsRadios(parameter)" class="button-group-container">
<CRow>
<ParameterIcon :parameter="parameter" />
<CFormLabel :for="parameter.id">
{{ parameter.label }}
</CFormLabel>
</CRow>
<CRow>
<CButtonGroup
role="group"
:aria-label="parameter.label"
:size="largeScreen ? 'lg' : undefined"
>
<!-- This component's "v-model" prop type signature dictates we can't pass it a number. -->
<CFormCheck
v-for="(option) in parameter.options"
:id="option.id"
:key="option.id"
v-model="formData[parameter.id] as string"
type="radio"
:button="{ color: 'primary', variant: 'outline' }"
:name="parameter.id"
autocomplete="off"
:label="option.label"
:value="option.id"
/>
</CButtonGroup>
</CRow>
</CCol>
<div v-else-if="renderAsSelect(parameter)">
<ParameterIcon :parameter="parameter" />
<CFormLabel :for="parameter.id">
{{ parameter.label }}
</CFormLabel>
<select
:id="parameter.id"
v-model="formData[parameter.id]"
:aria-label="parameter.label"
class="form-select" :class="[largeScreen ? 'form-select-lg' : '']"
>
<option
v-for="(option) in parameter.options"
:key="option.id"
:value="option.id"
:selected="option.id === formData[parameter.id]"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<CButton
id="run-button"
color="primary"
:size="largeScreen ? 'lg' : undefined"
type="submit"
>
Run
<CIcon
icon="cilArrowRight"
/>
</CButton>
</CForm>
<CAlert v-else-if="props.metadataFetchStatus === 'error'" color="warning">
Failed to retrieve metadata from R API. {{ metadataFetchError }}
</CAlert>
<CSpinner v-else-if="props.metadataFetchStatus === 'pending'" />
</div>
</template>

<script lang="ts" setup>
import type { FetchError } from "ofetch";
import { CIcon } from "@coreui/icons-vue";
import type { Metadata, Parameter } from "@/types/daedalusApiResponseTypes";
import { ParameterType } from "@/types/daedalusApiResponseTypes";
import type { AsyncDataRequestStatus } from "#app";
const props = defineProps<{
metadata: Metadata | undefined
metadataFetchStatus: AsyncDataRequestStatus
metadataFetchError: FetchError | null
}>();
const formData = ref(
// Create a new object with keys set to the id values of the metadata.parameters array of objects, and all values set to default values.
props.metadata?.parameters.reduce((acc, { id, defaultOption, options }) => {
acc[id] = defaultOption || options[0].id;
return acc;
}, {} as { [key: string]: string | number }),
);
const optionsAreTerse = (parameter: Parameter) => {
const eachOptionIsASingleWord = parameter.options.every((option) => {
return !option.label.includes(" ");
});
return parameter.options.length <= 5 && eachOptionIsASingleWord;
};
const renderAsSelect = (parameter: Parameter) => {
return parameter.parameterType === ParameterType.Select || parameter.parameterType === ParameterType.GlobeSelect;
};
const renderAsRadios = (parameter: Parameter) => {
return parameter.parameterType === ParameterType.Select && optionsAreTerse(parameter);
};
const submitForm = () => {
// Not implemented yet
};
const largeScreen = ref(true);
const breakpoint = 992; // CoreUI's "lg" breakpoint
const setFieldSizes = () => {
if (window.innerWidth < breakpoint) {
largeScreen.value = false;
} else {
largeScreen.value = true;
}
};
onMounted(() => {
setFieldSizes();
window.addEventListener("resize", setFieldSizes);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", setFieldSizes);
});
</script>

<style lang="scss">
.inputs {
display: flex;
flex-wrap: wrap;
row-gap: 1rem;
column-gap: 1rem;
}
.field-container {
min-width: 15rem;
flex-grow: 1;
}
#run-button {
align-self: flex-end;
min-width: 6rem;
margin-left: auto;
}
</style>
57 changes: 57 additions & 0 deletions components/ParameterIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<template>
<span v-if="iconDetails" class="form-icon">
<CIconSvg v-if="iconDetails.custom" class="icon parameter-icon">
<img :src="customIconPath()">
</CIconSvg>
<CIcon v-else :icon="iconDetails.icon" class="parameter-icon" />
</span>
</template>

<script lang="ts" setup>
import { CIcon, CIconSvg } from "@coreui/icons-vue";
import type { Parameter } from "@/types/apiResponseTypes";
const props = defineProps<{
parameter: Parameter
}>();
const iconDetails = computed((): { icon: string, custom: boolean } | undefined => {
switch (props.parameter.id) {
case "country":
return { icon: "cilGlobeAlt", custom: false };
case "response":
return { icon: "cilShieldAlt", custom: false };
case "vaccine":
return { icon: "cilIndustry", custom: false };
case "pathogen":
return { icon: "wikimediaVirusIcon", custom: true };
default:
return undefined;
}
});
const customIconPath = () => {
if (!iconDetails.value || !iconDetails.value.custom) {
return "";
}
// The icon svg is exceptionally stored in /public to facilitate a simple way of having a dynamic img src attribute:
// https://www.lichter.io/articles/nuxt3-vue3-dynamic-images/#the-public-folder-strategy
// Bear in mind that this means the image will be cached by the browser, so to update the image, you must also change
// the file name.
return `/icons/${iconDetails.value.icon}.svg`;
};
</script>

<style lang="scss">
.field-container {
.parameter-icon {
margin-left: 0.75rem;
margin-right: 0.5rem;
padding: 0;
}
.button-group-container .parameter-icon {
margin-left: 1.5rem;
}
}
</style>
1 change: 1 addition & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ ENV NODE_ENV=production
WORKDIR /src

COPY . .
RUN npm install -g npm@latest
RUN npm ci

# Generate the prisma client code
Expand Down
9 changes: 6 additions & 3 deletions layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/>
<div class="wrapper d-flex flex-column">
<div class="body flex-grow-1">
<CContainer xxl class="px-4">
<CContainer xxl>
<slot />
</CContainer>
</div>
Expand All @@ -27,8 +27,6 @@ function handleToggleSidebarVisibility() {

<style lang="scss">
@use "sass:map";
$sidebar-narrow-width: 4rem;
.body {
@media (min-width: map.get($grid-breakpoints, 'lg')) {
padding-left: $sidebar-narrow-width;
Expand All @@ -37,6 +35,11 @@ $sidebar-narrow-width: 4rem;
.wrapper {
min-height: $min-wrapper-height;
.container-xxl {
padding-left: $container-padding !important;
padding-right: $container-padding !important;
}
}
.sidebar { // .sidebar selector does not work if placed in the Sidebar component
Expand Down
Loading

0 comments on commit 45014aa

Please sign in to comment.