Refactor workflow evaluation components and improve code organization

• Move components to dedicated files
• Create new EvaluationEditView component
• Update router configuration
• Relocate useEvaluationForm composable
• Add new types for evaluations
This commit is contained in:
Oleg Ivaniv 2024-11-12 16:05:52 +01:00
parent 49bd5c6acc
commit fd94fe3ce4
No known key found for this signature in database
10 changed files with 525 additions and 384 deletions

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
interface Props {
modelValue: string;
}
withDefaults(defineProps<Props>(), {
modelValue: 'Change me Description Default',
});
defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
<template>
<div :class="[$style.formGroup, $style.description]">
<n8n-input-label label="Description" :bold="false" size="small" :class="$style.field">
<N8nInput
:model-value="modelValue"
type="textarea"
placeholder="Enter evaluation description"
@update:model-value="$emit('update:modelValue', $event)"
/>
</n8n-input-label>
</div>
</template>
<style module lang="scss">
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
.field {
width: 100%;
margin-top: var(--spacing-xs);
}
</style>

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
export interface EvaluationHeaderProps {
modelValue: {
value: string;
isEditing: boolean;
tempValue: string;
};
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
handleKeydown: (e: KeyboardEvent, field: string) => void;
}
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
defineProps<EvaluationHeaderProps>();
</script>
<template>
<div :class="$style.header">
<n8n-icon-button
icon="arrow-left"
:class="$style.backButton"
type="tertiary"
:title="$locale.baseText('common.back')"
@click="$router.back()"
/>
<h2 :class="$style.title">
<template v-if="!modelValue.isEditing">
{{ modelValue.value }}
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
@click="startEditing('name')"
/>
</template>
<N8nInput
v-else
ref="nameInput"
:model-value="modelValue.tempValue"
type="text"
:placeholder="$locale.baseText('common.name')"
@update:model-value="$emit('update:modelValue', { ...modelValue, tempValue: $event })"
@blur="() => saveChanges('name')"
@keydown="(e: KeyboardEvent) => handleKeydown(e, 'name')"
/>
</h2>
</div>
</template>
<style module lang="scss">
.header {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-l);
&:hover {
.editInputButton {
opacity: 1;
}
}
}
.title {
margin: 0;
flex-grow: 1;
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
.editInputButton {
opacity: 0;
border: none;
--button-font-color: var(--prim-gray-490);
}
.backButton {
border: none;
--button-font-color: var(--color-text-light);
}
</style>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
export interface MetricsInputProps {
modelValue: string[];
helpText: string;
}
const props = defineProps<MetricsInputProps>();
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
function addNewMetric() {
emit('update:modelValue', [...props.modelValue, '']);
}
function updateMetric(index: number, value: string) {
const newMetrics = [...props.modelValue];
newMetrics[index] = value;
emit('update:modelValue', newMetrics);
}
</script>
<template>
<div :class="[$style.formGroup, $style.metrics]">
<n8n-text color="text-dark">Metrics</n8n-text>
<hr :class="$style.metricsDivider" />
<n8n-text size="small" color="text-light">
{{ helpText }}
</n8n-text>
<n8n-input-label label="Output field(s)" :bold="false" size="small" :class="$style.metricField">
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index">
<N8nInput
:ref="`metric_${index}`"
:model-value="metric"
:placeholder="'Enter metric name'"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
</div>
<n8n-button
type="tertiary"
:label="'New metric'"
:class="$style.newMetricButton"
@click="addNewMetric"
/>
</div>
</n8n-input-label>
</div>
</template>
<style module lang="scss">
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
.metricsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);
}
.metricsDivider {
margin-top: var(--spacing-4xs);
margin-bottom: var(--spacing-3xs);
}
.newMetricButton {
align-self: flex-start;
margin-top: var(--spacing-2xs);
width: 100%;
background-color: var(--color-sticky-code-background);
border-color: var(--color-button-secondary-focus-outline);
color: var(--color-button-secondary-font);
}
</style>

View file

