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] <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:
Ram Narayan Balaji 2025-09-11 15:14:38 +05:30 committed by GitHub
parent e229689bb7
commit 9fd34c8f89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1353 additions and 94 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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> {

View File

@ -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()) {

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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);

View File

@ -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"

View File

@ -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"

View File

@ -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;
});
});
});

View File

@ -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 })

View File

@ -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;

View File

@ -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" />

View File

@ -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;

View File

@ -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;
}

View File

@ -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();
});
});
});

View File

@ -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;

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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}}",

View File

@ -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}}",

View File

@ -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}}",

View File

@ -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}}",

View File

@ -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}} の値を入力",

View File

@ -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}} 값 입력",

View File

@ -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}} मूल्य प्रविष्ट करा",

View File

@ -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",

View File

@ -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}} را وارد کنید",

View File

@ -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}}",

View File

@ -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}}",

View File

@ -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}}",

View File

@ -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}}",

View File

@ -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",

View File

@ -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}}的值",

View File

@ -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}} 值",

View File

@ -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')}

View File

@ -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);
}

View File

@ -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;
};

View File

@ -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) && (

View File

@ -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' },