Fix # 16475 : Support dashboards field in chart entity (#16646)

* Fix # 16475 : Add "all" alias for chart_search_index

* Backend : support "dashboards" field for Chart entity

* Backend : support "dashboards" field for Chart entity

* support to show charts in suggestions and glossary asset and redirect their dashboard page

* localization keys

* added unit test for component

* minor fix

* Add backend tests

* added playwright test for chart in glossary

* minor change

---------

Co-authored-by: Ashish Gupta <ashish@getcollate.io>
This commit is contained in:
sonika-shah 2024-07-01 20:27:34 +05:30 committed by GitHub
parent f766ba872d
commit b16460fba2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1252 additions and 16 deletions

View File

@ -13,6 +13,11 @@
package org.openmetadata.service.jdbi3;
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.sqlobject.transaction.Transaction;
@ -21,21 +26,27 @@ import org.openmetadata.schema.entity.data.Chart;
import org.openmetadata.schema.entity.services.DashboardService;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.Relationship;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.charts.ChartResource;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.EntityUtil.Fields;
import org.openmetadata.service.util.FullyQualifiedName;
@Slf4j
public class ChartRepository extends EntityRepository<Chart> {
private static final String CHART_UPDATE_FIELDS = "dashboards";
private static final String CHART_PATCH_FIELDS = "dashboards";
public ChartRepository() {
super(
ChartResource.COLLECTION_PATH,
Entity.CHART,
Chart.class,
Entity.getCollectionDAO().chartDAO(),
"",
"");
CHART_PATCH_FIELDS,
CHART_UPDATE_FIELDS);
supportsSearch = true;
}
@ -50,26 +61,35 @@ public class ChartRepository extends EntityRepository<Chart> {
DashboardService dashboardService = Entity.getEntity(chart.getService(), "", Include.ALL);
chart.setService(dashboardService.getEntityReference());
chart.setServiceType(dashboardService.getServiceType());
chart.setDashboards(EntityUtil.getEntityReferences(chart.getDashboards(), Include.NON_DELETED));
}
@Override
public void storeEntity(Chart chart, boolean update) {
// Relationships and fields such as tags are not stored as part of json
EntityReference service = chart.getService();
chart.withService(null);
List<EntityReference> dashboards = chart.getDashboards();
chart.withService(null).withDashboards(null);
store(chart, update);
chart.withService(service);
chart.withService(service).withDashboards(dashboards);
}
@Override
@SneakyThrows
public void storeRelationships(Chart chart) {
addServiceRelationship(chart, chart.getService());
// Add relationship from dashboard to chart
for (EntityReference dashboard : listOrEmpty(chart.getDashboards())) {
addRelationship(
dashboard.getId(), chart.getId(), Entity.DASHBOARD, Entity.CHART, Relationship.HAS);
}
}
@Override
public void setFields(Chart chart, Fields fields) {
chart.withService(getContainer(chart.getId()));
chart.setDashboards(
fields.contains("dashboards") ? getRelatedEntities(chart, Entity.DASHBOARD) : null);
}
@Override
@ -94,6 +114,12 @@ public class ChartRepository extends EntityRepository<Chart> {
return Entity.getEntity(entity.getService(), fields, Include.ALL);
}
private List<EntityReference> getRelatedEntities(Chart chart, String entityType) {
return chart == null
? Collections.emptyList()
: findFrom(chart.getId(), Entity.CHART, Relationship.HAS, entityType);
}
public class ChartUpdater extends ColumnEntityUpdater {
public ChartUpdater(Chart chart, Chart updated, Operation operation) {
super(chart, updated, operation);
@ -105,6 +131,32 @@ public class ChartRepository extends EntityRepository<Chart> {
recordChange("chartType", original.getChartType(), updated.getChartType());
recordChange("sourceUrl", original.getSourceUrl(), updated.getSourceUrl());
recordChange("sourceHash", original.getSourceHash(), updated.getSourceHash());
update(
Entity.DASHBOARD,
"dashboards",
listOrEmpty(updated.getDashboards()),
listOrEmpty(original.getDashboards()));
}
private void update(
String entityType,
String field,
List<EntityReference> updEntities,
List<EntityReference> oriEntities) {
// Remove all entity type associated with this dashboard
deleteTo(updated.getId(), Entity.CHART, Relationship.HAS, entityType);
// Add relationship from dashboard to chart type
for (EntityReference entity : updEntities) {
addRelationship(
entity.getId(), updated.getId(), entityType, Entity.CHART, Relationship.HAS);
}
List<EntityReference> added = new ArrayList<>();
List<EntityReference> deleted = new ArrayList<>();
recordListChange(
field, oriEntities, updEntities, added, deleted, EntityUtil.entityReferenceMatch);
}
}
}

View File

@ -74,7 +74,7 @@ import org.openmetadata.service.util.ResultList;
@Collection(name = "charts")
public class ChartResource extends EntityResource<Chart, ChartRepository> {
public static final String COLLECTION_PATH = "v1/charts/";
static final String FIELDS = "owner,followers,tags,domain,dataProducts,sourceHash";
static final String FIELDS = "owner,followers,tags,domain,dataProducts,sourceHash,dashboards";
@Override
public Chart addHref(UriInfo uriInfo, Chart chart) {
@ -530,6 +530,7 @@ public class ChartResource extends EntityResource<Chart, ChartRepository> {
.withService(EntityUtil.getEntityReference(Entity.DASHBOARD_SERVICE, create.getService()))
.withChartType(create.getChartType())
.withSourceUrl(create.getSourceUrl())
.withSourceHash(create.getSourceHash());
.withSourceHash(create.getSourceHash())
.withDashboards(getEntityReferences(Entity.DASHBOARD, create.getDashboards()));
}
}

View File

@ -342,6 +342,54 @@
},
"descriptionStatus": {
"type": "keyword"
},
"dashboards": {
"properties": {
"id": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 36
}
}
},
"type": {
"type": "text"
},
"name": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"displayName": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"normalizer": "lowercase_normalizer",
"ignore_above": 256
}
}
},
"fullyQualifiedName": {
"type": "text"
},
"description": {
"type": "text",
"analyzer": "om_analyzer"
},
"deleted": {
"type": "text"
},
"href": {
"type": "text"
}
}
}
}
}

View File

@ -31,7 +31,7 @@
"indexName": "chart_search_index",
"alias": "chart",
"indexMappingFile": "/elasticsearch/%s/chart_index_mapping.json",
"parentAliases": ["dashboard", "dashboardService", "dataAsset"],
"parentAliases": ["dashboard", "dashboardService", "all", "dataAsset"],
"childAliases": []
},
"dashboard": {

View File

@ -349,6 +349,54 @@
},
"descriptionStatus": {
"type": "keyword"
},
"dashboards": {
"properties": {
"id": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 36
}
}
},
"type": {
"type": "text"
},
"name": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"displayName": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"normalizer": "lowercase_normalizer",
"ignore_above": 256
}
}
},
"fullyQualifiedName": {
"type": "text"
},
"description": {
"type": "text",
"analyzer": "om_analyzer"
},
"deleted": {
"type": "text"
},
"href": {
"type": "text"
}
}
}
}
}

View File

