From cc46729137c654161113a86c505f9e2a0e0e9ff1 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Tue, 25 Jul 2023 13:53:16 +0530 Subject: [PATCH 1/6] chore(ingest): add example of training metric/hyper parameters (#8491) --- metadata-ingestion/examples/library/create_mlmodel.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/metadata-ingestion/examples/library/create_mlmodel.py b/metadata-ingestion/examples/library/create_mlmodel.py index c324f8a820639..630e682eff842 100644 --- a/metadata-ingestion/examples/library/create_mlmodel.py +++ b/metadata-ingestion/examples/library/create_mlmodel.py @@ -31,6 +31,16 @@ description="my feature", groups=model_group_urns, mlFeatures=feature_urns, + trainingMetrics=[ + models.MLMetricClass( + name="accuracy", description="accuracy of the model", value="1.0" + ) + ], + hyperParams=[ + models.MLHyperParamClass( + name="hyper_1", description="hyper_1", value="0.102" + ) + ], ), ) From eac003ccf4aeddf96b66a5401ba48d1309ad0541 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Tue, 25 Jul 2023 01:46:27 -0700 Subject: [PATCH 2/6] feat(ingest): enable pipeline reporting by default (#8472) --- docs/how/updating-datahub.md | 1 + metadata-ingestion/src/datahub/cli/check_cli.py | 3 ++- metadata-ingestion/src/datahub/cli/docker_cli.py | 2 +- metadata-ingestion/src/datahub/cli/ingest_cli.py | 2 +- .../src/datahub/ingestion/graph/client.py | 12 ++++++------ .../src/datahub/ingestion/run/pipeline.py | 2 +- .../ingestion/transformer/add_dataset_terms.py | 5 +++-- 7 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index b705c973cdbb5..ad12aacd00339 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -15,6 +15,7 @@ This file documents any backwards-incompatible changes in DataHub and assists pe certain column-level metrics. Instead, set `profile_table_level_only` to `false` and individually enable / disable desired field metrics. - #8451: The `bigquery-beta` and `snowflake-beta` source aliases have been dropped. Use `bigquery` and `snowflake` as the source type instead. +- #8472: Ingestion runs created with Pipeline.create will show up in the DataHub ingestion tab as CLI-based runs. To revert to the previous behavior of not showing these runs in DataHub, pass `no_default_report=True`. ### Potential Downtime diff --git a/metadata-ingestion/src/datahub/cli/check_cli.py b/metadata-ingestion/src/datahub/cli/check_cli.py index bec1672264b88..f20272ecd9dbf 100644 --- a/metadata-ingestion/src/datahub/cli/check_cli.py +++ b/metadata-ingestion/src/datahub/cli/check_cli.py @@ -61,7 +61,8 @@ def metadata_file(json_file: str, rewrite: bool, unpack_mces: bool) -> None: "type": "file", "config": {"filename": out_file.name}, }, - } + }, + no_default_report=True, ) pipeline.run() diff --git a/metadata-ingestion/src/datahub/cli/docker_cli.py b/metadata-ingestion/src/datahub/cli/docker_cli.py index e2b7b2a2e1ff4..918f610ce4635 100644 --- a/metadata-ingestion/src/datahub/cli/docker_cli.py +++ b/metadata-ingestion/src/datahub/cli/docker_cli.py @@ -985,7 +985,7 @@ def ingest_sample_data(path: Optional[str], token: Optional[str]) -> None: if token is not None: recipe["sink"]["config"]["token"] = token - pipeline = Pipeline.create(recipe) + pipeline = Pipeline.create(recipe, no_default_report=True) pipeline.run() ret = pipeline.pretty_print_summary() sys.exit(ret) diff --git a/metadata-ingestion/src/datahub/cli/ingest_cli.py b/metadata-ingestion/src/datahub/cli/ingest_cli.py index c8c352d1f83ff..72c15e92257aa 100644 --- a/metadata-ingestion/src/datahub/cli/ingest_cli.py +++ b/metadata-ingestion/src/datahub/cli/ingest_cli.py @@ -253,7 +253,7 @@ def mcps(path: str) -> None: }, } - pipeline = Pipeline.create(recipe) + pipeline = Pipeline.create(recipe, no_default_report=True) pipeline.run() ret = pipeline.pretty_print_summary() sys.exit(ret) diff --git a/metadata-ingestion/src/datahub/ingestion/graph/client.py b/metadata-ingestion/src/datahub/ingestion/graph/client.py index cac53c350f2ea..de8b28d4b95a8 100644 --- a/metadata-ingestion/src/datahub/ingestion/graph/client.py +++ b/metadata-ingestion/src/datahub/ingestion/graph/client.py @@ -57,12 +57,12 @@ class DatahubClientConfig(ConfigModel): """Configuration class for holding connectivity to datahub gms""" server: str = "http://localhost:8080" - token: Optional[str] - timeout_sec: Optional[int] - retry_status_codes: Optional[List[int]] - retry_max_times: Optional[int] - extra_headers: Optional[Dict[str, str]] - ca_certificate_path: Optional[str] + token: Optional[str] = None + timeout_sec: Optional[int] = None + retry_status_codes: Optional[List[int]] = None + retry_max_times: Optional[int] = None + extra_headers: Optional[Dict[str, str]] = None + ca_certificate_path: Optional[str] = None disable_ssl_verification: bool = False _max_threads_moved_to_sink = pydantic_removed_field( diff --git a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py index 7fe39ef3e64c6..79d959965e0dd 100644 --- a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py +++ b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py @@ -328,7 +328,7 @@ def create( dry_run: bool = False, preview_mode: bool = False, preview_workunits: int = 10, - report_to: Optional[str] = None, + report_to: Optional[str] = "datahub", no_default_report: bool = False, raw_config: Optional[dict] = None, ) -> "Pipeline": diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_terms.py b/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_terms.py index 996846e8dd061..f21e3ec319349 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_terms.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_terms.py @@ -132,8 +132,9 @@ class PatternAddDatasetTerms(AddDatasetTerms): def __init__(self, config: PatternDatasetTermsConfig, ctx: PipelineContext): term_pattern = config.term_pattern generic_config = AddDatasetTermsConfig( - get_terms_to_add=lambda _: [ - GlossaryTermAssociationClass(urn=urn) for urn in term_pattern.value(_) + get_terms_to_add=lambda entity_urn: [ + GlossaryTermAssociationClass(urn=term_urn) + for term_urn in term_pattern.value(entity_urn) ], replace_existing=config.replace_existing, semantics=config.semantics, From 77ace587f743df688ddb464bbe35d30a9e730d76 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Tue, 25 Jul 2023 10:02:02 -0400 Subject: [PATCH 3/6] feat(docs) Add guide for generating browsePathsV2 aspects (#8448) --- docs-website/sidebars.js | 1 + docs/browseV2/browse-paths-v2.md | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/browseV2/browse-paths-v2.md diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index b5ffd1964d7c1..7d651fd5d1894 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -486,6 +486,7 @@ module.exports = { "docs/how/add-custom-ingestion-source", "docs/how/add-custom-data-platform", "docs/advanced/browse-paths-upgrade", + "docs/browseV2/browse-paths-v2", ], }, ], diff --git a/docs/browseV2/browse-paths-v2.md b/docs/browseV2/browse-paths-v2.md new file mode 100644 index 0000000000000..b1f63b4a182ea --- /dev/null +++ b/docs/browseV2/browse-paths-v2.md @@ -0,0 +1,51 @@ +import FeatureAvailability from '@site/src/components/FeatureAvailability'; + +# Generating Browse Paths (V2) + + + +## Introduction + +Browse (V2) is a way for users to explore and dive deeper into their data. Its integration with the search experience allows users to combine search queries and filters with entity type and platform nested folders. + +Most entities should have a browse path that allows users to navigate the left side panel on the search page to find groups of entities under different folders that come from these browse paths. Below, you can see an example of the sidebar with some new browse paths. + +

+ +

+ +This new browse sidebar always starts with Entity Type, then optionally shows Environment (PROD, DEV, etc.) if there are 2 or more Environments, then Platform. Below the Platform level, we render out folders that come directly from entity's [browsePathsV2](https://datahubproject.io/docs/generated/metamodel/entities/dataset#browsepathsv2) aspects. + +## Generating Custom Browse Paths + +A `browsePathsV2` aspect has a field called `path` which contains a list of `BrowsePathEntry` objects. Each object in the path represents one level of the entity's browse path where the first entry is the highest level and the last entry is the lowest level. + +If an entity has this aspect filled out, their browse path will show up in the browse sidebar so that you can navigate its folders and select one to filter search results down. + +For example, in the browse sidebar on the left of the image above, there are 10 Dataset entities from the BigQuery Platform that have `browsePathsV2` aspects that look like the following: + +``` +[ { id: "bigquery-public-data" }, { id: "covid19_public_forecasts" } ] +``` + +The `id` in a `BrowsePathEntry` is required and is what will be shown in the UI unless the optional `urn` field is populated. If the `urn` field is populated, we will try to resolve this path entry into an entity object and display that entity's name. We will also show a link to allow you to open up the entity profile. + +The `urn` field should only be populated if there is an entity in your DataHub instance that belongs in that entity's browse path. This makes most sense for Datasets to have Container entities in the browse paths as well as some other cases such as a DataFlow being part of a DataJob's browse path. For any other situation, feel free to leave `urn` empty and populate `id` with the text you want to be shown in the UI for your entity's path. + +## Additional Resources + +### GraphQL + +* [browseV2](../../graphql/queries.md#browsev2) + +## FAQ and Troubleshooting + +**How are browsePathsV2 aspects created?** + +We create `browsePathsV2` aspects for all entities that should have one by default when you ingest your data if this aspect is not already provided. This happens based on separator characters that appear within an Urn. + +Our ingestion sources are also producing `browsePathsV2` aspects since CLI version v0.10.5. + +### Related Features + +* [Search](../how/search.md) From db42998a69ba1c0168b8025c5964b69ea290dfde Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Tue, 25 Jul 2023 21:58:44 +0530 Subject: [PATCH 4/6] fix(browsepathv2): default browse path with empty space (#8503) --- .../linkedin/metadata/search/utils/BrowsePathV2Utils.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java index 1487ac58d6a0a..a7f5ea7a51e29 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java @@ -24,6 +24,7 @@ import java.util.Collections; import java.util.List; import java.util.regex.Pattern; +import java.util.stream.Collectors; import static com.linkedin.metadata.Constants.CONTAINER_ASPECT_NAME; @@ -140,7 +141,9 @@ private static BrowsePathEntryArray getContainerPathEntries(@Nonnull final Urn e private static BrowsePathEntryArray getDefaultDatasetPathEntries(@Nonnull final String datasetName, @Nonnull final Character delimiter) { BrowsePathEntryArray browsePathEntries = new BrowsePathEntryArray(); if (datasetName.contains(delimiter.toString())) { - final List datasetNamePathParts = Arrays.asList(datasetName.split(Pattern.quote(delimiter.toString()))); + final List datasetNamePathParts = Arrays.stream(datasetName.split(Pattern.quote(delimiter.toString()))) + .filter((name) -> !name.isEmpty()) + .collect(Collectors.toList()); // Omit the name from the path. datasetNamePathParts.subList(0, datasetNamePathParts.size() - 1).forEach((part -> { browsePathEntries.add(createBrowsePathEntry(part, null)); From b12de099aa72758faaf01b73d38250d323951a9d Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Tue, 25 Jul 2023 10:48:37 -0700 Subject: [PATCH 5/6] docs: add docs on sqlglot lineage (#8482) --- .../src/datahub/utilities/sqlglot_lineage.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py index 123a965e5a80a..57f93f27e9147 100644 --- a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py +++ b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py @@ -761,6 +761,54 @@ def sqlglot_lineage( default_db: Optional[str] = None, default_schema: Optional[str] = None, ) -> SqlParsingResult: + """Parse a SQL statement and generate lineage information. + + This is a schema-aware lineage generator, meaning that it will use the + schema information for the tables involved to generate lineage information + for the columns involved. The schema_resolver is responsible for providing + the table schema information. + + The parser supports most types of DML statements (SELECT, INSERT, UPDATE, + DELETE, MERGE) as well as CREATE TABLE AS SELECT (CTAS) statements. It + does not support DDL statements (CREATE TABLE, ALTER TABLE, etc.). + + The table-level lineage tends to be fairly reliable, while column-level + can be brittle with respect to missing schema information and complex + SQL logic like UNNESTs. + + The SQL dialect is inferred from the schema_resolver's platform. The + set of supported dialects is the same as sqlglot's. See their + `documentation `_ + for the full list. + + The default_db and default_schema parameters are used to resolve unqualified + table names. For example, the statement "SELECT * FROM my_table" would be + converted to "SELECT * FROM default_db.default_schema.my_table". + + Args: + sql: The SQL statement to parse. This should be a single statement, not + a multi-statement string. + schema_resolver: The schema resolver to use for resolving table schemas. + default_db: The default database to use for unqualified table names. + default_schema: The default schema to use for unqualified table names. + + Returns: + A SqlParsingResult object containing the parsed lineage information. + + The in_tables and out_tables fields contain the input and output tables + for the statement, respectively. These are represented as urns. + The out_tables field will be empty for SELECT statements. + + The column_lineage field contains the column-level lineage information + for the statement. This is a list of ColumnLineageInfo objects, each + representing the lineage for a single output column. The downstream + field contains the output column, and the upstreams field contains the + (urn, column) pairs for the input columns. + + The debug_info field contains debug information about the parsing. If + table_error or column_error are set, then the parsing failed and the + other fields may be incomplete. + """ try: return _sqlglot_lineage_inner( sql=sql, From 8a23c37e7cd628c647a39fcb85b5da90eefdc36b Mon Sep 17 00:00:00 2001 From: John Joyce Date: Tue, 25 Jul 2023 17:17:19 -0700 Subject: [PATCH 6/6] feat(search ui): Adding support for pluggable filter rendering (#8455) --- .../src/app/search/SimpleSearchFilters.tsx | 31 ++++--- .../src/app/search/filters/BasicFilters.tsx | 28 +++++-- .../app/search/filters/MoreFilterOption.tsx | 22 +---- .../src/app/search/filters/MoreFilters.tsx | 34 +++++--- .../app/search/filters/SearchFilterView.tsx | 26 +----- .../search/filters/render/FilterRenderer.tsx | 29 +++++++ .../filters/render/FilterRendererRegistry.tsx | 42 ++++++++++ .../__tests__/FilterRendererRegistry.test.tsx | 80 +++++++++++++++++++ .../render/shared/styledComponents.tsx | 19 +++++ .../src/app/search/filters/render/types.ts | 29 +++++++ .../filters/render/useFilterRenderer.tsx | 17 ++++ .../app/search/filters/styledComponents.ts | 35 ++++++++ 12 files changed, 323 insertions(+), 69 deletions(-) create mode 100644 datahub-web-react/src/app/search/filters/render/FilterRenderer.tsx create mode 100644 datahub-web-react/src/app/search/filters/render/FilterRendererRegistry.tsx create mode 100644 datahub-web-react/src/app/search/filters/render/__tests__/FilterRendererRegistry.test.tsx create mode 100644 datahub-web-react/src/app/search/filters/render/shared/styledComponents.tsx create mode 100644 datahub-web-react/src/app/search/filters/render/types.ts create mode 100644 datahub-web-react/src/app/search/filters/render/useFilterRenderer.tsx diff --git a/datahub-web-react/src/app/search/SimpleSearchFilters.tsx b/datahub-web-react/src/app/search/SimpleSearchFilters.tsx index f6639855f4619..416b04403723f 100644 --- a/datahub-web-react/src/app/search/SimpleSearchFilters.tsx +++ b/datahub-web-react/src/app/search/SimpleSearchFilters.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; import { FacetFilterInput, FacetMetadata } from '../../types.generated'; +import { FilterScenarioType } from './filters/render/types'; +import { useFilterRendererRegistry } from './filters/render/useFilterRenderer'; import { SimpleSearchFilter } from './SimpleSearchFilter'; import { ENTITY_FILTER_NAME, ENTITY_INDEX_FILTER_NAME, LEGACY_ENTITY_FILTER_NAME } from './utils/constants'; @@ -53,17 +55,28 @@ export const SimpleSearchFilters = ({ facets, selectedFilters, onFilterSelect, l return TOP_FILTERS.indexOf(facetA.field) - TOP_FILTERS.indexOf(facetB.field); }); + const filterRendererRegistry = useFilterRendererRegistry(); + return ( <> - {sortedFacets.map((facet) => ( - - ))} + {sortedFacets.map((facet) => { + return filterRendererRegistry.hasRenderer(facet.field) ? ( + filterRendererRegistry.render(facet.field, { + scenario: FilterScenarioType.SEARCH_V1, + filter: facet, + activeFilters: selectedFilters, + onChangeFilters: onFilterSelect, + }) + ) : ( + + ); + })} ); }; diff --git a/datahub-web-react/src/app/search/filters/BasicFilters.tsx b/datahub-web-react/src/app/search/filters/BasicFilters.tsx index d0d65fca04c1d..e8f56e5c2cd5e 100644 --- a/datahub-web-react/src/app/search/filters/BasicFilters.tsx +++ b/datahub-web-react/src/app/search/filters/BasicFilters.tsx @@ -22,6 +22,8 @@ import { SEARCH_RESULTS_ADVANCED_SEARCH_ID, SEARCH_RESULTS_FILTERS_ID, } from '../../onboarding/config/SearchOnboardingConfig'; +import { useFilterRendererRegistry } from './render/useFilterRenderer'; +import { FilterScenarioType } from './render/types'; const NUM_VISIBLE_FILTER_DROPDOWNS = 5; @@ -80,19 +82,29 @@ export default function BasicFilters({ const shouldShowMoreDropdown = filters && filters.length > NUM_VISIBLE_FILTER_DROPDOWNS + 1; const visibleFilters = shouldShowMoreDropdown ? filters?.slice(0, NUM_VISIBLE_FILTER_DROPDOWNS) : filters; const hiddenFilters = shouldShowMoreDropdown ? filters?.slice(NUM_VISIBLE_FILTER_DROPDOWNS) : []; + const filterRendererRegistry = useFilterRendererRegistry(); return ( - {visibleFilters?.map((filter) => ( - - ))} + {visibleFilters?.map((filter) => { + return filterRendererRegistry.hasRenderer(filter.field) ? ( + filterRendererRegistry.render(filter.field, { + scenario: FilterScenarioType.SEARCH_V2_PRIMARY, + filter, + activeFilters, + onChangeFilters, + }) + ) : ( + + ); + })} {hiddenFilters && hiddenFilters.length > 0 && ( ` - padding: 5px 12px; - font-size: 14px; - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - - &:hover { - background-color: ${ANTD_GRAY[3]}; - } - - ${(props) => props.isActive && `color: ${props.theme.styles['primary-color']};`} - ${(props) => props.isOpen && `background-color: ${ANTD_GRAY[3]};`} -`; +import { MoreFilterOptionLabel } from './styledComponents'; const IconNameWrapper = styled.span` display: flex; @@ -72,7 +56,7 @@ export default function MoreFilterOption({ filter, activeFilters, onChangeFilter /> )} > - updateIsMenuOpen(!isMenuOpen)} isActive={!!numActiveFilters} isOpen={isMenuOpen} @@ -83,7 +67,7 @@ export default function MoreFilterOption({ filter, activeFilters, onChangeFilter {capitalizeFirstLetterOnly(filter.displayName)} {numActiveFilters ? `(${numActiveFilters}) ` : ''} - + ); } diff --git a/datahub-web-react/src/app/search/filters/MoreFilters.tsx b/datahub-web-react/src/app/search/filters/MoreFilters.tsx index dfadb293f7e5a..12ac7e7378e35 100644 --- a/datahub-web-react/src/app/search/filters/MoreFilters.tsx +++ b/datahub-web-react/src/app/search/filters/MoreFilters.tsx @@ -5,8 +5,10 @@ import styled from 'styled-components'; import { FacetFilterInput, FacetMetadata } from '../../../types.generated'; import MoreFilterOption from './MoreFilterOption'; import { getNumActiveFiltersForGroupOfFilters } from './utils'; -import { DropdownLabel } from './SearchFilterView'; +import { SearchFilterLabel } from './styledComponents'; import useSearchFilterAnalytics from './useSearchFilterAnalytics'; +import { useFilterRendererRegistry } from './render/useFilterRenderer'; +import { FilterScenarioType } from './render/types'; const StyledPlus = styled(PlusOutlined)` svg { @@ -36,6 +38,7 @@ export default function MoreFilters({ filters, activeFilters, onChangeFilters }: const { trackShowMoreEvent } = useSearchFilterAnalytics(); const [isMenuOpen, setIsMenuOpen] = useState(false); const numActiveFilters = getNumActiveFiltersForGroupOfFilters(activeFilters, filters); + const filterRendererRegistry = useFilterRendererRegistry(); function updateFiltersAndClose(newFilters: FacetFilterInput[]) { onChangeFilters(newFilters); @@ -52,23 +55,32 @@ export default function MoreFilters({ filters, activeFilters, onChangeFilters }: trigger={['click']} dropdownRender={() => ( - {filters.map((filter) => ( - - ))} + {filters.map((filter) => { + return filterRendererRegistry.hasRenderer(filter.field) ? ( + filterRendererRegistry.render(filter.field, { + scenario: FilterScenarioType.SEARCH_V2_SECONDARY, + filter, + activeFilters, + onChangeFilters: updateFiltersAndClose, + }) + ) : ( + + ); + })} )} open={isMenuOpen} onOpenChange={onOpenChange} > - + More Filters {numActiveFilters ? `(${numActiveFilters}) ` : ''} - + ); } diff --git a/datahub-web-react/src/app/search/filters/SearchFilterView.tsx b/datahub-web-react/src/app/search/filters/SearchFilterView.tsx index 18348448bc70e..ee448e6e1acf2 100644 --- a/datahub-web-react/src/app/search/filters/SearchFilterView.tsx +++ b/datahub-web-react/src/app/search/filters/SearchFilterView.tsx @@ -1,29 +1,11 @@ import { CaretDownFilled } from '@ant-design/icons'; -import { Button, Dropdown } from 'antd'; +import { Dropdown } from 'antd'; import React from 'react'; import styled from 'styled-components'; import OptionsDropdownMenu from './OptionsDropdownMenu'; import { capitalizeFirstLetterOnly } from '../../shared/textUtil'; import { DisplayedFilterOption } from './mapFilterOption'; -import { ANTD_GRAY } from '../../entity/shared/constants'; - -export const DropdownLabel = styled(Button)<{ isActive: boolean }>` - font-size: 14px; - font-weight: 700; - margin-right: 12px; - border: 1px solid ${ANTD_GRAY[5]}; - border-radius: 8px; - display: flex; - align-items: center; - box-shadow: none; - ${(props) => - props.isActive && - ` - background-color: ${props.theme.styles['primary-color']}; - border: 1px solid ${props.theme.styles['primary-color']}; - color: white; - `} -`; +import { SearchFilterLabel } from './styledComponents'; export const IconWrapper = styled.div` margin-right: 8px; @@ -76,7 +58,7 @@ export default function SearchFilterView({ /> )} > - updateIsMenuOpen(!isMenuOpen)} isActive={!!numActiveFilters} data-testid={`filter-dropdown-${capitalizeFirstLetterOnly(displayName)}`} @@ -84,7 +66,7 @@ export default function SearchFilterView({ {filterIcon && {filterIcon}} {capitalizeFirstLetterOnly(displayName)} {numActiveFilters ? `(${numActiveFilters}) ` : ''} - + ); } diff --git a/datahub-web-react/src/app/search/filters/render/FilterRenderer.tsx b/datahub-web-react/src/app/search/filters/render/FilterRenderer.tsx new file mode 100644 index 0000000000000..bf1aa347f92c5 --- /dev/null +++ b/datahub-web-react/src/app/search/filters/render/FilterRenderer.tsx @@ -0,0 +1,29 @@ +import { FilterRenderProps } from './types'; + +/** + * Base interface used for custom search filter renderers + * + */ +export interface FilterRenderer { + /** + * The filter field that is rendered by this renderer + */ + field: string; + + /** + * Renders the filter + */ + render: (props: FilterRenderProps) => JSX.Element; + + /** + * Ant-design icon associated with the Entity. For a list of all candidate icons, see + * https://ant.design/components/icon/ + */ + icon: () => JSX.Element; + + /** + * Returns a label for rendering the value of a particular field, e.g. for rendering the selected set of filters. + * Currently only for rendering the selected value set below Search V2 top-bar. + */ + valueLabel: (value: string) => JSX.Element; +} diff --git a/datahub-web-react/src/app/search/filters/render/FilterRendererRegistry.tsx b/datahub-web-react/src/app/search/filters/render/FilterRendererRegistry.tsx new file mode 100644 index 0000000000000..0ceed3af25d62 --- /dev/null +++ b/datahub-web-react/src/app/search/filters/render/FilterRendererRegistry.tsx @@ -0,0 +1,42 @@ +import { FilterRenderer } from './FilterRenderer'; +import { FilterRenderProps } from './types'; + +function validatedGet(key: K, map: Map): V { + if (map.has(key)) { + return map.get(key) as V; + } + throw new Error(`Unrecognized key ${key} provided in map ${JSON.stringify(map)}`); +} + +/** + * Serves as a singleton registry for custom filter renderers. + */ +export default class FilterRendererRegistry { + renderers: Array = new Array(); + + fieldNameToRenderer: Map = new Map(); + + register(renderer: FilterRenderer) { + this.renderers.push(renderer); + this.fieldNameToRenderer.set(renderer.field, renderer); + } + + hasRenderer(field: string): boolean { + return this.fieldNameToRenderer.has(field); + } + + render(field: string, props: FilterRenderProps): React.ReactNode { + const renderer = validatedGet(field, this.fieldNameToRenderer); + return renderer.render(props); + } + + getValueLabel(field: string, value: string): React.ReactNode { + const renderer = validatedGet(field, this.fieldNameToRenderer); + return renderer.valueLabel(value); + } + + getIcon(field: string): React.ReactNode { + const renderer = validatedGet(field, this.fieldNameToRenderer); + return renderer.icon(); + } +} diff --git a/datahub-web-react/src/app/search/filters/render/__tests__/FilterRendererRegistry.test.tsx b/datahub-web-react/src/app/search/filters/render/__tests__/FilterRendererRegistry.test.tsx new file mode 100644 index 0000000000000..35d41b3bb71a3 --- /dev/null +++ b/datahub-web-react/src/app/search/filters/render/__tests__/FilterRendererRegistry.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import FilterRendererRegistry from '../FilterRendererRegistry'; + +describe('FilterRendererRegistry', () => { + let registry; + let renderer; + let props; + + beforeEach(() => { + registry = new FilterRendererRegistry(); + + // Mock a FilterRenderer instance + renderer = { + field: 'mockField', + render: jest.fn().mockReturnValue(
Rendered
), + valueLabel: jest.fn().mockReturnValue(
ValueLabel
), + icon: jest.fn().mockReturnValue(
Icon
), + }; + + // Mock FilterRenderProps + props = { + /* assuming some props here */ + }; + }); + + describe('register', () => { + it('should register a new FilterRenderer', () => { + registry.register(renderer); + expect(registry.renderers).toContain(renderer); + expect(registry.fieldNameToRenderer.get(renderer.field)).toBe(renderer); + }); + }); + + describe('hasRenderer', () => { + it('should return true if the renderer for a field is registered', () => { + registry.register(renderer); + expect(registry.hasRenderer(renderer.field)).toBe(true); + }); + + it('should return false if the renderer for a field is not registered', () => { + expect(registry.hasRenderer('nonexistentField')).toBe(false); + }); + }); + + describe('render', () => { + it('should return the result of the renderer render method', () => { + registry.register(renderer); + expect(registry.render(renderer.field, props)).toEqual(
Rendered
); + expect(renderer.render).toHaveBeenCalledWith(props); + }); + + it('should throw an error if the renderer for a field is not registered', () => { + expect(() => registry.render('nonexistentField', props)).toThrow(); + }); + }); + + describe('getValueLabel', () => { + it('should return the result of the renderer valueLabel method', () => { + registry.register(renderer); + expect(registry.getValueLabel(renderer.field, 'mockValue')).toEqual(
ValueLabel
); + expect(renderer.valueLabel).toHaveBeenCalledWith('mockValue'); + }); + + it('should throw an error if the renderer for a field is not registered', () => { + expect(() => registry.getValueLabel('nonexistentField', 'mockValue')).toThrow(); + }); + }); + + describe('getIcon', () => { + it('should return the result of the renderer icon method', () => { + registry.register(renderer); + expect(registry.getIcon(renderer.field)).toEqual(
Icon
); + expect(renderer.icon).toHaveBeenCalled(); + }); + + it('should throw an error if the renderer for a field is not registered', () => { + expect(() => registry.getIcon('nonexistentField')).toThrow(); + }); + }); +}); diff --git a/datahub-web-react/src/app/search/filters/render/shared/styledComponents.tsx b/datahub-web-react/src/app/search/filters/render/shared/styledComponents.tsx new file mode 100644 index 0000000000000..7bcbcca048ac6 --- /dev/null +++ b/datahub-web-react/src/app/search/filters/render/shared/styledComponents.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; +import { MoreFilterOptionLabel } from '../../styledComponents'; + +export const SearchFilterWrapper = styled.div` + padding: 0 25px 15px 25px; +`; + +export const Title = styled.div` + align-items: center; + font-weight: bold; + margin-bottom: 10px; + display: flex; + justify-content: left; + cursor: pointer; +`; + +export const StyledMoreFilterOptionLabel = styled(MoreFilterOptionLabel)` + justify-content: left; +`; diff --git a/datahub-web-react/src/app/search/filters/render/types.ts b/datahub-web-react/src/app/search/filters/render/types.ts new file mode 100644 index 0000000000000..1f61919309260 --- /dev/null +++ b/datahub-web-react/src/app/search/filters/render/types.ts @@ -0,0 +1,29 @@ +import { FacetFilter, FacetFilterInput, FacetMetadata } from '../../../../types.generated'; + +/** + * The scenario in which filter rendering is required. + */ +export enum FilterScenarioType { + /** + * The V1 search experience, including embedded list search. + */ + SEARCH_V1, + /** + * The V2 search + browse experience, NOT inside the "More" dropdown. + */ + SEARCH_V2_PRIMARY, + /** + * The V2 search + browse experience, inside the "More" dropdown. + */ + SEARCH_V2_SECONDARY, +} + +/** + * Props passed to every filter renderer + */ +export interface FilterRenderProps { + scenario: FilterScenarioType; + filter: FacetMetadata; + activeFilters: FacetFilterInput[]; + onChangeFilters: (newFilters: FacetFilter[]) => void; +} diff --git a/datahub-web-react/src/app/search/filters/render/useFilterRenderer.tsx b/datahub-web-react/src/app/search/filters/render/useFilterRenderer.tsx new file mode 100644 index 0000000000000..df94cfe49b546 --- /dev/null +++ b/datahub-web-react/src/app/search/filters/render/useFilterRenderer.tsx @@ -0,0 +1,17 @@ +import FilterRendererRegistry from './FilterRendererRegistry'; + +/** + * Configure the render registry. + */ +const RENDERERS = [ + /* Renderers will be registered here */ +]; +const REGISTRY = new FilterRendererRegistry(); +RENDERERS.forEach((renderer) => REGISTRY.register(renderer)); + +/** + * Used to render custom filter views for a particular facet field. + */ +export const useFilterRendererRegistry = () => { + return REGISTRY; +}; diff --git a/datahub-web-react/src/app/search/filters/styledComponents.ts b/datahub-web-react/src/app/search/filters/styledComponents.ts index dafdb9ede9de0..eb70e0719169e 100644 --- a/datahub-web-react/src/app/search/filters/styledComponents.ts +++ b/datahub-web-react/src/app/search/filters/styledComponents.ts @@ -1,5 +1,40 @@ import { Button } from 'antd'; import styled from 'styled-components'; +import { ANTD_GRAY } from '../../entity/shared/constants'; + +export const SearchFilterLabel = styled(Button)<{ isActive: boolean }>` + font-size: 14px; + font-weight: 700; + margin-right: 12px; + border: 1px solid ${ANTD_GRAY[5]}; + border-radius: 8px; + display: flex; + align-items: center; + box-shadow: none; + ${(props) => + props.isActive && + ` + background-color: ${props.theme.styles['primary-color']}; + border: 1px solid ${props.theme.styles['primary-color']}; + color: white; + `} +`; + +export const MoreFilterOptionLabel = styled.div<{ isActive: boolean; isOpen: boolean }>` + padding: 5px 12px; + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + + &:hover { + background-color: ${ANTD_GRAY[3]}; + } + + ${(props) => props.isActive && `color: ${props.theme.styles['primary-color']};`} + ${(props) => props.isOpen && `background-color: ${ANTD_GRAY[3]};`} +`; export const TextButton = styled(Button)<{ marginTop?: number; height?: number }>` color: ${(props) => props.theme.styles['primary-color']};