From 9fd34c8f893b615c129577f5a84c33491d746c7b Mon Sep 17 00:00:00 2001 From: Ram Narayan Balaji <81347100+yan-3005@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:14:38 +0530 Subject: [PATCH] Feat #20586 Implementation of Custom Metrics Measurement Units (#22876) * Initial Implementation of Custom Metrics Measurement Units * Update generated TypeScript types * Removed Regex patterns and length validations as they are not needed * Add a new column with index for custom units * Remove comments in the sql * update ui and add playwright * fix metric selector * fix tests * address feedbacks * remove unused field --------- Co-authored-by: github-actions[bot] Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Co-authored-by: karanh37 --- .../native/1.10.0/mysql/schemaChanges.sql | 9 +- .../native/1.10.0/postgres/schemaChanges.sql | 9 +- .../service/jdbi3/CollectionDAO.java | 20 + .../service/jdbi3/MetricRepository.java | 29 ++ .../resources/metrics/MetricMapper.java | 3 +- .../resources/metrics/MetricResource.java | 22 ++ .../resources/metrics/MetricResourceTest.java | 186 +++++++++ .../json/schema/api/data/createMetric.json | 4 + .../json/schema/entity/data/metric.json | 7 +- .../e2e/Features/MetricCustomUnitFlow.spec.ts | 153 ++++++++ .../resources/ui/playwright/utils/metric.ts | 3 +- .../DataAssetsHeader.interface.ts | 2 +- .../MetricHeaderInfo/MetricHeaderInfo.tsx | 101 +++-- .../UnitOfMeasurementInfoItem.tsx | 219 +++++++++++ .../unit-of-measurement-header.less | 16 + .../CustomUnitSelect.test.tsx | 353 ++++++++++++++++++ .../CustomUnitSelect/CustomUnitSelect.tsx | 177 +++++++++ .../ui/src/generated/api/data/createMetric.ts | 5 + .../ui/src/generated/entity/data/metric.ts | 5 + .../ui/src/locale/languages/de-de.json | 1 + .../ui/src/locale/languages/en-us.json | 1 + .../ui/src/locale/languages/es-es.json | 1 + .../ui/src/locale/languages/fr-fr.json | 1 + .../ui/src/locale/languages/gl-es.json | 1 + .../ui/src/locale/languages/he-he.json | 1 + .../ui/src/locale/languages/ja-jp.json | 1 + .../ui/src/locale/languages/ko-kr.json | 1 + .../ui/src/locale/languages/mr-in.json | 1 + .../ui/src/locale/languages/nl-nl.json | 1 + .../ui/src/locale/languages/pr-pr.json | 1 + .../ui/src/locale/languages/pt-br.json | 1 + .../ui/src/locale/languages/pt-pt.json | 1 + .../ui/src/locale/languages/ru-ru.json | 1 + .../ui/src/locale/languages/th-th.json | 1 + .../ui/src/locale/languages/tr-tr.json | 1 + .../ui/src/locale/languages/zh-cn.json | 1 + .../ui/src/locale/languages/zh-tw.json | 1 + .../AddMetricPage/AddMetricPage.tsx | 63 ++-- .../MetricDetailsPage/MetricDetailsPage.tsx | 19 +- .../main/resources/ui/src/rest/metricsAPI.ts | 6 + .../utils/DataAssetsVersionHeaderUtils.tsx | 17 +- .../MetricEntityUtils/MetricUtils.test.ts | 1 + 42 files changed, 1353 insertions(+), 94 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricCustomUnitFlow.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/UnitOfMeasurementInfoItem.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/unit-of-measurement-header.less create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.tsx diff --git a/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql index 3b34c506421..4caaee09bfa 100644 --- a/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql @@ -16,4 +16,11 @@ WHERE configType = 'entityRulesSettings' AND NOT JSON_CONTAINS( JSON_EXTRACT(json, '$.entitySemantics[*].name'), JSON_QUOTE('Data Product Domain Validation') - ); \ No newline at end of file + ); + +-- Add virtual column for customUnitOfMeasurement +ALTER TABLE metric_entity +ADD COLUMN customUnitOfMeasurement VARCHAR(256) +GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.customUnitOfMeasurement'))) VIRTUAL; +-- Add index on the virtual column +CREATE INDEX idx_metric_custom_unit ON metric_entity(customUnitOfMeasurement); \ No newline at end of file diff --git a/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql index 6911da395b6..c032990c51d 100644 --- a/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql @@ -18,4 +18,11 @@ WHERE configtype = 'entityRulesSettings' SELECT 1 FROM jsonb_array_elements(json->'entitySemantics') AS rule WHERE rule->>'name' = 'Data Product Domain Validation' - ); \ No newline at end of file + ); + +-- Add generated column for customUnitOfMeasurement +ALTER TABLE metric_entity +ADD COLUMN customUnitOfMeasurement VARCHAR(256) +GENERATED ALWAYS AS ((json->>'customUnitOfMeasurement')::VARCHAR(256)) STORED; +-- Add index on the column +CREATE INDEX idx_metric_custom_unit ON metric_entity(customUnitOfMeasurement); \ No newline at end of file diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index b367278d054..ff0fca5cd6b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -3069,6 +3069,26 @@ public interface CollectionDAO { default String getNameHashColumn() { return "fqnHash"; } + + @ConnectionAwareSqlQuery( + value = + "SELECT DISTINCT customUnitOfMeasurement AS customUnit " + + "FROM metric_entity " + + "WHERE customUnitOfMeasurement IS NOT NULL " + + "AND customUnitOfMeasurement != '' " + + "AND deleted = false " + + "ORDER BY customUnitOfMeasurement", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT DISTINCT customUnitOfMeasurement AS customUnit " + + "FROM metric_entity " + + "WHERE customUnitOfMeasurement IS NOT NULL " + + "AND customUnitOfMeasurement != '' " + + "AND deleted = false " + + "ORDER BY customUnitOfMeasurement", + connectionType = POSTGRES) + List getDistinctCustomUnitsOfMeasurement(); } interface MlModelDAO extends EntityDAO { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java index eebe8a3b705..bd9c74429fe 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java @@ -23,8 +23,10 @@ import java.util.List; import java.util.Map; import java.util.UUID; import org.jdbi.v3.sqlobject.transaction.Transaction; +import org.openmetadata.common.utils.CommonUtil; import org.openmetadata.schema.entity.data.Metric; import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.MetricUnitOfMeasurement; import org.openmetadata.schema.type.Relationship; import org.openmetadata.schema.type.change.ChangeSource; import org.openmetadata.service.Entity; @@ -58,6 +60,24 @@ public class MetricRepository extends EntityRepository { @Override public void prepare(Metric metric, boolean update) { validateRelatedTerms(metric, metric.getRelatedMetrics()); + validateCustomUnitOfMeasurement(metric); + } + + private void validateCustomUnitOfMeasurement(Metric metric) { + MetricUnitOfMeasurement unitOfMeasurement = metric.getUnitOfMeasurement(); + String customUnit = metric.getCustomUnitOfMeasurement(); + + if (unitOfMeasurement == MetricUnitOfMeasurement.OTHER) { + if (CommonUtil.nullOrEmpty(customUnit)) { + throw new IllegalArgumentException( + "customUnitOfMeasurement is required when unitOfMeasurement is OTHER"); + } + // Trim and normalize + metric.setCustomUnitOfMeasurement(customUnit.trim()); + } else { + // Clear custom unit if not OTHER to maintain consistency + metric.setCustomUnitOfMeasurement(null); + } } @Override @@ -133,6 +153,10 @@ public class MetricRepository extends EntityRepository { recordChange("metricType", original.getMetricType(), updated.getMetricType()); recordChange( "unitOfMeasurement", original.getUnitOfMeasurement(), updated.getUnitOfMeasurement()); + recordChange( + "customUnitOfMeasurement", + original.getCustomUnitOfMeasurement(), + updated.getCustomUnitOfMeasurement()); if (updated.getMetricExpression() != null) { recordChange( "metricExpression", original.getMetricExpression(), updated.getMetricExpression()); @@ -156,6 +180,11 @@ public class MetricRepository extends EntityRepository { } } + public List getDistinctCustomUnitsOfMeasurement() { + // Execute efficient database query to get distinct custom units + return daoCollection.metricDAO().getDistinctCustomUnitsOfMeasurement(); + } + private Map> batchFetchRelatedMetrics(List metrics) { Map> relatedMetricsMap = new HashMap<>(); if (metrics == null || metrics.isEmpty()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java index 473783f5063..b63f4ebac42 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java @@ -15,6 +15,7 @@ public class MetricMapper implements EntityMapper { .withGranularity(create.getGranularity()) .withRelatedMetrics(getEntityReferences(Entity.METRIC, create.getRelatedMetrics())) .withMetricType(create.getMetricType()) - .withUnitOfMeasurement(create.getUnitOfMeasurement()); + .withUnitOfMeasurement(create.getUnitOfMeasurement()) + .withCustomUnitOfMeasurement(create.getCustomUnitOfMeasurement()); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java index cc77bdfa84e..79a8146d954 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java @@ -16,6 +16,7 @@ package org.openmetadata.service.resources.metrics; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; @@ -547,4 +548,25 @@ public class MetricResource extends EntityResource { @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } + + @GET + @Path("/customUnits") + @Operation( + operationId = "getCustomUnitsOfMeasurement", + summary = "Get list of custom units of measurement", + description = + "Get a list of all custom units of measurement that have been used in existing metrics. This helps UI provide autocomplete suggestions.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of custom units", + content = + @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(type = "string")))) + }) + public Response getCustomUnitsOfMeasurement(@Context SecurityContext securityContext) { + List customUnits = repository.getDistinctCustomUnitsOfMeasurement(); + return Response.ok(customUnits).build(); + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/metrics/MetricResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/metrics/MetricResourceTest.java index 040c6203aed..07344abeb63 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/metrics/MetricResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/metrics/MetricResourceTest.java @@ -1,5 +1,6 @@ package org.openmetadata.service.resources.metrics; +import static jakarta.ws.rs.core.Response.Status; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.service.util.EntityUtil.fieldAdded; @@ -215,6 +216,190 @@ public class MetricResourceTest extends EntityResourceTest } @SuppressWarnings("unchecked") + @Test + void test_createMetricWithCustomUnit() throws IOException { + // Test creating metric with custom unit + CreateMetric createMetric = + createRequest("test_custom_unit_metric") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement("EURO"); + + Metric metric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + + Assertions.assertEquals(MetricUnitOfMeasurement.OTHER, metric.getUnitOfMeasurement()); + Assertions.assertEquals("EURO", metric.getCustomUnitOfMeasurement()); + } + + @Test + void test_createMetricWithCustomUnitValidation() throws IOException { + // Test missing custom unit when OTHER is selected + CreateMetric createMetricMissingCustomUnit = + createRequest("test_missing_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER); + + assertResponse( + () -> createEntity(createMetricMissingCustomUnit, ADMIN_AUTH_HEADERS), + BAD_REQUEST, + "customUnitOfMeasurement is required when unitOfMeasurement is OTHER"); + } + + @Test + void test_createMetricWithLongCustomUnit() throws IOException { + // Test that very long custom units are accepted (no artificial limits) + String longUnit = + "Very Long Custom Unit Name That Could Be Used In Real World Scenarios Like Monthly Active Users Excluding Internal Test Accounts And Bots From Analytics Dashboard"; + CreateMetric createMetric = + createRequest("test_long_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement(longUnit); + + Metric metric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + Assertions.assertEquals(longUnit, metric.getCustomUnitOfMeasurement()); + } + + @Test + void test_createMetricWithSpecialCharacters() throws IOException { + // Test that all kinds of characters are accepted (no artificial restrictions) + CreateMetric createMetric = + createRequest("test_special_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement("Special@#$%^&*()Characters用户数"); + + Metric metric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + Assertions.assertEquals("Special@#$%^&*()Characters用户数", metric.getCustomUnitOfMeasurement()); + } + + @Test + void test_createMetricWithValidCustomUnits() throws IOException { + // Test various valid custom units including symbols, Unicode, punctuation + String[] validUnits = { + "EURO", + "Minutes", + "GB/sec", + "API_Calls", + "Users (Active)", + "Queries/Hour", + "CPU %", + "Memory [MB]", + "€", + "$", + "£", + "¥", + "₹", + "¢", + "API calls > 500ms", + "用户数", // Chinese characters + "Memory @ peak", + "Items #tagged", + "Rate: 95th percentile", + "Temperature °C" + }; + + for (int i = 0; i < validUnits.length; i++) { + CreateMetric createMetric = + createRequest("test_valid_custom_unit_" + i) + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement(validUnits[i]); + + Metric metric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + Assertions.assertEquals(validUnits[i], metric.getCustomUnitOfMeasurement()); + } + } + + @Test + void test_updateMetricCustomUnit() throws IOException { + // Create metric with standard unit + CreateMetric createMetric = + createRequest("test_update_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.COUNT); + + Metric originalMetric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + Assertions.assertNull(originalMetric.getCustomUnitOfMeasurement()); + + // Update to custom unit + CreateMetric updateRequest = + createRequest("test_update_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement("EURO"); + + Metric updatedMetric = updateEntity(updateRequest, Status.OK, ADMIN_AUTH_HEADERS); + + Assertions.assertEquals(MetricUnitOfMeasurement.OTHER, updatedMetric.getUnitOfMeasurement()); + Assertions.assertEquals("EURO", updatedMetric.getCustomUnitOfMeasurement()); + } + + @Test + void test_customUnitClearedWhenNotOther() throws IOException { + // Create metric with OTHER and custom unit + CreateMetric createMetric = + createRequest("test_clear_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement("EURO"); + + Metric originalMetric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + Assertions.assertEquals("EURO", originalMetric.getCustomUnitOfMeasurement()); + + // Update to standard unit - custom unit should be cleared + CreateMetric updateRequest = + createRequest("test_clear_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.DOLLARS) + .withCustomUnitOfMeasurement("EURO"); // This should be ignored/cleared + + Metric updatedMetric = updateEntity(updateRequest, Status.OK, ADMIN_AUTH_HEADERS); + + Assertions.assertEquals(MetricUnitOfMeasurement.DOLLARS, updatedMetric.getUnitOfMeasurement()); + Assertions.assertNull(updatedMetric.getCustomUnitOfMeasurement()); + } + + @Test + void test_getCustomUnitsAPI() throws IOException { + // Create metrics with different custom units + String[] customUnits = {"EURO", "Minutes", "GB/sec", "EURO"}; // Note: EURO repeated + + for (int i = 0; i < customUnits.length; i++) { + CreateMetric createMetric = + createRequest("test_custom_units_api_" + i) + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement(customUnits[i]); + createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + } + + // Get distinct custom units + List customUnitsList = + TestUtils.get(getCollection().path("customUnits"), List.class, ADMIN_AUTH_HEADERS); + + Assertions.assertNotNull(customUnitsList); + assertTrue(customUnitsList.contains("EURO")); + assertTrue(customUnitsList.contains("Minutes")); + assertTrue(customUnitsList.contains("GB/sec")); + + // Should be distinct - EURO should appear only once + long euroCount = customUnitsList.stream().filter("EURO"::equals).count(); + Assertions.assertEquals(1, euroCount); + } + + @Test + void test_customUnitTrimming() throws IOException { + CreateMetric createMetric = + createRequest("test_trim_custom_unit") + .withMetricType(MetricType.COUNT) + .withUnitOfMeasurement(MetricUnitOfMeasurement.OTHER) + .withCustomUnitOfMeasurement(" EURO "); // Spaces around + + Metric metric = createAndCheckEntity(createMetric, ADMIN_AUTH_HEADERS); + Assertions.assertEquals("EURO", metric.getCustomUnitOfMeasurement()); // Should be trimmed + } + @Override public void assertFieldChange(String fieldName, Object expected, Object actual) { if (expected != null && actual != null) { @@ -232,6 +417,7 @@ public class MetricResourceTest extends EntityResourceTest expected, MetricGranularity.valueOf(actual.toString())); case "unitOfMeasurement" -> Assertions.assertEquals( expected, MetricUnitOfMeasurement.valueOf(actual.toString())); + case "customUnitOfMeasurement" -> Assertions.assertEquals(expected, actual); case "metricType" -> Assertions.assertEquals( expected, MetricType.valueOf(actual.toString())); default -> assertCommonFieldChange(fieldName, expected, actual); diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createMetric.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createMetric.json index 218fbc5ae65..0cef85209b2 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createMetric.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createMetric.json @@ -31,6 +31,10 @@ "description": "Unit of measurement for the metric.", "$ref": "../../entity/data/metric.json#/definitions/unitOfMeasurement" }, + "customUnitOfMeasurement": { + "description": "Custom unit of measurement when unitOfMeasurement is OTHER.", + "type": "string" + }, "granularity": { "description": "Metric's granularity.", "$ref": "../../entity/data/metric.json#/definitions/metricGranularity" diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/metric.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/metric.json index 4bddbc8f81c..6203229fef0 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/metric.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/metric.json @@ -79,7 +79,8 @@ "SIZE", "REQUESTS", "EVENTS", - "TRANSACTIONS" + "TRANSACTIONS", + "OTHER" ] }, "metricGranularity": { @@ -131,6 +132,10 @@ "description": "Unit of measurement for the metric.", "$ref": "#/definitions/unitOfMeasurement" }, + "customUnitOfMeasurement": { + "description": "Custom unit of measurement when unitOfMeasurement is OTHER.", + "type": "string" + }, "granularity": { "description": "Metric's granularity.", "$ref": "#/definitions/metricGranularity" diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricCustomUnitFlow.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricCustomUnitFlow.spec.ts new file mode 100644 index 00000000000..adec14fd325 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricCustomUnitFlow.spec.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { expect, test } from '@playwright/test'; +import { SidebarItem } from '../../constant/sidebar'; +import { + clickOutside, + descriptionBox, + redirectToHomePage, +} from '../../utils/common'; +import { + removeUnitOfMeasurement, + updateUnitOfMeasurement, +} from '../../utils/metric'; +import { sidebarClick } from '../../utils/sidebar'; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.describe('Metric Custom Unit of Measurement Flow', () => { + test('Should create metric and test unit of measurement updates', async ({ + page, + }) => { + await redirectToHomePage(page); + + await test.step('Navigate to Metrics and create a metric', async () => { + // Navigate to Metrics + await sidebarClick(page, SidebarItem.METRICS); + + const listAPIPromise = page.waitForResponse( + '/api/v1/metrics?fields=owners%2Ctags&limit=15&include=all' + ); + await listAPIPromise; + + // Click Add Metric button and create metric using existing utility + await page.getByTestId('create-metric').click(); + + // Use a simplified metric creation approach + const metricName = `test-unit-metric-${Date.now()}`; + + // Click create to trigger validation + await page.getByTestId('create-button').click(); + + await expect(page.locator('#name_help')).toHaveText('Name is required'); + + // Fill required fields only + await page.locator('#root\\/name').fill(metricName); + await page.locator('#root\\/displayName').fill(metricName); + + // Fill description + await page + .locator(descriptionBox) + .fill(`Test metric for unit testing ${metricName}`); + + // Select granularity + await page.getByTestId('granularity').locator('input').fill('Quarter'); + await page.getByTitle('Quarter', { exact: true }).click(); + + // Select metric type + await page.getByTestId('metricType').locator('input').fill('Sum'); + await page.getByTitle('Sum', { exact: true }).click(); + + await clickOutside(page); + + // Select unit of measurement (use Bytes as initial unit) + await page + .getByTestId('unitOfMeasurement') + .locator('input') + .fill('Events'); + await page.getByTitle('Events', { exact: true }).click(); + await clickOutside(page); + + // Select language and add expression + await page.getByTestId('language').locator('input').fill('SQL'); + await page.getByTitle('SQL', { exact: true }).click(); + + await clickOutside(page); + + await page.locator("pre[role='presentation']").last().click(); + await page.keyboard.type('SELECT SUM(amount) FROM sales'); + + // Save the metric + const postPromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/api/v1/metrics') + ); + + const getPromise = page.waitForResponse((response) => + response.url().includes(`/api/v1/metrics/name/${metricName}`) + ); + + await page.getByTestId('create-button').click(); + await postPromise; + await getPromise; + + // Verify creation + await expect( + page.getByTestId('entity-header-display-name') + ).toContainText(metricName); + }); + + await test.step( + 'Verify initial unit of measurement is displayed', + async () => { + await expect( + page.getByTestId('data-asset-header-metadata').getByText('EVENTS') + ).toBeVisible(); + } + ); + + await test.step('Update unit of measurement to Dollars', async () => { + await updateUnitOfMeasurement(page, 'Dollars'); + }); + + await test.step('Remove unit of measurement', async () => { + await removeUnitOfMeasurement(page); + }); + + await test.step('Set unit back to Percentage', async () => { + await updateUnitOfMeasurement(page, 'Percentage'); + }); + + await test.step('Clean up - delete the metric', async () => { + await page.getByTestId('manage-button').click(); + await page.getByTestId('delete-button').click(); + await page.waitForSelector('[role="dialog"].ant-modal'); + + await expect(page.locator('[role="dialog"].ant-modal')).toBeVisible(); + + await page.fill('[data-testid="confirmation-text-input"]', 'DELETE'); + + const deletePromise = page.waitForResponse( + (response) => + response.request().method() === 'DELETE' && + response.url().includes('/api/v1/metrics/') + ); + + await page.click('[data-testid="confirm-button"]'); + + await deletePromise; + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/metric.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/metric.ts index 567ead14036..81cdcfd848d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/metric.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/metric.ts @@ -256,7 +256,8 @@ export const addMetric = async (page: Page) => { // Select the unit of measurement await page - .locator('[id="root\\/unitOfMeasurement"]') + .getByTestId('unitOfMeasurement') + .locator('input') .fill(metricData.unitOfMeasurement); await page .getByTitle(`${metricData.unitOfMeasurement}`, { exact: true }) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts index 044a7ddfb43..936ffa2eab9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts @@ -140,7 +140,7 @@ export type DataAssetsHeaderProps = { onUpdateVote?: (data: QueryVote, id: string) => Promise; onUpdateRetentionPeriod?: (value: string) => Promise; extraDropdownContent?: ManageButtonProps['extraDropdownContent']; - onMetricUpdate?: (updatedData: Metric, key: keyof Metric) => Promise; + onMetricUpdate?: (updatedData: Metric, key?: keyof Metric) => Promise; isCustomizedView?: boolean; disableRunAgentsButton?: boolean; afterTriggerAction?: VoidFunction; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/MetricHeaderInfo.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/MetricHeaderInfo.tsx index 9ca1325646b..2132971e28c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/MetricHeaderInfo.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/MetricHeaderInfo.tsx @@ -35,10 +35,10 @@ import { Metric, MetricGranularity, MetricType, - UnitOfMeasurement, } from '../../../generated/entity/data/metric'; import { getSortedOptions } from '../../../utils/MetricEntityUtils/MetricUtils'; import './metric-header-info.less'; +import UnitOfMeasurementInfoItem from './UnitOfMeasurementInfoItem'; interface MetricInfoItemOption { label: string; @@ -51,7 +51,7 @@ interface MetricHeaderInfoProps { metricDetails: Metric; onUpdateMetricDetails: ( updatedData: Metric, - key: keyof Metric + key?: keyof Metric ) => Promise; } @@ -80,22 +80,20 @@ const MetricInfoItem: FC = ({ const modiFiedLabel = label.toLowerCase().replace(/\s+/g, '-'); - const sortedOptions = useMemo( - () => getSortedOptions(options, value, valueKey), - [options, value, valueKey] - ); + const allOptions = useMemo(() => { + return getSortedOptions(options, value, valueKey); + }, [options, value, valueKey]); const handleUpdate = async (value: string | undefined) => { try { setIsUpdating(true); - const updatedMetricDetails = { + const updatedMetricDetails: Metric = { ...metricDetails, [valueKey]: value, }; await onUpdateMetricDetails(updatedMetricDetails, valueKey); - } catch (error) { - // + setPopupVisible(false); } finally { setIsUpdating(false); } @@ -103,43 +101,45 @@ const MetricInfoItem: FC = ({ const list = ( ( - - { - e.stopPropagation(); - handleUpdate(undefined); - setPopupVisible(false); - }} - /> - - ) - } - key={item.key} - title={item.label} - onClick={(e) => { - e.stopPropagation(); - handleUpdate(item.value); - setPopupVisible(false); - }}> - {item.label} - - )} + renderItem={(item) => { + const isActive = value === item.value; + + return ( + + { + e.stopPropagation(); + handleUpdate(undefined); + }} + /> + + ) + } + key={item.key} + title={item.label} + onClick={(e) => { + e.stopPropagation(); + handleUpdate(item.value); + }}> + {item.label} + + ); + }} size="small" style={{ maxHeight: '250px', @@ -219,18 +219,11 @@ const MetricHeaderInfo: FC = ({ /> - ({ - key: unitOfMeasurement, - label: startCase(unitOfMeasurement.toLowerCase()), - value: unitOfMeasurement, - }))} - value={metricDetails.unitOfMeasurement} - valueKey="unitOfMeasurement" - onUpdateMetricDetails={onUpdateMetricDetails} + onMetricUpdate={onUpdateMetricDetails} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/UnitOfMeasurementInfoItem.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/UnitOfMeasurementInfoItem.tsx new file mode 100644 index 00000000000..0e12976b26e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/UnitOfMeasurementInfoItem.tsx @@ -0,0 +1,219 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Icon from '@ant-design/icons/lib/components/Icon'; +import { Button, List, Popover, Space, Tooltip, Typography } from 'antd'; +import { AxiosError } from 'axios'; +import classNames from 'classnames'; +import { startCase } from 'lodash'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; +import { ReactComponent as IconRemoveColored } from '../../../assets/svg/ic-remove-colored.svg'; +import { + DE_ACTIVE_COLOR, + NO_DATA_PLACEHOLDER, +} from '../../../constants/constants'; +import { + Metric, + UnitOfMeasurement, +} from '../../../generated/entity/data/metric'; +import { getCustomUnitsOfMeasurement } from '../../../rest/metricsAPI'; +import { getSortedOptions } from '../../../utils/MetricEntityUtils/MetricUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import './unit-of-measurement-header.less'; + +interface UnitOfMeasurementInfoItemProps { + label: string; + hasPermission: boolean; + metricDetails: Metric; + onMetricUpdate: (updatedData: Metric, key?: keyof Metric) => Promise; +} + +const UnitOfMeasurementInfoItem: FC = ({ + label, + hasPermission, + metricDetails, + onMetricUpdate, +}) => { + const { t } = useTranslation(); + const [popupVisible, setPopupVisible] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [customUnits, setCustomUnits] = useState([]); + + const modiFiedLabel = label.toLowerCase().replace(/\s+/g, '-'); + + const fetchCustomUnits = useCallback(async () => { + try { + const units = await getCustomUnitsOfMeasurement(); + setCustomUnits(units || []); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, []); + + useEffect(() => { + fetchCustomUnits(); + }, []); + + const currentValue = useMemo(() => { + if (metricDetails.unitOfMeasurement === UnitOfMeasurement.Other) { + return metricDetails.customUnitOfMeasurement; + } + + return metricDetails.unitOfMeasurement; + }, [metricDetails.unitOfMeasurement, metricDetails.customUnitOfMeasurement]); + + const allOptions = useMemo(() => { + // Standard unit options (excluding 'Other' as it's handled via custom units) + const standardOptions = Object.values(UnitOfMeasurement) + .filter((unit) => unit !== UnitOfMeasurement.Other) + .map((unitOfMeasurement) => ({ + key: unitOfMeasurement, + label: startCase(unitOfMeasurement.toLowerCase()), + value: unitOfMeasurement, + })); + + // Custom unit options + const customOptions = customUnits.map((unit) => ({ + key: unit, + label: unit, + value: unit, + })); + + return getSortedOptions( + [...standardOptions, ...customOptions], + currentValue, + 'unitOfMeasurement' + ); + }, [customUnits, currentValue]); + + const handleUpdate = async (value: string | undefined) => { + try { + setIsUpdating(true); + const updatedMetricDetails: Metric = { + ...metricDetails, + }; + + if (!value) { + // Remove unit of measurement + updatedMetricDetails.unitOfMeasurement = undefined; + updatedMetricDetails.customUnitOfMeasurement = undefined; + } else if (customUnits.includes(value)) { + // Custom unit selected + updatedMetricDetails.unitOfMeasurement = UnitOfMeasurement.Other; + updatedMetricDetails.customUnitOfMeasurement = value; + } else { + // Standard unit selected + updatedMetricDetails.unitOfMeasurement = value as UnitOfMeasurement; + updatedMetricDetails.customUnitOfMeasurement = undefined; + } + + await onMetricUpdate(updatedMetricDetails, 'unitOfMeasurement'); + setPopupVisible(false); + } finally { + setIsUpdating(false); + } + }; + + const list = ( + { + const isActive = currentValue === item.value; + + return ( + + { + e.stopPropagation(); + handleUpdate(undefined); + }} + /> + + ) + } + key={item.key} + title={item.label} + onClick={(e) => { + e.stopPropagation(); + handleUpdate(item.value); + }}> + {item.label} + + ); + }} + size="small" + /> + ); + + return ( + +
+ +
+ {label} + {hasPermission && !metricDetails.deleted && ( + + +
+
+ {currentValue ?? NO_DATA_PLACEHOLDER} +
+
+
+
+ ); +}; + +export default UnitOfMeasurementInfoItem; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/unit-of-measurement-header.less b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/unit-of-measurement-header.less new file mode 100644 index 00000000000..50c938bc6da --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricHeaderInfo/unit-of-measurement-header.less @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.unit-of-measurement-header { + max-height: 250px; + overflow-y: auto; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.test.tsx new file mode 100644 index 00000000000..062904f7807 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.test.tsx @@ -0,0 +1,353 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { UnitOfMeasurement } from '../../../generated/entity/data/metric'; +import { getCustomUnitsOfMeasurement } from '../../../rest/metricsAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import CustomUnitSelect from './CustomUnitSelect'; + +// Mock dependencies +jest.mock('../../../rest/metricsAPI', () => ({ + getCustomUnitsOfMeasurement: jest.fn(), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'label.enter-custom-unit-of-measurement': + 'Enter custom unit of measurement', + 'label.add': 'Add', + }; + + return translations[key] || key; + }, + }), +})); + +const mockCustomUnits = ['Custom Unit 1', 'Custom Unit 2', 'Custom Unit 3']; + +describe('CustomUnitSelect Component', () => { + const mockOnChange = jest.fn(); + const mockGetCustomUnits = getCustomUnitsOfMeasurement as jest.Mock; + const mockShowErrorToast = showErrorToast as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetCustomUnits.mockResolvedValue(mockCustomUnits); + }); + + describe('Basic Rendering', () => { + it('should render component with default props', async () => { + render(); + + expect( + screen.getByTestId('unit-of-measurement-select') + ).toBeInTheDocument(); + }); + + it('should render with custom data-testid', async () => { + render(); + + expect(screen.getByTestId('custom-unit-select')).toBeInTheDocument(); + }); + + it('should render with placeholder text', async () => { + render(); + + expect(screen.getByText('Select unit')).toBeInTheDocument(); + }); + + it('should be disabled when disabled prop is true', async () => { + render(); + + const select = screen.getByRole('combobox'); + + expect(select).toBeDisabled(); + }); + }); + + describe('API Integration', () => { + it('should fetch custom units on mount', async () => { + render(); + + await waitFor(() => { + expect(mockGetCustomUnits).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle API error gracefully', async () => { + const error = new Error('API Error'); + mockGetCustomUnits.mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(mockShowErrorToast).toHaveBeenCalledWith(error); + }); + }); + + it('should handle empty API response', async () => { + mockGetCustomUnits.mockResolvedValue([]); + + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + }); + }); + + it('should handle null API response', async () => { + mockGetCustomUnits.mockResolvedValue(null); + + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + }); + }); + }); + + describe('Options Display', () => { + it('should display standard unit options', async () => { + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.queryByText('Other')).not.toBeInTheDocument(); + }); + }); + + it('should display custom units from API', async () => { + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Custom Unit 1')).toBeInTheDocument(); + expect(screen.getByText('Custom Unit 2')).toBeInTheDocument(); + }); + }); + + it('should show add custom unit interface', async () => { + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect( + screen.getByPlaceholderText('Enter custom unit of measurement') + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /add/i }) + ).toBeInTheDocument(); + }); + }); + }); + + describe('Unit Selection', () => { + it('should handle standard unit selection', async () => { + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Percentage')); + + expect(mockOnChange).toHaveBeenCalledWith( + UnitOfMeasurement.Percentage, + undefined + ); + }); + + it('should handle custom unit selection', async () => { + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Custom Unit 1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Custom Unit 1')); + + expect(mockOnChange).toHaveBeenCalledWith( + UnitOfMeasurement.Other, + 'Custom Unit 1' + ); + }); + + it('should display selected standard unit', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + }); + }); + + it('should display selected custom unit', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('My Custom Unit')).toBeInTheDocument(); + }); + }); + + it('should handle onChange being undefined', async () => { + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + }); + + expect(() => { + fireEvent.click(screen.getByText('Percentage')); + }).not.toThrow(); + }); + }); + + describe('Custom Value Initialization', () => { + it('should initialize with custom value', async () => { + render( + + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Initial Custom Unit')).toBeInTheDocument(); + }); + }); + + it('should not duplicate existing custom value', async () => { + mockGetCustomUnits.mockResolvedValue(['Existing Unit']); + + render( + + ); + + await waitFor(() => { + // The test passes if component renders without error + expect( + screen.getByTestId('unit-of-measurement-select') + ).toBeInTheDocument(); + }); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + // Should show options are available + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + }); + }); + + describe('Performance and Memoization', () => { + it('should use memoized options', async () => { + const { rerender } = render(); + + // Initial render should call API + await waitFor(() => { + expect(mockGetCustomUnits).toHaveBeenCalledTimes(1); + }); + + // Rerender with same props should not call API again + rerender(); + + // Should still only have been called once + expect(mockGetCustomUnits).toHaveBeenCalledTimes(1); + }); + + it('should handle component updates efficiently', async () => { + const { rerender } = render( + + ); + + await waitFor(() => { + expect(screen.getByText('Count')).toBeInTheDocument(); + }); + + // Change value + rerender(); + + await waitFor(() => { + expect(screen.getByText('Percentage')).toBeInTheDocument(); + }); + + // API should still only be called once + expect(mockGetCustomUnits).toHaveBeenCalledTimes(1); + }); + }); + + describe('Accessibility and Props', () => { + it('should support custom placeholder', () => { + render(); + + expect(screen.getByText('Choose measurement unit')).toBeInTheDocument(); + }); + + it('should support disabled state', () => { + render(); + + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + + it('should support search functionality', () => { + render(); + + expect(screen.getByRole('combobox')).toHaveAttribute('type', 'search'); + }); + + it('should disable search when showSearch is false', () => { + render(); + + const select = screen.getByTestId('unit-of-measurement-select'); + + expect(select).toBeInTheDocument(); + }); + + it('should use custom testid', () => { + render(); + + expect(screen.getByTestId('my-custom-select')).toBeInTheDocument(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.tsx new file mode 100644 index 00000000000..81b012f6a61 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomUnitSelect/CustomUnitSelect.tsx @@ -0,0 +1,177 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Divider, Input, Select } from 'antd'; +import { AxiosError } from 'axios'; +import { startCase } from 'lodash'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UnitOfMeasurement } from '../../../generated/entity/data/metric'; +import { getCustomUnitsOfMeasurement } from '../../../rest/metricsAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; + +interface CustomUnitSelectProps { + value?: string; + customValue?: string; + onChange?: ( + unitOfMeasurement: string, + customUnitOfMeasurement?: string + ) => void; + placeholder?: string; + disabled?: boolean; + showSearch?: boolean; + dataTestId?: string; +} + +const CustomUnitSelect: FC = ({ + value, + customValue, + onChange, + placeholder, + disabled = false, + showSearch = true, + dataTestId = 'unit-of-measurement-select', +}) => { + const { t } = useTranslation(); + const [customUnits, setCustomUnits] = useState([]); + const [userAddedUnits, setUserAddedUnits] = useState([]); + const [newCustomUnit, setNewCustomUnit] = useState(''); + const [loading, setLoading] = useState(false); + + // Memoized computed values + const { allCustomUnits, allOptions } = useMemo(() => { + // Combine custom units with deduplication + const combinedCustomUnits = [ + ...new Set([...customUnits, ...userAddedUnits]), + ]; + + // Standard unit options + const standardOptions = Object.values(UnitOfMeasurement) + .filter((unit) => unit !== UnitOfMeasurement.Other) + .map((unit) => ({ + label: startCase(unit.toLowerCase()), + value: unit, + })); + + // Custom options from combined units + const customOptions = combinedCustomUnits.map((unit) => ({ + label: unit, + value: unit, + })); + + // Combined options - standard units first, then custom units + const combinedOptions = [...standardOptions, ...customOptions]; + + return { + allCustomUnits: combinedCustomUnits, + allOptions: combinedOptions, + }; + }, [customUnits, userAddedUnits]); + + const fetchCustomUnits = useCallback(async () => { + try { + setLoading(true); + const units = await getCustomUnitsOfMeasurement(); + setCustomUnits(units || []); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setLoading(false); + } + }, []); + + // Fetch custom units from backend on component mount + useEffect(() => { + fetchCustomUnits(); + }, []); + + // Initialize with current custom value if it exists + useEffect(() => { + if (customValue && !allCustomUnits.includes(customValue)) { + setUserAddedUnits([customValue]); + } + }, [customValue, allCustomUnits]); + + const handleAddCustomUnit = () => { + const trimmedUnit = newCustomUnit.trim(); + if (trimmedUnit) { + // Check if it already exists in either list + if (!allCustomUnits.includes(trimmedUnit)) { + setUserAddedUnits([...userAddedUnits, trimmedUnit]); + } + // Select the newly added custom unit + onChange?.(UnitOfMeasurement.Other, trimmedUnit); + setNewCustomUnit(''); + } + }; + + const handleChange = (selectedValue: string) => { + // Check if it's a custom unit (from backend or user-added) + const isCustomUnit = allCustomUnits.includes(selectedValue); + + if (isCustomUnit) { + // Custom unit selected - set unitOfMeasurement to OTHER + onChange?.(UnitOfMeasurement.Other, selectedValue); + } else { + // Standard unit selected - clear customUnitOfMeasurement + onChange?.(selectedValue, undefined); + } + }; + + const handleNameChange = (e: React.ChangeEvent) => { + setNewCustomUnit(e.target.value); + }; + + // Determine display value + const displayValue = value === UnitOfMeasurement.Other ? customValue : value; + + return ( + { + e.stopPropagation(); + if (e.key === 'Enter') { + handleAddCustomUnit(); + } + }} + /> + + + + )} + loading={loading} + options={allOptions} + placeholder={placeholder} + showSearch={showSearch} + value={displayValue} + onChange={handleChange} + /> + ); +}; + +export default CustomUnitSelect; diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createMetric.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createMetric.ts index 6c3832fc085..6cb667915bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createMetric.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createMetric.ts @@ -14,6 +14,10 @@ * Create Metric entity request */ export interface CreateMetric { + /** + * Custom unit of measurement when unitOfMeasurement is OTHER. + */ + customUnitOfMeasurement?: string; /** * List of fully qualified names of data products this entity is part of. */ @@ -282,6 +286,7 @@ export enum UnitOfMeasurement { Count = "COUNT", Dollars = "DOLLARS", Events = "EVENTS", + Other = "OTHER", Percentage = "PERCENTAGE", Requests = "REQUESTS", Size = "SIZE", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/metric.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/metric.ts index 09131c8e677..12d44a250b8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/metric.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/metric.ts @@ -22,6 +22,10 @@ export interface Metric { * Change that lead to this version of the entity. */ changeDescription?: ChangeDescription; + /** + * Custom unit of measurement when unitOfMeasurement is OTHER. + */ + customUnitOfMeasurement?: string; /** * List of data products this entity is part of. */ @@ -429,6 +433,7 @@ export enum UnitOfMeasurement { Count = "COUNT", Dollars = "DOLLARS", Events = "EVENTS", + Other = "OTHER", Percentage = "PERCENTAGE", Requests = "REQUESTS", Size = "SIZE", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 9e6471b7e2e..ae1d3ac4eca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -557,6 +557,7 @@ "endpoint-url": "Endpunkt-URL", "endpoint-url-for-aws": "Endpunkt-URL für AWS", "enter": "Eingeben", + "enter-custom-unit-of-measurement": "Geben Sie benutzerdefinierte Maßeinheit ein und drücken Sie Enter", "enter-entity": "{{entity}} eingeben", "enter-entity-name": "Geben Sie einen Namen für {{entity}} ein", "enter-entity-value": "Enter {{entity}} Value", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 4d3ac450d21..2d5f02e26cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -557,6 +557,7 @@ "endpoint-url": "Endpoint URL", "endpoint-url-for-aws": "EndPoint URL for the AWS", "enter": "Enter", + "enter-custom-unit-of-measurement": "Enter Custom Measurement Unit and Press Enter", "enter-entity": "Enter {{entity}}", "enter-entity-name": "Enter {{entity}} name", "enter-entity-value": "Enter {{entity}} Value", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index bef2da7cfaf..e369d49bcc7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -557,6 +557,7 @@ "endpoint-url": "URL del terminal", "endpoint-url-for-aws": "URL del terminal para AWS", "enter": "Entrar", + "enter-custom-unit-of-measurement": "Ingrese la unidad de medida personalizada y presione Enter", "enter-entity": "Ingrese {{entity}}", "enter-entity-name": "Ingrese el nombre de {{entity}}", "enter-entity-value": "Ingrese valor de {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index d9180989ebb..14fd83fdfa3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -557,6 +557,7 @@ "endpoint-url": "URL du Point de terminaison", "endpoint-url-for-aws": "URL du Point de terminaison pour AWS", "enter": "Entrer", + "enter-custom-unit-of-measurement": "Entrez l'unité de mesure personnalisée et appuyez sur Entrée", "enter-entity": "Entrer {{entity}}", "enter-entity-name": "Entrer un nom pour {{entity}}", "enter-entity-value": "Entrer une valeur pour {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 0e03eae304d..a71a39e465b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -557,6 +557,7 @@ "endpoint-url": "URL do punto final", "endpoint-url-for-aws": "URL do punto final para AWS", "enter": "Introducir", + "enter-custom-unit-of-measurement": "Introduce a unidade de medida personalizada e preme Enter", "enter-entity": "Introducir {{entity}}", "enter-entity-name": "Introducir o nome de {{entity}}", "enter-entity-value": "Introducir o valor de {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index b53c7fd2c9d..e75c9968add 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -557,6 +557,7 @@ "endpoint-url": "URL נקודת סיום", "endpoint-url-for-aws": "כתובת URL עבור AWS", "enter": "הזן", + "enter-custom-unit-of-measurement": "הזן יחידת מדידה מותאמת אישית ולחץ Enter", "enter-entity": "הזן {{entity}}", "enter-entity-name": "הזן שם {{entity}}", "enter-entity-value": "הזן ערך {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 7481ddc87db..457d7a289a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -557,6 +557,7 @@ "endpoint-url": "エンドポイントURL", "endpoint-url-for-aws": "AWSエンドポイントURL", "enter": "入力", + "enter-custom-unit-of-measurement": "カスタム測定単位を入力してEnterを押してください", "enter-entity": "{{entity}} を入力", "enter-entity-name": "{{entity}} の名前を入力", "enter-entity-value": "{{entity}} の値を入力", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index ee905edaf4a..8272ff0e95c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -557,6 +557,7 @@ "endpoint-url": "엔드포인트 URL", "endpoint-url-for-aws": "AWS용 엔드포인트 URL", "enter": "입력", + "enter-custom-unit-of-measurement": "사용자 지정 측정 단위를 입력하고 Enter를 누르세요", "enter-entity": "{{entity}} 입력", "enter-entity-name": "{{entity}} 이름 입력", "enter-entity-value": "{{entity}} 값 입력", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 05049ce31ed..f76daf03bb3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -557,6 +557,7 @@ "endpoint-url": "एंडपॉईंट URL", "endpoint-url-for-aws": "AWS साठी एंडपॉईंट URL", "enter": "प्रविष्ट करा", + "enter-custom-unit-of-measurement": "स्वतःचे मापन विक्यात अदाकरा आणि Enter दाबा", "enter-entity": "{{entity}} प्रविष्ट करा", "enter-entity-name": "{{entity}} नाव प्रविष्ट करा", "enter-entity-value": "{{entity}} मूल्य प्रविष्ट करा", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 9db8f3412e2..665273198f6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -557,6 +557,7 @@ "endpoint-url": "Eindpunt-URL", "endpoint-url-for-aws": "Eindpunt-URL voor AWS", "enter": "Invoeren", + "enter-custom-unit-of-measurement": "Voer aangepaste maateenheid in en druk op Enter", "enter-entity": "{{entity}} invoeren", "enter-entity-name": "{{entity}}-naam invoeren", "enter-entity-value": "{{entity}}-waarde invoeren", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 5d8a753895a..698051046f3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -557,6 +557,7 @@ "endpoint-url": "URL نقطه انتهایی", "endpoint-url-for-aws": "URL نقطه انتهایی برای AWS", "enter": "ورود", + "enter-custom-unit-of-measurement": "واحد اندازه‌گیری سفارشی را وارد کرده و Enter را بزنید", "enter-entity": "ورود {{entity}}", "enter-entity-name": "نام {{entity}} را وارد کنید", "enter-entity-value": "مقدار {{entity}} را وارد کنید", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index d1c3a5b3e77..844c5a33fa8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -557,6 +557,7 @@ "endpoint-url": "URL do Ponto Final", "endpoint-url-for-aws": "URL do Ponto Final para AWS", "enter": "Entrar", + "enter-custom-unit-of-measurement": "Digite a unidade de medida personalizada e pressione Enter", "enter-entity": "Inserir {{entity}}", "enter-entity-name": "Inserir nome de {{entity}}", "enter-entity-value": "Inserir Valor de {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 936ba90e81d..17d89867a86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -557,6 +557,7 @@ "endpoint-url": "URL do Ponto Final", "endpoint-url-for-aws": "URL do Ponto Final para AWS", "enter": "Entrar", + "enter-custom-unit-of-measurement": "Digite a unidade de medida personalizada e prima Enter", "enter-entity": "Inserir {{entity}}", "enter-entity-name": "Inserir nome de {{entity}}", "enter-entity-value": "Inserir Valor de {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 9de4e203427..711c4030004 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -557,6 +557,7 @@ "endpoint-url": "URL конечной точки", "endpoint-url-for-aws": "URL-адрес конечной точки для AWS", "enter": "Введите", + "enter-custom-unit-of-measurement": "Введите пользовательскую единицу измерения и нажмите Enter", "enter-entity": "Введите {{entity}}", "enter-entity-name": "Введите имя {{entity}}", "enter-entity-value": "Введите значение {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index d6e9fd1d85b..4033923a447 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -557,6 +557,7 @@ "endpoint-url": "URL จุดสิ้นสุด", "endpoint-url-for-aws": "URL จุดสิ้นสุดสำหรับ AWS", "enter": "ป้อน", + "enter-custom-unit-of-measurement": "กรอกหน่วยวัดที่กำหนดเองและกด Enter", "enter-entity": "ป้อน {{entity}}", "enter-entity-name": "ป้อนชื่อ {{entity}}", "enter-entity-value": "ป้อนค่าของ {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index e4e9cbb8cf7..f0870f5782c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -557,6 +557,7 @@ "endpoint-url": "Uç Nokta URL'si", "endpoint-url-for-aws": "AWS için Uç Nokta URL'si", "enter": "Girin", + "enter-custom-unit-of-measurement": "Özel ölçü birimini girin ve Enter'a basın", "enter-entity": "{{entity}} Girin", "enter-entity-name": "{{entity}} adı girin", "enter-entity-value": "{{entity}} Değeri Girin", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 36479461d78..5802c0d123f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -557,6 +557,7 @@ "endpoint-url": "终点 URL", "endpoint-url-for-aws": "EndPoint URL for the AWS", "enter": "输入", + "enter-custom-unit-of-measurement": "输入自定义测量单位并按Enter键", "enter-entity": "输入{{entity}}", "enter-entity-name": "输入{{entity}}的名称", "enter-entity-value": "输入{{entity}}的值", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index c2e8547c2e0..0cb31140188 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -557,6 +557,7 @@ "endpoint-url": "端點 URL", "endpoint-url-for-aws": "AWS 的端點 URL", "enter": "輸入", + "enter-custom-unit-of-measurement": "輸入自定義測量單位並按Enter鍵", "enter-entity": "輸入 {{entity}}", "enter-entity-name": "輸入 {{entity}} 名稱", "enter-entity-value": "輸入 {{entity}} 值", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/AddMetricPage/AddMetricPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/AddMetricPage/AddMetricPage.tsx index 22d7f621747..d3e28b94d9e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/AddMetricPage/AddMetricPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/AddMetricPage/AddMetricPage.tsx @@ -16,6 +16,7 @@ import { omit, startCase } from 'lodash'; import { FocusEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import CustomUnitSelect from '../../../components/common/CustomUnitSelect/CustomUnitSelect'; import ResizablePanels from '../../../components/common/ResizablePanels/ResizablePanels'; import ServiceDocPanel from '../../../components/common/ServiceDocPanel/ServiceDocPanel'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; @@ -159,32 +160,6 @@ const AddMetricPage = () => { }, }, }, - { - name: 'unitOfMeasurement', - required: false, - label: t('label.unit-of-measurement'), - id: 'root/unitOfMeasurement', - type: FieldTypes.SELECT, - props: { - 'data-testid': 'unitOfMeasurement', - options: Object.values(UnitOfMeasurement).map( - (unitOfMeasurement) => ({ - key: unitOfMeasurement, - label: startCase(unitOfMeasurement.toLowerCase()), - value: unitOfMeasurement, - }) - ), - placeholder: `${t('label.select-field', { - field: t('label.unit-of-measurement'), - })}`, - showSearch: true, - filterOption: (input: string, option: { label: string }) => { - return (option?.label ?? '') - .toLowerCase() - .includes(input.toLowerCase()); - }, - }, - }, { name: 'language', required: false, @@ -230,10 +205,21 @@ const AddMetricPage = () => { setActiveField(activeField); }, []); + const handleUnitOfMeasurementChange = ( + unitOfMeasurement: string, + customUnitOfMeasurement?: string + ) => { + form.setFieldsValue({ + unitOfMeasurement, + customUnitOfMeasurement, + }); + }; + const handleSubmit = async ( values: Exclude & { code?: string; language?: Language; + customUnitOfMeasurement?: string; } ) => { setIsCreating(true); @@ -246,6 +232,14 @@ const AddMetricPage = () => { }, }; + if ( + values.unitOfMeasurement === UnitOfMeasurement.Other && + values.customUnitOfMeasurement + ) { + createMetricPayload.customUnitOfMeasurement = + values.customUnitOfMeasurement; + } + const response = await createMetric(createMetricPayload); navigate( getEntityDetailsPath( @@ -289,6 +283,23 @@ const AddMetricPage = () => { onFinish={handleSubmit} onFocus={handleFieldFocus}> {generateFormFields(formFields)} + + + + { try { const res = await saveUpdatedMetricData(updatedData); - setMetricDetails((previous) => { - return { + if (key === 'unitOfMeasurement') { + setMetricDetails((previous) => ({ ...previous, version: res.version, - ...(key ? { [key]: res[key] } : res), - }; - }); + unitOfMeasurement: res.unitOfMeasurement, + customUnitOfMeasurement: res.customUnitOfMeasurement, + })); + } else { + setMetricDetails((previous) => { + return { + ...previous, + version: res.version, + ...(key ? { [key]: res[key] } : res), + }; + }); + } } catch (error) { showErrorToast(error as AxiosError); } diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/metricsAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/metricsAPI.ts index fe9f283f184..7b52fc0c914 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/metricsAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/metricsAPI.ts @@ -121,3 +121,9 @@ export const createMetric = async (data: CreateMetric) => { return response.data; }; + +export const getCustomUnitsOfMeasurement = async () => { + const response = await APIClient.get('/metrics/customUnits'); + + return response.data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataAssetsVersionHeaderUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataAssetsVersionHeaderUtils.tsx index 360cdbc75ed..a50e33c9d9f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataAssetsVersionHeaderUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataAssetsVersionHeaderUtils.tsx @@ -22,7 +22,7 @@ import { EntityField } from '../constants/Feeds.constants'; import { EntityType } from '../enums/entity.enum'; import { Chart } from '../generated/entity/data/chart'; import { Dashboard } from '../generated/entity/data/dashboard'; -import { Metric } from '../generated/entity/data/metric'; +import { Metric, UnitOfMeasurement } from '../generated/entity/data/metric'; import { Pipeline } from '../generated/entity/data/pipeline'; import { Topic } from '../generated/entity/data/topic'; import { ChangeDescription } from '../generated/entity/type'; @@ -195,6 +195,17 @@ export const getDataAssetsVersionHeaderInfo = ( toString(metricDetails.unitOfMeasurement) ); + const customUnitOfMeasurement = getEntityVersionByField( + changeDescription, + 'customUnitOfMeasurement', + toString(metricDetails.customUnitOfMeasurement) + ); + + const displayUnitOfMeasurement = + unitOfMeasurement === UnitOfMeasurement.Other && customUnitOfMeasurement + ? customUnitOfMeasurement + : unitOfMeasurement; + const granularity = getEntityVersionByField( changeDescription, 'granularity', @@ -209,10 +220,10 @@ export const getDataAssetsVersionHeaderInfo = ( value={metricType} /> )} - {!isEmpty(unitOfMeasurement) && ( + {!isEmpty(displayUnitOfMeasurement) && ( )} {!isEmpty(granularity) && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/MetricEntityUtils/MetricUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/MetricEntityUtils/MetricUtils.test.ts index 2f0317bae54..b23bc61d9a6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/MetricEntityUtils/MetricUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/MetricEntityUtils/MetricUtils.test.ts @@ -67,6 +67,7 @@ describe('getSortedOptions', () => { { key: 'COUNT', label: 'COUNT', value: 'COUNT' }, { key: 'DOLLARS', label: 'DOLLARS', value: 'DOLLARS' }, { key: 'EVENTS', label: 'EVENTS', value: 'EVENTS' }, + { key: 'OTHER', label: 'OTHER', value: 'OTHER' }, { key: 'PERCENTAGE', label: 'PERCENTAGE', value: 'PERCENTAGE' }, { key: 'REQUESTS', label: 'REQUESTS', value: 'REQUESTS' }, { key: 'TIMESTAMP', label: 'TIMESTAMP', value: 'TIMESTAMP' },