mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-12-25 06:28:22 +00:00
* 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] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Co-authored-by: karanh37 <karanh37@gmail.com>
This commit is contained in:
parent
e229689bb7
commit
9fd34c8f89
@ -16,4 +16,11 @@ WHERE configType = 'entityRulesSettings'
|
||||
AND NOT JSON_CONTAINS(
|
||||
JSON_EXTRACT(json, '$.entitySemantics[*].name'),
|
||||
JSON_QUOTE('Data Product Domain Validation')
|
||||
);
|
||||
);
|
||||
|
||||
-- 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);
|
||||
@ -18,4 +18,11 @@ WHERE configtype = 'entityRulesSettings'
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(json->'entitySemantics') AS rule
|
||||
WHERE rule->>'name' = 'Data Product Domain Validation'
|
||||
);
|
||||
);
|
||||
|
||||
-- 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);
|
||||
@ -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<String> getDistinctCustomUnitsOfMeasurement();
|
||||
}
|
||||
|
||||
interface MlModelDAO extends EntityDAO<MlModel> {
|
||||
|
||||
@ -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<Metric> {
|
||||
@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<Metric> {
|
||||
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<Metric> {
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getDistinctCustomUnitsOfMeasurement() {
|
||||
// Execute efficient database query to get distinct custom units
|
||||
return daoCollection.metricDAO().getDistinctCustomUnitsOfMeasurement();
|
||||
}
|
||||
|
||||
private Map<UUID, List<EntityReference>> batchFetchRelatedMetrics(List<Metric> metrics) {
|
||||
Map<UUID, List<EntityReference>> relatedMetricsMap = new HashMap<>();
|
||||
if (metrics == null || metrics.isEmpty()) {
|
||||
|
||||
@ -15,6 +15,7 @@ public class MetricMapper implements EntityMapper<Metric, CreateMetric> {
|
||||
.withGranularity(create.getGranularity())
|
||||
.withRelatedMetrics(getEntityReferences(Entity.METRIC, create.getRelatedMetrics()))
|
||||
.withMetricType(create.getMetricType())
|
||||
.withUnitOfMeasurement(create.getUnitOfMeasurement());
|
||||
.withUnitOfMeasurement(create.getUnitOfMeasurement())
|
||||
.withCustomUnitOfMeasurement(create.getCustomUnitOfMeasurement());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Metric, MetricRepository> {
|
||||
@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<String> customUnits = repository.getDistinctCustomUnitsOfMeasurement();
|
||||
return Response.ok(customUnits).build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Metric, CreateMetric>
|
||||
}
|
||||
|
||||
@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<String> 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<Metric, CreateMetric>
|
||||
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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 })
|
||||
|
||||
@ -140,7 +140,7 @@ export type DataAssetsHeaderProps = {
|
||||
onUpdateVote?: (data: QueryVote, id: string) => Promise<void>;
|
||||
onUpdateRetentionPeriod?: (value: string) => Promise<void>;
|
||||
extraDropdownContent?: ManageButtonProps['extraDropdownContent'];
|
||||
onMetricUpdate?: (updatedData: Metric, key: keyof Metric) => Promise<void>;
|
||||
onMetricUpdate?: (updatedData: Metric, key?: keyof Metric) => Promise<void>;
|
||||
isCustomizedView?: boolean;
|
||||
disableRunAgentsButton?: boolean;
|
||||
afterTriggerAction?: VoidFunction;
|
||||
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
@ -80,22 +80,20 @@ const MetricInfoItem: FC<MetricInfoItemProps> = ({
|
||||
|
||||
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<MetricInfoItemProps> = ({
|
||||
|
||||
const list = (
|
||||
<List
|
||||
dataSource={sortedOptions}
|
||||
dataSource={allOptions}
|
||||
itemLayout="vertical"
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={classNames('selectable-list-item', 'cursor-pointer', {
|
||||
active: value === item.value,
|
||||
})}
|
||||
extra={
|
||||
value === item.value && (
|
||||
<Tooltip
|
||||
title={t('label.remove-entity', {
|
||||
entity: label,
|
||||
})}>
|
||||
<Icon
|
||||
className="align-middle"
|
||||
component={IconRemoveColored}
|
||||
data-testid={`remove-${modiFiedLabel}-button`}
|
||||
style={{ fontSize: '16px' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(undefined);
|
||||
setPopupVisible(false);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(item.value);
|
||||
setPopupVisible(false);
|
||||
}}>
|
||||
<Typography.Text>{item.label}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
renderItem={(item) => {
|
||||
const isActive = value === item.value;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={classNames('selectable-list-item', 'cursor-pointer', {
|
||||
active: isActive,
|
||||
})}
|
||||
extra={
|
||||
isActive && (
|
||||
<Tooltip
|
||||
title={t('label.remove-entity', {
|
||||
entity: label,
|
||||
})}>
|
||||
<Icon
|
||||
className="align-middle"
|
||||
component={IconRemoveColored}
|
||||
data-testid={`remove-${modiFiedLabel}-button`}
|
||||
style={{ fontSize: '16px' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(undefined);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(item.value);
|
||||
}}>
|
||||
<Typography.Text>{item.label}</Typography.Text>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
style={{
|
||||
maxHeight: '250px',
|
||||
@ -219,18 +219,11 @@ const MetricHeaderInfo: FC<MetricHeaderInfoProps> = ({
|
||||
/>
|
||||
<Divider className="self-center vertical-divider" type="vertical" />
|
||||
|
||||
<MetricInfoItem
|
||||
<UnitOfMeasurementInfoItem
|
||||
hasPermission={hasPermission}
|
||||
label={t('label.unit-of-measurement')}
|
||||
metricDetails={metricDetails}
|
||||
options={Object.values(UnitOfMeasurement).map((unitOfMeasurement) => ({
|
||||
key: unitOfMeasurement,
|
||||
label: startCase(unitOfMeasurement.toLowerCase()),
|
||||
value: unitOfMeasurement,
|
||||
}))}
|
||||
value={metricDetails.unitOfMeasurement}
|
||||
valueKey="unitOfMeasurement"
|
||||
onUpdateMetricDetails={onUpdateMetricDetails}
|
||||
onMetricUpdate={onUpdateMetricDetails}
|
||||
/>
|
||||
<Divider className="self-center vertical-divider" type="vertical" />
|
||||
|
||||
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
const UnitOfMeasurementInfoItem: FC<UnitOfMeasurementInfoItemProps> = ({
|
||||
label,
|
||||
hasPermission,
|
||||
metricDetails,
|
||||
onMetricUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [customUnits, setCustomUnits] = useState<string[]>([]);
|
||||
|
||||
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 = (
|
||||
<List
|
||||
className="unit-of-measurement-header"
|
||||
dataSource={allOptions}
|
||||
itemLayout="vertical"
|
||||
renderItem={(item) => {
|
||||
const isActive = currentValue === item.value;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={classNames('selectable-list-item', 'cursor-pointer', {
|
||||
active: isActive,
|
||||
})}
|
||||
extra={
|
||||
isActive && (
|
||||
<Tooltip
|
||||
title={t('label.remove-entity', {
|
||||
entity: label,
|
||||
})}>
|
||||
<Icon
|
||||
className="align-middle"
|
||||
component={IconRemoveColored}
|
||||
data-testid={`remove-${modiFiedLabel}-button`}
|
||||
style={{ fontSize: '16px' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(undefined);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(item.value);
|
||||
}}>
|
||||
<Typography.Text>{item.label}</Typography.Text>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space
|
||||
className="d-flex metric-header-info-container align-start"
|
||||
data-testid={modiFiedLabel}>
|
||||
<div className="d-flex extra-info-container align-start ">
|
||||
<Typography.Text
|
||||
className="whitespace-nowrap text-sm d-flex flex-col gap-2"
|
||||
data-testid={modiFiedLabel}>
|
||||
<div className="d-flex items-center gap-1">
|
||||
<span className="extra-info-label-heading">{label}</span>
|
||||
{hasPermission && !metricDetails.deleted && (
|
||||
<Popover
|
||||
destroyTooltipOnHide
|
||||
content={list}
|
||||
open={popupVisible}
|
||||
overlayClassName="metric-header-info-popover"
|
||||
placement="bottomRight"
|
||||
showArrow={false}
|
||||
trigger="click"
|
||||
onOpenChange={setPopupVisible}>
|
||||
<Tooltip
|
||||
title={t('label.edit-entity', {
|
||||
entity: label,
|
||||
})}>
|
||||
<Button
|
||||
className="flex-center edit-metrics p-0"
|
||||
data-testid={`edit-${modiFiedLabel}-button`}
|
||||
icon={<EditIcon color={DE_ACTIVE_COLOR} width="12px" />}
|
||||
loading={isUpdating}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames('font-medium extra-info-value')}>
|
||||
{currentValue ?? NO_DATA_PLACEHOLDER}
|
||||
</div>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnitOfMeasurementInfoItem;
|
||||
@ -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;
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
'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(<CustomUnitSelect />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('unit-of-measurement-select')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom data-testid', async () => {
|
||||
render(<CustomUnitSelect dataTestId="custom-unit-select" />);
|
||||
|
||||
expect(screen.getByTestId('custom-unit-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with placeholder text', async () => {
|
||||
render(<CustomUnitSelect placeholder="Select unit" />);
|
||||
|
||||
expect(screen.getByText('Select unit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', async () => {
|
||||
render(<CustomUnitSelect disabled />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
expect(select).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Integration', () => {
|
||||
it('should fetch custom units on mount', async () => {
|
||||
render(<CustomUnitSelect />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetCustomUnits).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API error gracefully', async () => {
|
||||
const error = new Error('API Error');
|
||||
mockGetCustomUnits.mockRejectedValue(error);
|
||||
|
||||
render(<CustomUnitSelect />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowErrorToast).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty API response', async () => {
|
||||
mockGetCustomUnits.mockResolvedValue([]);
|
||||
|
||||
render(<CustomUnitSelect />);
|
||||
|
||||
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(<CustomUnitSelect />);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Percentage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Options Display', () => {
|
||||
it('should display standard unit options', async () => {
|
||||
render(<CustomUnitSelect />);
|
||||
|
||||
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(<CustomUnitSelect />);
|
||||
|
||||
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(<CustomUnitSelect />);
|
||||
|
||||
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(<CustomUnitSelect onChange={mockOnChange} />);
|
||||
|
||||
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(<CustomUnitSelect onChange={mockOnChange} />);
|
||||
|
||||
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(<CustomUnitSelect value={UnitOfMeasurement.Percentage} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Percentage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display selected custom unit', async () => {
|
||||
render(
|
||||
<CustomUnitSelect
|
||||
customValue="My Custom Unit"
|
||||
value={UnitOfMeasurement.Other}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Custom Unit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle onChange being undefined', async () => {
|
||||
render(<CustomUnitSelect />);
|
||||
|
||||
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(
|
||||
<CustomUnitSelect
|
||||
customValue="Initial Custom Unit"
|
||||
value={UnitOfMeasurement.Other}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<CustomUnitSelect
|
||||
customValue="Existing Unit"
|
||||
value={UnitOfMeasurement.Other}
|
||||
/>
|
||||
);
|
||||
|
||||
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(<CustomUnitSelect />);
|
||||
|
||||
// Initial render should call API
|
||||
await waitFor(() => {
|
||||
expect(mockGetCustomUnits).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rerender with same props should not call API again
|
||||
rerender(<CustomUnitSelect />);
|
||||
|
||||
// Should still only have been called once
|
||||
expect(mockGetCustomUnits).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle component updates efficiently', async () => {
|
||||
const { rerender } = render(
|
||||
<CustomUnitSelect value={UnitOfMeasurement.Count} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change value
|
||||
rerender(<CustomUnitSelect value={UnitOfMeasurement.Percentage} />);
|
||||
|
||||
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(<CustomUnitSelect placeholder="Choose measurement unit" />);
|
||||
|
||||
expect(screen.getByText('Choose measurement unit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support disabled state', () => {
|
||||
render(<CustomUnitSelect disabled />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should support search functionality', () => {
|
||||
render(<CustomUnitSelect showSearch />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('type', 'search');
|
||||
});
|
||||
|
||||
it('should disable search when showSearch is false', () => {
|
||||
render(<CustomUnitSelect showSearch={false} />);
|
||||
|
||||
const select = screen.getByTestId('unit-of-measurement-select');
|
||||
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use custom testid', () => {
|
||||
render(<CustomUnitSelect dataTestId="my-custom-select" />);
|
||||
|
||||
expect(screen.getByTestId('my-custom-select')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<CustomUnitSelectProps> = ({
|
||||
value,
|
||||
customValue,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
showSearch = true,
|
||||
dataTestId = 'unit-of-measurement-select',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [customUnits, setCustomUnits] = useState<string[]>([]);
|
||||
const [userAddedUnits, setUserAddedUnits] = useState<string[]>([]);
|
||||
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<HTMLInputElement>) => {
|
||||
setNewCustomUnit(e.target.value);
|
||||
};
|
||||
|
||||
// Determine display value
|
||||
const displayValue = value === UnitOfMeasurement.Other ? customValue : value;
|
||||
|
||||
return (
|
||||
<Select
|
||||
data-testid={dataTestId}
|
||||
disabled={disabled}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider className="m-y-sm m-x-0" />
|
||||
<div className="p-x-sm gap-2 d-flex items-center">
|
||||
<Input
|
||||
placeholder={t('label.enter-custom-unit-of-measurement')}
|
||||
value={newCustomUnit}
|
||||
onChange={handleNameChange}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
handleAddCustomUnit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="text"
|
||||
onClick={handleAddCustomUnit}>
|
||||
{t('label.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
loading={loading}
|
||||
options={allOptions}
|
||||
placeholder={placeholder}
|
||||
showSearch={showSearch}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUnitSelect;
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}} の値を入力",
|
||||
|
||||
@ -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}} 값 입력",
|
||||
|
||||
@ -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}} मूल्य प्रविष्ट करा",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}} را وارد کنید",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}}的值",
|
||||
|
||||
@ -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}} 值",
|
||||
|
||||
@ -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<CreateMetric, 'metricExpression'> & {
|
||||
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)}
|
||||
<Form.Item
|
||||
label={t('label.unit-of-measurement')}
|
||||
name="unitOfMeasurement">
|
||||
<CustomUnitSelect
|
||||
customValue={form.getFieldValue(
|
||||
'customUnitOfMeasurement'
|
||||
)}
|
||||
dataTestId="unitOfMeasurement"
|
||||
placeholder={t('label.select-field', {
|
||||
field: t('label.unit-of-measurement'),
|
||||
})}
|
||||
onChange={handleUnitOfMeasurementChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item hidden name="customUnitOfMeasurement">
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
data-testid="expression-code-container"
|
||||
label={t('label.code')}
|
||||
|
||||
@ -85,13 +85,22 @@ const MetricDetailsPage = () => {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -121,3 +121,9 @@ export const createMetric = async (data: CreateMetric) => {
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCustomUnitsOfMeasurement = async () => {
|
||||
const response = await APIClient.get<string[]>('/metrics/customUnits');
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@ -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) && (
|
||||
<VersionExtraInfoLabel
|
||||
label={t('label.unit-of-measurement')}
|
||||
value={unitOfMeasurement}
|
||||
value={displayUnitOfMeasurement}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(granularity) && (
|
||||
|
||||
@ -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' },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user