diff --git a/frontend/src/metabase-lib/metadata/utils/fields.ts b/frontend/src/metabase-lib/metadata/utils/fields.ts index 545df4ffccf9f12234691ad9bc01a1f6783ce4fa..c9e1367893a8024e1cc96a54f072423aa6ed5849 100644 --- a/frontend/src/metabase-lib/metadata/utils/fields.ts +++ b/frontend/src/metabase-lib/metadata/utils/fields.ts @@ -49,3 +49,7 @@ function getFieldIdentifier(field: Field): number | string { return id || name; } + +export function isVirtualFieldId(id: Field["id"]) { + return typeof id !== "number"; +} diff --git a/frontend/src/metabase-lib/parameters/utils/parameter-fields.ts b/frontend/src/metabase-lib/parameters/utils/parameter-fields.ts new file mode 100644 index 0000000000000000000000000000000000000000..60238fffa54739dbb50e71fb2f1915e4a4c33b71 --- /dev/null +++ b/frontend/src/metabase-lib/parameters/utils/parameter-fields.ts @@ -0,0 +1,23 @@ +import { isVirtualFieldId } from "metabase-lib/metadata/utils/fields"; +import { + FieldFilterUiParameter, + UiParameter, +} from "metabase-lib/parameters/types"; + +export const hasFields = ( + parameter: UiParameter, +): parameter is FieldFilterUiParameter => { + return (parameter as FieldFilterUiParameter).fields != null; +}; + +export const getFields = (parameter: UiParameter) => { + if (hasFields(parameter)) { + return parameter.fields; + } else { + return []; + } +}; + +export const getNonVirtualFields = (parameter: UiParameter) => { + return getFields(parameter).filter(field => !isVirtualFieldId(field.id)); +}; diff --git a/frontend/src/metabase-lib/parameters/utils/parameter-source.ts b/frontend/src/metabase-lib/parameters/utils/parameter-source.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d04f6c013f34696a14d711add8676908dba8e7e --- /dev/null +++ b/frontend/src/metabase-lib/parameters/utils/parameter-source.ts @@ -0,0 +1,27 @@ +import { ValuesSourceConfig, ValuesSourceType } from "metabase-types/api"; + +export const isValidSourceConfig = ( + sourceType: ValuesSourceType, + sourceConfig: ValuesSourceConfig, +) => { + switch (sourceType) { + case "card": + return sourceConfig.card_id != null && sourceConfig.value_field != null; + case "static-list": + return sourceConfig.values != null && sourceConfig.values.length > 0; + default: + return true; + } +}; + +export const getDefaultSourceConfig = ( + sourceType: ValuesSourceType, + sourceValues?: string[], +) => { + switch (sourceType) { + case "static-list": + return { values: sourceValues }; + default: + return {}; + } +}; diff --git a/frontend/src/metabase-types/api/mocks/parameters.ts b/frontend/src/metabase-types/api/mocks/parameters.ts index ceb6e138021a6b32936ad89843061006fe65a746..ce15e59058072abcfbf72d80d0a5bd8eda7b2801 100644 --- a/frontend/src/metabase-types/api/mocks/parameters.ts +++ b/frontend/src/metabase-types/api/mocks/parameters.ts @@ -1,4 +1,4 @@ -import { Parameter, ValuesSourceConfig } from "metabase-types/api"; +import { Parameter } from "metabase-types/api"; export const createMockParameter = (opts?: Partial<Parameter>): Parameter => ({ id: "1", @@ -7,9 +7,3 @@ export const createMockParameter = (opts?: Partial<Parameter>): Parameter => ({ slug: "text", ...opts, }); - -export const createMockValuesSourceConfig = ( - opts?: Partial<ValuesSourceConfig>, -): ValuesSourceConfig => ({ - ...opts, -}); diff --git a/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx b/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx index e6b2214550e0464da739a9762bde183f9f0114ad..5fbf9c9ab41f43fee612fbd3ac09ab77138892db 100644 --- a/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx +++ b/frontend/src/metabase/containers/DataPicker/DataPickerContainer.tsx @@ -54,6 +54,7 @@ function mapStateToProps(state: State) { } function DataPicker({ + value, databases, search: modelLookupResult, filters: customFilters = {}, @@ -114,7 +115,7 @@ function DataPicker({ ); useOnMount(() => { - if (dataTypes.length === 1) { + if (dataTypes.length === 1 && value.type !== dataTypes[0].id) { handleDataTypeChange(dataTypes[0].id); } }); @@ -133,6 +134,7 @@ function DataPicker({ return ( <DataPickerView {...props} + value={value} dataTypes={dataTypes} searchQuery={search.query} hasDataAccess={hasDataAccess} diff --git a/frontend/src/metabase/containers/DataPicker/tests/DataPicker-RawData.unit.spec.ts b/frontend/src/metabase/containers/DataPicker/tests/DataPicker-RawData.unit.spec.ts index db37ba4d10032208040edaf20d60b03211441578..d43bb01762c6bae701a8afe2fdf16ad21c3e5745 100644 --- a/frontend/src/metabase/containers/DataPicker/tests/DataPicker-RawData.unit.spec.ts +++ b/frontend/src/metabase/containers/DataPicker/tests/DataPicker-RawData.unit.spec.ts @@ -114,6 +114,9 @@ describe("DataPicker — picking raw data", () => { schemaId: generateSchemaId(SAMPLE_DATABASE.id, "PUBLIC"), tableIds: [SAMPLE_DATABASE.PRODUCTS.id], }, + filters: { + types: type => type === "raw-data", + }, }); const tableListItem = await screen.findByRole("menuitem", { diff --git a/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardStepModal.styled.tsx b/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardStepModal.styled.tsx deleted file mode 100644 index 31f5ea0709728e3081f9a440bfa53007f63798a8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardStepModal.styled.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import styled from "@emotion/styled"; - -export const SearchInputContainer = styled.div` - margin-bottom: 1.5rem; -`; - -export const DataPickerContainer = styled.div` - height: 50vh; - overflow-y: auto; -`; diff --git a/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardStepModal.tsx b/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardStepModal.tsx deleted file mode 100644 index e08dff873254c24babd7e25b5d596970a28a263d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardStepModal.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import React, { ChangeEvent, useCallback } from "react"; -import { connect } from "react-redux"; -import { t } from "ttag"; -import _ from "underscore"; -import Button from "metabase/core/components/Button/Button"; -import Input from "metabase/core/components/Input"; -import ModalContent from "metabase/components/ModalContent"; -import DataPicker, { - DataPickerValue, - useDataPicker, - useDataPickerValue, -} from "metabase/containers/DataPicker"; -import Questions from "metabase/entities/questions"; -import Collections from "metabase/entities/collections"; -import { getMetadata } from "metabase/selectors/metadata"; -import { Card, CardId, Collection } from "metabase-types/api"; -import { State } from "metabase-types/store"; -import Question from "metabase-lib/Question"; -import { - getCollectionVirtualSchemaId, - getQuestionIdFromVirtualTableId, - getQuestionVirtualTableId, -} from "metabase-lib/metadata/utils/saved-questions"; -import { - DataPickerContainer, - SearchInputContainer, -} from "./CardStepModal.styled"; - -interface CardStepModalOwnProps { - cardId: CardId | undefined; - onChangeCard: (cardId: CardId | undefined) => void; - onSubmit: () => void; - onClose: () => void; -} - -interface CardStepModalCardProps { - card: Card | undefined; -} - -interface CardStepModalCollectionProps { - collection: Collection | undefined; -} - -interface CardStepModalStateProps { - question: Question | undefined; -} - -type CardStepModalProps = CardStepModalOwnProps & - CardStepModalCardProps & - CardStepModalCollectionProps & - CardStepModalStateProps; - -const CardStepModal = ({ - question, - collection, - onChangeCard, - onSubmit, - onClose, -}: CardStepModalProps): JSX.Element => { - const initialValue = getInitialValue(question, collection); - const [value, setValue] = useDataPickerValue(initialValue); - const cardId = getCardIdFromValue(value); - - const handleSubmit = useCallback(() => { - onChangeCard(cardId); - onSubmit(); - }, [cardId, onChangeCard, onSubmit]); - - return ( - <ModalContent - title={t`Pick a model or question to use for the values of this widget`} - footer={[ - <Button key="cancel" onClick={onClose}>{t`Cancel`}</Button>, - <Button - key="submit" - primary - disabled={cardId == null} - onClick={handleSubmit} - >{t`Select column`}</Button>, - ]} - onClose={onClose} - > - <DataPicker.Provider> - <DataPickerSearchInput /> - <DataPickerContainer> - <DataPicker value={value} onChange={setValue} /> - </DataPickerContainer> - </DataPicker.Provider> - </ModalContent> - ); -}; - -const DataPickerSearchInput = () => { - const { search } = useDataPicker(); - const { query, setQuery } = search; - - const handleChange = useCallback( - (event: ChangeEvent<HTMLInputElement>) => { - setQuery(event.target.value); - }, - [setQuery], - ); - - return ( - <SearchInputContainer> - <Input - value={query} - placeholder={t`Search for a question or model`} - leftIcon="search" - fullWidth - onChange={handleChange} - /> - </SearchInputContainer> - ); -}; - -const getInitialValue = ( - question?: Question, - collection?: Collection, -): Partial<DataPickerValue> | undefined => { - if (question) { - const id = question.id(); - const isDatasets = question.isDataset(); - - return { - type: isDatasets ? "models" : "questions", - schemaId: getCollectionVirtualSchemaId(collection, { isDatasets }), - collectionId: collection?.id, - tableIds: [getQuestionVirtualTableId(id)], - }; - } -}; - -const getCardIdFromValue = ({ tableIds }: DataPickerValue) => { - if (tableIds.length) { - const cardId = getQuestionIdFromVirtualTableId(tableIds[0]); - if (cardId != null) { - return cardId; - } - } -}; - -export default _.compose( - Questions.load({ - id: (state: State, { cardId }: CardStepModalOwnProps) => cardId, - entityAlias: "card", - }), - Collections.load({ - id: (state: State, { card }: CardStepModalCardProps) => - card?.collection_id ?? "root", - }), - connect((state: State, { card }: CardStepModalCardProps) => ({ - question: card ? new Question(card, getMetadata(state)) : undefined, - })), -)(CardStepModal); diff --git a/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardValuesSourceModal.tsx b/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardValuesSourceModal.tsx deleted file mode 100644 index 33d9b154f4f081e891a369726dd0311fe159b472..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/CardValuesSourceModal/CardValuesSourceModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { ValuesSourceConfig } from "metabase-types/api"; -import CardStepModal from "./CardStepModal"; -import FieldStepModal from "./FieldStepModal"; - -type ModalStep = "card" | "field"; - -export interface CardValuesSourceModalProps { - sourceConfig: ValuesSourceConfig; - onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; - onClose: () => void; -} - -const CardValuesSourceModal = ({ - sourceConfig, - onChangeSourceConfig, - onClose, -}: CardValuesSourceModalProps): JSX.Element | null => { - const [step, setStep] = useState<ModalStep>("card"); - const [cardId, setCardId] = useState(sourceConfig.card_id); - const [fieldReference, setFieldReference] = useState( - sourceConfig.value_field, - ); - - const handleCardSubmit = useCallback(() => { - setStep("field"); - }, []); - - const handleFieldSubmit = useCallback(() => { - onChangeSourceConfig({ card_id: cardId, value_field: fieldReference }); - onClose(); - }, [cardId, fieldReference, onChangeSourceConfig, onClose]); - - const handleFieldCancel = useCallback(() => { - setStep("card"); - }, []); - - switch (step) { - case "card": - return ( - <CardStepModal - cardId={cardId} - onChangeCard={setCardId} - onSubmit={handleCardSubmit} - onClose={onClose} - /> - ); - case "field": - return ( - <FieldStepModal - cardId={cardId} - fieldReference={fieldReference} - onChangeField={setFieldReference} - onSubmit={handleFieldSubmit} - onCancel={handleFieldCancel} - onClose={onClose} - /> - ); - default: - return null; - } -}; - -export default CardValuesSourceModal; diff --git a/frontend/src/metabase/parameters/components/CardValuesSourceModal/FieldStepModal.tsx b/frontend/src/metabase/parameters/components/CardValuesSourceModal/FieldStepModal.tsx deleted file mode 100644 index a32b1d224b34bd850e962da58973f0fb17646e79..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/CardValuesSourceModal/FieldStepModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback, useMemo } from "react"; -import { t } from "ttag"; -import _ from "underscore"; -import Button from "metabase/core/components/Button/Button"; -import Select, { - Option, - SelectChangeEvent, -} from "metabase/core/components/Select"; -import ModalContent from "metabase/components/ModalContent"; -import Tables from "metabase/entities/tables"; -import { CardId } from "metabase-types/api"; -import { State } from "metabase-types/store"; -import Field from "metabase-lib/metadata/Field"; -import Table from "metabase-lib/metadata/Table"; -import { getQuestionVirtualTableId } from "metabase-lib/metadata/utils/saved-questions"; -import { ModalBody } from "./FieldStepModal.styled"; - -interface FieldStepModalOwnProps { - cardId: CardId | undefined; - fieldReference: unknown[] | undefined; - onChangeField: (field: unknown[]) => void; - onSubmit: () => void; - onCancel: () => void; - onClose: () => void; -} - -interface FieldStepModalTableProps { - table: Table; -} - -type FieldStepModalProps = FieldStepModalOwnProps & FieldStepModalTableProps; - -const FieldStepModal = ({ - table, - fieldReference, - onChangeField, - onSubmit, - onCancel, - onClose, -}: FieldStepModalProps): JSX.Element => { - const fields = useMemo(() => { - return getSupportedFields(table); - }, [table]); - - const selectedField = useMemo(() => { - return fieldReference && getFieldByReference(fields, fieldReference); - }, [fields, fieldReference]); - - const handleChange = useCallback( - (event: SelectChangeEvent<Field>) => { - onChangeField(event.target.value.reference()); - }, - [onChangeField], - ); - - return ( - <ModalContent - title={t`Which column from ${table.displayName()} should be used`} - footer={[ - <Button key="cancel" onClick={onCancel}>{t`Back`}</Button>, - <Button - key="submit" - primary - disabled={!selectedField} - onClick={onSubmit} - >{t`Done`}</Button>, - ]} - onClose={onClose} - > - <ModalBody> - <Select - value={selectedField} - placeholder={t`Pick a column`} - onChange={handleChange} - > - {fields.map((field, index) => ( - <Option key={index} name={field.displayName()} value={field} /> - ))} - </Select> - </ModalBody> - </ModalContent> - ); -}; - -const getFieldByReference = (fields: Field[], fieldReference: unknown[]) => { - return fields.find(field => _.isEqual(field.reference(), fieldReference)); -}; - -const getSupportedFields = (table: Table) => { - return table.fields.filter(field => field.isString()); -}; - -export default Tables.load({ - id: (state: State, { cardId }: FieldStepModalOwnProps) => - getQuestionVirtualTableId(cardId), - requestType: "fetchMetadata", -})(FieldStepModal); diff --git a/frontend/src/metabase/parameters/components/CardValuesSourceModal/index.ts b/frontend/src/metabase/parameters/components/CardValuesSourceModal/index.ts deleted file mode 100644 index 9d270024f7990fd0bdd5456f564ec4e3d2d717e0..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/CardValuesSourceModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CardValuesSourceModal"; diff --git a/frontend/src/metabase/parameters/components/FormattedParameterValue/FormattedParameterValue.tsx b/frontend/src/metabase/parameters/components/FormattedParameterValue/FormattedParameterValue.tsx index 8d08be9be8073a85c5472c155d9b228d04585c3b..3fc3fb69359642264fa4cbacc7be393c186c9f44 100644 --- a/frontend/src/metabase/parameters/components/FormattedParameterValue/FormattedParameterValue.tsx +++ b/frontend/src/metabase/parameters/components/FormattedParameterValue/FormattedParameterValue.tsx @@ -2,10 +2,8 @@ import React from "react"; import { formatParameterValue } from "metabase/parameters/utils/formatting"; import ParameterFieldWidgetValue from "metabase/parameters/components/widgets/ParameterFieldWidget/ParameterFieldWidgetValue/ParameterFieldWidgetValue"; -import { - UiParameter, - FieldFilterUiParameter, -} from "metabase-lib/parameters/types"; +import { UiParameter } from "metabase-lib/parameters/types"; +import { hasFields } from "metabase-lib/parameters/utils/parameter-fields"; import { isDateParameter } from "metabase-lib/parameters/utils/parameter-type"; type FormattedParameterValueProps = { @@ -32,10 +30,4 @@ function FormattedParameterValue({ return <span>{formatParameterValue(value, parameter)}</span>; } -function hasFields( - parameter: UiParameter, -): parameter is FieldFilterUiParameter { - return !!(parameter as FieldFilterUiParameter).fields; -} - export default FormattedParameterValue; diff --git a/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.styled.tsx b/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.styled.tsx deleted file mode 100644 index 943bdf672834d728ce9783c0f18f102b1708eedc..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.styled.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; -import TextArea from "metabase/core/components/TextArea"; - -export const ModalMessage = styled.div` - color: ${color("text-medium")}; - margin-bottom: 1rem; -`; - -export const ModalTextArea = styled(TextArea)` - resize: vertical; -`; diff --git a/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.tsx b/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.tsx deleted file mode 100644 index 978f70fb64a1dedab6a2fa00e31dad4983e269f2..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { ChangeEvent, useCallback, useState } from "react"; -import { t } from "ttag"; -import Button from "metabase/core/components/Button"; -import ModalContent from "metabase/components/ModalContent"; -import { ValuesSourceConfig } from "metabase-types/api"; -import { ModalMessage, ModalTextArea } from "./ListValuesSourceModal.styled"; - -const NEW_LINE = "\n"; -const PLACEHOLDER = [t`banana`, t`orange`].join(NEW_LINE); - -export interface ListValuesSourceModalProps { - sourceConfig: ValuesSourceConfig; - onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; - onClose: () => void; -} - -const ListValuesSourceModal = ({ - sourceConfig, - onChangeSourceConfig, - onClose, -}: ListValuesSourceModalProps): JSX.Element => { - const [value, setValue] = useState(getInputValue(sourceConfig.values)); - const isEmpty = !value.trim().length; - - const handleChange = useCallback( - (event: ChangeEvent<HTMLTextAreaElement>) => { - setValue(event.target.value); - }, - [], - ); - - const handleSubmit = useCallback(() => { - onChangeSourceConfig({ values: getSourceValues(value) }); - onClose(); - }, [value, onChangeSourceConfig, onClose]); - - return ( - <ModalContent - title={t`Create a custom list`} - footer={[ - <Button key="cancel" onClick={onClose}>{t`Cancel`}</Button>, - <Button - key="submit" - primary - disabled={isEmpty} - onClick={handleSubmit} - >{t`Done`}</Button>, - ]} - onClose={onClose} - > - <div> - <ModalMessage>{t`Enter one value per line.`}</ModalMessage> - <ModalTextArea - value={value} - placeholder={PLACEHOLDER} - autoFocus - fullWidth - onChange={handleChange} - /> - </div> - </ModalContent> - ); -}; - -const getInputValue = (values?: string[]) => { - return values?.join(NEW_LINE) ?? ""; -}; - -const getSourceValues = (value: string) => { - return value - .split(NEW_LINE) - .map(line => line.trim()) - .filter(line => line.length > 0); -}; - -export default ListValuesSourceModal; diff --git a/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.unit.spec.tsx b/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.unit.spec.tsx deleted file mode 100644 index 2188d327ca68966ccc0618bcadd6c589596ded38..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/ListValuesSourceModal/ListValuesSourceModal.unit.spec.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent, { specialChars } from "@testing-library/user-event"; -import { createMockValuesSourceConfig } from "metabase-types/api/mocks"; -import ListValuesSourceModal, { - ListValuesSourceModalProps, -} from "./ListValuesSourceModal"; - -describe("ListValuesSourceModal", () => { - it("should trim and set source values", () => { - const props = getProps(); - - render(<ListValuesSourceModal {...props} />); - - const input = screen.getByRole("textbox"); - userEvent.type(input, `Gadget ${specialChars.enter}`); - userEvent.type(input, `Widget ${specialChars.enter}`); - userEvent.click(screen.getByText("Done")); - - expect(props.onChangeSourceConfig).toHaveBeenCalledWith({ - values: ["Gadget", "Widget"], - }); - }); - - it("should not allow to submit empty values", () => { - const props = getProps({ - sourceConfig: createMockValuesSourceConfig({ - values: ["Gadget", "Gizmo"], - }), - }); - - render(<ListValuesSourceModal {...props} />); - userEvent.clear(screen.getByRole("textbox")); - - expect(screen.getByRole("button", { name: "Done" })).toBeDisabled(); - }); -}); - -const getProps = ( - opts?: Partial<ListValuesSourceModalProps>, -): ListValuesSourceModalProps => ({ - sourceConfig: createMockValuesSourceConfig(), - onChangeSourceConfig: jest.fn(), - onClose: jest.fn(), - ...opts, -}); diff --git a/frontend/src/metabase/parameters/components/ListValuesSourceModal/index.ts b/frontend/src/metabase/parameters/components/ListValuesSourceModal/index.ts deleted file mode 100644 index 93b838895a5dfc4530955158158c1d13acbbea13..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/ListValuesSourceModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ListValuesSourceModal"; diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx index 364520e5ab2cd25318c507586e2a37d3ce3b17e1..095e988d5abc6557321fe086002c970a0e5f0dbf 100644 --- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx @@ -13,6 +13,7 @@ export const SettingSection = styled.div` export const SettingLabel = styled.label` display: block; + color: ${color("text-medium")}; margin-bottom: 0.5rem; font-weight: bold; `; diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx index 6722cc369568dffe431c0ef301b4fef084f0bc30..1e0dd14780a8c6d7c6ecdc2a743a9f372c4c74da 100644 --- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx @@ -1,12 +1,6 @@ -import React, { - ChangeEvent, - FocusEvent, - useCallback, - useLayoutEffect, - useState, -} from "react"; +import React, { ChangeEvent, useCallback } from "react"; import { t } from "ttag"; -import Input from "metabase/core/components/Input"; +import InputBlurChange from "metabase/components/InputBlurChange"; import Radio from "metabase/core/components/Radio"; import { ValuesSourceConfig, ValuesSourceType } from "metabase-types/api"; import { UiParameter } from "metabase-lib/parameters/types"; @@ -42,21 +36,31 @@ export interface ParameterSettingsProps { const ParameterSettings = ({ parameter, onChangeName, - onChangeSourceType, - onChangeSourceConfig, onChangeDefaultValue, onChangeIsMultiSelect, + onChangeSourceType, + onChangeSourceConfig, onRemoveParameter, }: ParameterSettingsProps): JSX.Element => { + const handleNameChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => { + onChangeName(event.target.value); + }, + [onChangeName], + ); + return ( <SettingsRoot> <SettingSection> <SettingLabel>{t`Label`}</SettingLabel> - <ParameterInput initialValue={parameter.name} onChange={onChangeName} /> + <InputBlurChange + value={parameter.name} + onBlurChange={handleNameChange} + /> </SettingSection> {canUseCustomSource(parameter) && ( <SettingSection> - <SettingLabel>{t`Options to pick from`}</SettingLabel> + <SettingLabel>{t`How should users filter on this column?`}</SettingLabel> <ParameterSourceSettings parameter={parameter} onChangeSourceType={onChangeSourceType} @@ -92,37 +96,4 @@ const ParameterSettings = ({ ); }; -interface ParameterInputProps { - initialValue: string; - onChange: (value: string) => void; -} - -const ParameterInput = ({ initialValue, onChange }: ParameterInputProps) => { - const [value, setValue] = useState(initialValue); - - useLayoutEffect(() => { - setValue(initialValue); - }, [initialValue]); - - const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => { - setValue(event.target.value); - }, []); - - const handleBlur = useCallback( - (event: FocusEvent<HTMLInputElement>) => { - onChange(event.target.value); - }, - [onChange], - ); - - return ( - <Input - value={value} - fullWidth - onChange={handleChange} - onBlur={handleBlur} - /> - ); -}; - export default ParameterSettings; diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx deleted file mode 100644 index cfeb4a64b44dd6635d1bdfbf57cd71de324bd9bc..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { createMockUiParameter } from "metabase-lib/mocks"; -import ParameterSettings, { ParameterSettingsProps } from "./ParameterSettings"; - -describe("ParameterSettings", () => { - it("should show source settings only for string dropdowns", () => { - const props = getProps({ - parameter: createMockUiParameter({ - type: "string/=", - }), - }); - - render(<ParameterSettings {...props} />); - - expect(screen.getByText("Options to pick from")).toBeInTheDocument(); - }); - - it("should not show source settings for other parameter types", () => { - const props = getProps({ - parameter: createMockUiParameter({ - type: "string/!=", - }), - }); - - render(<ParameterSettings {...props} />); - - expect(screen.queryByText("Options to pick from")).not.toBeInTheDocument(); - }); -}); - -const getProps = ( - opts?: Partial<ParameterSettingsProps>, -): ParameterSettingsProps => ({ - parameter: createMockUiParameter(), - onChangeName: jest.fn(), - onChangeDefaultValue: jest.fn(), - onChangeIsMultiSelect: jest.fn(), - onChangeSourceType: jest.fn(), - onChangeSourceConfig: jest.fn(), - onRemoveParameter: jest.fn(), - ...opts, -}); diff --git a/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.tsx b/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.tsx index 7ba3740129f58200ba22fde77a67d0d8f92ac975..cdc5e3f6a51ad37ac5568ced65a267522ed22918 100644 --- a/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.tsx @@ -2,14 +2,9 @@ import React, { useCallback, useMemo, useState } from "react"; import { t } from "ttag"; import Radio from "metabase/core/components/Radio/Radio"; import Modal from "metabase/components/Modal"; -import { - getSourceConfig, - getSourceType, -} from "metabase/parameters/utils/dashboards"; import { ValuesSourceConfig, ValuesSourceType } from "metabase-types/api"; import { UiParameter } from "metabase-lib/parameters/types"; -import CardValuesSourceModal from "../CardValuesSourceModal"; -import ListValuesSourceModal from "../ListValuesSourceModal"; +import ValuesSourceModal from "../ValuesSourceModal"; import { RadioLabelButton, RadioLabelRoot, @@ -27,64 +22,33 @@ const ParameterSourceSettings = ({ onChangeSourceType, onChangeSourceConfig, }: ParameterSourceSettingsProps): JSX.Element => { - const sourceType = getSourceType(parameter); - const sourceConfig = getSourceConfig(parameter); - const [editingType, setEditingType] = useState<ValuesSourceType>(); + const [isModalOpened, setIsModalOpened] = useState(false); - const radioOptions = useMemo( - () => getRadioOptions(sourceType, setEditingType), - [sourceType], - ); + const radioOptions = useMemo(() => { + return getRadioOptions(() => setIsModalOpened(true)); + }, []); - const handleSourceTypeChange = useCallback( - (sourceType: ValuesSourceType) => { - if (sourceType == null) { - onChangeSourceType(sourceType); - onChangeSourceConfig({}); - } else { - setEditingType(sourceType); - } + const handleSubmit = useCallback( + (sourceType: ValuesSourceType, sourceConfig: ValuesSourceConfig) => { + onChangeSourceType(sourceType); + onChangeSourceConfig(sourceConfig); }, [onChangeSourceType, onChangeSourceConfig], ); - const handleSourceConfigChange = useCallback( - (sourceConfig: ValuesSourceConfig) => { - if (editingType) { - onChangeSourceType(editingType); - onChangeSourceConfig(sourceConfig); - } - }, - [editingType, onChangeSourceType, onChangeSourceConfig], - ); - - const handleClose = useCallback(() => { - setEditingType(undefined); + const handleModalClose = useCallback(() => { + setIsModalOpened(false); }, []); return ( <> - <Radio - value={sourceType} - options={radioOptions} - vertical - onChange={handleSourceTypeChange} - /> - {editingType === "card" && ( - <Modal medium onClose={handleClose}> - <CardValuesSourceModal - sourceConfig={sourceConfig} - onChangeSourceConfig={handleSourceConfigChange} - onClose={handleClose} - /> - </Modal> - )} - {editingType === "static-list" && ( - <Modal onClose={handleClose}> - <ListValuesSourceModal - sourceConfig={sourceConfig} - onChangeSourceConfig={handleSourceConfigChange} - onClose={handleClose} + <Radio value="list" options={radioOptions} vertical /> + {isModalOpened && ( + <Modal medium onClose={handleModalClose}> + <ValuesSourceModal + parameter={parameter} + onSubmit={handleSubmit} + onClose={handleModalClose} /> </Modal> )} @@ -94,58 +58,23 @@ const ParameterSourceSettings = ({ interface RadioLabelProps { title: string; - isSelected?: boolean; - onEditClick?: () => void; + onEditClick: () => void; } -const RadioLabel = ({ - title, - isSelected, - onEditClick, -}: RadioLabelProps): JSX.Element => { +const RadioLabel = ({ title, onEditClick }: RadioLabelProps): JSX.Element => { return ( <RadioLabelRoot> <RadioLabelTitle>{title}</RadioLabelTitle> - {isSelected && onEditClick && ( - <RadioLabelButton onClick={onEditClick}>{t`Edit`}</RadioLabelButton> - )} + <RadioLabelButton onClick={onEditClick}>{t`Edit`}</RadioLabelButton> </RadioLabelRoot> ); }; -const getRadioOptions = ( - sourceType: ValuesSourceType, - onEdit: (sourceType: ValuesSourceType) => void, -) => { +const getRadioOptions = (onEditClick: () => void) => { return [ { - name: ( - <RadioLabel - title={t`Values from column`} - isSelected={sourceType === null} - /> - ), - value: null, - }, - { - name: ( - <RadioLabel - title={t`Values from a model or question`} - isSelected={sourceType === "card"} - onEditClick={() => onEdit("card")} - /> - ), - value: "card", - }, - { - name: ( - <RadioLabel - title={t`Custom list`} - isSelected={sourceType === "static-list"} - onEditClick={() => onEdit("static-list")} - /> - ), - value: "static-list", + name: <RadioLabel title={t`Dropdown list`} onEditClick={onEditClick} />, + value: "list", }, ]; }; diff --git a/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.unit.spec.tsx b/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.unit.spec.tsx deleted file mode 100644 index 63da33cb293e8c2ab5df223e840da6a4b98d5695..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/parameters/components/ParameterSourceSettings/ParameterSourceSettings.unit.spec.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from "react"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "__support__/ui"; -import { createMockUiParameter } from "metabase-lib/mocks"; -import ParameterSourceSettings, { - ParameterSourceSettingsProps, -} from "./ParameterSourceSettings"; - -describe("ParameterSourceSettings", () => { - it("should set the default source type", () => { - const props = getProps({ - parameter: createMockUiParameter({ - values_source_type: "static-list", - }), - }); - - renderWithProviders(<ParameterSourceSettings {...props} />); - userEvent.click(screen.getByText("Values from column")); - - expect(props.onChangeSourceType).toHaveBeenCalledWith(null); - }); - - it("should set up the static list source via the modal", () => { - const props = getProps(); - - renderWithProviders(<ParameterSourceSettings {...props} />); - userEvent.click(screen.getByText("Custom list")); - userEvent.type(screen.getByRole("textbox"), "Gadget"); - userEvent.click(screen.getByText("Done")); - - expect(props.onChangeSourceType).toHaveBeenCalledWith("static-list"); - expect(props.onChangeSourceConfig).toHaveBeenCalledWith({ - values: ["Gadget"], - }); - }); - - it("should edit the static list source via the modal", () => { - const props = getProps({ - parameter: createMockUiParameter({ - values_source_type: "static-list", - values_source_config: { values: ["Gadget"] }, - }), - }); - - renderWithProviders(<ParameterSourceSettings {...props} />); - userEvent.click(screen.getByText("Edit")); - userEvent.clear(screen.getByRole("textbox")); - userEvent.type(screen.getByRole("textbox"), "Widget"); - userEvent.click(screen.getByText("Done")); - - expect(props.onChangeSourceType).toHaveBeenCalledWith("static-list"); - expect(props.onChangeSourceConfig).toHaveBeenCalledWith({ - values: ["Widget"], - }); - }); - - it("should not change the source type if the modal was dismissed", () => { - const props = getProps(); - - renderWithProviders(<ParameterSourceSettings {...props} />); - userEvent.click(screen.getByText("Custom list")); - userEvent.click(screen.getByText("Cancel")); - - expect(props.onChangeSourceType).not.toHaveBeenCalled(); - expect(props.onChangeSourceConfig).not.toHaveBeenCalled(); - }); -}); - -const getProps = ( - opts?: Partial<ParameterSourceSettingsProps>, -): ParameterSourceSettingsProps => ({ - parameter: createMockUiParameter(), - onChangeSourceType: jest.fn(), - onChangeSourceConfig: jest.fn(), - ...opts, -}); diff --git a/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceCardModal.styled.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceCardModal.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d33ddd092495b84089643d33258892f00bb2a8bf --- /dev/null +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceCardModal.styled.tsx @@ -0,0 +1,15 @@ +import styled from "@emotion/styled"; +import { ModalBody } from "./ValuesSourceModal.styled"; + +export const ModalBodyWithSearch = styled(ModalBody)` + display: flex; + flex-direction: column; +`; + +export const SearchInputContainer = styled.div` + margin-bottom: 1rem; +`; + +export const DataPickerContainer = styled.div` + overflow-y: auto; +`; diff --git a/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceCardModal.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceCardModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c4b7e493daec63b789f8364fac15f4595e11d91 --- /dev/null +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceCardModal.tsx @@ -0,0 +1,206 @@ +import React, { ChangeEvent, useCallback, useEffect } from "react"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import _ from "underscore"; +import Button from "metabase/core/components/Button"; +import Input from "metabase/core/components/Input"; +import ModalContent from "metabase/components/ModalContent"; +import DataPicker, { + DataPickerDataType, + DataPickerValue, + useDataPicker, + useDataPickerValue, +} from "metabase/containers/DataPicker"; +import Questions from "metabase/entities/questions"; +import Collections from "metabase/entities/collections"; +import Tables from "metabase/entities/tables"; +import { getMetadata } from "metabase/selectors/metadata"; +import { + Card, + CardId, + Collection, + ValuesSourceConfig, +} from "metabase-types/api"; +import { State } from "metabase-types/store"; +import Question from "metabase-lib/Question"; +import { + getCollectionVirtualSchemaId, + getQuestionIdFromVirtualTableId, + getQuestionVirtualTableId, +} from "metabase-lib/metadata/utils/saved-questions"; +import { + DataPickerContainer, + ModalBodyWithSearch, + SearchInputContainer, +} from "./ValuesSourceCardModal.styled"; + +const DATA_PICKER_FILTERS = { + types: (type: DataPickerDataType) => + type === "questions" || type === "models", +}; + +interface ModalOwnProps { + name: string; + sourceConfig: ValuesSourceConfig; + onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; + onSubmit: () => void; + onClose: () => void; +} + +interface ModalCardProps { + card: Card | undefined; +} + +interface ModalCollectionProps { + collection: Collection | undefined; +} + +interface ModalStateProps { + question: Question | undefined; +} + +interface ModalDispatchProps { + onFetchCard: (cardId: CardId) => void; + onFetchMetadata: (cardId: CardId) => void; +} + +type ModalProps = ModalOwnProps & + ModalCardProps & + ModalCollectionProps & + ModalStateProps & + ModalDispatchProps; + +const ValuesSourceCardModal = ({ + name, + question, + collection, + onFetchCard, + onFetchMetadata, + onChangeSourceConfig, + onSubmit, + onClose, +}: ModalProps): JSX.Element => { + const initialValue = getInitialValue(question, collection); + const [value, setValue] = useDataPickerValue(initialValue); + const cardId = getCardIdFromValue(value); + + const handleSubmit = useCallback(() => { + onChangeSourceConfig({ card_id: cardId }); + onSubmit(); + }, [cardId, onChangeSourceConfig, onSubmit]); + + useEffect(() => { + if (cardId) { + onFetchCard(cardId); + onFetchMetadata(cardId); + } + }, [cardId, onFetchCard, onFetchMetadata]); + + return ( + <DataPicker.Provider> + <ModalContent + title={t`Selectable values for ${name}`} + footer={[ + <Button key="cancel" onClick={onSubmit}>{t`Back`}</Button>, + <Button + key="submit" + primary + disabled={!cardId} + onClick={handleSubmit} + > + {t`Done`} + </Button>, + ]} + onClose={onClose} + > + <ModalBodyWithSearch> + <DataPickerSearchInput /> + <DataPickerContainer> + <DataPicker + value={value} + filters={DATA_PICKER_FILTERS} + onChange={setValue} + /> + </DataPickerContainer> + </ModalBodyWithSearch> + </ModalContent> + </DataPicker.Provider> + ); +}; + +const DataPickerSearchInput = () => { + const { search } = useDataPicker(); + const { query, setQuery } = search; + + const handleChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => { + setQuery(event.target.value); + }, + [setQuery], + ); + + return ( + <SearchInputContainer> + <Input + value={query} + placeholder={t`Search for a question or model`} + leftIcon="search" + fullWidth + onChange={handleChange} + /> + </SearchInputContainer> + ); +}; + +const getInitialValue = ( + question?: Question, + collection?: Collection, +): Partial<DataPickerValue> | undefined => { + if (question) { + const id = question.id(); + const isDatasets = question.isDataset(); + + return { + type: isDatasets ? "models" : "questions", + schemaId: getCollectionVirtualSchemaId(collection, { isDatasets }), + collectionId: collection?.id, + tableIds: [getQuestionVirtualTableId(id)], + }; + } +}; + +const getCardIdFromValue = ({ tableIds }: DataPickerValue) => { + if (tableIds.length) { + const cardId = getQuestionIdFromVirtualTableId(tableIds[0]); + if (cardId != null) { + return cardId; + } + } +}; + +const mapStateToProps = ( + state: State, + { card }: ModalCardProps, +): ModalStateProps => ({ + question: card ? new Question(card, getMetadata(state)) : undefined, +}); + +const mapDispatchToProps: ModalDispatchProps = { + onFetchCard: (cardId: CardId) => Questions.actions.fetch({ id: cardId }), + onFetchMetadata: (cardId: CardId) => + Tables.actions.fetchMetadata({ id: getQuestionVirtualTableId(cardId) }), +}; + +export default _.compose( + Questions.load({ + id: (state: State, { sourceConfig: { card_id } }: ModalOwnProps) => card_id, + entityAlias: "card", + loadingAndErrorWrapper: false, + }), + Collections.load({ + id: (state: State, { card }: ModalCardProps) => + card?.collection_id ?? "root", + loadingAndErrorWrapper: false, + }), + connect(mapStateToProps, mapDispatchToProps), +)(ValuesSourceCardModal); diff --git a/frontend/src/metabase/parameters/components/CardValuesSourceModal/FieldStepModal.styled.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.styled.tsx similarity index 82% rename from frontend/src/metabase/parameters/components/CardValuesSourceModal/FieldStepModal.styled.tsx rename to frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.styled.tsx index aa97a2f3d6e4650847c683ac6f57079026c52ae3..094a8c3c2aacb80b161579e9a04aa8740c385340 100644 --- a/frontend/src/metabase/parameters/components/CardValuesSourceModal/FieldStepModal.styled.tsx +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.styled.tsx @@ -2,5 +2,4 @@ import styled from "@emotion/styled"; export const ModalBody = styled.div` height: 50vh; - overflow-y: auto; `; diff --git a/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c8a8c69ff5fd33ebb404a36a56028df8cbca245 --- /dev/null +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { ValuesSourceConfig, ValuesSourceType } from "metabase-types/api"; +import { getNonVirtualFields } from "metabase-lib/parameters/utils/parameter-fields"; +import { UiParameter } from "metabase-lib/parameters/types"; +import { getSourceConfig, getSourceType } from "../../utils/dashboards"; +import ValuesSourceTypeModal from "./ValuesSourceTypeModal"; +import ValuesSourceCardModal from "./ValuesSourceCardModal"; + +type ModalStep = "main" | "card"; + +interface ModalProps { + parameter: UiParameter; + onSubmit: ( + sourceType: ValuesSourceType, + sourceConfig: ValuesSourceConfig, + ) => void; + onClose: () => void; +} + +const ValuesSourceModal = ({ + parameter, + onSubmit, + onClose, +}: ModalProps): JSX.Element => { + const [step, setStep] = useState<ModalStep>("main"); + const [sourceType, setSourceType] = useState(getSourceType(parameter)); + const [sourceConfig, setSourceConfig] = useState(getSourceConfig(parameter)); + + const fields = useMemo(() => { + return getNonVirtualFields(parameter); + }, [parameter]); + + const handlePickerOpen = useCallback(() => { + setStep("card"); + }, []); + + const handlePickerClose = useCallback(() => { + setStep("main"); + }, []); + + const handleSubmit = useCallback(() => { + onSubmit(sourceType, sourceConfig); + onClose(); + }, [sourceType, sourceConfig, onSubmit, onClose]); + + return step === "main" ? ( + <ValuesSourceTypeModal + name={parameter.name} + fields={fields} + sourceType={sourceType} + sourceConfig={sourceConfig} + onChangeSourceType={setSourceType} + onChangeSourceConfig={setSourceConfig} + onChangeCard={handlePickerOpen} + onSubmit={handleSubmit} + onClose={onClose} + /> + ) : ( + <ValuesSourceCardModal + name={parameter.name} + sourceConfig={sourceConfig} + onChangeSourceConfig={setSourceConfig} + onSubmit={handlePickerClose} + onClose={onClose} + /> + ); +}; + +export default ValuesSourceModal; diff --git a/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceTypeModal.styled.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceTypeModal.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5dcbb135163bba9a300588a50111533f30d403fa --- /dev/null +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceTypeModal.styled.tsx @@ -0,0 +1,64 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import TextArea from "metabase/core/components/TextArea"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import { ModalBody } from "metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.styled"; + +export const ModalBodyWithPane = styled(ModalBody)` + display: flex; + gap: 2rem; +`; + +export const ModalPane = styled.div` + flex: 1; +`; + +export const ModalMain = styled.div` + display: flex; + flex: 2; + flex-direction: row; +`; + +export const ModalSection = styled.div` + margin-bottom: 1rem; +`; + +export const ModalLabel = styled.label` + display: block; + color: ${color("text-medium")}; + margin-bottom: 0.5rem; + font-weight: bold; +`; + +export const ModalTextArea = styled(TextArea)` + display: block; + resize: none; +`; + +export const ModalHelpMessage = styled.div` + color: ${color("text-medium")}; + margin-top: 0.25rem; + margin-left: 1.25rem; +`; + +export const ModalErrorMessage = styled.div` + color: ${color("text-medium")}; + padding: 1rem; + border: 1px solid ${color("error")}; + border-radius: 0.5rem; +`; + +export const ModalEmptyState = styled.div` + display: flex; + flex: 1; + justify-content: center; + align-items: center; + padding: 2rem; + border: 1px solid ${color("border")}; + border-radius: 0.5rem; + background-color: ${color("bg-light")}; + color: ${color("text-medium")}; + font-weight: bold; + line-height: 1.5rem; + text-align: center; +`; diff --git a/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceTypeModal.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceTypeModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92fe7333bd794baff7984f0008d2a859b5e5ffa8 --- /dev/null +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceTypeModal.tsx @@ -0,0 +1,411 @@ +import React, { ChangeEvent, useCallback, useEffect, useMemo } from "react"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import _ from "underscore"; +import Button from "metabase/core/components/Button"; +import Radio from "metabase/core/components/Radio"; +import Select, { + Option, + SelectChangeEvent, +} from "metabase/core/components/Select"; +import SelectButton from "metabase/core/components/SelectButton"; +import ModalContent from "metabase/components/ModalContent"; +import Collections from "metabase/entities/collections"; +import Fields from "metabase/entities/fields"; +import Tables from "metabase/entities/tables"; +import Questions from "metabase/entities/questions"; +import { getMetadata } from "metabase/selectors/metadata"; +import { Card, ValuesSourceConfig, ValuesSourceType } from "metabase-types/api"; +import { Dispatch, State } from "metabase-types/store"; +import Question from "metabase-lib/Question"; +import Field from "metabase-lib/metadata/Field"; +import { getQuestionVirtualTableId } from "metabase-lib/metadata/utils/saved-questions"; +import { + getDefaultSourceConfig, + isValidSourceConfig, +} from "metabase-lib/parameters/utils/parameter-source"; +import { + ModalHelpMessage, + ModalLabel, + ModalBodyWithPane, + ModalMain, + ModalPane, + ModalSection, + ModalTextArea, + ModalErrorMessage, + ModalEmptyState, +} from "./ValuesSourceTypeModal.styled"; + +const NEW_LINE = "\n"; + +const SOURCE_TYPE_OPTIONS = [ + { name: t`From connected fields`, value: null }, + { name: t`From another model or question`, value: "card" }, + { name: t`Custom list`, value: "static-list" }, +]; + +interface ModalOwnProps { + name: string; + fields: Field[]; + sourceType: ValuesSourceType; + sourceConfig: ValuesSourceConfig; + onChangeSourceType: (sourceType: ValuesSourceType) => void; + onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; + onChangeCard: () => void; + onSubmit: () => void; + onClose: () => void; +} + +interface ModalCardProps { + card: Card | undefined; +} + +interface ModalStateProps { + question: Question | undefined; + fieldValues: string[][][]; + isLoadingFieldValues: boolean; +} + +interface ModalDispatchProps { + onFetchFieldValues: (fields: Field[]) => void; +} + +type ModalProps = ModalOwnProps & + ModalCardProps & + ModalStateProps & + ModalDispatchProps; + +const ValuesSourceTypeModal = ({ + name, + fields, + fieldValues, + isLoadingFieldValues, + question, + sourceType, + sourceConfig, + onFetchFieldValues, + onChangeSourceType, + onChangeSourceConfig, + onChangeCard, + onSubmit, + onClose, +}: ModalProps): JSX.Element => { + const allFieldValues = useMemo(() => { + return getUniqueFieldValues(fieldValues); + }, [fieldValues]); + + const handleTypeChange = useCallback( + (sourceType: ValuesSourceType) => { + onChangeSourceType(sourceType); + onChangeSourceConfig(getDefaultSourceConfig(sourceType, allFieldValues)); + }, + [allFieldValues, onChangeSourceType, onChangeSourceConfig], + ); + + useEffect(() => { + onFetchFieldValues(fields); + }, [fields, onFetchFieldValues]); + + return ( + <ModalContent + title={t`Selectable values for ${name}`} + footer={[ + <Button + key="submit" + primary + disabled={!isValidSourceConfig(sourceType, sourceConfig)} + onClick={onSubmit} + >{t`Done`}</Button>, + ]} + onClose={onClose} + > + {sourceType === null ? ( + <FieldSourceModal + fields={fields} + fieldValues={allFieldValues} + isLoadingFieldValues={isLoadingFieldValues} + sourceType={sourceType} + onChangeSourceType={handleTypeChange} + /> + ) : sourceType === "card" ? ( + <CardSourceModal + question={question} + sourceType={sourceType} + sourceConfig={sourceConfig} + onChangeCard={onChangeCard} + onChangeSourceType={handleTypeChange} + onChangeSourceConfig={onChangeSourceConfig} + /> + ) : sourceType === "static-list" ? ( + <ListSourceModal + sourceType={sourceType} + sourceConfig={sourceConfig} + fieldValues={allFieldValues} + onChangeSourceType={handleTypeChange} + onChangeSourceConfig={onChangeSourceConfig} + /> + ) : null} + </ModalContent> + ); +}; + +interface FieldSourceModalProps { + fields: Field[]; + fieldValues: string[]; + isLoadingFieldValues: boolean; + sourceType: ValuesSourceType; + onChangeSourceType: (sourceType: ValuesSourceType) => void; +} + +const FieldSourceModal = ({ + fields, + fieldValues, + isLoadingFieldValues, + sourceType, + onChangeSourceType, +}: FieldSourceModalProps) => { + const hasFields = fields.length > 0; + const hasFieldValues = fieldValues.length > 0; + + const fieldValuesText = useMemo(() => { + return getValuesText(fieldValues); + }, [fieldValues]); + + return ( + <ModalBodyWithPane> + <ModalPane> + <ModalSection> + <ModalLabel>{t`Where values should come from`}</ModalLabel> + <Radio + value={sourceType} + options={SOURCE_TYPE_OPTIONS} + vertical + onChange={onChangeSourceType} + /> + </ModalSection> + </ModalPane> + <ModalMain> + {!hasFields ? ( + <ModalEmptyState> + {t`You haven’t connected a field to this filter yet, so there aren’t any values.`} + </ModalEmptyState> + ) : !hasFieldValues && !isLoadingFieldValues ? ( + <ModalEmptyState> + {t`We don’t have any cached values for the connected fields. Try one of the other options, or change this widget to a search box.`} + </ModalEmptyState> + ) : ( + <ModalTextArea value={fieldValuesText} readOnly fullWidth /> + )} + </ModalMain> + </ModalBodyWithPane> + ); +}; + +interface CardSourceModalProps { + question: Question | undefined; + sourceType: ValuesSourceType; + sourceConfig: ValuesSourceConfig; + onChangeCard: () => void; + onChangeSourceType: (sourceType: ValuesSourceType) => void; + onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; +} + +const CardSourceModal = ({ + question, + sourceType, + sourceConfig, + onChangeCard, + onChangeSourceType, + onChangeSourceConfig, +}: CardSourceModalProps) => { + const fields = useMemo(() => { + return question ? getSupportedFields(question) : []; + }, [question]); + + const selectedField = useMemo(() => { + return getFieldByReference(fields, sourceConfig.value_field); + }, [fields, sourceConfig]); + + const handleFieldChange = useCallback( + (event: SelectChangeEvent<Field>) => { + onChangeSourceConfig({ + ...sourceConfig, + value_field: event.target.value.reference(), + }); + }, + [sourceConfig, onChangeSourceConfig], + ); + + return ( + <ModalBodyWithPane> + <ModalPane> + <ModalSection> + <ModalLabel>{t`Where values should come from`}</ModalLabel> + <Radio + value={sourceType} + options={SOURCE_TYPE_OPTIONS} + vertical + onChange={onChangeSourceType} + /> + </ModalSection> + <ModalSection> + <ModalLabel>{t`Model or question to supply the values`}</ModalLabel> + <SelectButton onClick={onChangeCard}> + {question ? question.displayName() : t`Pick a model or question…`} + </SelectButton> + </ModalSection> + {question && ( + <ModalSection> + <ModalLabel>{t`Column to supply the values`}</ModalLabel> + {fields.length ? ( + <Select + value={selectedField} + placeholder={t`Pick a column…`} + onChange={handleFieldChange} + > + {fields.map((field, index) => ( + <Option + key={index} + name={field.displayName()} + value={field} + /> + ))} + </Select> + ) : ( + <ModalErrorMessage> + {question.isDataset() + ? t`This model doesn’t have any text columns.` + : t`This question doesn’t have any text columns.`}{" "} + {t`Please pick a different model or question.`} + </ModalErrorMessage> + )} + </ModalSection> + )} + </ModalPane> + <ModalMain> + {!question ? ( + <ModalEmptyState>{t`Pick a model or question`}</ModalEmptyState> + ) : !selectedField ? ( + <ModalEmptyState>{t`Pick a column`}</ModalEmptyState> + ) : ( + <ModalTextArea readOnly fullWidth /> + )} + </ModalMain> + </ModalBodyWithPane> + ); +}; + +interface ListSourceModalProps { + sourceType: ValuesSourceType; + sourceConfig: ValuesSourceConfig; + fieldValues: string[]; + onChangeSourceType: (sourceType: ValuesSourceType) => void; + onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; +} + +const ListSourceModal = ({ + sourceType, + sourceConfig, + onChangeSourceType, + onChangeSourceConfig, +}: ListSourceModalProps) => { + const handleValuesChange = useCallback( + (event: ChangeEvent<HTMLTextAreaElement>) => { + onChangeSourceConfig({ values: getStaticValues(event.target.value) }); + }, + [onChangeSourceConfig], + ); + + return ( + <ModalBodyWithPane> + <ModalPane> + <ModalSection> + <ModalLabel>{t`Where values should come from`}</ModalLabel> + <Radio + value={sourceType} + options={SOURCE_TYPE_OPTIONS} + vertical + onChange={onChangeSourceType} + /> + <ModalHelpMessage>{t`Enter one value per line.`}</ModalHelpMessage> + </ModalSection> + </ModalPane> + <ModalMain> + <ModalTextArea + defaultValue={getValuesText(sourceConfig.values)} + fullWidth + onChange={handleValuesChange} + /> + </ModalMain> + </ModalBodyWithPane> + ); +}; + +const getValuesText = (values?: string[]) => { + return values?.join(NEW_LINE) ?? ""; +}; + +const getUniqueFieldValues = (fieldsValues: string[][][]) => { + const allValues = fieldsValues.flatMap(values => values.map(([key]) => key)); + return Array.from(new Set(allValues)); +}; + +const getStaticValues = (value: string) => { + return value + .split(NEW_LINE) + .map(line => line.trim()) + .filter(line => line.length > 0); +}; + +const getFieldByReference = (fields: Field[], fieldReference?: unknown[]) => { + return fields.find(field => _.isEqual(field.reference(), fieldReference)); +}; + +const getSupportedFields = (question: Question) => { + const fields = question.composeThisQuery()?.table()?.fields ?? []; + return fields.filter(field => field.isString()); +}; + +const mapStateToProps = ( + state: State, + { card, fields }: ModalOwnProps & ModalCardProps, +): ModalStateProps => ({ + question: card ? new Question(card, getMetadata(state)) : undefined, + fieldValues: fields.map(field => + Fields.selectors.getFieldValues(state, { entityId: field.id }), + ), + isLoadingFieldValues: fields.every(field => + Fields.selectors.getLoading(state, { + entityId: field.id, + requestType: "values", + }), + ), +}); + +const mapDispatchToProps = (dispatch: Dispatch): ModalDispatchProps => ({ + onFetchFieldValues: (fields: Field[]) => { + fields.forEach(field => + dispatch(Fields.actions.fetchFieldValues({ id: field.id })), + ); + }, +}); + +export default _.compose( + Tables.load({ + id: (state: State, { sourceConfig: { card_id } }: ModalOwnProps) => + card_id ? getQuestionVirtualTableId(card_id) : undefined, + requestType: "fetchMetadata", + loadingAndErrorWrapper: false, + }), + Questions.load({ + id: (state: State, { sourceConfig: { card_id } }: ModalOwnProps) => card_id, + entityAlias: "card", + loadingAndErrorWrapper: false, + }), + Collections.load({ + id: (state: State, { card }: ModalCardProps) => + card?.collection_id ?? "root", + loadingAndErrorWrapper: false, + }), + connect(mapStateToProps, mapDispatchToProps), +)(ValuesSourceTypeModal); diff --git a/frontend/src/metabase/parameters/components/ValuesSourceModal/index.ts b/frontend/src/metabase/parameters/components/ValuesSourceModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..92f817d3a3f40b98ac61ef86fd02c9cf520bf5b5 --- /dev/null +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ValuesSourceModal"; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js b/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js index 05fb76e0570d3c4c336a24a25e414ec868b6549b..499f9a9c8bddf3b23025a62c4e66e3d4df658f05 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js @@ -53,8 +53,9 @@ describe("scenarios > dashboard > filters", () => { editDashboard(); setFilter("Text or Category", "Dropdown"); - setupStructuredQuestionSource(); mapFilterToQuestion(); + editDropdown(); + setupStructuredQuestionSource(); saveDashboard(); filterDashboard(); }); @@ -69,8 +70,9 @@ describe("scenarios > dashboard > filters", () => { editDashboard(); setFilter("Text or Category", "Dropdown"); - setupNativeQuestionSource(); mapFilterToQuestion(); + editDropdown(); + setupNativeQuestionSource(); saveDashboard(); filterDashboard(); }); @@ -84,53 +86,73 @@ describe("scenarios > dashboard > filters", () => { editDashboard(); setFilter("Text or Category", "Dropdown"); - setupCustomList(); mapFilterToQuestion(); + editDropdown(); + setupCustomList(); saveDashboard(); filterDashboard(); }); }); +const editDropdown = () => { + cy.findByText("Dropdown list").click(); + cy.findByText("Edit").click(); +}; + const setupStructuredQuestionSource = () => { - cy.findByText("Values from a model or question").click(); + modal().within(() => { + cy.findByText("From another model or question").click(); + cy.findByText("Pick a model or question…").click(); + }); + modal().within(() => { cy.findByPlaceholderText(/Search for a question/).type("Categories"); cy.findByText("Categories").click(); - cy.button("Select column").click(); + cy.button("Done").click(); }); + modal().within(() => { - cy.findByText("Pick a column").click(); + cy.findByText("Pick a column…").click(); }); + popover().within(() => { cy.findByText("Category").click(); }); + modal().within(() => { cy.button("Done").click(); }); }; const setupNativeQuestionSource = () => { - cy.findByText("Values from a model or question").click(); modal().within(() => { - cy.findByText("Saved Questions").click(); + cy.findByText("From another model or question").click(); + cy.findByText("Pick a model or question…").click(); + }); + + modal().within(() => { cy.findByText("Categories").click(); - cy.button("Select column").click(); + cy.button("Done").click(); }); + modal().within(() => { - cy.findByText("Pick a column").click(); + cy.findByText("Pick a column…").click(); }); + popover().within(() => { cy.findByText("CATEGORY").click(); }); + modal().within(() => { cy.button("Done").click(); }); }; const setupCustomList = () => { - cy.findByText("Custom list").click(); modal().within(() => { - cy.findByPlaceholderText(/banana/).type("Doohickey\nGadget"); + cy.findByText("Custom list").click(); + cy.findByRole("textbox").should("contain.value", "Gizmo"); + cy.findByRole("textbox").clear().type("Doohickey\nGadget"); cy.button("Done").click(); }); }; @@ -142,6 +164,7 @@ const mapFilterToQuestion = () => { const filterDashboard = () => { cy.findByText("Text").click(); + popover().within(() => { cy.findByText("Doohickey").should("be.visible"); cy.findByText("Gadget").should("be.visible"); @@ -151,6 +174,6 @@ const filterDashboard = () => { cy.findByText("Doohickey").should("not.exist"); cy.findByText("Gadget").click(); cy.button("Add filter").click(); + cy.wait("@getCardQuery"); }); - cy.wait("@getCardQuery"); };