@ -332,6 +332,54 @@
},
"descriptionStatus": {
"type": "keyword"
},
"dashboards": {
"properties": {
"id": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 36
}
}
},
"type": {
"type": "text"
},
"name": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"displayName": {
"type": "keyword",
"fields": {
"keyword": {
"type": "keyword",
"normalizer": "lowercase_normalizer",
"ignore_above": 256
}
}
},
"fullyQualifiedName": {
"type": "text"
},
"description": {
"type": "text",
"analyzer": "om_analyzer"
},
"deleted": {
"type": "text"
},
"href": {
"type": "text"
}
}
}
}
}

View File

@ -333,6 +333,7 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
public static EntityReference METABASE_REFERENCE;
public static EntityReference LOOKER_REFERENCE;
public static List<String> CHART_REFERENCES;
public static List<String> DASHBOARD_REFERENCES;
public static Database DATABASE;
public static DatabaseSchema DATABASE_SCHEMA;

View File

@ -15,10 +15,12 @@ package org.openmetadata.service.resources.charts;
import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
import static javax.ws.rs.core.Response.Status.OK;
import static org.junit.Assert.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.openmetadata.service.security.SecurityUtil.authHeaders;
import static org.openmetadata.service.util.EntityUtil.fieldAdded;
import static org.openmetadata.service.util.EntityUtil.fieldDeleted;
import static org.openmetadata.service.util.EntityUtil.fieldUpdated;
import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS;
import static org.openmetadata.service.util.TestUtils.UpdateType.CHANGE_CONSOLIDATED;
@ -29,6 +31,7 @@ import static org.openmetadata.service.util.TestUtils.assertResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.HttpResponseException;
@ -37,8 +40,10 @@ import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openmetadata.schema.api.data.CreateChart;
import org.openmetadata.schema.api.data.CreateDashboard;
import org.openmetadata.schema.api.services.CreateDashboardService;
import org.openmetadata.schema.entity.data.Chart;
import org.openmetadata.schema.entity.data.Dashboard;
import org.openmetadata.schema.entity.services.DashboardService;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.ChartType;
@ -46,6 +51,7 @@ import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.EntityResourceTest;
import org.openmetadata.service.resources.charts.ChartResource.ChartList;
import org.openmetadata.service.resources.dashboards.DashboardResourceTest;
import org.openmetadata.service.resources.services.DashboardServiceResourceTest;
import org.openmetadata.service.util.JsonUtils;
import org.openmetadata.service.util.ResultList;
@ -279,6 +285,99 @@ public class ChartResourceTest extends EntityResourceTest<Chart, CreateChart> {
chart, originalJson, ADMIN_AUTH_HEADERS, CHANGE_CONSOLIDATED, change);
}
@Test
public void testChartWithDashboards() throws IOException {
DashboardResourceTest dashboardResourceTest = new DashboardResourceTest();
// Create a new CreateChart request with a populated "dashboards" field
CreateChart request =
createRequest("chartWithDashboards")
.withService(METABASE_REFERENCE.getName())
.withDashboards(DASHBOARD_REFERENCES);
Chart chart = createAndCheckEntity(request, ADMIN_AUTH_HEADERS);
// Validate that the created Chart entity has the expected "dashboards" field
assertNotNull(chart.getDashboards());
assertEquals(3, chart.getDashboards().size());
assertEquals("dashboard0", chart.getDashboards().get(0).getName());
assertEquals("dashboard1", chart.getDashboards().get(1).getName());
assertEquals("dashboard2", chart.getDashboards().get(2).getName());
// Check that each dashboard contains the newly created chart in their charts field
for (EntityReference dashboardRef : chart.getDashboards()) {
Dashboard dashboard =
dashboardResourceTest.getEntity(dashboardRef.getId(), "charts", ADMIN_AUTH_HEADERS);
assertNotNull(dashboard.getCharts());
assertTrue(
dashboard.getCharts().stream()
.map(EntityReference::getId)
.anyMatch(chart.getId()::equals));
}
// Create a new Dashboard entity
CreateDashboard createDashboardRequest =
new CreateDashboard().withName("dashboard3").withService(METABASE_REFERENCE.getName());
Dashboard dashboard3 =
dashboardResourceTest.createAndCheckEntity(createDashboardRequest, ADMIN_AUTH_HEADERS);
// Update the "dashboards" field of the Chart entity with PATCH request with newly created
// dashboard3
String originalJson = JsonUtils.pojoToJson(chart);
ChangeDescription change = getChangeDescription(chart, MINOR_UPDATE);
fieldDeleted(change, "dashboards", chart.getDashboards());
fieldAdded(change, "dashboards", List.of(dashboard3.getEntityReference()));
chart.withDashboards(List.of(dashboard3.getEntityReference()));
chart = patchEntityAndCheck(chart, originalJson, ADMIN_AUTH_HEADERS, MINOR_UPDATE, change);
// Retrieve the Chart entity and validate that the retrieved entity has the updated "dashboards"
// field
chart = getEntity(chart.getId(), "dashboards", ADMIN_AUTH_HEADERS);
assertNotNull(chart.getDashboards());
assertEquals(1, chart.getDashboards().size());
assertEquals("dashboard3", chart.getDashboards().get(0).getName());
// Verify dashboard3 contains the respective chart
dashboard3 = dashboardResourceTest.getEntity(dashboard3.getId(), "charts", ADMIN_AUTH_HEADERS);
assertTrue(
dashboard3.getCharts().stream()
.map(EntityReference::getId)
.anyMatch(chart.getId()::equals));
// Create a new Dashboard entity
createDashboardRequest =
new CreateDashboard().withName("dashboard4").withService(METABASE_REFERENCE.getName());
Dashboard dashboard4 =
dashboardResourceTest.createAndCheckEntity(createDashboardRequest, ADMIN_AUTH_HEADERS);
// Update the "dashboards" field of the Chart entity with PUT request with newly created
// dashboard4
change = getChangeDescription(chart, MINOR_UPDATE);
fieldDeleted(change, "dashboards", chart.getDashboards());
fieldAdded(change, "dashboards", List.of(dashboard4.getEntityReference()));
chart.withDashboards(List.of(dashboard4.getEntityReference()));
updateAndCheckEntity(
request.withDashboards(List.of(dashboard4.getEntityReference().getFullyQualifiedName())),
OK,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change);
// Verify dashboard4 contains the respective chart
dashboard4 = dashboardResourceTest.getEntity(dashboard4.getId(), "charts", ADMIN_AUTH_HEADERS);
assertTrue(
dashboard4.getCharts().stream()
.map(EntityReference::getId)
.anyMatch(chart.getId()::equals));
// Delete the chart
deleteEntity(chart.getId(), ADMIN_AUTH_HEADERS);
// Check that dashboard4 does not contain the deleted chart in their charts field
dashboard4 = dashboardResourceTest.getEntity(dashboard4.getId(), "charts", ADMIN_AUTH_HEADERS);
assertTrue(
dashboard4.getCharts().stream()
.map(EntityReference::getId)
.anyMatch(chart.getId()::equals));
}
@Override
public void compareEntities(Chart expected, Chart patched, Map<String, String> authHeaders) {
assertReference(expected.getService(), patched.getService());
@ -295,6 +394,8 @@ public class ChartResourceTest extends EntityResourceTest<Chart, CreateChart> {
ChartType expectedChartType = ChartType.fromValue(expected.toString());
ChartType actualChartType = ChartType.fromValue(actual.toString());
assertEquals(expectedChartType, actualChartType);
} else if (fieldName.contains("dashboards")) {
assertEntityReferencesFieldChange(expected, actual);
} else {
assertCommonFieldChange(fieldName, expected, actual);
}

View File

@ -40,9 +40,11 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.openmetadata.common.utils.CommonUtil;
import org.openmetadata.schema.api.data.CreateChart;
import org.openmetadata.schema.api.data.CreateDashboard;
import org.openmetadata.schema.api.data.CreateDashboardDataModel.DashboardServiceType;
import org.openmetadata.schema.api.services.CreateDashboardService;
import org.openmetadata.schema.entity.data.Chart;
import org.openmetadata.schema.entity.data.Dashboard;
import org.openmetadata.schema.entity.services.DashboardService;
import org.openmetadata.schema.entity.services.connections.TestConnectionResult;
import org.openmetadata.schema.entity.services.connections.TestConnectionResultStatus;
@ -52,6 +54,7 @@ import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.DashboardConnection;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.charts.ChartResourceTest;
import org.openmetadata.service.resources.dashboards.DashboardResourceTest;
import org.openmetadata.service.resources.services.dashboard.DashboardServiceResource.DashboardServiceList;
import org.openmetadata.service.secrets.masker.PasswordEntityMasker;
import org.openmetadata.service.util.JsonUtils;
@ -292,9 +295,10 @@ public class DashboardServiceResourceTest
public void setupDashboardServices(TestInfo test)
throws HttpResponseException, URISyntaxException {
DashboardServiceResourceTest dashboardResourceTest = new DashboardServiceResourceTest();
DashboardServiceResourceTest dashboardServiceResourceTest = new DashboardServiceResourceTest();
DashboardResourceTest dashboardResourceTest = new DashboardResourceTest();
CreateDashboardService createDashboardService =
dashboardResourceTest
dashboardServiceResourceTest
.createRequest("superset", "", "", null)
.withServiceType(DashboardServiceType.Metabase);
DashboardConnection dashboardConnection =
@ -312,7 +316,7 @@ public class DashboardServiceResourceTest
METABASE_REFERENCE = dashboardService.getEntityReference();
CreateDashboardService lookerDashboardService =
dashboardResourceTest
dashboardServiceResourceTest
.createRequest("looker", "", "", null)
.withServiceType(DashboardServiceType.Looker);
DashboardConnection lookerConnection =
@ -334,5 +338,16 @@ public class DashboardServiceResourceTest
Chart chart = chartResourceTest.createEntity(createChart, ADMIN_AUTH_HEADERS);
CHART_REFERENCES.add(chart.getFullyQualifiedName());
}
DASHBOARD_REFERENCES = new ArrayList<>();
for (int i = 0; i < 3; i++) {
CreateDashboard createDashboard1 =
dashboardResourceTest
.createRequest("dashboard" + i, "", "", null)
.withService(METABASE_REFERENCE.getName());
createDashboard1.withDomain(DOMAIN.getFullyQualifiedName());
Dashboard dashboard1 =
new DashboardResourceTest().createEntity(createDashboard1, ADMIN_AUTH_HEADERS);
DASHBOARD_REFERENCES.add(dashboard1.getFullyQualifiedName());
}
}
}