@ -0,0 +1,94 @@
<script setup lang="ts">
import type { ITag } from '@/Interface';
import { computed } from 'vue';
export interface TagsInputProps {
modelValue?: {
isEditing: boolean;
appliedTagIds: string[];
};
allTags: ITag[];
tagsById: Record<string, ITag>;
isLoading: boolean;
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
cancelEditing: (field: string) => void;
helpText: string;
}
const props = withDefaults(defineProps<TagsInputProps>(), {
modelValue: () => ({
isEditing: false,
appliedTagIds: [],
}),
});
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
const getTagName = computed(() => (tagId: string) => {
return props.tagsById[tagId]?.name ?? '';
});
function updateTags(tags: string[]) {
const newTags = tags[0] ? [tags[0]] : [];
emit('update:modelValue', {
...props.modelValue,
appliedTagIds: newTags,
});
}
</script>
<template>
<div :class="$style.formGroup">
<n8n-input-label label="Tag name" :bold="false" size="small">
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
<n8n-text v-if="modelValue.appliedTagIds.length === 0" size="small">Select tag...</n8n-text>
<n8n-tag v-for="tagId in modelValue.appliedTagIds" :key="tagId" :text="getTagName(tagId)" />
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
size="small"
transparent
/>
</div>
<TagsDropdown
v-else
:model-value="modelValue.appliedTagIds"
:placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="false"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@update:model-value="updateTags"
@esc="cancelEditing('tags')"
@blur="saveChanges('tags')"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">{{ helpText }}</n8n-text>
</div>
</template>
<style module lang="scss">
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
.tagsRead {
&:hover .editInputButton {
opacity: 1;
}
}
.editInputButton {
opacity: 0;
border: none;
--button-font-color: var(--prim-gray-490);
}
</style>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import type { INodeParameterResourceLocator } from 'n8n-workflow';
interface WorkflowSelectorProps {
modelValue: INodeParameterResourceLocator;
helpText: string;
}
withDefaults(defineProps<WorkflowSelectorProps>(), {
modelValue: () => ({
mode: 'id',
value: 'Test Workflow?',
__rl: true,
}),
});
defineEmits<{ 'update:modelValue': [value: WorkflowSelectorProps['modelValue']] }>();
</script>
<template>
<div :class="$style.formGroup">
<n8n-input-label label="Evaluation workflow" :bold="false" size="small">
<WorkflowSelectorParameterInput
ref="workflowInput"
:parameter="{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
}"
:model-value="modelValue"
:display-title="'Evaluation Workflow'"
:is-value-expression="false"
:expression-edit-dialog-visible="false"
:path="'workflows'"
allow-new
@update:model-value="$emit('update:modelValue', $event)"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">
{{ helpText }}
</n8n-text>
</div>
</template>
<style module lang="scss">
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
</style>

View file

