diff --git a/apps/metadata-utils/src/types.ts b/apps/metadata-utils/src/types.ts index c31dd39a4f..111f907ccc 100644 --- a/apps/metadata-utils/src/types.ts +++ b/apps/metadata-utils/src/types.ts @@ -91,6 +91,13 @@ export interface IFieldError { message: string; } +export interface IFormLegendSection { + label: string; + domId: string; + isActive?: boolean; + errorCount?: number; +} + export type columnId = string; export type columnValue = string | number | boolean | columnValueObject; diff --git a/apps/tailwind-components/assets/css/main.css b/apps/tailwind-components/assets/css/main.css index 03a8436077..81f2ffc914 100644 --- a/apps/tailwind-components/assets/css/main.css +++ b/apps/tailwind-components/assets/css/main.css @@ -38,6 +38,7 @@ --color-orange-500: #E1B53E; --color-red-200: #FCECEF; --color-red-500: #E14F62; + --color-red-700: #AE2A3F; --color-gradient1: #0062C6; --color-gradient2: #0072E4; @@ -79,6 +80,7 @@ --background-color-input: var(--color-white); --background-color-input-checked: var(--color-yellow-500); --background-color-table: var(--color-white); + --background-color-notification: var(--color-red-700); --text-color-button-primary: var(--color-white); @@ -129,6 +131,7 @@ --text-color-table-column-header: var(--color-gray-600); --text-color-form-header: var(--color-blue-800); --text-color-input-description: var(--color-blue-800); + --text-color-legend-error-count: var(--color-white); --text-color-invalid: var(--color-red-500); --text-color-valid: var(--color-green-800); diff --git a/apps/tailwind-components/components/form/Fields.vue b/apps/tailwind-components/components/form/Fields.vue index 55fcedc568..cf3c75cb70 100644 --- a/apps/tailwind-components/components/form/Fields.vue +++ b/apps/tailwind-components/components/form/Fields.vue @@ -101,13 +101,15 @@ defineExpose({ validate });

{{ chapter.title }}

+ + + + diff --git a/apps/tailwind-components/pages/Form.story.vue b/apps/tailwind-components/pages/Form.story.vue index 38410566c5..7c3df99dfa 100644 --- a/apps/tailwind-components/pages/Form.story.vue +++ b/apps/tailwind-components/pages/Form.story.vue @@ -2,6 +2,7 @@ import type { FormFields } from "#build/components"; import type { columnValue, + IColumn, IFieldError, ISchemaMetaData, ITableMetaData, @@ -41,7 +42,6 @@ const formFields = ref>(); const formValues = ref>({}); function onModelUpdate(value: Record) { - console.log("story update", value); formValues.value = value; } @@ -50,25 +50,107 @@ const errors = ref>({}); function onErrors(newErrors: Record) { errors.value = newErrors; } + +function chapterFieldIds(chapterId: string) { + const chapterFieldIds = []; + let inChapter = false; + for (const column of tableMeta.value.columns) { + if (column.columnType === "HEADING" && column.id === chapterId) { + inChapter = true; + } else if (column.columnType === "HEADING" && column.id !== chapterId) { + inChapter = false; + } else if (inChapter) { + chapterFieldIds.push(column.id); + } + } + return chapterFieldIds; +} + +function chapterErrorCount(chapterId: string) { + return chapterFieldIds(chapterId).reduce((acc, fieldId) => { + return acc + (errors.value[fieldId]?.length ?? 0); + }, 0); +} + +const currentSectionDomId = ref(""); + +const sections = computed(() => { + return tableMeta.value?.columns + .filter((column: IColumn) => column.columnType == "HEADING") + .map((column: IColumn) => { + return { + label: column.label, + domId: column.id, + isActive: currentSectionDomId.value.startsWith(column.id), + errorCount: chapterErrorCount(column.id), + }; + }); +}); + +function setUpChapterIsInViewObserver() { + if (import.meta.client) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const id = entry.target.getAttribute("id"); + if (id && entry.intersectionRatio > 0) { + currentSectionDomId.value = id; + } + }); + }, + { + root: formFields.value?.$el, + rootMargin: "0px", + threshold: 0.5, + } + ); + + document.querySelectorAll("[id$=chapter-title]").forEach((section) => { + observer.observe(section); + }); + } +} + +onMounted(() => setUpChapterIsInViewObserver()); + +watch( + () => tableMeta.value, + async () => { + await nextTick(); + setUpChapterIsInViewObserver(); + } +); + + diff --git a/apps/tailwind-components/tailwind.config.js b/apps/tailwind-components/tailwind.config.js index 53519f3e7e..d0c18cb5b0 100644 --- a/apps/tailwind-components/tailwind.config.js +++ b/apps/tailwind-components/tailwind.config.js @@ -80,6 +80,7 @@ module.exports = { }, red: { 500: "#E14F62", + 700: "#AE2A3F", }, }, extend: { @@ -154,6 +155,7 @@ module.exports = { "invalid": "var(--background-color-invalid)", "input": "var(--background-color-input)", "table": "var(--background-color-table)", + "notification": "var(--background-color-notification)", }), textColor: ({ theme }) => ({ "button-primary": "var(--text-color-button-primary)", @@ -206,6 +208,7 @@ module.exports = { "valid": "var(--text-color-valid)", "disabled": "var(--text-color-disabled)", "required": "var(--text-color-required)", + "legend-error-count": "var(--text-color-legend-error-count)", }), borderColor: ({ theme }) => ({ "button-primary": "var(--border-color-button-primary)", @@ -232,10 +235,13 @@ module.exports = { }), stroke: ({ theme }) => ({ "input": "var(--border-color-input)", + "notification-text": "var(--text-color-legend-error-count)", }), fill: ({ theme }) => ({ "input": "var(--background-color-input)", "input-checked": "var(--background-color-input-checked)", + "notification": "var(--background-color-notification)", + "notification-text": "var(--text-color-legend-error-count)", }), borderRadius: { "3px": "var(--border-radius-3px)", diff --git a/apps/tailwind-components/tests/components/form/renderForm.spec.ts b/apps/tailwind-components/tests/components/form/renderForm.spec.ts index 38370a4912..61f4e9bb31 100644 --- a/apps/tailwind-components/tests/components/form/renderForm.spec.ts +++ b/apps/tailwind-components/tests/components/form/renderForm.spec.ts @@ -24,3 +24,28 @@ test("it should handle input", async ({ page }) => { await page.getByRole("heading", { name: "Values" }).click(); await expect(page.getByRole("definition")).toContainText("test"); }); + +test("it should show the chapters in the legend", async ({ page }) => { + await expect( + page.locator("span").filter({ hasText: "details" }) + ).toBeVisible(); + await expect(page.getByText("Heading2", { exact: true })).toBeVisible(); +}); + +test("the legend should show number of errors per chapter (if any)", async ({ + page, +}) => { + await page.getByLabel("Demo data").selectOption("complex", { force: true }); + // touch the form + await page.getByLabel("name", { exact: true }).click(); + // skip a required field + await page.getByLabel("name", { exact: true }).press("Tab"); + await expect(page.locator("span").filter({ hasText: /^2$/ })).toBeVisible(); +}); + +test("clicking on the chapter should scroll to the chapter", async ({ + page, +}) => { + await page.getByText("Heading2", { exact: true }).click(); + await expect(page.getByRole("heading", { name: "heading2" })).toBeVisible(); +});