View File

@ -62,6 +62,14 @@
"type": "string",
"minLength": 1,
"maxLength": 32
},
"dashboards": {
"description": "List of fully qualified name of dashboards containing this Chart.",
"type": "array",
"items": {
"$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName"
},
"default": null
}
},
"required": ["name", "service"],

View File

@ -160,6 +160,11 @@
"type": "string",
"minLength": 1,
"maxLength": 32
},
"dashboards": {
"description": "All the dashboards containing this chart.",
"$ref": "../../type/entityReferenceList.json",
"default": null
}
},
"required": ["id", "name", "service"],

View File

@ -10,8 +10,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from '@playwright/test';
import test, { expect } from '@playwright/test';
import { SidebarItem } from '../../constant/sidebar';
import { DashboardClass } from '../../support/entity/DashboardClass';
import { Glossary } from '../../support/glossary/Glossary';
import { GlossaryTerm } from '../../support/glossary/GlossaryTerm';
import { TeamClass } from '../../support/team/TeamClass';
@ -25,6 +26,7 @@ import {
approveGlossaryTermTask,
createGlossary,
createGlossaryTerms,
goToAssetsTab,
selectActiveGlossary,
validateGlossaryTerm,
verifyGlossaryDetails,
@ -136,6 +138,247 @@ test.describe('Glossary tests', () => {
await afterAction();
});
test('Add and Remove Assets', async ({ browser }) => {
const { page, afterAction, apiContext } = await performAdminLogin(browser);
const glossary1 = new Glossary();
const glossaryTerm1 = new GlossaryTerm(glossary1);
const glossaryTerm2 = new GlossaryTerm(glossary1);
glossary1.data.owner = { name: 'admin', type: 'user' };
glossary1.data.mutuallyExclusive = true;
glossary1.data.terms = [glossaryTerm1, glossaryTerm2];
const glossary2 = new Glossary();
const glossaryTerm3 = new GlossaryTerm(glossary2);
const glossaryTerm4 = new GlossaryTerm(glossary2);
glossary2.data.owner = { name: 'admin', type: 'user' };
glossary2.data.terms = [glossaryTerm3, glossaryTerm4];
await glossary1.create(apiContext);
await glossary2.create(apiContext);
await glossaryTerm1.create(apiContext);
await glossaryTerm2.create(apiContext);
await glossaryTerm3.create(apiContext);
await glossaryTerm4.create(apiContext);
const dashboardEntity = new DashboardClass();
await dashboardEntity.create(apiContext);
try {
await test.step('Add asset to glossary term using entity', async () => {
await sidebarClick(page, SidebarItem.GLOSSARY);
await selectActiveGlossary(page, glossary2.data.displayName);
await goToAssetsTab(page, glossaryTerm3.data.displayName);
await page.waitForSelector(
'text=Adding a new Asset is easy, just give it a spin!'
);
await dashboardEntity.visitEntityPage(page);
// Dashboard Entity Right Panel
await page.click(
'[data-testid="entity-right-panel"] [data-testid="glossary-container"] [data-testid="add-tag"]'
);
// Select 1st term
await page.click('[data-testid="tag-selector"] #tagsForm_tags');
const glossaryRequest = page.waitForResponse(
`/api/v1/search/query?q=*&index=glossary_term_search_index&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true`
);
await page.type(
'[data-testid="tag-selector"] #tagsForm_tags',
glossaryTerm1.data.name
);
await glossaryRequest;
await page.getByText(glossaryTerm1.data.displayName).click();
await page.waitForSelector(
'[data-testid="tag-selector"]:has-text("' +
glossaryTerm1.data.displayName +
'")'
);
// Select 2nd term
await page.click('[data-testid="tag-selector"] #tagsForm_tags');
const glossaryRequest2 = page.waitForResponse(
`/api/v1/search/query?q=*&index=glossary_term_search_index&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true`
);
await page.type(
'[data-testid="tag-selector"] #tagsForm_tags',
glossaryTerm2.data.name
);
await glossaryRequest2;
await page.getByText(glossaryTerm2.data.displayName).click();
await page.waitForSelector(
'[data-testid="tag-selector"]:has-text("' +
glossaryTerm2.data.displayName +
'")'
);
const patchRequest = page.waitForResponse(`/api/v1/dashboards/*`);
await expect(page.getByTestId('saveAssociatedTag')).toBeEnabled();
await page.getByTestId('saveAssociatedTag').click();
await patchRequest;
await expect(page.getByRole('alert').first()).toContainText(
"mutually exclusive and can't be assigned together"
);
await page.getByLabel('close').first().click();
// Add non mutually exclusive tags
await page.click(
'[data-testid="entity-right-panel"] [data-testid="glossary-container"] [data-testid="add-tag"]'
);
// Select 1st term
await page.click('[data-testid="tag-selector"] #tagsForm_tags');
const glossaryRequest3 = page.waitForResponse(
`/api/v1/search/query?q=*&index=glossary_term_search_index&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true`
);
await page.type(
'[data-testid="tag-selector"] #tagsForm_tags',
glossaryTerm3.data.name
);
await glossaryRequest3;
await page.getByText(glossaryTerm3.data.displayName).click();
await page.waitForSelector(
'[data-testid="tag-selector"]:has-text("' +
glossaryTerm3.data.displayName +
'")'
);
// Select 2nd term
await page.click('[data-testid="tag-selector"] #tagsForm_tags');
const glossaryRequest4 = page.waitForResponse(
`/api/v1/search/query?q=*&index=glossary_term_search_index&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true`
);
await page.type(
'[data-testid="tag-selector"] #tagsForm_tags',
glossaryTerm4.data.name
);
await glossaryRequest4;
await page.getByText(glossaryTerm4.data.displayName).click();
await page.waitForSelector(
'[data-testid="tag-selector"]:has-text("' +
glossaryTerm4.data.displayName +
'")'
);
const patchRequest2 = page.waitForResponse(`/api/v1/dashboards/*`);
await expect(page.getByTestId('saveAssociatedTag')).toBeEnabled();
await page.getByTestId('saveAssociatedTag').click();
await patchRequest2;
// Check if the terms are present
const glossaryContainer = await page.locator(
'[data-testid="entity-right-panel"] [data-testid="glossary-container"]'
);
const glossaryContainerText = await glossaryContainer.innerText();
expect(glossaryContainerText).toContain(glossaryTerm3.data.displayName);
expect(glossaryContainerText).toContain(glossaryTerm4.data.displayName);
// Check if the icons are present
const icons = await page.locator(
'[data-testid="entity-right-panel"] [data-testid="glossary-container"] [data-testid="glossary-icon"]'
);
expect(await icons.count()).toBe(2);
// Add Glossary to Dashboard Charts
await page.click(
'[data-testid="glossary-tags-0"] > [data-testid="tags-wrapper"] > [data-testid="glossary-container"] > [data-testid="entity-tags"] [data-testid="add-tag"]'
);
await page.click('[data-testid="tag-selector"]');
const glossaryRequest5 = page.waitForResponse(
`/api/v1/search/query?q=*&index=glossary_term_search_index&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true`
);
await page.type(
'[data-testid="tag-selector"] #tagsForm_tags',
glossaryTerm3.data.name
);
await glossaryRequest5;
await page
.getByRole('tree')
.getByTestId(`tag-${glossaryTerm3.data.fullyQualifiedName}`)
.click();
await page.waitForSelector(
'[data-testid="tag-selector"]:has-text("' +
glossaryTerm3.data.displayName +
'")'
);
const patchRequest3 = page.waitForResponse(`/api/v1/charts/*`);
await expect(page.getByTestId('saveAssociatedTag')).toBeEnabled();
await page.getByTestId('saveAssociatedTag').click();
await patchRequest3;
// Check if the term is present
const tagSelectorText = await page
.locator(
'[data-testid="glossary-tags-0"] [data-testid="glossary-container"] [data-testid="tags"]'
)
.innerText();
expect(tagSelectorText).toContain(glossaryTerm3.data.displayName);
// Check if the icon is visible
const icon = await page.locator(
'[data-testid="glossary-tags-0"] > [data-testid="tags-wrapper"] > [data-testid="glossary-container"] [data-testid="glossary-icon"]'
);
expect(await icon.isVisible()).toBe(true);
await sidebarClick(page, SidebarItem.GLOSSARY);
await selectActiveGlossary(page, glossary2.data.displayName);
await goToAssetsTab(page, glossaryTerm3.data.displayName, '2');
// Check if the selected asset are present
const assetContainer = await page.locator(
'[data-testid="table-container"] .assets-data-container'
);
const assetContainerText = await assetContainer.innerText();
expect(assetContainerText).toContain(dashboardEntity.entity.name);
expect(assetContainerText).toContain(dashboardEntity.charts.name);
});
} finally {
await glossary1.delete(apiContext);
await glossary2.delete(apiContext);
await glossaryTerm1.delete(apiContext);
await glossaryTerm2.delete(apiContext);
await glossaryTerm3.delete(apiContext);
await glossaryTerm4.delete(apiContext);
await dashboardEntity.delete(apiContext);
await afterAction();
}
});
test.afterAll(async ({ browser }) => {
const { afterAction, apiContext } = await performAdminLogin(browser);
await user1.delete(apiContext);

View File

@ -33,6 +33,11 @@ export class DashboardClass extends EntityClass {
},
},
};
charts = {
name: `pw-chart-${uuid()}`,
displayName: `PW Chart ${uuid()}`,
service: this.service.name,
};
entity = {
name: `pw-dashboard-${uuid()}`,
displayName: `pw-dashboard-${uuid()}`,
@ -41,6 +46,7 @@ export class DashboardClass extends EntityClass {
serviceResponseData: unknown;
entityResponseData: unknown;
chartsResponseData: unknown;
constructor(name?: string) {
super(EntityTypeEndpoint.Dashboard);
@ -55,16 +61,25 @@ export class DashboardClass extends EntityClass {
data: this.service,
}
);
const chartsResponse = await apiContext.post('/api/v1/charts', {
data: this.charts,
});
const entityResponse = await apiContext.post('/api/v1/dashboards', {
data: this.entity,
data: {
...this.entity,
charts: [`${this.service.name}.${this.charts.name}`],
},
});
this.serviceResponseData = await serviceResponse.json();
this.chartsResponseData = await chartsResponse.json();
this.entityResponseData = await entityResponse.json();
return {
service: serviceResponse.body,
entity: entityResponse.body,
charts: chartsResponse.body,
};
}
@ -90,9 +105,16 @@ export class DashboardClass extends EntityClass {
)}?recursive=true&hardDelete=true`
);
const chartResponse = await apiContext.delete(
`/api/v1/charts/name/${encodeURIComponent(
this.chartsResponseData?.['fullyQualifiedName']
)}?recursive=true&hardDelete=true`
);
return {
service: serviceResponse.body,
entity: this.entityResponseData,
chart: chartResponse.body,
};
}
}

View File

@ -61,6 +61,35 @@ export const selectActiveGlossary = async (
}
};
export const selectActiveGlossaryTerm = async (
page: Page,
glossaryTermName: string
) => {
const glossaryTermResponse = page.waitForResponse(
'/api/v1/glossaryTerms/name/*?fields=relatedTerms%2Creviewers%2Ctags%2Cowner%2Cchildren%2Cvotes%2Cdomain%2Cextension'
);
await page.getByTestId(glossaryTermName).click();
await glossaryTermResponse;
expect(
page.locator('[data-testid="entity-header-display-name"]')
).toContainText(glossaryTermName);
};
export const goToAssetsTab = async (
page: Page,
displayName: string,
count = '0'
) => {
await selectActiveGlossaryTerm(page, displayName);
await page.getByTestId('assets').click();
await page.waitForSelector('.ant-tabs-tab-active:has-text("Assets")');
await expect(
page.getByTestId('assets').getByTestId('filter-count')
).toContainText(count);
};
export const addMultiOwner = async (data: {
page: Page;
ownerNames: string | string[];

View File

@ -18,6 +18,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PAGE_SIZE_BASE } from '../../constants/constants';
import {
ChartSource,
DashboardSource,
DataProductSource,
GlossarySource,
@ -98,6 +99,8 @@ const Suggestions = ({
DataProductSource[]
>([]);
const [chartSuggestions, setChartSuggestions] = useState<ChartSource[]>([]);
const isMounting = useRef(true);
const updateSuggestions = (options: Array<Option>) => {
@ -127,6 +130,8 @@ const Suggestions = ({
setDataProductSuggestions(
filterOptionsByIndex(options, SearchIndex.DATA_PRODUCT)
);
setChartSuggestions(filterOptionsByIndex(options, SearchIndex.CHART));
};
const getSuggestionsForIndex = (
@ -189,6 +194,10 @@ const Suggestions = ({
suggestions: dataProductSuggestions,
searchIndex: SearchIndex.DATA_PRODUCT,
},
{
suggestions: chartSuggestions,
searchIndex: SearchIndex.CHART,
},
...searchClassBase.getEntitiesSuggestions(options ?? []),
].map(({ suggestions, searchIndex }) =>
getSuggestionsForIndex(suggestions, searchIndex)

View File

@ -0,0 +1,92 @@
/*
* 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 { Col, Divider, Row, Typography } from 'antd';
import { get } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SummaryEntityType } from '../../../../enums/EntitySummary.enum';
import { ExplorePageTabs } from '../../../../enums/Explore.enum';
import { Chart } from '../../../../generated/entity/data/chart';
import {
getFormattedEntityData,
getSortedTagsWithHighlight,
} from '../../../../utils/EntitySummaryPanelUtils';
import {
DRAWER_NAVIGATION_OPTIONS,
getEntityOverview,
} from '../../../../utils/EntityUtils';
import SummaryTagsDescription from '../../../common/SummaryTagsDescription/SummaryTagsDescription.component';
import { SearchedDataProps } from '../../../SearchedData/SearchedData.interface';
import CommonEntitySummaryInfo from '../CommonEntitySummaryInfo/CommonEntitySummaryInfo';
import SummaryList from '../SummaryList/SummaryList.component';
import { BasicEntityInfo } from '../SummaryList/SummaryList.interface';
interface ChartsSummaryProps {
entityDetails: Chart;
highlights?: SearchedDataProps['data'][number]['highlight'];
}
const ChartSummary = ({ entityDetails, highlights }: ChartsSummaryProps) => {
const { t } = useTranslation();
const entityInfo = useMemo(
() => getEntityOverview(ExplorePageTabs.CHARTS, entityDetails),
[entityDetails]
);
const formattedDashboardData: BasicEntityInfo[] = useMemo(
() =>
getFormattedEntityData(
SummaryEntityType.DASHBOARD,
entityDetails.dashboards
),
[entityDetails.dashboards]
);
return (
<>
<Row className="m-md m-t-0" gutter={[0, 4]}>
<Col span={24}>
<CommonEntitySummaryInfo
componentType={DRAWER_NAVIGATION_OPTIONS.explore}
entityInfo={entityInfo}
/>
</Col>
</Row>
<Divider className="m-y-xs" />
<SummaryTagsDescription
entityDetail={entityDetails}
tags={getSortedTagsWithHighlight(
entityDetails.tags,
get(highlights, 'tag.name')
)}
/>
<Divider className="m-y-xs" />
<Row className="m-md" gutter={[0, 8]}>
<Col span={24}>
<Typography.Text
className="summary-panel-section-title"
data-testid="charts-header">
{t('label.dashboard-plural')}
</Typography.Text>
</Col>
<Col span={24}>
<SummaryList formattedEntityData={formattedDashboardData} />
</Col>
</Row>
</>
);
};
export default ChartSummary;

View File

@ -0,0 +1,57 @@
/*
* 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 { act, render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MOCK_CHART_DATA } from '../../../../mocks/Chart.mock';
import ChartSummary from './ChartSummary.component';
jest.mock('../SummaryList/SummaryList.component', () =>
jest.fn().mockImplementation(() => <p>SummaryList</p>)
);
jest.mock('../CommonEntitySummaryInfo/CommonEntitySummaryInfo', () =>
jest.fn().mockImplementation(() => <p>testCommonEntitySummaryInfo</p>)
);
jest.mock(
'../../../common/SummaryTagsDescription/SummaryTagsDescription.component',
() => jest.fn().mockImplementation(() => <p>SummaryTagsDescription</p>)
);
jest.mock('../../../../utils/EntityUtils', () => ({
getEntityOverview: jest.fn(),
DRAWER_NAVIGATION_OPTIONS: {
explore: 'explore',
},
}));
jest.mock('../../../../utils/EntitySummaryPanelUtils', () => ({
getSortedTagsWithHighlight: jest.fn(),
getFormattedEntityData: jest.fn(),
}));
describe('ChartSummary component tests', () => {
it('Component should render properly', async () => {
await act(async () => {
render(<ChartSummary entityDetails={MOCK_CHART_DATA} />, {
wrapper: MemoryRouter,
});
});
expect(screen.getByTestId('charts-header')).toBeInTheDocument();
expect(screen.getByText('SummaryList')).toBeInTheDocument();
expect(screen.getByText('SummaryTagsDescription')).toBeInTheDocument();
expect(screen.getByText('testCommonEntitySummaryInfo')).toBeInTheDocument();
});
});

View File

@ -24,6 +24,7 @@ import { ERROR_PLACEHOLDER_TYPE, SIZE } from '../../../enums/common.enum';
import { EntityType } from '../../../enums/entity.enum';
import { ExplorePageTabs } from '../../../enums/Explore.enum';
import { Tag } from '../../../generated/entity/classification/tag';
import { Chart } from '../../../generated/entity/data/chart';
import { Container } from '../../../generated/entity/data/container';
import { Dashboard } from '../../../generated/entity/data/dashboard';
import { DashboardDataModel } from '../../../generated/entity/data/dashboardDataModel';
@ -50,6 +51,7 @@ import searchClassBase from '../../../utils/SearchClassBase';
import { stringToHTML } from '../../../utils/StringsUtils';
import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
import Loader from '../../common/Loader/Loader';
import ChartSummary from './ChartSummary/ChartSummary.component';
import ContainerSummary from './ContainerSummary/ContainerSummary.component';
import DashboardSummary from './DashboardSummary/DashboardSummary.component';
import DatabaseSchemaSummary from './DatabaseSchemaSummary/DatabaseSchemaSummary.component';
@ -149,6 +151,14 @@ export default function EntitySummaryPanel({
/>
);
case EntityType.CHART:
return (
<ChartSummary
entityDetails={entity as Chart}
highlights={highlights}
/>
);
case EntityType.PIPELINE:
return (
<PipelineSummary

View File

@ -72,6 +72,14 @@ jest.mock('./MlModelSummary/MlModelSummary.component', () =>
))
);
jest.mock('./ChartSummary/ChartSummary.component', () =>
jest
.fn()
.mockImplementation(() => (
<div data-testid="ChartSummary">ChartSummary</div>
))
);
jest.mock('react-router-dom', () => ({
useParams: jest.fn().mockImplementation(() => ({ tab: 'table' })),
Link: jest.fn().mockImplementation(({ children }) => <>{children}</>),
@ -186,4 +194,24 @@ describe('EntitySummaryPanel component tests', () => {
expect(mlModelSummary).toBeInTheDocument();
});
it('ChartSummary should render for chart data', async () => {
await act(async () => {
render(
<EntitySummaryPanel
entityDetails={{
details: {
...mockMlModelEntityDetails,
entityType: EntityType.CHART,
},
}}
handleClosePanel={mockHandleClosePanel}
/>
);
});
const chartSummary = screen.getByTestId('ChartSummary');
expect(chartSummary).toBeInTheDocument();
});
});

View File

@ -91,6 +91,11 @@ export interface DataProductSource extends CommonSource {
data_product_name: string;
}
export interface ChartSource extends CommonSource {
chart_id: string;
chart_name: string;
}
export interface Option {
_index: string;
_id: string;
@ -105,7 +110,8 @@ export interface Option {
GlossarySource &
TagSource &
SearchIndexSource &
DataProductSource;
DataProductSource &
ChartSource;
}
export type SearchSuggestions =
@ -120,4 +126,5 @@ export type SearchSuggestions =
| SearchIndexSource[]
| StoredProcedureSearchSource[]
| DashboardDataModelSearchSource[]
| DataProductSource[];
| DataProductSource[]
| ChartSource[];

View File

@ -18,4 +18,5 @@ export enum SummaryEntityType {
MLFEATURE = 'mlFeature',
SCHEMAFIELD = 'schemaField',
FIELD = 'field',
DASHBOARD = 'dashboard',
}

View File

@ -24,6 +24,7 @@ export enum ExplorePageTabs {
PIPELINES = 'pipelines',
MLMODELS = 'mlmodels',
CONTAINERS = 'containers',
CHARTS = 'charts',
GLOSSARY = 'glossaries',
TAG = 'tags',
DATA_PRODUCT = 'dataProducts',

View File

@ -142,6 +142,7 @@
"chart": "Diagramm",
"chart-entity": "{{entity}} Diagramm",
"chart-plural": "Diagramme",
"chart-type": "Chart type",
"check-status": "Status überprüfen",
"children": "Kinder",
"children-lowercase": "kinder",

View File

@ -142,6 +142,7 @@
"chart": "Chart",
"chart-entity": "Chart {{entity}}",
"chart-plural": "Charts",
"chart-type": "Chart type",
"check-status": "Check status",
"children": "Children",
"children-lowercase": "children",

View File

@ -142,6 +142,7 @@
"chart": "Gráfico",
"chart-entity": "Gráfico {{entity}}",
"chart-plural": "Gráficos",
"chart-type": "Chart type",
"check-status": "Verificar estado",
"children": "Hijos",
"children-lowercase": "hijos",

View File

@ -142,6 +142,7 @@
"chart": "Graphique",
"chart-entity": "Graphique {{entity}}",
"chart-plural": "Graphiques",
"chart-type": "Chart type",
"check-status": "Vérifier le Statut",
"children": "Enfants",
"children-lowercase": "enfants",

View File

@ -142,6 +142,7 @@
"chart": "תרשים",
"chart-entity": "תרשים {{entity}}",
"chart-plural": "תרשימים",
"chart-type": "Chart type",
"check-status": "בדוק סטטוס",
"children": "ילדים",
"children-lowercase": "ילדים",

View File

@ -142,6 +142,7 @@
"chart": "チャート",
"chart-entity": "{{entity}}のチャート",
"chart-plural": "チャート",
"chart-type": "Chart type",
"check-status": "ステータスチェック",
"children": "Children",
"children-lowercase": "children",

View File

@ -142,6 +142,7 @@
"chart": "Chart",
"chart-entity": "Chart {{entity}}",
"chart-plural": "Charts",
"chart-type": "Chart type",
"check-status": "Status controleren",
"children": "Kinderen",
"children-lowercase": "kinderen",

View File

@ -142,6 +142,7 @@
"chart": "Gráfico",
"chart-entity": "Gráfico {{entity}}",
"chart-plural": "Gráficos",
"chart-type": "Chart type",
"check-status": "Verificar status",
"children": "Filhos",
"children-lowercase": "filhos",

View File

@ -142,6 +142,7 @@
"chart": "Диаграмма",
"chart-entity": "Диаграмма {{entity}}",
"chart-plural": "Диаграммы",
"chart-type": "Chart type",
"check-status": "Проверить статус",
"children": "Наследники",
"children-lowercase": "наследники",

View File

@ -142,6 +142,7 @@
"chart": "图表",
"chart-entity": "图表{{entity}}",
"chart-plural": "图表",
"chart-type": "Chart type",
"check-status": "检查状态",
"children": "子级",
"children-lowercase": "子级",

View File

@ -0,0 +1,102 @@
/*
* 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 {
Chart,
ChartType,
DashboardServiceType,
LabelType,
State,
TagSource,
} from '../generated/entity/data/chart';
export const MOCK_CHART_DATA: Chart = {
id: 'f4464d71-f900-4f8c-aca1-b7b95cc077e1',
name: '127',
displayName: 'Are you an ethnic minority in your city?',
fullyQualifiedName: 'sample_superset.127',
description: '',
version: 0.2,
updatedAt: 1718702267352,
updatedBy: 'admin',
chartType: ChartType.Other,
sourceUrl:
'http://localhost:8088/superset/explore/?form_data=%7B%22slice_id%22%3A%20127%7D',
followers: [],
tags: [
{
tagFQN: 'Glossary.Glossary=Term',
displayName: 'Glossary=Term',
name: 'Glossary=Term',
labelType: LabelType.Manual,
description: 'Glossary=Term',
style: {},
source: TagSource.Glossary,
state: State.Confirmed,
},
],
service: {
deleted: false,
displayName: 'sample_superset',
name: 'sample_superset',
id: 'f70b7a78-8327-4565-a069-6cac70fa99cf',
type: 'dashboardService',
fullyQualifiedName: 'sample_superset',
},
serviceType: DashboardServiceType.Superset,
deleted: false,
dataProducts: [],
votes: {
upVoters: [],
downVoters: [],
upVotes: 0,
downVotes: 0,
},
dashboards: [
{
deleted: false,
displayName: 'deck.gl Demo',
name: '10',
description: '',
id: '77a0ac8a-ca1a-4f21-9a37-406faa482008',
type: 'dashboard',
fullyQualifiedName: 'sample_superset.10',
},
{
deleted: false,
displayName: 'Misc Charts',
name: '12',
description: '',
id: '54e2f981-ba28-4393-b21b-f85a8b7e63e9',
type: 'dashboard',
fullyQualifiedName: 'sample_superset.12',
},
{
deleted: false,
displayName: 'Slack Dashboard',
name: '33',
description: '',
id: '35e7b4db-9daa-4711-b4f0-a273fc966050',
type: 'dashboard',
fullyQualifiedName: 'sample_superset.33',
},
{
deleted: false,
displayName: 'Video Game Sales',
name: '51',
description: '',
id: '135fdd19-471c-4edf-a226-066c54f2f889',
type: 'dashboard',
fullyQualifiedName: 'sample_superset.51',
},
],
};

View File

@ -26,11 +26,13 @@ import {
mockEntityDataWithNestingResponse,
mockEntityDataWithoutNesting,
mockEntityDataWithoutNestingResponse,
mockEntityReferenceDashboardData,
mockGetHighlightOfListItemResponse,
mockGetMapOfListHighlightsResponse,
mockGetSummaryListItemTypeResponse,
mockHighlights,
mockInvalidDataResponse,
mockLinkBasedSummaryTitleDashboardResponse,
mockLinkBasedSummaryTitleResponse,
mockListItemNameHighlight,
mockTagFQNsForHighlight,
@ -134,6 +136,14 @@ describe('EntitySummaryPanelUtils tests', () => {
expect(linkBasedTitle).toEqual(mockLinkBasedSummaryTitleResponse);
});
it('getTitle should return title as link without icon if type: dashboard present in listItem', () => {
const linkBasedTitle = getTitle(mockEntityReferenceDashboardData);
expect(linkBasedTitle).toEqual(
mockLinkBasedSummaryTitleDashboardResponse
);
});
});
describe('getMapOfListHighlights', () => {

View File

@ -24,6 +24,7 @@ import {
} from '../components/Explore/EntitySummaryPanel/SummaryList/SummaryList.interface';
import { ICON_DIMENSION, NO_DATA_PLACEHOLDER } from '../constants/constants';
import { SummaryListHighlightKeys } from '../constants/EntitySummaryPanelUtils.constant';
import { EntityType } from '../enums/entity.enum';
import { SummaryEntityType } from '../enums/EntitySummary.enum';
import { Chart } from '../generated/entity/data/chart';
import { TagLabel } from '../generated/entity/data/container';
@ -31,6 +32,8 @@ import { MlFeature } from '../generated/entity/data/mlmodel';
import { Task } from '../generated/entity/data/pipeline';
import { Column, TableConstraint } from '../generated/entity/data/table';
import { Field } from '../generated/entity/data/topic';
import { EntityReference } from '../generated/tests/testCase';
import entityUtilClassBase from './EntityUtilClassBase';
import { getEntityName } from './EntityUtils';
import { stringToHTML } from './StringsUtils';
@ -65,6 +68,23 @@ export const getTitle = (
: getEntityName(listItem) || NO_DATA_PLACEHOLDER;
const sourceUrl = (listItem as Chart | Task).sourceUrl;
if ((listItem as EntityReference).type === SummaryEntityType.DASHBOARD) {
return (
<Link
to={entityUtilClassBase.getEntityLink(
EntityType.DASHBOARD,
listItem.fullyQualifiedName ?? ''
)}>
<Text
className="entity-title text-link-color font-medium m-r-xss"
data-testid="entity-title"
ellipsis={{ tooltip: true }}>
{title}
</Text>
</Link>
);
}
return sourceUrl ? (
<Link target="_blank" to={{ pathname: sourceUrl }}>
<div className="d-flex items-center">

View File

@ -12,11 +12,14 @@
*/
import { getEntityDetailsPath } from '../constants/constants';
import { EntityTabs, EntityType } from '../enums/entity.enum';
import { ExplorePageTabs } from '../enums/Explore.enum';
import { TestSuite } from '../generated/tests/testCase';
import { MOCK_CHART_DATA } from '../mocks/Chart.mock';
import {
columnSorter,
getBreadcrumbForTestSuite,
getEntityLinkFromType,
getEntityOverview,
highlightEntityNameAndDescription,
} from './EntityUtils';
import {
@ -28,6 +31,7 @@ import {
jest.mock('../constants/constants', () => ({
getEntityDetailsPath: jest.fn(),
getServiceDetailsPath: jest.fn(),
}));
jest.mock('./RouterUtils', () => ({
@ -106,4 +110,25 @@ describe('EntityUtils unit tests', () => {
]);
});
});
describe('getEntityOverview', () => {
it('should call getChartOverview and get ChartData if ExplorePageTabs is charts', () => {
const result = JSON.stringify(
getEntityOverview(ExplorePageTabs.CHARTS, MOCK_CHART_DATA)
);
expect(result).toContain('label.owner');
expect(result).toContain('label.chart');
expect(result).toContain('label.url-uppercase');
expect(result).toContain('Are you an ethnic minority in your city?');
expect(result).toContain(
`http://localhost:8088/superset/explore/?form_data=%7B%22slice_id%22%3A%20127%7D`
);
expect(result).toContain('label.service');
expect(result).toContain('sample_superset');
expect(result).toContain('Other');
expect(result).toContain('label.service-type');
expect(result).toContain('Superset');
});
});
});