@ -5,7 +5,6 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useEvaluationsStore } from '@/stores/evaluations.store.ee';
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
import type { N8nInput } from 'n8n-design-system';
import { VIEWS } from '@/constants';
interface EditableField {
value: string;
@ -15,7 +14,7 @@ interface EditableField {
export interface IEvaluationFormState {
name: EditableField;
description?: string;
description: string;
tags: {
isEditing: boolean;
appliedTagIds: string[];
@ -71,11 +70,12 @@ export function useEvaluationForm(testId?: number) {
if (!testId) return;
try {
await evaluationsStore.fetchAll();
await evaluationsStore.fetchAll({ force: true });
const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) {
state.value = {
description: '',
name: {
value: testDefinition.name,
isEditing: false,
@ -134,7 +134,7 @@ export function useEvaluationForm(testId?: number) {
}
};
const startEditing = async (field: 'name' | 'tags') => {
const startEditing = async (field: string) => {
if (field === 'name') {
state.value.name.tempValue = state.value.name.value;
state.value.name.isEditing = true;
@ -143,7 +143,7 @@ export function useEvaluationForm(testId?: number) {
}
};
const saveChanges = (field: 'name' | 'tags') => {
const saveChanges = (field: string) => {
if (field === 'name') {
state.value.name.value = state.value.name.tempValue;
state.value.name.isEditing = false;
@ -152,7 +152,7 @@ export function useEvaluationForm(testId?: number) {
}
};
const cancelEditing = (field: 'name' | 'tags') => {
const cancelEditing = (field: string) => {
if (field === 'name') {
state.value.name.isEditing = false;
} else {
@ -160,7 +160,7 @@ export function useEvaluationForm(testId?: number) {
}
};
const handleKeydown = (event: KeyboardEvent, field: 'name' | 'tags') => {
const handleKeydown = (event: KeyboardEvent, field: string) => {
if (event.key === 'Escape') {
cancelEditing(field);
} else if (event.key === 'Enter' && !event.shiftKey) {
@ -169,18 +169,6 @@ export function useEvaluationForm(testId?: number) {
}
};
const updateMetrics = (metrics: string[]) => {
state.value.metrics = metrics;
};
const onTagUpdate = (tags: string[]) => {
state.value.tags.appliedTagIds = tags[0] ? [tags[0]] : [];
};
const onWorkflowUpdate = (value: INodeParameterResourceLocator) => {
state.value.evaluationWorkflow = value;
};
// Initialize
const init = async () => {
await tagsStore.fetchAll();
@ -203,8 +191,5 @@ export function useEvaluationForm(testId?: number) {
saveChanges,
cancelEditing,
handleKeydown,
updateMetrics,
onTagUpdate,
onWorkflowUpdate,
};
}

View file

@ -0,0 +1,13 @@
export interface TestExecution {
lastRun: string | null;
errorRate: number | null;
metrics: Record<string, number>;
}
export interface TestListItem {
id: number;
name: string;
tagName: string;
testCases: number;
execution: TestExecution;
}

View file

@ -18,8 +18,8 @@ import type { RouterMiddleware } from '@/types/router';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes';
import ListEvaluations from './views/WorkflowEvaluation/ListEvaluations.vue';
import NewEvaluation from './views/WorkflowEvaluation/NewEvaluation.vue';
import EvaluationListView from './views/WorkflowEvaluation/EvaluationListView.vue';
import EvaluationEditView from './views/WorkflowEvaluation/EvaluationEditView.vue';
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
const ErrorView = async () => await import('./views/ErrorView.vue');
@ -253,52 +253,51 @@ export const routes: RouteRecordRaw[] = [
{
path: '/workflow/:name/evaluation',
name: VIEWS.WORKFLOW_EVALUATION,
components: {
default: ListEvaluations,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
// children: [
// {
// path: '',
// name: VIEWS.EXECUTION_HOME,
// components: {
// executionPreview: WorkflowExecutionsLandingPage,
// },
// meta: {
// keepWorkflowAlive: true,
// middleware: ['authenticated'],
// },
// },
// {
// path: ':executionId',
// name: VIEWS.EXECUTION_PREVIEW,
// components: {
// executionPreview: WorkflowExecutionsPreview,
// },
// meta: {
// keepWorkflowAlive: true,
// middleware: ['authenticated'],
// },
// },
// ],
},
{
path: '/workflow/:name/evaluation/new',
name: VIEWS.NEW_WORKFLOW_EVALUATION,
components: {
default: NewEvaluation,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
children: [
{
path: '',
name: VIEWS.WORKFLOW_EVALUATION,
components: {
default: EvaluationListView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
{
path: 'new',
name: VIEWS.NEW_WORKFLOW_EVALUATION,
components: {
default: EvaluationEditView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
{
path: ':testId',
name: VIEWS.WORKFLOW_EVALUATION_EDIT,
components: {
default: EvaluationEditView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
],
// children: [
// {
// path: '',

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
import EvaluationHeader from '@/components/WorkflowEvaluation/EditEvaluation/EvaluationHeader.vue';
import DescriptionInput from '@/components/WorkflowEvaluation/EditEvaluation/DescriptionInput.vue';
import TagsInput from '@/components/WorkflowEvaluation/EditEvaluation/TagsInput.vue';
import WorkflowSelector from '@/components/WorkflowEvaluation/EditEvaluation/WorkflowSelector.vue';
import MetricsInput from '@/components/WorkflowEvaluation/EditEvaluation/MetricsInput.vue';
import { useEvaluationForm } from '@/components/WorkflowEvaluation/composables/useEvaluationForm';
const props = defineProps<{
testId?: number;
}>();
const router = useRouter();
const route = useRoute();
const testId = props.testId ?? (route.params.testId as unknown as number);
const toast = useToast();
const {
state,
isEditing,
isLoading,
isSaving,
allTags,
tagsById,
init,
saveTest,
startEditing,
saveChanges,
cancelEditing,
handleKeydown,
} = useEvaluationForm(testId);
// Help texts
const helpText = computed(
() => 'Executions with this tag will be added as test cases to this test.',
);
const workflowHelpText = computed(() => 'This workflow will be called once for each test case.');
const metricsHelpText = computed(
() =>
'The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.',
);
onMounted(() => {
void init();
});
async function onSaveTest() {
try {
await saveTest();
toast.showMessage({ title: 'Test saved', type: 'success' });
void router.push({ name: VIEWS.WORKFLOW_EVALUATION });
} catch (e: unknown) {
toast.showError(e, 'Failed to save test');
}
}
</script>
<template>
<div :class="$style.container">
<EvaluationHeader
v-model="state.name"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<DescriptionInput v-model="state.description" />
<TagsInput
v-model="state.tags"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:start-editing="startEditing"
:save-changes="saveChanges"
:cancel-editing="cancelEditing"
:help-text="helpText"
/>
<WorkflowSelector v-model="state.evaluationWorkflow" :help-text="workflowHelpText" />
<MetricsInput v-model="state.metrics" :help-text="metricsHelpText" />
<div :class="$style.footer">
<n8n-button
type="primary"
:label="isEditing ? 'Update Test' : 'Save Test'"
:loading="isSaving"
@click="onSaveTest"
/>
</div>
</div>
</template>
<style module lang="scss">
.container {
width: 383px;
height: 100%;
padding: var(--spacing-s);
border-right: 1px solid var(--color-foreground-base);
background: var(--color-background-xlight);
margin-right: auto;
}
.footer {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
}
</style>

View file

@ -1,318 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useEvaluationForm } from './composables/useEvaluationForm';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
const props = defineProps<{
testId?: number;
}>();
const router = useRouter();
const toast = useToast();
const {
state,
isEditing,
isLoading,
isSaving,
allTags,
tagsById,
init,
saveTest,
startEditing,
saveChanges,
cancelEditing,
handleKeydown,
updateMetrics,
onTagUpdate,
onWorkflowUpdate,
} = useEvaluationForm(props.testId);
// Help texts
const helpText = computed(
() => 'Executions with this tag will be added as test cases to this test.',
);
const workflowHelpText = computed(() => 'This workflow will be called once for each test case.');
const metricsHelpText = computed(
() =>
'The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.',
);
function getTagName(tagId: string) {
return tagsById.value[tagId]?.name ?? '';
}
onMounted(() => {
void init();
});
// Utility functions specific to the UI
function addNewMetric() {
updateMetrics([...state.value.metrics, '']);
}
function updateMetric(index: number, value: string) {
const newMetrics = [...state.value.metrics];
newMetrics[index] = value;
updateMetrics(newMetrics);
}
async function onSaveTest() {
try {
await saveTest();
toast.showMessage({ title: 'Test saved', type: 'success' });
void router.push({ name: VIEWS.WORKFLOW_EVALUATION });
} catch (e: unknown) {
toast.showError(e, 'Failed to save test');
}
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.header">
<n8n-icon-button
icon="arrow-left"
:class="$style.backButton"
type="tertiary"
:title="$locale.baseText('common.back')"
@click="$router.back()"
/>
<h2 :class="$style.title">
<template v-if="!state.name.isEditing">
{{ state.name.value }}
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
@click="startEditing('name')"
/>
</template>
<N8nInput
v-else
ref="nameInput"
v-model="state.name.tempValue"
type="text"
:placeholder="$locale.baseText('common.name')"
@blur="() => saveChanges('name')"
@keydown="(e) => handleKeydown(e, 'name')"
/>
</h2>
</div>
<!-- Description -->
<div :class="[$style.formGroup, $style.metrics]">
<n8n-input-label label="Description" :bold="false" size="small" :class="$style.metricField">
<N8nInput
v-model="state.description"
type="textarea"
:placeholder="'Enter evaluation description'"
/>
</n8n-input-label>
</div>
<!-- Tags -->
<div :class="$style.formGroup">
<n8n-input-label label="Tag name" :bold="false" size="small">
<div v-if="!state.tags.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
<n8n-text v-if="state.tags.appliedTagIds.length === 0" size="small"
>Select tag...</n8n-text
>
<n8n-tag
v-for="tagId in state.tags.appliedTagIds"
:key="tagId"
:text="getTagName(tagId)"
/>
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
size="small"
transparent
/>
</div>
<TagsDropdown
v-else
ref="tagsInput"
:model-value="state.tags.appliedTagIds"
:placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="false"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@update:model-value="onTagUpdate"
@esc="cancelEditing('tags')"
@blur="saveChanges('tags')"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">{{ helpText }}</n8n-text>
</div>
<!-- Evaluation Workflow -->
<div :class="$style.formGroup">
<n8n-input-label label="Evaluation workflow" :bold="false" size="small">
<WorkflowSelectorParameterInput
ref="workflowInput"
:parameter="{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
}"
:model-value="state.evaluationWorkflow"
:display-title="'Evaluation Workflow'"
:is-value-expression="false"
:expression-edit-dialog-visible="false"
:path="'workflows'"
allow-new
@update:model-value="onWorkflowUpdate"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">
{{ workflowHelpText }}
</n8n-text>
</div>
<!-- Metrics -->
<div :class="[$style.formGroup, $style.metrics]">
<n8n-text color="text-dark"> Metrics </n8n-text>
<hr :class="$style.metricsDivider" />
<n8n-text size="small" color="text-light">
{{ metricsHelpText }}
</n8n-text>
<n8n-input-label
label="Output field(s)"
:bold="false"
size="small"
:class="$style.metricField"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in state.metrics" :key="index">
<N8nInput
:ref="`metric_${index}`"
:model-value="metric"
:placeholder="'Enter metric name'"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
</div>
<n8n-button
type="tertiary"
:label="'New metric'"
:class="$style.newMetricButton"
@click="addNewMetric"
/>
</div>
</n8n-input-label>
</div>
<!-- Save Test Button -->
<div :class="$style.footer">
<n8n-button
type="primary"
:label="isEditing ? 'Update Test' : 'Save Test'"
:loading="isSaving"
@click="onSaveTest"
/>
</div>
</div>
</template>
<style module lang="scss">
.container {
width: 383px;
height: 100%;
padding: var(--spacing-s);
border-right: 1px solid var(--color-foreground-base);
// border-top-color: transparent;
// border-left-color: transparent;
background: var(--color-background-xlight);
// Pin the container to the left
margin-right: auto;
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-l);
&:hover {
.editInputButton {
opacity: 1;
}
}
}
.title {
margin: 0;
flex-grow: 1;
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
.readOnlyField {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xs) var(--spacing-xs);
background-color: var(--color-background-light);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-small);
}
.metricsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);
}
.metricsDivider {
margin-top: var(--spacing-4xs);
margin-bottom: var(--spacing-3xs);
}
.newMetricButton {
align-self: flex-start;
margin-top: var(--spacing-2xs);
width: 100%;
background-color: var(--color-sticky-code-background);
border-color: var(--color-button-secondary-focus-outline);
color: var(--color-button-secondary-font);
}
.footer {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
}
.tagsRead {
&:hover .editInputButton {
opacity: 1;
}
}
.editInputButton {
opacity: 0;
border: none;
--button-font-color: var(--prim-gray-490);
}
.backButton {
border: none;
--button-font-color: var(--color-text-light);
}
</style>