From 25e63cfe201c90838a2f2d312c802a774304b91c Mon Sep 17 00:00:00 2001 From: Ryan Laurie <30528226+iethree@users.noreply.github.com> Date: Mon, 10 Jul 2023 12:42:08 -0600 Subject: [PATCH] Convert FieldValuesWidget to Typescript (#32202) * convert FieldValuesWidget to typescript --- frontend/src/metabase-lib/metadata/Field.ts | 2 +- frontend/src/metabase-types/api/field.ts | 1 + .../src/metabase-types/api/mocks/field.ts | 1 + ...ValuesWidget.jsx => FieldValuesWidget.tsx} | 234 +++++++++++++----- ...pec.js => FieldValuesWidget.unit.spec.tsx} | 60 +++-- .../components/FieldValuesWidget/index.jsx | 1 - .../components/FieldValuesWidget/index.ts | 3 + .../{testMocks.js => testMocks.ts} | 2 +- .../components/FieldValuesWidget/types.ts | 2 + .../FieldValuesWidget/{utils.js => utils.ts} | 117 ++++++--- 10 files changed, 316 insertions(+), 107 deletions(-) rename frontend/src/metabase/components/FieldValuesWidget/{FieldValuesWidget.jsx => FieldValuesWidget.tsx} (67%) rename frontend/src/metabase/components/FieldValuesWidget/{FieldValuesWidget.unit.spec.js => FieldValuesWidget.unit.spec.tsx} (85%) delete mode 100644 frontend/src/metabase/components/FieldValuesWidget/index.jsx create mode 100644 frontend/src/metabase/components/FieldValuesWidget/index.ts rename frontend/src/metabase/components/FieldValuesWidget/{testMocks.js => testMocks.ts} (98%) create mode 100644 frontend/src/metabase/components/FieldValuesWidget/types.ts rename frontend/src/metabase/components/FieldValuesWidget/{utils.js => utils.ts} (66%) diff --git a/frontend/src/metabase-lib/metadata/Field.ts b/frontend/src/metabase-lib/metadata/Field.ts index 56a4b5d7e24..a3ad8ff3015 100644 --- a/frontend/src/metabase-lib/metadata/Field.ts +++ b/frontend/src/metabase-lib/metadata/Field.ts @@ -511,7 +511,7 @@ class FieldInner extends Base { })); }; - clone(fieldMetadata) { + clone(fieldMetadata?: FieldMetadata) { if (fieldMetadata instanceof Field) { throw new Error("`fieldMetadata` arg must be a plain object"); } diff --git a/frontend/src/metabase-types/api/field.ts b/frontend/src/metabase-types/api/field.ts index 97010da8171..b81fee3d4bf 100644 --- a/frontend/src/metabase-types/api/field.ts +++ b/frontend/src/metabase-types/api/field.ts @@ -103,6 +103,7 @@ export interface Field { max_value?: number; min_value?: number; has_field_values: FieldValuesType; + has_more_values?: boolean; caveats?: string | null; points_of_interest?: string; diff --git a/frontend/src/metabase-types/api/mocks/field.ts b/frontend/src/metabase-types/api/mocks/field.ts index 2c4becdccc7..6b6ee4de405 100644 --- a/frontend/src/metabase-types/api/mocks/field.ts +++ b/frontend/src/metabase-types/api/mocks/field.ts @@ -32,6 +32,7 @@ export const createMockField = (opts?: Partial<Field>): Field => ({ fingerprint: null, has_field_values: "list", + has_more_values: false, last_analyzed: new Date().toISOString(), created_at: new Date().toISOString(), diff --git a/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.jsx b/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.tsx similarity index 67% rename from frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.jsx rename to frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.tsx index efbae67125d..9ff35242a97 100644 --- a/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.jsx +++ b/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.tsx @@ -1,9 +1,6 @@ -/* eslint-disable react/prop-types */ -import { useState, useRef } from "react"; - +import { useState, useRef, StyleHTMLAttributes } from "react"; import { useMount, useUnmount } from "react-use"; -import PropTypes from "prop-types"; import { connect } from "react-redux"; import { jt, t } from "ttag"; import _ from "underscore"; @@ -21,6 +18,7 @@ import AutoExpanding from "metabase/hoc/AutoExpanding"; import { addRemappings } from "metabase/redux/metadata"; import { defer } from "metabase/lib/promise"; +import type { LayoutRendererArgs } from "metabase/components/TokenField/TokenField"; import { fetchCardParameterValues, fetchDashboardParameterValues, @@ -28,6 +26,25 @@ import { } from "metabase/parameters/actions"; import Fields from "metabase/entities/fields"; +import type { State } from "metabase-types/store"; + +import type { + CardId, + Dashboard, + DashboardId, + Parameter, + FieldId, + FieldReference, + FieldValue, + RowValue, + Field as APIField, + ParameterValues, +} from "metabase-types/api"; + +import type Field from "metabase-lib/metadata/Field"; +import type Question from "metabase-lib/Question"; + +import type { ValuesMode, LoadingStateType } from "./types"; import { canUseParameterEndpoints, @@ -49,11 +66,6 @@ import { OptionsMessage, StyledEllipsified } from "./FieldValuesWidget.styled"; const MAX_SEARCH_RESULTS = 100; -const fieldValuesWidgetPropTypes = { - addRemappings: PropTypes.func, - expand: PropTypes.bool, -}; - const mapDispatchToProps = { addRemappings, fetchFieldValues: Fields.objectActions.fetchFieldValues, @@ -62,7 +74,7 @@ const mapDispatchToProps = { fetchDashboardParameterValues, }; -function mapStateToProps(state, { fields = [] }) { +function mapStateToProps(state: State, { fields = [] }: { fields: Field[] }) { return { fields: fields.map( field => @@ -71,7 +83,74 @@ function mapStateToProps(state, { fields = [] }) { }; } -function FieldValuesWidgetInner({ +type FieldValuesResponse = { + payload: APIField; +}; + +interface FetcherOptions { + query?: string; + parameter?: Parameter; + parameters?: Parameter[]; + dashboardId?: DashboardId; + cardId?: CardId; +} + +export interface IFieldValuesWidgetProps { + color?: string; + maxResults?: number; + style?: StyleHTMLAttributes<HTMLDivElement>; + formatOptions?: Record<string, any>; + maxWidth?: number; + minWidth?: number; + + expand?: boolean; + disableList?: boolean; + disableSearch?: boolean; + disablePKRemappingForSearch?: boolean; + alwaysShowOptions?: boolean; + showOptionsInPopover?: boolean; + + fetchFieldValues: ({ + id, + }: { + id: FieldId | FieldReference; + }) => Promise<FieldValuesResponse>; + fetchParameterValues: (options: FetcherOptions) => Promise<ParameterValues>; + fetchCardParameterValues: ( + options: FetcherOptions, + ) => Promise<ParameterValues>; + fetchDashboardParameterValues: ( + options: FetcherOptions, + ) => Promise<ParameterValues>; + + addRemappings: ( + value: FieldReference | FieldId, + options: FieldValue[], + ) => void; + + parameter?: Parameter; + parameters?: Parameter[]; + fields: Field[]; + dashboard?: Dashboard; + question?: Question; + + value: string[]; + onChange: (value: string[]) => void; + + multi?: boolean; + autoFocus?: boolean; + className?: string; + prefix?: string; + placeholder?: string; + forceTokenField?: boolean; + checkedColor?: string; + + valueRenderer?: (value: string | number) => JSX.Element; + optionRenderer?: (option: FieldValue) => JSX.Element; + layoutRenderer?: (props: LayoutRendererArgs) => JSX.Element; +} + +export function FieldValuesWidgetInner({ color = "purple", maxResults = MAX_SEARCH_RESULTS, alwaysShowOptions = true, @@ -106,11 +185,11 @@ function FieldValuesWidgetInner({ valueRenderer, optionRenderer, layoutRenderer, -}) { - const [options, setOptions] = useState([]); - const [loadingState, setLoadingState] = useState("INIT"); - const [lastValue, setLastValue] = useState(""); - const [valuesMode, setValuesMode] = useState( +}: IFieldValuesWidgetProps) { + const [options, setOptions] = useState<FieldValue[]>([]); + const [loadingState, setLoadingState] = useState<LoadingStateType>("INIT"); + const [lastValue, setLastValue] = useState<string>(""); + const [valuesMode, setValuesMode] = useState<ValuesMode>( getValuesMode({ parameter, fields, @@ -125,19 +204,17 @@ function FieldValuesWidgetInner({ } }); - const _cancel = useRef(null); + const _cancel = useRef<null | (() => void)>(null); useUnmount(() => { - if (_cancel.current) { - _cancel.current(); - } + _cancel?.current?.(); }); - const fetchValues = async query => { + const fetchValues = async (query?: string) => { setLoadingState("LOADING"); setOptions([]); - let newOptions = []; + let newOptions: FieldValue[] = []; let newValuesMode = valuesMode; try { if (canUseDashboardEndpoints(dashboard)) { @@ -175,7 +252,7 @@ function FieldValuesWidgetInner({ } }; - const fetchFieldValues = async query => { + const fetchFieldValues = async (query?: string): Promise<FieldValue[]> => { if (query == null) { const nonVirtualFields = getNonVirtualFields(fields); @@ -185,7 +262,7 @@ function FieldValuesWidgetInner({ // extract the field values from the API response(s) // the entity loader has inconsistent return structure, so we have to handle both - const fieldValues = nonVirtualFields.map( + const fieldValues: FieldValue[][] = nonVirtualFields.map( (field, index) => results[index]?.payload?.values ?? Fields.selectors.getFieldValues(results[index]?.payload, { @@ -196,7 +273,7 @@ function FieldValuesWidgetInner({ return dedupeValues(fieldValues); } else { const cancelDeferred = defer(); - const cancelled = cancelDeferred.promise; + const cancelled: Promise<unknown> = cancelDeferred.promise; _cancel.current = () => { _cancel.current = null; cancelDeferred.resolve(); @@ -217,22 +294,22 @@ function FieldValuesWidgetInner({ } }; - const fetchParameterValues = async query => { + const fetchParameterValues = async (query?: string) => { return fetchParameterValuesProp({ parameter, query, }); }; - const fetchCardParameterValues = async query => { + const fetchCardParameterValues = async (query?: string) => { return fetchCardParameterValuesProp({ - cardId: question.id(), + cardId: question?.id(), parameter, query, }); }; - const fetchDashboardParameterValues = async query => { + const fetchDashboardParameterValues = async (query?: string) => { return fetchDashboardParameterValuesProp({ dashboardId: dashboard?.id, parameter, @@ -241,7 +318,8 @@ function FieldValuesWidgetInner({ }); }; - const updateRemappings = options => { + // ? this may rely on field mutations + const updateRemappings = (options: FieldValue[]) => { if (showRemapping(fields)) { const [field] = fields; if ( @@ -252,7 +330,7 @@ function FieldValuesWidgetInner({ } }; - const onInputChange = value => { + const onInputChange = (value: string) => { let localValuesMode = valuesMode; // override "search" mode when searching is unnecessary @@ -273,7 +351,7 @@ function FieldValuesWidgetInner({ }; const search = useRef( - _.debounce(async value => { + _.debounce(async (value: string) => { if (!value) { setLoadingState("LOADED"); return; @@ -285,7 +363,7 @@ function FieldValuesWidgetInner({ }, 500), ); - const _search = value => { + const _search = (value: string) => { if (_cancel.current) { _cancel.current(); } @@ -295,26 +373,33 @@ function FieldValuesWidgetInner({ }; if (!valueRenderer) { - valueRenderer = value => - renderValue(fields, formatOptions, value, { + valueRenderer = (value: string | number) => + renderValue({ + fields, + formatOptions, + value, autoLoad: true, compact: false, }); } if (!optionRenderer) { - optionRenderer = option => - renderValue(fields, formatOptions, option[0], { - autoLoad: false, - }); + optionRenderer = (option: FieldValue) => + renderValue({ fields, formatOptions, value: option[0], autoLoad: false }); } if (!layoutRenderer) { layoutRenderer = showOptionsInPopover ? undefined - : layoutProps => ( + : ({ + optionsList, + isFocused, + isAllSelected, + isFiltered, + valuesList, + }: LayoutRendererArgs) => ( <div> - {layoutProps.valuesList} + {valuesList} {renderOptions({ alwaysShowOptions, parameter, @@ -324,7 +409,10 @@ function FieldValuesWidgetInner({ loadingState, options, valuesMode, - ...layoutProps, + optionsList, + isFocused, + isAllSelected, + isFiltered, })} </div> ); @@ -336,7 +424,6 @@ function FieldValuesWidgetInner({ disableSearch, placeholder, disablePKRemappingForSearch, - loadingState, options, valuesMode, }); @@ -354,7 +441,7 @@ function FieldValuesWidgetInner({ options, }); - const parseFreeformValue = value => { + const parseFreeformValue = (value: string | number) => { return isNumeric(fields[0], parameter) ? parseNumberValue(value) : parseStringValue(value); @@ -362,8 +449,9 @@ function FieldValuesWidgetInner({ return ( <div + data-testid="field-values-widget" style={{ - width: expand ? maxWidth : null, + width: expand ? maxWidth : undefined, minWidth: minWidth, maxWidth: maxWidth, }} @@ -372,9 +460,9 @@ function FieldValuesWidgetInner({ <LoadingState /> ) : isListMode && hasListValues && multi ? ( <ListField - isDashboardFilter={parameter} + isDashboardFilter={!!parameter} placeholder={tokenFieldPlaceholder} - value={value.filter(v => v != null)} + value={value?.filter((v: string) => v != null)} onChange={onChange} options={options} optionRenderer={optionRenderer} @@ -382,7 +470,7 @@ function FieldValuesWidgetInner({ /> ) : isListMode && hasListValues && !multi ? ( <SingleSelectListField - isDashboardFilter={parameter} + isDashboardFilter={!!parameter} placeholder={tokenFieldPlaceholder} value={value.filter(v => v != null)} onChange={onChange} @@ -430,22 +518,22 @@ function FieldValuesWidgetInner({ export const FieldValuesWidget = AutoExpanding(FieldValuesWidgetInner); -FieldValuesWidget.propTypes = fieldValuesWidgetPropTypes; - const LoadingState = () => ( <div className="flex layout-centered align-center" style={{ minHeight: 82 }}> <LoadingSpinner size={32} /> </div> ); -const NoMatchState = ({ fields }) => { - if (fields.length === 1) { +const NoMatchState = ({ fields }: { fields: (Field | null)[] }) => { + if (fields.length === 1 && !!fields[0]) { const [{ display_name }] = fields; return ( <OptionsMessage> {jt`No matching ${( - <StyledEllipsified>{display_name}</StyledEllipsified> + <StyledEllipsified key={display_name}> + {display_name} + </StyledEllipsified> )} found.`} </OptionsMessage> ); @@ -458,8 +546,24 @@ const EveryOptionState = () => ( <OptionsMessage>{t`Including every option in your filter probably won’t do much…`}</OptionsMessage> ); +// eslint-disable-next-line import/no-default-export export default connect(mapStateToProps, mapDispatchToProps)(FieldValuesWidget); +interface RenderOptionsProps { + alwaysShowOptions: boolean; + parameter?: Parameter; + fields: Field[]; + disableSearch: boolean; + disablePKRemappingForSearch?: boolean; + loadingState: LoadingStateType; + options: FieldValue[]; + valuesMode: ValuesMode; + optionsList: React.ReactNode; + isFocused: boolean; + isAllSelected: boolean; + isFiltered: boolean; +} + function renderOptions({ alwaysShowOptions, parameter, @@ -473,7 +577,7 @@ function renderOptions({ isFocused, isAllSelected, isFiltered, -}) { +}: RenderOptionsProps) { if (alwaysShowOptions || isFocused) { if (optionsList) { return optionsList; @@ -503,8 +607,9 @@ function renderOptions({ } else if (loadingState === "LOADED" && isFiltered) { return ( <NoMatchState - fields={fields.map(field => - field.searchField(disablePKRemappingForSearch), + fields={fields.map( + field => + field.searchField(disablePKRemappingForSearch) as Field | null, )} /> ); @@ -513,7 +618,19 @@ function renderOptions({ } } -function renderValue(fields, formatOptions, value, options) { +function renderValue({ + fields, + formatOptions, + value, + autoLoad, + compact, +}: { + fields: Field[]; + formatOptions: Record<string, any>; + value: RowValue; + autoLoad?: boolean; + compact?: boolean; +}) { return ( <ValueComponent value={value} @@ -521,7 +638,8 @@ function renderValue(fields, formatOptions, value, options) { maximumFractionDigits={20} remap={showRemapping(fields)} {...formatOptions} - {...options} + autoLoad={autoLoad} + compact={compact} /> ); } diff --git a/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.unit.spec.js b/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.unit.spec.tsx similarity index 85% rename from frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.unit.spec.js rename to frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.unit.spec.tsx index 617197cb414..bfd57fc27d0 100644 --- a/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.unit.spec.js +++ b/frontend/src/metabase/components/FieldValuesWidget/FieldValuesWidget.unit.spec.tsx @@ -1,5 +1,3 @@ -import "mutationobserver-shim"; - import userEvent from "@testing-library/user-event"; import { getBrokenUpTextMatcher, @@ -10,7 +8,10 @@ import { import { setupFieldSearchValuesEndpoints } from "__support__/server-mocks"; import { checkNotNull } from "metabase/core/utils/types"; -import { FieldValuesWidget } from "metabase/components/FieldValuesWidget"; +import { + FieldValuesWidget, + IFieldValuesWidgetProps, +} from "metabase/components/FieldValuesWidget"; import { ORDERS, @@ -19,6 +20,7 @@ import { PRODUCT_CATEGORY_VALUES, PEOPLE_SOURCE_VALUES, } from "metabase-types/api/mocks/presets"; +import Field from "metabase-lib/metadata/Field"; import { state, @@ -30,22 +32,37 @@ import { metadataWithSearchValuesField, } from "./testMocks"; -async function setup({ fields, values, searchValue, ...props }) { +async function setup({ + fields, + prefix, + searchValue, + ...props +}: { + fields: (Field | null | undefined)[]; + searchValue?: string; + prefix?: string; +} & Omit<Partial<IFieldValuesWidgetProps>, "fields">) { const fetchFieldValues = jest.fn(({ id }) => ({ - payload: fields.find(f => f.id === id), + payload: fields.filter(checkNotNull).find(f => f?.id === id), })); - fields.forEach(field => { - setupFieldSearchValuesEndpoints(field.id, searchValue); - }); + if (searchValue) { + fields.forEach(field => { + setupFieldSearchValuesEndpoints(field?.id as number, searchValue); + }); + } renderWithProviders( <FieldValuesWidget value={[]} - fields={fields} + fields={fields.filter(checkNotNull)} onChange={jest.fn()} - fetchFieldValues={fetchFieldValues} + fetchFieldValues={fetchFieldValues as any} + fetchParameterValues={jest.fn()} + fetchDashboardParameterValues={jest.fn()} + fetchCardParameterValues={jest.fn()} addRemappings={jest.fn()} + prefix={prefix} {...props} />, { @@ -150,9 +167,12 @@ describe("FieldValuesWidget", () => { describe("has_field_values = search", () => { it("should have 'Search by Category or enter an ID' as the placeholder text", async () => { - const field = metadata.field(SEARCHABLE_FK_FIELD_ID).clone(); + const field = metadata.field(SEARCHABLE_FK_FIELD_ID)?.clone(); const remappedField = metadata.field(PRODUCTS.CATEGORY); - field.remappedField = () => remappedField; + + if (field) { + field.remappedField = () => remappedField; + } await setup({ fields: [field] }); @@ -174,11 +194,13 @@ describe("FieldValuesWidget", () => { describe("multiple fields", () => { it("list multiple fields together", async () => { - const categoryField = metadata.field(PRODUCTS.CATEGORY).clone(); - categoryField.values = PRODUCT_CATEGORY_VALUES.values; + const categoryField = metadata.field(PRODUCTS.CATEGORY)?.clone(); + const sourceField = metadata.field(PEOPLE.SOURCE)?.clone(); - const sourceField = metadata.field(PEOPLE.SOURCE).clone(); - sourceField.values = PEOPLE_SOURCE_VALUES.values; + if (categoryField && sourceField) { + categoryField.values = PRODUCT_CATEGORY_VALUES.values; + sourceField.values = PEOPLE_SOURCE_VALUES.values; + } await setup({ fields: [categoryField, sourceField] }); @@ -232,7 +254,9 @@ describe("FieldValuesWidget", () => { describe("custom expressions", () => { const valuesField = checkNotNull(metadata.field(LISTABLE_PK_FIELD_ID)); - const expressionField = checkNotNull(metadata.field(EXPRESSION_FIELD_ID)); + const expressionField = checkNotNull( + metadata.field(EXPRESSION_FIELD_ID as any), + ); it("should not call fetchFieldValues", async () => { const { fetchFieldValues } = await setup({ @@ -252,7 +276,7 @@ describe("FieldValuesWidget", () => { describe("NoMatchState", () => { it("should display field title when one field passed and there are no matching results", async () => { const field = metadataWithSearchValuesField.field(PEOPLE.PASSWORD); - const displayName = field.display_name; // "Password" + const displayName = field?.display_name; // "Password" const searchValue = "somerandomvalue"; await setup({ diff --git a/frontend/src/metabase/components/FieldValuesWidget/index.jsx b/frontend/src/metabase/components/FieldValuesWidget/index.jsx deleted file mode 100644 index 58e8c6d0f9d..00000000000 --- a/frontend/src/metabase/components/FieldValuesWidget/index.jsx +++ /dev/null @@ -1 +0,0 @@ -export { default, FieldValuesWidget } from "./FieldValuesWidget"; diff --git a/frontend/src/metabase/components/FieldValuesWidget/index.ts b/frontend/src/metabase/components/FieldValuesWidget/index.ts new file mode 100644 index 00000000000..7737d6d05aa --- /dev/null +++ b/frontend/src/metabase/components/FieldValuesWidget/index.ts @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/no-default-export -- deprecated usage +export { default } from "./FieldValuesWidget"; +export * from "./FieldValuesWidget"; diff --git a/frontend/src/metabase/components/FieldValuesWidget/testMocks.js b/frontend/src/metabase/components/FieldValuesWidget/testMocks.ts similarity index 98% rename from frontend/src/metabase/components/FieldValuesWidget/testMocks.js rename to frontend/src/metabase/components/FieldValuesWidget/testMocks.ts index bf5e4589d7d..3678ea2cfe3 100644 --- a/frontend/src/metabase/components/FieldValuesWidget/testMocks.js +++ b/frontend/src/metabase/components/FieldValuesWidget/testMocks.ts @@ -69,7 +69,7 @@ const database = createSampleDatabase({ has_more_values: true, }), createMockField({ - id: EXPRESSION_FIELD_ID, + id: EXPRESSION_FIELD_ID as any, field_ref: ["expression", "CC"], }), ], diff --git a/frontend/src/metabase/components/FieldValuesWidget/types.ts b/frontend/src/metabase/components/FieldValuesWidget/types.ts new file mode 100644 index 00000000000..b3fe496aaea --- /dev/null +++ b/frontend/src/metabase/components/FieldValuesWidget/types.ts @@ -0,0 +1,2 @@ +export type ValuesMode = "search" | "list" | "none"; +export type LoadingStateType = "LOADING" | "LOADED" | "INIT"; diff --git a/frontend/src/metabase/components/FieldValuesWidget/utils.js b/frontend/src/metabase/components/FieldValuesWidget/utils.ts similarity index 66% rename from frontend/src/metabase/components/FieldValuesWidget/utils.js rename to frontend/src/metabase/components/FieldValuesWidget/utils.ts index a0fbb7abd58..cf0c9f2669e 100644 --- a/frontend/src/metabase/components/FieldValuesWidget/utils.js +++ b/frontend/src/metabase/components/FieldValuesWidget/utils.ts @@ -4,6 +4,9 @@ import _ from "underscore"; import { MetabaseApi } from "metabase/services"; import { stripId } from "metabase/lib/formatting"; +import type { Dashboard, Parameter, FieldValue } from "metabase-types/api"; +import type Field from "metabase-lib/metadata/Field"; + import { isIdParameter, isNumberParameter, @@ -16,19 +19,32 @@ import { canSearchParameterValues, getSourceType, } from "metabase-lib/parameters/utils/parameter-source"; +import Question from "metabase-lib/Question"; + +import type { ValuesMode } from "./types"; export async function searchFieldValues( - { fields, value, disablePKRemappingForSearch, maxResults }, - cancelled, + { + fields, + value, + disablePKRemappingForSearch, + maxResults, + }: { + fields: Field[]; + value: string; + disablePKRemappingForSearch?: boolean; + maxResults: number; + }, + cancelled: Promise<unknown>, ) { - let options = dedupeValues( + let options: null | FieldValue[] = dedupeValues( await Promise.all( - fields.map(field => + fields.map((field: Field) => MetabaseApi.field_search( { value, fieldId: field.id, - searchFieldId: field.searchField(disablePKRemappingForSearch).id, + searchFieldId: field.searchField(disablePKRemappingForSearch)?.id, limit: maxResults, }, { cancelled }, @@ -37,36 +53,44 @@ export async function searchFieldValues( ), ); - options = options.map(result => [].concat(result)); + options = options?.map(result => (Array.isArray(result) ? result : [result])); return options; } -export function getNonVirtualFields(fields) { +export function getNonVirtualFields(fields: Field[]) { return fields.filter(field => !field.isVirtual()); } -export function dedupeValues(valuesList) { +export function dedupeValues(valuesList: FieldValue[][]): FieldValue[] { const uniqueValueMap = new Map(valuesList.flat().map(o => [o[0], o])); return Array.from(uniqueValueMap.values()); } -export function canUseParameterEndpoints(parameter) { +export function canUseParameterEndpoints(parameter?: Parameter) { return parameter != null; } -export function canUseCardEndpoints(question) { +export function canUseCardEndpoints(question?: Question) { return question?.isSaved(); } -export function canUseDashboardEndpoints(dashboard) { +export function canUseDashboardEndpoints(dashboard?: Dashboard) { return dashboard?.id; } -export function showRemapping(fields) { +export function showRemapping(fields: Field[]) { return fields.length === 1; } -export function shouldList({ parameter, fields, disableSearch }) { +export function shouldList({ + parameter, + fields, + disableSearch, +}: { + parameter?: Parameter; + fields: Field[]; + disableSearch: boolean; +}) { if (disableSearch) { return false; } else { @@ -76,7 +100,10 @@ export function shouldList({ parameter, fields, disableSearch }) { } } -function getNonSearchableTokenFieldPlaceholder(firstField, parameter) { +function getNonSearchableTokenFieldPlaceholder( + firstField: Field, + parameter?: Parameter, +) { if (parameter) { if (isIdParameter(parameter)) { return t`Enter an ID`; @@ -105,21 +132,26 @@ function getNonSearchableTokenFieldPlaceholder(firstField, parameter) { return t`Enter some text`; } -export function searchField(field, disablePKRemappingForSearch) { +export function searchField( + field: Field, + disablePKRemappingForSearch: boolean, +) { return field.searchField(disablePKRemappingForSearch); } function getSearchableTokenFieldPlaceholder( - parameter, - fields, - firstField, - disablePKRemappingForSearch, + parameter: Parameter | undefined, + fields: Field[], + firstField: Field, + disablePKRemappingForSearch?: boolean, ) { let placeholder; const names = new Set( - fields.map(field => - stripId(field.searchField(disablePKRemappingForSearch).display_name), + fields.map((field: Field) => + stripId( + field?.searchField?.(disablePKRemappingForSearch)?.display_name ?? "", + ), ), ); @@ -143,7 +175,17 @@ function getSearchableTokenFieldPlaceholder( return placeholder; } -export function hasList({ parameter, fields, disableSearch, options }) { +export function hasList({ + parameter, + fields, + disableSearch, + options, +}: { + parameter?: Parameter; + fields: Field[]; + disableSearch: boolean; + options: FieldValue[]; +}) { return ( shouldList({ parameter, fields, disableSearch }) && !_.isEmpty(options) ); @@ -153,10 +195,10 @@ export function hasList({ parameter, fields, disableSearch, options }) { // wasn't truncated, then we don't need to do another search because TypeaheadListing // will filter the previous result client-side export function isExtensionOfPreviousSearch( - value, - lastValue, - options, - maxResults, + value: string, + lastValue: string, + options: FieldValue[], + maxResults: number, ) { return ( lastValue && @@ -171,6 +213,12 @@ export function isSearchable({ disableSearch, disablePKRemappingForSearch, valuesMode, +}: { + parameter?: Parameter; + fields: Field[]; + disableSearch: boolean; + disablePKRemappingForSearch?: boolean; + valuesMode?: ValuesMode; }) { if (disableSearch) { return false; @@ -191,6 +239,14 @@ export function getTokenFieldPlaceholder({ disablePKRemappingForSearch, options, valuesMode, +}: { + fields: Field[]; + parameter?: Parameter; + disableSearch: boolean; + placeholder?: string; + disablePKRemappingForSearch?: boolean; + options: FieldValue[]; + valuesMode: ValuesMode; }) { if (placeholder) { return placeholder; @@ -232,7 +288,12 @@ export function getValuesMode({ fields, disableSearch, disablePKRemappingForSearch, -}) { +}: { + parameter?: Parameter; + fields: Field[]; + disableSearch: boolean; + disablePKRemappingForSearch?: boolean; +}): ValuesMode { if ( isSearchable({ parameter, @@ -252,7 +313,7 @@ export function getValuesMode({ return "none"; } -export function isNumeric(field, parameter) { +export function isNumeric(field: Field, parameter?: Parameter) { if (parameter) { return isNumberParameter(parameter); } -- GitLab