View File

@ -68,6 +68,7 @@ import { SearchIndex } from '../enums/search.enum';
import { ServiceCategory, ServiceCategoryPlural } from '../enums/service.enum';
import { PrimaryTableDataTypes } from '../enums/table.enum';
import { Classification } from '../generated/entity/classification/classification';
import { Chart } from '../generated/entity/data/chart';
import { Container } from '../generated/entity/data/container';
import { Dashboard } from '../generated/entity/data/dashboard';
import { DashboardDataModel } from '../generated/entity/data/dashboardDataModel';
@ -582,6 +583,67 @@ const getContainerOverview = (containerDetails: Container) => {
return overview;
};
const getChartOverview = (chartDetails: Chart) => {
const { owner, sourceUrl, chartType, service, serviceType, displayName } =
chartDetails;
const serviceDisplayName = getEntityName(service);
const overview = [
{
name: i18next.t('label.owner'),
value: <OwnerLabel hasPermission={false} owner={owner} />,
url: getOwnerValue(owner as EntityReference),
isLink: !isEmpty(owner?.name),
visible: [DRAWER_NAVIGATION_OPTIONS.lineage],
},
{
name: `${i18next.t('label.chart')} ${i18next.t('label.url-uppercase')}`,
value: stringToHTML(displayName ?? '') || NO_DATA,
url: sourceUrl,
isLink: true,
isExternal: true,
visible: [
DRAWER_NAVIGATION_OPTIONS.lineage,
DRAWER_NAVIGATION_OPTIONS.explore,
],
},
{
name: i18next.t('label.service'),
value: serviceDisplayName || NO_DATA,
url: getServiceDetailsPath(
service?.name ?? '',
ServiceCategory.DASHBOARD_SERVICES
),
isExternal: false,
isLink: true,
visible: [
DRAWER_NAVIGATION_OPTIONS.lineage,
DRAWER_NAVIGATION_OPTIONS.explore,
],
},
{
name: i18next.t('label.chart-type'),
value: chartType ?? NO_DATA,
isLink: false,
visible: [
DRAWER_NAVIGATION_OPTIONS.explore,
DRAWER_NAVIGATION_OPTIONS.lineage,
],
},
{
name: i18next.t('label.service-type'),
value: serviceType ?? NO_DATA,
isLink: false,
visible: [
DRAWER_NAVIGATION_OPTIONS.explore,
DRAWER_NAVIGATION_OPTIONS.lineage,
],
},
];
return overview;
};
const getDataModelOverview = (dataModelDetails: DashboardDataModel) => {
const {
owner,
@ -896,6 +958,9 @@ export const getEntityOverview = (
case ExplorePageTabs.CONTAINERS: {
return getContainerOverview(entityDetail as Container);
}
case ExplorePageTabs.CHARTS: {
return getChartOverview(entityDetail as Chart);
}
case ExplorePageTabs.DASHBOARD_DATA_MODEL: {
return getDataModelOverview(entityDetail as DashboardDataModel);

View File

@ -26,6 +26,8 @@ import {
} from '../constants/AdvancedSearch.constants';
import { EntityType } from '../enums/entity.enum';
import { SearchIndex } from '../enums/search.enum';
import { Chart } from '../generated/entity/data/chart';
import { getEntityLinkFromType } from './EntityUtils';
import { SearchClassBase } from './SearchClassBase';
import { getTestSuiteDetailsPath, getTestSuiteFQN } from './TestSuiteUtils';
@ -34,6 +36,12 @@ jest.mock('./TestSuiteUtils', () => ({
getTestSuiteFQN: jest.fn(),
}));
jest.mock('./EntityUtils', () => ({
getEntityLinkFromType: jest.fn(),
getEntityName: jest.fn(),
getEntityBreadcrumbs: jest.fn(),
}));
describe('SearchClassBase', () => {
let searchClassBase: SearchClassBase;
@ -227,7 +235,59 @@ describe('SearchClassBase', () => {
expect(getTestSuiteDetailsPath).toHaveBeenCalled();
});
it('should call not getTestSuiteDetailsPath if entity type is not TestSuite', () => {
it('should call getEntityLinkFromType with dashboard data if entity type is Chart', () => {
searchClassBase.getEntityLink({
id: '123',
service: {
id: '11',
type: 'dashboard',
fullyQualifiedName: 'superset',
name: 'superset',
},
fullyQualifiedName: 'test.chart',
entityType: EntityType.CHART,
name: 'chart',
dashboards: [
{
id: '12',
fullyQualifiedName: 'test.dashboard',
name: 'dashboard',
type: 'dashboard',
},
],
} as Chart);
expect(getEntityLinkFromType).toHaveBeenCalledWith(
'test.dashboard',
'dashboard',
{
fullyQualifiedName: 'test.dashboard',
id: '12',
name: 'dashboard',
type: 'dashboard',
}
);
});
it('should call not getEntityLinkFromType entity type is Chart and there is no dashboard in it', () => {
const result = searchClassBase.getEntityLink({
id: '123',
service: {
id: '11',
type: 'dashboard',
fullyQualifiedName: 'superset',
name: 'superset',
},
fullyQualifiedName: 'test.chart',
entityType: EntityType.CHART,
name: 'chart',
} as Chart);
expect(getEntityLinkFromType).not.toHaveBeenCalledWith();
expect(result).toBe('');
});
it('should not call getTestSuiteDetailsPath if entity type is not TestSuite', () => {
searchClassBase.getEntityLink({
fullyQualifiedName: 'test.testSuite',
entityType: EntityType.TABLE,

View File

@ -57,6 +57,7 @@ import {
import { EntityType } from '../enums/entity.enum';
import { ExplorePageTabs } from '../enums/Explore.enum';
import { SearchIndex } from '../enums/search.enum';
import { Chart } from '../generated/entity/data/chart';
import { TestSuite } from '../generated/tests/testCase';
import { SearchSourceAlias } from '../interface/search.interface';
import { TabsInfoData } from '../pages/ExplorePage/ExplorePage.interface';
@ -344,6 +345,18 @@ class SearchClassBase {
});
}
if (entity.entityType === EntityType.CHART) {
const dashboard = (entity as Chart).dashboards?.[0];
return dashboard
? getEntityLinkFromType(
dashboard.fullyQualifiedName ?? '',
EntityType.DASHBOARD,
dashboard as SourceType
)
: '';
}
if (entity.fullyQualifiedName && entity.entityType) {
return getEntityLinkFromType(
entity.fullyQualifiedName,

View File

@ -12,7 +12,7 @@
*/
import { EntityType } from '../enums/entity.enum';
import { SearchIndex } from '../enums/search.enum';
import { getEntityTypeFromSearchIndex } from './SearchUtils';
import { getEntityTypeFromSearchIndex, getGroupLabel } from './SearchUtils';
describe('getEntityTypeFromSearchIndex', () => {
it.each([
@ -46,3 +46,79 @@ describe('getEntityTypeFromSearchIndex', () => {
expect(getEntityTypeFromSearchIndex('DUMMY_INDEX')).toBeNull();
});
});
describe('getGroupLabel', () => {
it('should return topic details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.TOPIC));
expect(result).toContain('label.topic-plural');
});
it('should return dashboard details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.DASHBOARD));
expect(result).toContain('label.dashboard-plural');
});
it('should return pipeline details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.PIPELINE));
expect(result).toContain('label.pipeline-plural');
});
it('should return ml-model details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.MLMODEL));
expect(result).toContain('label.ml-model-plural');
});
it('should return glossary-term details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.GLOSSARY_TERM));
expect(result).toContain('label.glossary-term-plural');
});
it('should return chart details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.CHART));
expect(result).toContain('label.chart-plural');
});
it('should return tag details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.TAG));
expect(result).toContain('label.tag-plural');
});
it('should return container details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.CONTAINER));
expect(result).toContain('label.container-plural');
});
it('should return stored-procedure details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.STORED_PROCEDURE));
expect(result).toContain('label.stored-procedure-plural');
});
it('should return data-model details if index type is chart', () => {
const result = JSON.stringify(
getGroupLabel(SearchIndex.DASHBOARD_DATA_MODEL)
);
expect(result).toContain('label.data-model-plural');
});
it('should return search-index details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.SEARCH_INDEX));
expect(result).toContain('label.search-index-plural');
});
it('should return data-product details if index type is chart', () => {
const result = JSON.stringify(getGroupLabel(SearchIndex.DATA_PRODUCT));
expect(result).toContain('label.data-product-plural');
});
});

View File

@ -17,6 +17,7 @@ import i18next from 'i18next';
import { isEmpty } from 'lodash';
import React from 'react';
import { Link } from 'react-router-dom';
import { ReactComponent as IconChart } from '../assets/svg/chart.svg';
import { ReactComponent as IconDashboard } from '../assets/svg/dashboard-grey.svg';
import { ReactComponent as DataProductIcon } from '../assets/svg/ic-data-product.svg';
import { ReactComponent as IconContainer } from '../assets/svg/ic-storage.svg';
@ -159,6 +160,12 @@ export const getGroupLabel = (index: string) => {
break;
case SearchIndex.CHART:
label = i18next.t('label.chart-plural');
GroupIcon = IconChart;
break;
default: {
const { label: indexLabel, GroupIcon: IndexIcon } =
searchClassBase.getIndexGroupLabel(index);

View File

@ -25,6 +25,7 @@ import {
State,
TagSource,
} from '../../generated/entity/data/table';
import { EntityReference } from '../../generated/type/entityReference';
import { ReactComponent as IconExternalLink } from '../assets/svg/external-links.svg';
const { Text } = Typography;
@ -54,6 +55,17 @@ export const mockLinkBasedSummaryTitleResponse = (
</Link>
);
export const mockLinkBasedSummaryTitleDashboardResponse = (
<Link to="/dashboard/sample_superset.10">
<Text
className="entity-title text-link-color font-medium m-r-xss"
data-testid="entity-title"
ellipsis={{ tooltip: true }}>
deck.gl Demo
</Text>
</Link>
);
export const mockGetSummaryListItemTypeResponse = 'PrestoOperator';
export const mockTagsSortAndHighlightResponse = [
@ -130,6 +142,16 @@ export const mockEntityDataWithoutNesting: Task[] = [
},
];
export const mockEntityReferenceDashboardData: EntityReference = {
deleted: false,
description: '',
displayName: 'deck.gl Demo',
fullyQualifiedName: 'sample_superset.10',
id: '77a0ac8a-ca1a-4f21-9a37-406faa482008',
name: '10',
type: 'dashboard',
};
export const mockEntityDataWithoutNestingResponse: BasicEntityInfo[] = [
{
name: 'dim_address_task',