From de6db2a4fed29f6de66ed9e30564b3d9c2a07f03 Mon Sep 17 00:00:00 2001 From: Rohit Bharmal Date: Wed, 8 Apr 2026 14:47:11 +0530 Subject: [PATCH 1/6] feat: Add modals for creating and editing data sources, entities, and feature views Signed-off-by: Rohit Bharmal --- ui/src/components/DataSourceFormModal.tsx | 458 ++++++++++++++++++ ui/src/components/EntityFormModal.tsx | 149 ++++++ ui/src/components/FeatureViewFormModal.tsx | 276 +++++++++++ .../components/forms/FeatureFieldEditor.tsx | 163 +++++++ ui/src/components/forms/FormModal.tsx | 50 ++ .../forms/NameDescriptionOwnerFields.tsx | 72 +++ ui/src/components/forms/TagsEditor.tsx | 112 +++++ ui/src/components/forms/ValueTypeSelect.tsx | 47 ++ .../data-sources/DataSourceOverviewTab.tsx | 79 ++- ui/src/pages/data-sources/Index.tsx | 45 +- ui/src/pages/entities/EntityOverviewTab.tsx | 65 ++- ui/src/pages/entities/Index.tsx | 52 +- ui/src/pages/feature-views/Index.tsx | 45 +- .../RegularFeatureViewOverviewTab.tsx | 99 +++- 14 files changed, 1705 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/DataSourceFormModal.tsx create mode 100644 ui/src/components/EntityFormModal.tsx create mode 100644 ui/src/components/FeatureViewFormModal.tsx create mode 100644 ui/src/components/forms/FeatureFieldEditor.tsx create mode 100644 ui/src/components/forms/FormModal.tsx create mode 100644 ui/src/components/forms/NameDescriptionOwnerFields.tsx create mode 100644 ui/src/components/forms/TagsEditor.tsx create mode 100644 ui/src/components/forms/ValueTypeSelect.tsx diff --git a/ui/src/components/DataSourceFormModal.tsx b/ui/src/components/DataSourceFormModal.tsx new file mode 100644 index 00000000000..4eff32b9a61 --- /dev/null +++ b/ui/src/components/DataSourceFormModal.tsx @@ -0,0 +1,458 @@ +import React, { useState, useEffect } from "react"; +import { + EuiFormRow, + EuiFieldText, + EuiSelect, + EuiSpacer, + EuiHorizontalRule, + EuiText, +} from "@elastic/eui"; +import { feast } from "../protos"; +import FormModal from "./forms/FormModal"; +import TagsEditor, { TagEntry } from "./forms/TagsEditor"; +import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; + +const SOURCE_TYPE_OPTIONS = [ + { + value: String(feast.core.DataSource.SourceType.BATCH_FILE), + text: "File (Parquet / CSV)", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_BIGQUERY), + text: "BigQuery", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE), + text: "Snowflake", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_REDSHIFT), + text: "Redshift", + }, + { + value: String(feast.core.DataSource.SourceType.STREAM_KAFKA), + text: "Kafka", + }, + { + value: String(feast.core.DataSource.SourceType.BATCH_SPARK), + text: "Spark", + }, + { + value: String(feast.core.DataSource.SourceType.REQUEST_SOURCE), + text: "Request Source", + }, + { + value: String(feast.core.DataSource.SourceType.PUSH_SOURCE), + text: "Push Source", + }, +]; + +interface DataSourceFormData { + name: string; + description: string; + owner: string; + sourceType: string; + timestampField: string; + createdTimestampColumn: string; + tags: TagEntry[]; + fileUri: string; + bigqueryTable: string; + bigqueryQuery: string; + snowflakeTable: string; + snowflakeDatabase: string; + snowflakeSchema: string; + redshiftTable: string; + redshiftDatabase: string; + redshiftSchema: string; + kafkaBootstrapServers: string; + kafkaTopic: string; + sparkTable: string; + sparkPath: string; +} + +interface DataSourceFormModalProps { + onClose: () => void; + onSubmit: (data: DataSourceFormData) => void; + initialData?: DataSourceFormData; + isEdit?: boolean; +} + +const EMPTY_FORM: DataSourceFormData = { + name: "", + description: "", + owner: "", + sourceType: String(feast.core.DataSource.SourceType.BATCH_FILE), + timestampField: "", + createdTimestampColumn: "", + tags: [], + fileUri: "", + bigqueryTable: "", + bigqueryQuery: "", + snowflakeTable: "", + snowflakeDatabase: "", + snowflakeSchema: "", + redshiftTable: "", + redshiftDatabase: "", + redshiftSchema: "", + kafkaBootstrapServers: "", + kafkaTopic: "", + sparkTable: "", + sparkPath: "", +}; + +const DataSourceFormModal: React.FC = ({ + onClose, + onSubmit, + initialData, + isEdit = false, +}) => { + const [formData, setFormData] = useState( + initialData || EMPTY_FORM, + ); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Data source name is required."; + } else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) { + newErrors.name = + "Must start with a letter or underscore, and contain only letters, numbers, and underscores."; + } + + const st = formData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + if (!formData.fileUri.trim()) { + newErrors.fileUri = "File URI is required for file sources."; + } + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + if (!formData.bigqueryTable.trim() && !formData.bigqueryQuery.trim()) { + newErrors.bigqueryTable = "Either table or query is required."; + } + } else if ( + st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE) + ) { + if (!formData.snowflakeTable.trim()) { + newErrors.snowflakeTable = "Table name is required for Snowflake."; + } + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + if (!formData.kafkaBootstrapServers.trim()) { + newErrors.kafkaBootstrapServers = "Bootstrap servers are required."; + } + if (!formData.kafkaTopic.trim()) { + newErrors.kafkaTopic = "Topic is required."; + } + } + + const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.trim()); + if (new Set(tagKeys).size !== tagKeys.length) { + newErrors.tags = "Tag keys must be unique."; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + setSubmitted(true); + if (validate()) { + const cleanedData = { + ...formData, + tags: formData.tags.filter((t) => t.key.trim()), + }; + onSubmit(cleanedData); + } + }; + + const updateField = ( + field: K, + value: DataSourceFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const renderSourceTypeFields = () => { + const st = formData.sourceType; + + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + return ( + + updateField("fileUri", e.target.value)} + isInvalid={!!errors.fileUri} + placeholder="s3://bucket/path/to/data.parquet" + /> + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + return ( + <> + + updateField("bigqueryTable", e.target.value)} + isInvalid={!!errors.bigqueryTable} + placeholder="project:dataset.table" + /> + + + updateField("bigqueryQuery", e.target.value)} + placeholder="SELECT * FROM ..." + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE)) { + return ( + <> + + updateField("snowflakeTable", e.target.value)} + isInvalid={!!errors.snowflakeTable} + placeholder="MY_TABLE" + /> + + + + updateField("snowflakeDatabase", e.target.value) + } + placeholder="MY_DATABASE" + /> + + + updateField("snowflakeSchema", e.target.value)} + placeholder="PUBLIC" + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { + return ( + <> + + updateField("redshiftTable", e.target.value)} + placeholder="my_table" + /> + + + + updateField("redshiftDatabase", e.target.value) + } + placeholder="my_database" + /> + + + updateField("redshiftSchema", e.target.value)} + placeholder="public" + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + return ( + <> + + + updateField("kafkaBootstrapServers", e.target.value) + } + isInvalid={!!errors.kafkaBootstrapServers} + placeholder="localhost:9092" + /> + + + updateField("kafkaTopic", e.target.value)} + isInvalid={!!errors.kafkaTopic} + placeholder="my-feature-topic" + /> + + + ); + } + + if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { + return ( + <> + + updateField("sparkTable", e.target.value)} + placeholder="catalog.database.table" + /> + + + updateField("sparkPath", e.target.value)} + placeholder="s3://bucket/path/" + /> + + + ); + } + + if ( + st === String(feast.core.DataSource.SourceType.REQUEST_SOURCE) || + st === String(feast.core.DataSource.SourceType.PUSH_SOURCE) + ) { + return ( + + No additional configuration required for this source type. + + ); + } + + return null; + }; + + const isBatchSource = + formData.sourceType !== + String(feast.core.DataSource.SourceType.STREAM_KAFKA) && + formData.sourceType !== + String(feast.core.DataSource.SourceType.REQUEST_SOURCE) && + formData.sourceType !== + String(feast.core.DataSource.SourceType.PUSH_SOURCE); + + return ( + + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + onChangeOwner={(v) => updateField("owner", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique name for this data source." + namePlaceholder="e.g. customer_transactions" + descriptionPlaceholder="Describe this data source..." + /> + + + updateField("sourceType", e.target.value)} + disabled={isEdit} + /> + + + + + +

Source Configuration

+
+ + + {renderSourceTypeFields()} + + {isBatchSource && ( + <> + + + updateField("timestampField", e.target.value)} + placeholder="event_timestamp" + /> + + + + updateField("createdTimestampColumn", e.target.value) + } + placeholder="created_at" + /> + + + )} + + + + updateField("tags", tags)} + error={errors.tags} + /> +
+ ); +}; + +export default DataSourceFormModal; +export type { DataSourceFormData }; diff --git a/ui/src/components/EntityFormModal.tsx b/ui/src/components/EntityFormModal.tsx new file mode 100644 index 00000000000..9d18ecdfd7f --- /dev/null +++ b/ui/src/components/EntityFormModal.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from "react"; +import { EuiFormRow, EuiFieldText, EuiSpacer } from "@elastic/eui"; +import { feast } from "../protos"; +import FormModal from "./forms/FormModal"; +import TagsEditor, { TagEntry } from "./forms/TagsEditor"; +import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; +import ValueTypeSelect from "./forms/ValueTypeSelect"; + +interface EntityFormData { + name: string; + description: string; + joinKey: string; + valueType: string; + tags: TagEntry[]; +} + +interface EntityFormModalProps { + onClose: () => void; + onSubmit: (data: EntityFormData) => void; + initialData?: EntityFormData; + isEdit?: boolean; +} + +const EMPTY_FORM: EntityFormData = { + name: "", + description: "", + joinKey: "", + valueType: String(feast.types.ValueType.Enum.STRING), + tags: [], +}; + +const EntityFormModal: React.FC = ({ + onClose, + onSubmit, + initialData, + isEdit = false, +}) => { + const [formData, setFormData] = useState( + initialData || EMPTY_FORM, + ); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Entity name is required."; + } else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) { + newErrors.name = + "Must start with a letter or underscore, and contain only letters, numbers, and underscores."; + } + + if (!formData.joinKey.trim()) { + newErrors.joinKey = "Join key is required."; + } + + const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.trim()); + if (new Set(tagKeys).size !== tagKeys.length) { + newErrors.tags = "Tag keys must be unique."; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + setSubmitted(true); + if (validate()) { + const cleanedData = { + ...formData, + tags: formData.tags.filter((t) => t.key.trim()), + }; + onSubmit(cleanedData); + } + }; + + const updateField = ( + field: K, + value: EntityFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + return ( + + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique identifier for this entity (e.g. customer_id)." + namePlaceholder="e.g. customer_id" + descriptionPlaceholder="Describe what this entity represents..." + /> + + + updateField("joinKey", e.target.value)} + isInvalid={!!errors.joinKey} + placeholder="e.g. customer_id" + /> + + + updateField("valueType", v)} + helpText="Data type of the join key." + /> + + + + updateField("tags", tags)} + error={errors.tags} + /> + + ); +}; + +export default EntityFormModal; +export type { EntityFormData, TagEntry }; diff --git a/ui/src/components/FeatureViewFormModal.tsx b/ui/src/components/FeatureViewFormModal.tsx new file mode 100644 index 00000000000..40d6c2a28c4 --- /dev/null +++ b/ui/src/components/FeatureViewFormModal.tsx @@ -0,0 +1,276 @@ +import React, { useState, useEffect, useContext } from "react"; +import { + EuiFormRow, + EuiFieldText, + EuiFieldNumber, + EuiSelect, + EuiSwitch, + EuiComboBox, + EuiComboBoxOptionOption, + EuiSpacer, +} from "@elastic/eui"; +import { useParams } from "react-router-dom"; +import FormModal from "./forms/FormModal"; +import TagsEditor, { TagEntry } from "./forms/TagsEditor"; +import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; +import FeatureFieldEditor, { + FeatureFieldEntry, +} from "./forms/FeatureFieldEditor"; +import RegistryPathContext from "../contexts/RegistryPathContext"; +import useLoadRegistry from "../queries/useLoadRegistry"; + +const TTL_UNIT_OPTIONS = [ + { value: "seconds", text: "Seconds" }, + { value: "minutes", text: "Minutes" }, + { value: "hours", text: "Hours" }, + { value: "days", text: "Days" }, +]; + +interface FeatureViewFormData { + name: string; + description: string; + owner: string; + entities: string[]; + features: FeatureFieldEntry[]; + batchSource: string; + ttlValue: number; + ttlUnit: string; + online: boolean; + tags: TagEntry[]; +} + +interface FeatureViewFormModalProps { + onClose: () => void; + onSubmit: (data: FeatureViewFormData) => void; + initialData?: FeatureViewFormData; + isEdit?: boolean; +} + +const EMPTY_FORM: FeatureViewFormData = { + name: "", + description: "", + owner: "", + entities: [], + features: [], + batchSource: "", + ttlValue: 0, + ttlUnit: "seconds", + online: true, + tags: [], +}; + +const FeatureViewFormModal: React.FC = ({ + onClose, + onSubmit, + initialData, + isEdit = false, +}) => { + const [formData, setFormData] = useState( + initialData || EMPTY_FORM, + ); + const [errors, setErrors] = useState>({}); + const [submitted, setSubmitted] = useState(false); + + const registryUrl = useContext(RegistryPathContext); + const { projectName } = useParams(); + const registryQuery = useLoadRegistry(registryUrl, projectName); + + const entityOptions: EuiComboBoxOptionOption[] = + registryQuery.data?.objects?.entities?.map((e: any) => ({ + label: e?.spec?.name || "", + })) || []; + + const dataSourceOptions = + registryQuery.data?.objects?.dataSources?.map((ds: any) => ({ + value: ds?.name || "", + text: ds?.name || "", + })) || []; + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } + }, [initialData]); + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "Feature view name is required."; + } else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) { + newErrors.name = + "Must start with a letter or underscore, and contain only letters, numbers, and underscores."; + } + + if (formData.features.length === 0) { + newErrors.features = "At least one feature is required."; + } else { + const hasEmptyName = formData.features.some((f) => !f.name.trim()); + if (hasEmptyName) { + newErrors.features = "All features must have a name."; + } + const featureNames = formData.features.map((f) => f.name.trim()); + if (new Set(featureNames).size !== featureNames.length) { + newErrors.features = "Feature names must be unique."; + } + } + + if (!formData.batchSource.trim()) { + newErrors.batchSource = "A batch source is required."; + } + + const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.trim()); + if (new Set(tagKeys).size !== tagKeys.length) { + newErrors.tags = "Tag keys must be unique."; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + setSubmitted(true); + if (validate()) { + const cleanedData = { + ...formData, + tags: formData.tags.filter((t) => t.key.trim()), + }; + onSubmit(cleanedData); + } + }; + + const updateField = ( + field: K, + value: FeatureViewFormData[K], + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (submitted) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const selectedEntityOptions = formData.entities.map((e) => ({ label: e })); + + return ( + + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + onChangeOwner={(v) => updateField("owner", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique name for this feature view." + namePlaceholder="e.g. customer_features" + descriptionPlaceholder="Describe what this feature view provides..." + /> + + + + updateField( + "entities", + selected.map((s) => s.label), + ) + } + isClearable + /> + + + + {dataSourceOptions.length > 0 ? ( + updateField("batchSource", e.target.value)} + isInvalid={!!errors.batchSource} + /> + ) : ( + updateField("batchSource", e.target.value)} + isInvalid={!!errors.batchSource} + placeholder="data_source_name" + /> + )} + + + + + updateField("features", features)} + error={errors.features} + /> + + + + +
+ + updateField("ttlValue", parseInt(e.target.value) || 0) + } + min={0} + style={{ width: 120 }} + /> + updateField("ttlUnit", e.target.value)} + style={{ width: 140 }} + /> +
+
+ + + updateField("online", e.target.checked)} + /> + + + + + updateField("tags", tags)} + error={errors.tags} + /> +
+ ); +}; + +export default FeatureViewFormModal; +export type { FeatureViewFormData }; diff --git a/ui/src/components/forms/FeatureFieldEditor.tsx b/ui/src/components/forms/FeatureFieldEditor.tsx new file mode 100644 index 00000000000..be8b4f9f210 --- /dev/null +++ b/ui/src/components/forms/FeatureFieldEditor.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiSelect, + EuiButtonEmpty, + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiSpacer, + EuiCallOut, +} from "@elastic/eui"; +import { VALUE_TYPE_OPTIONS } from "./ValueTypeSelect"; +import { feast } from "../../protos"; + +interface FeatureFieldEntry { + name: string; + valueType: string; + description: string; +} + +interface FeatureFieldEditorProps { + features: FeatureFieldEntry[]; + onChange: (features: FeatureFieldEntry[]) => void; + error?: string; +} + +const EMPTY_FEATURE: FeatureFieldEntry = { + name: "", + valueType: String(feast.types.ValueType.Enum.INT64), + description: "", +}; + +const FeatureFieldEditor: React.FC = ({ + features, + onChange, + error, +}) => { + const addFeature = () => { + onChange([...features, { ...EMPTY_FEATURE }]); + }; + + const removeFeature = (index: number) => { + onChange(features.filter((_, i) => i !== index)); + }; + + const updateFeature = ( + index: number, + field: keyof FeatureFieldEntry, + val: string, + ) => { + const updated = [...features]; + updated[index] = { ...updated[index], [field]: val }; + onChange(updated); + }; + + return ( + <> + + + + +

Features

+
+
+ + + Add feature + + +
+ + {error && ( + <> + + + + )} + + {features.length > 0 && ( + + + + Name + + + + + Type + + + + + Description + + + + + )} + + {features.map((feature, index) => ( + + + updateFeature(index, "name", e.target.value)} + compressed + /> + + + + updateFeature(index, "valueType", e.target.value) + } + compressed + /> + + + + updateFeature(index, "description", e.target.value) + } + compressed + /> + + + removeFeature(index)} + /> + + + ))} + + {features.length === 0 && ( + + No features added yet. Click "Add feature" above. + + )} + + ); +}; + +export default FeatureFieldEditor; +export type { FeatureFieldEntry }; diff --git a/ui/src/components/forms/FormModal.tsx b/ui/src/components/forms/FormModal.tsx new file mode 100644 index 00000000000..83a9b9befec --- /dev/null +++ b/ui/src/components/forms/FormModal.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, + EuiButtonEmpty, + EuiForm, +} from "@elastic/eui"; + +interface FormModalProps { + title: string; + submitLabel: string; + onClose: () => void; + onSubmit: () => void; + children: React.ReactNode; + width?: number; +} + +const FormModal: React.FC = ({ + title, + submitLabel, + onClose, + onSubmit, + children, + width = 600, +}) => { + return ( + + + {title} + + + + {children} + + + + Cancel + + {submitLabel} + + + + ); +}; + +export default FormModal; diff --git a/ui/src/components/forms/NameDescriptionOwnerFields.tsx b/ui/src/components/forms/NameDescriptionOwnerFields.tsx new file mode 100644 index 00000000000..0817a0d8e24 --- /dev/null +++ b/ui/src/components/forms/NameDescriptionOwnerFields.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { EuiFormRow, EuiFieldText, EuiTextArea } from "@elastic/eui"; + +interface NameDescriptionOwnerFieldsProps { + name: string; + description: string; + owner?: string; + onChangeName: (value: string) => void; + onChangeDescription: (value: string) => void; + onChangeOwner?: (value: string) => void; + nameDisabled?: boolean; + nameError?: string; + nameHelpText?: string; + namePlaceholder?: string; + descriptionPlaceholder?: string; +} + +const NameDescriptionOwnerFields: React.FC< + NameDescriptionOwnerFieldsProps +> = ({ + name, + description, + owner, + onChangeName, + onChangeDescription, + onChangeOwner, + nameDisabled = false, + nameError, + nameHelpText, + namePlaceholder = "e.g. my_resource", + descriptionPlaceholder = "Describe this resource...", +}) => { + return ( + <> + + onChangeName(e.target.value)} + isInvalid={!!nameError} + disabled={nameDisabled} + placeholder={namePlaceholder} + /> + + + + onChangeDescription(e.target.value)} + placeholder={descriptionPlaceholder} + rows={2} + /> + + + {onChangeOwner !== undefined && ( + + onChangeOwner(e.target.value)} + placeholder="e.g. team-ml-platform" + /> + + )} + + ); +}; + +export default NameDescriptionOwnerFields; diff --git a/ui/src/components/forms/TagsEditor.tsx b/ui/src/components/forms/TagsEditor.tsx new file mode 100644 index 00000000000..73ea5df9c82 --- /dev/null +++ b/ui/src/components/forms/TagsEditor.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiButtonEmpty, + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiSpacer, + EuiCallOut, +} from "@elastic/eui"; + +interface TagEntry { + key: string; + value: string; +} + +interface TagsEditorProps { + tags: TagEntry[]; + onChange: (tags: TagEntry[]) => void; + error?: string; +} + +const TagsEditor: React.FC = ({ tags, onChange, error }) => { + const addTag = () => { + onChange([...tags, { key: "", value: "" }]); + }; + + const removeTag = (index: number) => { + onChange(tags.filter((_, i) => i !== index)); + }; + + const updateTag = (index: number, field: "key" | "value", val: string) => { + const updated = [...tags]; + updated[index] = { ...updated[index], [field]: val }; + onChange(updated); + }; + + return ( + <> + + + + +

Labels

+
+
+ + + Add label + + +
+ + {error && ( + <> + + + + )} + + {tags.map((tag, index) => ( + + + updateTag(index, "key", e.target.value)} + compressed + /> + + + updateTag(index, "value", e.target.value)} + compressed + /> + + + removeTag(index)} + /> + + + ))} + + {tags.length === 0 && ( + + No labels added yet. + + )} + + ); +}; + +export default TagsEditor; +export type { TagEntry }; diff --git a/ui/src/components/forms/ValueTypeSelect.tsx b/ui/src/components/forms/ValueTypeSelect.tsx new file mode 100644 index 00000000000..2718b7c027e --- /dev/null +++ b/ui/src/components/forms/ValueTypeSelect.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { EuiFormRow, EuiSelect } from "@elastic/eui"; +import { feast } from "../../protos"; + +const VALUE_TYPE_OPTIONS = [ + { value: String(feast.types.ValueType.Enum.STRING), text: "STRING" }, + { value: String(feast.types.ValueType.Enum.INT32), text: "INT32" }, + { value: String(feast.types.ValueType.Enum.INT64), text: "INT64" }, + { value: String(feast.types.ValueType.Enum.FLOAT), text: "FLOAT" }, + { value: String(feast.types.ValueType.Enum.DOUBLE), text: "DOUBLE" }, + { value: String(feast.types.ValueType.Enum.BOOL), text: "BOOL" }, + { value: String(feast.types.ValueType.Enum.BYTES), text: "BYTES" }, + { + value: String(feast.types.ValueType.Enum.UNIX_TIMESTAMP), + text: "UNIX_TIMESTAMP", + }, +]; + +interface ValueTypeSelectProps { + value: string; + onChange: (value: string) => void; + label?: string; + helpText?: string; + compressed?: boolean; +} + +const ValueTypeSelect: React.FC = ({ + value, + onChange, + label = "Value Type", + helpText, + compressed = false, +}) => { + return ( + + onChange(e.target.value)} + compressed={compressed} + /> + + ); +}; + +export default ValueTypeSelect; +export { VALUE_TYPE_OPTIONS }; diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 8d570f3f26d..9cdab1f1132 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -4,6 +4,8 @@ import { EuiLoadingSpinner, EuiText, EuiTitle, + EuiButtonEmpty, + EuiCallOut, } from "@elastic/eui"; import { EuiPanel, @@ -13,9 +15,12 @@ import { EuiDescriptionListDescription, EuiSpacer, } from "@elastic/eui"; -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; import { useParams } from "react-router-dom"; import PermissionsDisplay from "../../components/PermissionsDisplay"; +import DataSourceFormModal, { + DataSourceFormData, +} from "../../components/DataSourceFormModal"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import { FEAST_FCO_TYPES } from "../../parsers/types"; import { feast } from "../../protos"; @@ -26,6 +31,35 @@ import FeatureViewEdgesList from "../entities/FeatureViewEdgesList"; import RequestDataSourceSchemaTable from "./RequestDataSourceSchemaTable"; import useLoadDataSource from "./useLoadDataSource"; +const buildEditFormData = (ds: feast.core.IDataSource): DataSourceFormData => { + const tags = ds.tags + ? Object.entries(ds.tags).map(([key, value]) => ({ key, value })) + : []; + + return { + name: ds.name || "", + description: ds.description || "", + owner: ds.owner || "", + sourceType: String(ds.type ?? 0), + timestampField: ds.timestampField || "", + createdTimestampColumn: ds.createdTimestampColumn || "", + tags, + fileUri: ds.fileOptions?.uri || "", + bigqueryTable: ds.bigqueryOptions?.table || "", + bigqueryQuery: ds.bigqueryOptions?.query || "", + snowflakeTable: ds.snowflakeOptions?.table || "", + snowflakeDatabase: ds.snowflakeOptions?.database || "", + snowflakeSchema: ds.snowflakeOptions?.schema || "", + redshiftTable: ds.redshiftOptions?.table || "", + redshiftDatabase: ds.redshiftOptions?.database || "", + redshiftSchema: ds.redshiftOptions?.schema || "", + kafkaBootstrapServers: ds.kafkaOptions?.kafkaBootstrapServers || "", + kafkaTopic: ds.kafkaOptions?.topic || "", + sparkTable: ds.sparkOptions?.table || "", + sparkPath: ds.sparkOptions?.path || "", + }; +}; + const DataSourceOverviewTab = () => { let { dataSourceName, projectName } = useParams(); const registryUrl = useContext(RegistryPathContext); @@ -36,6 +70,18 @@ const DataSourceOverviewTab = () => { useLoadDataSource(dsName); const isEmpty = data === undefined; + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const handleEditSubmit = (formData: DataSourceFormData) => { + console.log("Data source edit payload:", formData); + setIsEditModalOpen(false); + setSuccessMessage( + `Changes to "${formData.name}" are ready to apply. Backend integration coming soon.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }; + return ( {isLoading && ( @@ -47,6 +93,28 @@ const DataSourceOverviewTab = () => { {isError &&

Error loading data source: {dataSourceName}

} {isSuccess && data && ( + {successMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Data Source + + + + @@ -141,6 +209,15 @@ const DataSourceOverviewTab = () => { )} + + {isEditModalOpen && data && ( + setIsEditModalOpen(false)} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + /> + )}
); }; diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 84309775e0b..8675f1ba45a 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { @@ -9,6 +9,8 @@ import { EuiTitle, EuiFieldSearch, EuiSpacer, + EuiButton, + EuiCallOut, } from "@elastic/eui"; import DatasourcesListingTable from "./DataSourcesListingTable"; @@ -18,6 +20,9 @@ import { DataSourceIcon } from "../../graphics/DataSourceIcon"; import { useSearchQuery } from "../../hooks/useSearchInputWithTags"; import { feast } from "../../protos"; import ExportButton from "../../components/ExportButton"; +import DataSourceFormModal, { + DataSourceFormData, +} from "../../components/DataSourceFormModal"; import useResourceQuery, { dataSourceListPath, } from "../../queries/useResourceQuery"; @@ -50,6 +55,8 @@ const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => { const Index = () => { const { isLoading, isSuccess, isError, data } = useLoadDatasources(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); useDocumentTitle(`Data Sources | Feast`); @@ -57,6 +64,15 @@ const Index = () => { const filterResult = data ? filterFn(data, searchTokens) : data; + const handleCreateSubmit = (formData: DataSourceFormData) => { + console.log("Data source create payload:", formData); + setIsModalOpen(false); + setSuccessMessage( + `Data source "${formData.name}" is ready to be created. Backend integration coming soon.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }; + return ( { iconType={DataSourceIcon} pageTitle="Data Sources" rightSideItems={[ + setIsModalOpen(true)} + key="create" + > + Create Data Source + , , ]} /> + {successMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -100,6 +136,13 @@ const Index = () => { )} + + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 8a20688d140..c355e09f67f 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -3,6 +3,8 @@ import { EuiHorizontalRule, EuiLoadingSpinner, EuiTitle, + EuiButtonEmpty, + EuiCallOut, } from "@elastic/eui"; import { EuiPanel, @@ -13,9 +15,12 @@ import { EuiDescriptionListTitle, EuiDescriptionListDescription, } from "@elastic/eui"; -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; import { useParams } from "react-router-dom"; import PermissionsDisplay from "../../components/PermissionsDisplay"; +import EntityFormModal, { + EntityFormData, +} from "../../components/EntityFormModal"; import TagsDisplay from "../../components/TagsDisplay"; import RegistryPathContext from "../../contexts/RegistryPathContext"; import { FEAST_FCO_TYPES } from "../../parsers/types"; @@ -27,6 +32,20 @@ import FeatureViewEdgesList from "./FeatureViewEdgesList"; import useFeatureViewEdgesByEntity from "./useFeatureViewEdgesByEntity"; import useLoadEntity from "./useLoadEntity"; +const buildEditFormData = (entity: feast.core.IEntity): EntityFormData => { + const tags = entity.spec?.tags + ? Object.entries(entity.spec.tags).map(([key, value]) => ({ key, value })) + : []; + + return { + name: entity.spec?.name || "", + description: entity.spec?.description || "", + joinKey: entity.spec?.joinKey || "", + valueType: String(entity.spec?.valueType ?? 0), + tags, + }; +}; + const EntityOverviewTab = () => { let { entityName, projectName } = useParams(); const registryUrl = useContext(RegistryPathContext); @@ -40,6 +59,19 @@ const EntityOverviewTab = () => { const fvEdgesSuccess = fvEdges.isSuccess; const fvEdgesData = fvEdges.data; + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const handleEditSubmit = (formData: EntityFormData) => { + // TODO: Wire to REST API when backend write endpoints are available + console.log("Entity edit payload:", formData); + setIsEditModalOpen(false); + setSuccessMessage( + `Changes to "${formData.name}" are ready to apply. Backend integration coming soon.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }; + return ( {isLoading && ( @@ -51,6 +83,28 @@ const EntityOverviewTab = () => { {isError &&

Error loading entity: {entityName}

} {isSuccess && data && ( + {successMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Entity + + + + @@ -162,6 +216,15 @@ const EntityOverviewTab = () => { )} + + {isEditModalOpen && data && ( + setIsEditModalOpen(false)} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + /> + )} ); }; diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index 216e713f382..8c4e44ac154 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -1,7 +1,13 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; -import { EuiPageTemplate, EuiLoadingSpinner } from "@elastic/eui"; +import { + EuiPageTemplate, + EuiLoadingSpinner, + EuiButton, + EuiCallOut, + EuiSpacer, +} from "@elastic/eui"; import { EntityIcon } from "../../graphics/EntityIcon"; @@ -9,6 +15,9 @@ import EntitiesListingTable from "./EntitiesListingTable"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; import EntityIndexEmptyState from "./EntityIndexEmptyState"; import ExportButton from "../../components/ExportButton"; +import EntityFormModal, { + EntityFormData, +} from "../../components/EntityFormModal"; import useResourceQuery, { entityListPath, } from "../../queries/useResourceQuery"; @@ -25,9 +34,21 @@ const useLoadEntities = () => { const Index = () => { const { isLoading, isSuccess, isError, data } = useLoadEntities(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); useDocumentTitle(`Entities | Feast`); + const handleCreateSubmit = (formData: EntityFormData) => { + // TODO: Wire to REST API when backend write endpoints are available + console.log("Entity create payload:", formData); + setIsModalOpen(false); + setSuccessMessage( + `Entity "${formData.name}" is ready to be created. Backend integration coming soon.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }; + return ( { iconType={EntityIcon} pageTitle="Entities" rightSideItems={[ + setIsModalOpen(true)} + key="create" + > + Create Entity + , , ]} /> + {successMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -52,6 +93,13 @@ const Index = () => { {isSuccess && !data && } {isSuccess && data && } + + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index 849d1899a3e..57beacbca3b 100644 --- a/ui/src/pages/feature-views/Index.tsx +++ b/ui/src/pages/feature-views/Index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { @@ -9,6 +9,8 @@ import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem, + EuiButton, + EuiCallOut, } from "@elastic/eui"; import { FeatureViewIcon } from "../../graphics/FeatureViewIcon"; @@ -25,6 +27,9 @@ import FeatureViewIndexEmptyState from "./FeatureViewIndexEmptyState"; import { useFeatureViewTagsAggregation } from "../../hooks/useTagsAggregation"; import TagSearch from "../../components/TagSearch"; import ExportButton from "../../components/ExportButton"; +import FeatureViewFormModal, { + FeatureViewFormData, +} from "../../components/FeatureViewFormModal"; import useResourceQuery, { featureViewListPath, restFeatureViewsToMergedList, @@ -89,6 +94,8 @@ const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { const Index = () => { const { isLoading, isSuccess, isError, data } = useLoadFeatureViews(); const tagAggregationQuery = useFeatureViewTagsAggregation(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); useDocumentTitle(`Feature Views | Feast`); @@ -110,6 +117,15 @@ const Index = () => { ? filterFn(data, { tagTokenGroups, searchTokens }) : data; + const handleCreateSubmit = (formData: FeatureViewFormData) => { + console.log("Feature view create payload:", formData); + setIsModalOpen(false); + setSuccessMessage( + `Feature view "${formData.name}" is ready to be created. Backend integration coming soon.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }; + return ( { iconType={FeatureViewIcon} pageTitle="Feature Views" rightSideItems={[ + setIsModalOpen(true)} + key="create" + > + Create Feature View + , , ]} /> + {successMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -167,6 +203,13 @@ const Index = () => { )} + + {isModalOpen && ( + setIsModalOpen(false)} + onSubmit={handleCreateSubmit} + /> + )} ); }; diff --git a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx index e766e4fd0ab..b074d831091 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx @@ -1,5 +1,7 @@ import { EuiBadge, + EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, @@ -9,10 +11,13 @@ import { EuiText, EuiTitle, } from "@elastic/eui"; -import React from "react"; +import React, { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import FeaturesListDisplay from "../../components/FeaturesListDisplay"; +import FeatureViewFormModal, { + FeatureViewFormData, +} from "../../components/FeatureViewFormModal"; import PermissionsDisplay from "../../components/PermissionsDisplay"; import TagsDisplay from "../../components/TagsDisplay"; import { encodeSearchQueryString } from "../../hooks/encodeSearchQueryString"; @@ -39,6 +44,55 @@ interface RegularFeatureViewOverviewTabProps { permissions?: any[]; } +const buildEditFormData = ( + fv: feast.core.IFeatureView, +): FeatureViewFormData => { + const tags = fv.spec?.tags + ? Object.entries(fv.spec.tags).map(([key, value]) => ({ key, value })) + : []; + + const features = (fv.spec?.features || []).map((f) => ({ + name: f.name || "", + valueType: String(f.valueType ?? 0), + description: f.description || "", + })); + + let ttlValue = 0; + let ttlUnit = "seconds"; + if (fv.spec?.ttl?.seconds) { + const secs = + typeof fv.spec.ttl.seconds === "number" + ? fv.spec.ttl.seconds + : (fv.spec.ttl.seconds as any).toNumber?.() ?? 0; + if (secs > 0 && secs % 86400 === 0) { + ttlValue = secs / 86400; + ttlUnit = "days"; + } else if (secs > 0 && secs % 3600 === 0) { + ttlValue = secs / 3600; + ttlUnit = "hours"; + } else if (secs > 0 && secs % 60 === 0) { + ttlValue = secs / 60; + ttlUnit = "minutes"; + } else { + ttlValue = secs; + ttlUnit = "seconds"; + } + } + + return { + name: fv.spec?.name || "", + description: fv.spec?.description || "", + owner: fv.spec?.owner || "", + entities: fv.spec?.entities || [], + features, + batchSource: fv.spec?.batchSource?.name || "", + ttlValue, + ttlUnit, + online: fv.spec?.online ?? true, + tags, + }; +}; + const RegularFeatureViewOverviewTab = ({ data, permissions, @@ -59,8 +113,42 @@ const RegularFeatureViewOverviewTab = ({ : []; const numOfFs = fsNames.length; + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const handleEditSubmit = (formData: FeatureViewFormData) => { + console.log("Feature view edit payload:", formData); + setIsEditModalOpen(false); + setSuccessMessage( + `Changes to "${formData.name}" are ready to apply. Backend integration coming soon.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }; + return ( + {successMessage && ( + <> + + + + )} + + + setIsEditModalOpen(true)} + > + Edit Feature View + + + + @@ -197,6 +285,15 @@ const RegularFeatureViewOverviewTab = ({ })} + + {isEditModalOpen && data && ( + setIsEditModalOpen(false)} + onSubmit={handleEditSubmit} + initialData={buildEditFormData(data)} + isEdit + /> + )} ); }; From ab4540cc483a98df98e902310bc5b4d5c17c381c Mon Sep 17 00:00:00 2001 From: Rohit Bharmal Date: Wed, 8 Apr 2026 14:47:11 +0530 Subject: [PATCH 2/6] feat: Introduce UI versioning context and toggle component Signed-off-by: Rohit Bharmal --- ui/src/FeastUISansProviders.tsx | 13 +- ui/src/components/EntityFormModal.tsx | 128 +++++++++++++++--- ui/src/components/UIVersionToggle.tsx | 25 ++++ ui/src/contexts/UIVersionContext.tsx | 46 +++++++ ui/src/pages/Layout.tsx | 6 +- .../data-sources/DataSourceOverviewTab.tsx | 28 ++-- ui/src/pages/data-sources/Index.tsx | 22 +-- ui/src/pages/entities/EntityOverviewTab.tsx | 32 +++-- ui/src/pages/entities/Index.tsx | 22 +-- ui/src/pages/feature-views/Index.tsx | 22 +-- .../RegularFeatureViewOverviewTab.tsx | 28 ++-- 11 files changed, 290 insertions(+), 82 deletions(-) create mode 100644 ui/src/components/UIVersionToggle.tsx create mode 100644 ui/src/contexts/UIVersionContext.tsx diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 50de27b5944..c2eec84fbbc 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -5,6 +5,7 @@ import "./index.css"; import { Routes, Route } from "react-router-dom"; import { EuiProvider, EuiErrorBoundary } from "@elastic/eui"; import { ThemeProvider, useTheme } from "./contexts/ThemeContext"; +import { UIVersionProvider } from "./contexts/UIVersionContext"; import ProjectOverviewPage from "./pages/ProjectOverviewPage"; import Layout from "./pages/Layout"; @@ -78,11 +79,13 @@ const FeastUISansProviders = ({ return ( - + + + ); }; diff --git a/ui/src/components/EntityFormModal.tsx b/ui/src/components/EntityFormModal.tsx index 9d18ecdfd7f..0439fc9b378 100644 --- a/ui/src/components/EntityFormModal.tsx +++ b/ui/src/components/EntityFormModal.tsx @@ -1,5 +1,15 @@ import React, { useState, useEffect } from "react"; -import { EuiFormRow, EuiFieldText, EuiSpacer } from "@elastic/eui"; +import { + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiButtonEmpty, + EuiButtonIcon, + EuiText, + EuiHorizontalRule, + EuiCallOut, +} from "@elastic/eui"; import { feast } from "../protos"; import FormModal from "./forms/FormModal"; import TagsEditor, { TagEntry } from "./forms/TagsEditor"; @@ -9,7 +19,7 @@ import ValueTypeSelect from "./forms/ValueTypeSelect"; interface EntityFormData { name: string; description: string; - joinKey: string; + joinKeys: string[]; valueType: string; tags: TagEntry[]; } @@ -24,7 +34,7 @@ interface EntityFormModalProps { const EMPTY_FORM: EntityFormData = { name: "", description: "", - joinKey: "", + joinKeys: [""], valueType: String(feast.types.ValueType.Enum.STRING), tags: [], }; @@ -57,8 +67,19 @@ const EntityFormModal: React.FC = ({ "Must start with a letter or underscore, and contain only letters, numbers, and underscores."; } - if (!formData.joinKey.trim()) { - newErrors.joinKey = "Join key is required."; + const nonEmptyKeys = formData.joinKeys.filter((k) => k.trim()); + if (nonEmptyKeys.length === 0) { + newErrors.joinKeys = "At least one join key is required."; + } else { + if (new Set(nonEmptyKeys).size !== nonEmptyKeys.length) { + newErrors.joinKeys = "Join keys must be unique."; + } + const invalidKey = nonEmptyKeys.find( + (k) => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k), + ); + if (invalidKey) { + newErrors.joinKeys = `Invalid join key "${invalidKey}". Use only letters, numbers, and underscores.`; + } } const tagKeys = formData.tags.map((t) => t.key).filter((k) => k.trim()); @@ -75,6 +96,7 @@ const EntityFormModal: React.FC = ({ if (validate()) { const cleanedData = { ...formData, + joinKeys: formData.joinKeys.filter((k) => k.trim()), tags: formData.tags.filter((t) => t.key.trim()), }; onSubmit(cleanedData); @@ -95,6 +117,24 @@ const EntityFormModal: React.FC = ({ } }; + const addJoinKey = () => { + updateField("joinKeys", [...formData.joinKeys, ""]); + }; + + const removeJoinKey = (index: number) => { + if (formData.joinKeys.length <= 1) return; + updateField( + "joinKeys", + formData.joinKeys.filter((_, i) => i !== index), + ); + }; + + const updateJoinKey = (index: number, value: string) => { + const updated = [...formData.joinKeys]; + updated[index] = value; + updateField("joinKeys", updated); + }; + return ( = ({ descriptionPlaceholder="Describe what this entity represents..." /> - - updateField("joinKey", e.target.value)} - isInvalid={!!errors.joinKey} - placeholder="e.g. customer_id" - /> - + + + + + + +

Join Keys

+ + + + + Add join key + + + + + + Column name(s) used to join this entity in data sources. + + + {errors.joinKeys && ( + <> + + + + )} + + {formData.joinKeys.map((key, index) => ( + + + updateJoinKey(index, e.target.value)} + placeholder={ + index === 0 + ? "e.g. customer_id" + : "e.g. timestamp_field" + } + compressed + isInvalid={!!errors.joinKeys && !key.trim()} + /> + + + removeJoinKey(index)} + disabled={formData.joinKeys.length <= 1} + /> + + + ))} + + { + const { uiVersion, setUIVersion } = useUIVersion(); + + return ( + setUIVersion(id as "v1" | "v2")} + buttonSize="compressed" + isFullWidth={false} + /> + ); +}; + +export default UIVersionToggle; diff --git a/ui/src/contexts/UIVersionContext.tsx b/ui/src/contexts/UIVersionContext.tsx new file mode 100644 index 00000000000..548692ea194 --- /dev/null +++ b/ui/src/contexts/UIVersionContext.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useState, useContext, useEffect } from "react"; + +type UIVersion = "v1" | "v2"; + +interface UIVersionContextType { + uiVersion: UIVersion; + isV2: boolean; + setUIVersion: (version: UIVersion) => void; + toggleVersion: () => void; +} + +const UIVersionContext = createContext({ + uiVersion: "v1", + isV2: false, + setUIVersion: () => {}, + toggleVersion: () => {}, +}); + +export const UIVersionProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [uiVersion, setUIVersion] = useState(() => { + const saved = localStorage.getItem("feast-ui-version"); + return saved === "v2" ? "v2" : "v1"; + }); + + useEffect(() => { + localStorage.setItem("feast-ui-version", uiVersion); + }, [uiVersion]); + + const toggleVersion = () => { + setUIVersion((prev) => (prev === "v1" ? "v2" : "v1")); + }; + + return ( + + {children} + + ); +}; + +export const useUIVersion = () => useContext(UIVersionContext); + +export default UIVersionContext; diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index 0e3341b8820..0c1f4e2787a 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -26,6 +26,7 @@ import RegistrySearch, { } from "../components/RegistrySearch"; import GlobalSearchShortcut from "../components/GlobalSearchShortcut"; import CommandPalette from "../components/CommandPalette"; +import UIVersionToggle from "../components/UIVersionToggle"; const Layout = () => { // Registry Path Context has to be inside Layout @@ -246,7 +247,7 @@ const Layout = () => { width: "100%", }} > - + { categories={globalCategories} /> + + + )} diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 9cdab1f1132..7e50f551763 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -30,6 +30,7 @@ import BatchSourcePropertiesView from "./BatchSourcePropertiesView"; import FeatureViewEdgesList from "../entities/FeatureViewEdgesList"; import RequestDataSourceSchemaTable from "./RequestDataSourceSchemaTable"; import useLoadDataSource from "./useLoadDataSource"; +import { useUIVersion } from "../../contexts/UIVersionContext"; const buildEditFormData = (ds: feast.core.IDataSource): DataSourceFormData => { const tags = ds.tags @@ -70,6 +71,7 @@ const DataSourceOverviewTab = () => { useLoadDataSource(dsName); const isEmpty = data === undefined; + const { isV2 } = useUIVersion(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); @@ -104,17 +106,21 @@ const DataSourceOverviewTab = () => { )} - - - setIsEditModalOpen(true)} - > - Edit Data Source - - - - + {isV2 && ( + <> + + + setIsEditModalOpen(true)} + > + Edit Data Source + + + + + + )} diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 8675f1ba45a..38c408e3ad4 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -23,6 +23,7 @@ import ExportButton from "../../components/ExportButton"; import DataSourceFormModal, { DataSourceFormData, } from "../../components/DataSourceFormModal"; +import { useUIVersion } from "../../contexts/UIVersionContext"; import useResourceQuery, { dataSourceListPath, } from "../../queries/useResourceQuery"; @@ -55,6 +56,7 @@ const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => { const Index = () => { const { isLoading, isSuccess, isError, data } = useLoadDatasources(); + const { isV2 } = useUIVersion(); const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); @@ -80,14 +82,18 @@ const Index = () => { iconType={DataSourceIcon} pageTitle="Data Sources" rightSideItems={[ - setIsModalOpen(true)} - key="create" - > - Create Data Source - , + ...(isV2 + ? [ + setIsModalOpen(true)} + key="create" + > + Create Data Source + , + ] + : []), { const tags = entity.spec?.tags ? Object.entries(entity.spec.tags).map(([key, value]) => ({ key, value })) : []; + const joinKeys = entity.spec?.joinKey ? [entity.spec.joinKey] : [""]; + return { name: entity.spec?.name || "", description: entity.spec?.description || "", - joinKey: entity.spec?.joinKey || "", + joinKeys, valueType: String(entity.spec?.valueType ?? 0), tags, }; @@ -59,6 +62,7 @@ const EntityOverviewTab = () => { const fvEdgesSuccess = fvEdges.isSuccess; const fvEdgesData = fvEdges.data; + const { isV2 } = useUIVersion(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); @@ -94,17 +98,21 @@ const EntityOverviewTab = () => { )} - - - setIsEditModalOpen(true)} - > - Edit Entity - - - - + {isV2 && ( + <> + + + setIsEditModalOpen(true)} + > + Edit Entity + + + + + + )} diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index 8c4e44ac154..d48e0702a66 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -18,6 +18,7 @@ import ExportButton from "../../components/ExportButton"; import EntityFormModal, { EntityFormData, } from "../../components/EntityFormModal"; +import { useUIVersion } from "../../contexts/UIVersionContext"; import useResourceQuery, { entityListPath, } from "../../queries/useResourceQuery"; @@ -34,6 +35,7 @@ const useLoadEntities = () => { const Index = () => { const { isLoading, isSuccess, isError, data } = useLoadEntities(); + const { isV2 } = useUIVersion(); const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); @@ -56,14 +58,18 @@ const Index = () => { iconType={EntityIcon} pageTitle="Entities" rightSideItems={[ - setIsModalOpen(true)} - key="create" - > - Create Entity - , + ...(isV2 + ? [ + setIsModalOpen(true)} + key="create" + > + Create Entity + , + ] + : []), { const Index = () => { const { isLoading, isSuccess, isError, data } = useLoadFeatureViews(); + const { isV2 } = useUIVersion(); const tagAggregationQuery = useFeatureViewTagsAggregation(); const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); @@ -133,14 +135,18 @@ const Index = () => { iconType={FeatureViewIcon} pageTitle="Feature Views" rightSideItems={[ - setIsModalOpen(true)} - key="create" - > - Create Feature View - , + ...(isV2 + ? [ + setIsModalOpen(true)} + key="create" + > + Create Feature View + , + ] + : []), { return (r: EntityRelation) => { @@ -113,6 +114,7 @@ const RegularFeatureViewOverviewTab = ({ : []; const numOfFs = fsNames.length; + const { isV2 } = useUIVersion(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); @@ -138,17 +140,21 @@ const RegularFeatureViewOverviewTab = ({ )} - - - setIsEditModalOpen(true)} - > - Edit Feature View - - - - + {isV2 && ( + <> + + + setIsEditModalOpen(true)} + > + Edit Feature View + + + + + + )} From edcef8f88ac082b68c5bacc6c08f7918d61145d3 Mon Sep 17 00:00:00 2001 From: Rohit Bharmal Date: Mon, 18 May 2026 11:35:31 +0530 Subject: [PATCH 3/6] feat: Add UI REST integration for entity management Co-authored-by: Cursor Signed-off-by: Rohit Bharmal --- ui/package.json | 1 + ui/src/pages/entities/EntityOverviewTab.tsx | 47 ++++++++-- ui/src/pages/entities/Index.tsx | 59 ++++++++++-- .../queries/mutations/useEntityMutations.ts | 90 +++++++++++++++++++ ui/src/queries/restApi.ts | 19 ++++ ui/src/queries/useLoadEntitiesREST.ts | 41 +++++++++ 6 files changed, 242 insertions(+), 15 deletions(-) create mode 100644 ui/src/queries/mutations/useEntityMutations.ts create mode 100644 ui/src/queries/restApi.ts create mode 100644 ui/src/queries/useLoadEntitiesREST.ts diff --git a/ui/package.json b/ui/package.json index 354334df406..5347cca0a69 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,6 +22,7 @@ "optional": true } }, + "proxy": "http://localhost:6572", "dependencies": { "@elastic/datemath": "^5.0.3", "@elastic/eui": "^95.12.0", diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 7b9e73b570b..74a587b8a93 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -32,6 +32,7 @@ import FeatureViewEdgesList from "./FeatureViewEdgesList"; import useFeatureViewEdgesByEntity from "./useFeatureViewEdgesByEntity"; import useLoadEntity from "./useLoadEntity"; import { useUIVersion } from "../../contexts/UIVersionContext"; +import { useApplyEntity } from "../../queries/mutations/useEntityMutations"; const buildEditFormData = (entity: feast.core.IEntity): EntityFormData => { const tags = entity.spec?.tags @@ -65,15 +66,36 @@ const EntityOverviewTab = () => { const { isV2 } = useUIVersion(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyEntity = useApplyEntity(); const handleEditSubmit = (formData: EntityFormData) => { - // TODO: Wire to REST API when backend write endpoints are available - console.log("Entity edit payload:", formData); - setIsEditModalOpen(false); - setSuccessMessage( - `Changes to "${formData.name}" are ready to apply. Backend integration coming soon.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); + const payload = { + name: formData.name, + project: projectName || "", + join_key: formData.joinKeys[0] || formData.name, + value_type: parseInt(formData.valueType, 10), + description: formData.description, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + owner: "", + }; + applyEntity.mutate(payload, { + onSuccess: () => { + setIsEditModalOpen(false); + setErrorMessage(null); + setSuccessMessage( + `Entity "${formData.name}" updated successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); }; return ( @@ -98,6 +120,17 @@ const EntityOverviewTab = () => { )} + {errorMessage && ( + <> + + + + )} {isV2 && ( <> diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index d48e0702a66..14582023640 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -18,6 +18,7 @@ import ExportButton from "../../components/ExportButton"; import EntityFormModal, { EntityFormData, } from "../../components/EntityFormModal"; +import { useApplyEntity } from "../../queries/mutations/useEntityMutations"; import { useUIVersion } from "../../contexts/UIVersionContext"; import useResourceQuery, { entityListPath, @@ -33,22 +34,53 @@ const useLoadEntities = () => { }); }; +const formDataToPayload = (formData: EntityFormData, project: string) => ({ + name: formData.name, + project, + join_key: formData.joinKeys[0] || formData.name, + value_type: parseInt(formData.valueType, 10), + description: formData.description, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + owner: "", +}); + const Index = () => { - const { isLoading, isSuccess, isError, data } = useLoadEntities(); + const { projectName } = useParams(); const { isV2 } = useUIVersion(); + + const v1Query = useLoadEntitiesV1(); + const v2Query = useLoadEntitiesREST(projectName || ""); + + const isLoading = isV2 ? v2Query.isLoading : v1Query.isLoading; + const isSuccess = isV2 ? v2Query.isSuccess : v1Query.isSuccess; + const isError = isV2 ? v2Query.isError : v1Query.isError; + const data = isV2 ? v2Query.data?.entities : v1Query.data; + const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyEntity = useApplyEntity(); useDocumentTitle(`Entities | Feast`); const handleCreateSubmit = (formData: EntityFormData) => { - // TODO: Wire to REST API when backend write endpoints are available - console.log("Entity create payload:", formData); - setIsModalOpen(false); - setSuccessMessage( - `Entity "${formData.name}" is ready to be created. Backend integration coming soon.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); + const payload = formDataToPayload(formData, projectName || ""); + applyEntity.mutate(payload, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(null); + setSuccessMessage(`Entity "${formData.name}" created successfully.`); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); }; return ( @@ -90,6 +122,17 @@ const Index = () => { )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading diff --git a/ui/src/queries/mutations/useEntityMutations.ts b/ui/src/queries/mutations/useEntityMutations.ts new file mode 100644 index 00000000000..05498a2f556 --- /dev/null +++ b/ui/src/queries/mutations/useEntityMutations.ts @@ -0,0 +1,90 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface ApplyEntityPayload { + name: string; + project: string; + join_key?: string; + value_type?: number; + description?: string; + tags?: Record; + owner?: string; +} + +interface DeleteEntityPayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyEntity = async ( + payload: ApplyEntityPayload, +): Promise => { + const response = await fetch(`${API_BASE}/entities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to apply entity: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteEntity = async ( + payload: DeleteEntityPayload, +): Promise => { + const response = await fetch( + `${API_BASE}/entities/${encodeURIComponent(payload.name)}?project=${encodeURIComponent(payload.project)}`, + { method: "DELETE" }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to delete entity: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyEntity = () => { + const queryClient = useQueryClient(); + + return useMutation(applyEntity, { + onSuccess: () => { + queryClient.invalidateQueries(["entities-rest"]); + queryClient.invalidateQueries(["entity-rest"]); + }, + }); +}; + +const useDeleteEntity = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteEntity, { + onSuccess: () => { + queryClient.invalidateQueries(["entities-rest"]); + queryClient.invalidateQueries(["entity-rest"]); + }, + }); +}; + +export { useApplyEntity, useDeleteEntity }; +export type { ApplyEntityPayload, DeleteEntityPayload }; diff --git a/ui/src/queries/restApi.ts b/ui/src/queries/restApi.ts new file mode 100644 index 00000000000..f3734962a00 --- /dev/null +++ b/ui/src/queries/restApi.ts @@ -0,0 +1,19 @@ +const API_BASE = "/api/v1"; + +export async function fetchApi( + path: string, + params?: Record, +): Promise { + const url = new URL(`${API_BASE}${path}`, window.location.origin); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + const res = await fetch(url.toString(), { + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(body.detail || `API error: ${res.status}`); + } + return res.json(); +} diff --git a/ui/src/queries/useLoadEntitiesREST.ts b/ui/src/queries/useLoadEntitiesREST.ts new file mode 100644 index 00000000000..7127de656ee --- /dev/null +++ b/ui/src/queries/useLoadEntitiesREST.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { fetchApi } from "./restApi"; + +interface EntityListResponse { + entities: any[]; + pagination: Record; + relationships?: Record; +} + +const useLoadEntitiesREST = (project: string) => { + return useQuery( + ["entities-rest", project], + () => + fetchApi("/entities", { + project, + allow_cache: "false", + }), + { + enabled: !!project, + staleTime: 30000, + }, + ); +}; + +const useLoadEntityREST = (name: string, project: string) => { + return useQuery( + ["entity-rest", name, project], + () => + fetchApi(`/entities/${encodeURIComponent(name)}`, { + project, + include_relationships: "true", + allow_cache: "false", + }), + { + enabled: !!name && !!project, + staleTime: 30000, + }, + ); +}; + +export { useLoadEntitiesREST, useLoadEntityREST }; From 384cb398c1f059094fd73927eebf765626cce7e6 Mon Sep 17 00:00:00 2001 From: Rohit Bharmal Date: Mon, 18 May 2026 11:35:43 +0530 Subject: [PATCH 4/6] feat: Add UI REST integration for data sources and feature views Co-authored-by: Cursor Signed-off-by: Rohit Bharmal --- ui/src/components/FeatureViewFormModal.tsx | 378 ++++++++++++------ .../data-sources/DataSourceOverviewTab.tsx | 174 +++++--- .../data-sources/DataSourcesListingTable.tsx | 3 +- ui/src/pages/data-sources/Index.tsx | 102 ++++- .../pages/entities/EntitiesListingTable.tsx | 3 +- ui/src/pages/entities/Index.tsx | 10 +- ui/src/pages/feature-views/Index.tsx | 136 ++++++- .../RegularFeatureViewOverviewTab.tsx | 62 ++- .../mutations/useDataSourceMutations.ts | 97 +++++ .../mutations/useFeatureViewMutations.ts | 98 +++++ ui/src/queries/useLoadDataSourcesREST.ts | 41 ++ ui/src/queries/useLoadFeatureViewsREST.ts | 41 ++ 12 files changed, 943 insertions(+), 202 deletions(-) create mode 100644 ui/src/queries/mutations/useDataSourceMutations.ts create mode 100644 ui/src/queries/mutations/useFeatureViewMutations.ts create mode 100644 ui/src/queries/useLoadDataSourcesREST.ts create mode 100644 ui/src/queries/useLoadFeatureViewsREST.ts diff --git a/ui/src/components/FeatureViewFormModal.tsx b/ui/src/components/FeatureViewFormModal.tsx index 40d6c2a28c4..ba51ea3b3b3 100644 --- a/ui/src/components/FeatureViewFormModal.tsx +++ b/ui/src/components/FeatureViewFormModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from "react"; +import React, { useState, useEffect } from "react"; import { EuiFormRow, EuiFieldText, @@ -8,6 +8,8 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiSpacer, + EuiCallOut, + EuiButtonEmpty, } from "@elastic/eui"; import { useParams } from "react-router-dom"; import FormModal from "./forms/FormModal"; @@ -16,8 +18,13 @@ import NameDescriptionOwnerFields from "./forms/NameDescriptionOwnerFields"; import FeatureFieldEditor, { FeatureFieldEntry, } from "./forms/FeatureFieldEditor"; -import RegistryPathContext from "../contexts/RegistryPathContext"; -import useLoadRegistry from "../queries/useLoadRegistry"; +import EntityFormModal, { EntityFormData } from "./EntityFormModal"; +import DataSourceFormModal, { DataSourceFormData } from "./DataSourceFormModal"; +import { useLoadEntitiesREST } from "../queries/useLoadEntitiesREST"; +import { useLoadDataSourcesREST } from "../queries/useLoadDataSourcesREST"; +import { useApplyEntity } from "../queries/mutations/useEntityMutations"; +import { useApplyDataSource } from "../queries/mutations/useDataSourceMutations"; +import { feast } from "../protos"; const TTL_UNIT_OPTIONS = [ { value: "seconds", text: "Seconds" }, @@ -70,21 +77,32 @@ const FeatureViewFormModal: React.FC = ({ ); const [errors, setErrors] = useState>({}); const [submitted, setSubmitted] = useState(false); + const [showEntityForm, setShowEntityForm] = useState(false); + const [showDataSourceForm, setShowDataSourceForm] = useState(false); - const registryUrl = useContext(RegistryPathContext); const { projectName } = useParams(); - const registryQuery = useLoadRegistry(registryUrl, projectName); + const entitiesQuery = useLoadEntitiesREST(projectName || ""); + const dataSourcesQuery = useLoadDataSourcesREST(projectName || ""); + const applyEntity = useApplyEntity(); + const applyDataSource = useApplyDataSource(); - const entityOptions: EuiComboBoxOptionOption[] = - registryQuery.data?.objects?.entities?.map((e: any) => ({ - label: e?.spec?.name || "", - })) || []; + const entities = entitiesQuery.data?.entities || []; + const dataSources = dataSourcesQuery.data?.dataSources || []; - const dataSourceOptions = - registryQuery.data?.objects?.dataSources?.map((ds: any) => ({ - value: ds?.name || "", - text: ds?.name || "", - })) || []; + const entityOptions: EuiComboBoxOptionOption[] = entities.map( + (e: any) => ({ + label: e?.spec?.name || e?.name || "", + }), + ); + + const dataSourceOptions = dataSources.map((ds: any) => ({ + value: ds?.name || ds?.spec?.name || "", + text: ds?.name || ds?.spec?.name || "", + })); + + const hasNoEntities = entitiesQuery.isSuccess && entities.length === 0; + const hasNoDataSources = + dataSourcesQuery.isSuccess && dataSources.length === 0; useEffect(() => { if (initialData) { @@ -153,122 +171,254 @@ const FeatureViewFormModal: React.FC = ({ } }; + const handleInlineEntityCreate = (entityData: EntityFormData) => { + const payload = { + name: entityData.name, + project: projectName || "", + join_key: entityData.joinKeys[0] || entityData.name, + value_type: parseInt(entityData.valueType, 10), + description: entityData.description, + tags: Object.fromEntries( + entityData.tags + .filter((t) => t.key.trim()) + .map((t) => [t.key, t.value]), + ), + }; + applyEntity.mutate(payload, { + onSuccess: () => { + setShowEntityForm(false); + updateField("entities", [...formData.entities, entityData.name]); + }, + }); + }; + + const handleInlineDataSourceCreate = (dsData: DataSourceFormData) => { + const payload: Record = { + name: dsData.name, + project: projectName || "", + type: parseInt(dsData.sourceType, 10), + timestamp_field: dsData.timestampField, + created_timestamp_column: dsData.createdTimestampColumn, + description: dsData.description, + owner: dsData.owner, + tags: Object.fromEntries( + dsData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + }; + + const st = dsData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + payload.file_options = { uri: dsData.fileUri }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + payload.bigquery_options = { + table: dsData.bigqueryTable, + query: dsData.bigqueryQuery, + }; + } else if ( + st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE) + ) { + payload.snowflake_options = { + table: dsData.snowflakeTable, + database: dsData.snowflakeDatabase, + schema_: dsData.snowflakeSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + payload.kafka_options = { + kafka_bootstrap_servers: dsData.kafkaBootstrapServers, + topic: dsData.kafkaTopic, + }; + } + + applyDataSource.mutate(payload as any, { + onSuccess: () => { + setShowDataSourceForm(false); + updateField("batchSource", dsData.name); + }, + }); + }; + const selectedEntityOptions = formData.entities.map((e) => ({ label: e })); return ( - - updateField("name", v)} - onChangeDescription={(v) => updateField("description", v)} - onChangeOwner={(v) => updateField("owner", v)} - nameDisabled={isEdit} - nameError={errors.name} - nameHelpText="A unique name for this feature view." - namePlaceholder="e.g. customer_features" - descriptionPlaceholder="Describe what this feature view provides..." - /> - - + - - updateField( - "entities", - selected.map((s) => s.label), - ) - } - isClearable + updateField("name", v)} + onChangeDescription={(v) => updateField("description", v)} + onChangeOwner={(v) => updateField("owner", v)} + nameDisabled={isEdit} + nameError={errors.name} + nameHelpText="A unique name for this feature view." + namePlaceholder="e.g. customer_features" + descriptionPlaceholder="Describe what this feature view provides..." /> - - - {dataSourceOptions.length > 0 ? ( - updateField("batchSource", e.target.value)} - isInvalid={!!errors.batchSource} - /> - ) : ( - updateField("batchSource", e.target.value)} - isInvalid={!!errors.batchSource} - placeholder="data_source_name" + {hasNoEntities && ( + <> + + +

+ Feature views typically reference entities. You can create one + now. +

+ setShowEntityForm(true)} + > + Create Entity + +
+ + + )} + + + + updateField( + "entities", + selected.map((s) => s.label), + ) + } + isClearable + isLoading={entitiesQuery.isLoading} /> + + + {hasNoDataSources && ( + <> + + +

+ A batch source is required. You can create a data source now. +

+ setShowDataSourceForm(true)} + > + Create Data Source + +
+ + )} - - + + {dataSourceOptions.length > 0 ? ( + updateField("batchSource", e.target.value)} + isInvalid={!!errors.batchSource} + /> + ) : ( + updateField("batchSource", e.target.value)} + isInvalid={!!errors.batchSource} + placeholder="data_source_name" + /> + )} + - updateField("features", features)} - error={errors.features} - /> + - + updateField("features", features)} + error={errors.features} + /> - -
- - updateField("ttlValue", parseInt(e.target.value) || 0) - } - min={0} - style={{ width: 120 }} - /> - updateField("ttlUnit", e.target.value)} - style={{ width: 140 }} + + + +
+ + updateField("ttlValue", parseInt(e.target.value) || 0) + } + min={0} + style={{ width: 120 }} + /> + updateField("ttlUnit", e.target.value)} + style={{ width: 140 }} + /> +
+
+ + + updateField("online", e.target.checked)} /> -
-
- - - updateField("online", e.target.checked)} + + + + + updateField("tags", tags)} + error={errors.tags} /> - + - + {showEntityForm && ( + setShowEntityForm(false)} + onSubmit={handleInlineEntityCreate} + /> + )} - updateField("tags", tags)} - error={errors.tags} - /> - + {showDataSourceForm && ( + setShowDataSourceForm(false)} + onSubmit={handleInlineDataSourceCreate} + /> + )} + ); }; diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 7e50f551763..1617f8bad4d 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -15,56 +15,115 @@ import { EuiDescriptionListDescription, EuiSpacer, } from "@elastic/eui"; -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; -import PermissionsDisplay from "../../components/PermissionsDisplay"; import DataSourceFormModal, { DataSourceFormData, } from "../../components/DataSourceFormModal"; -import RegistryPathContext from "../../contexts/RegistryPathContext"; -import { FEAST_FCO_TYPES } from "../../parsers/types"; import { feast } from "../../protos"; -import useLoadRegistry from "../../queries/useLoadRegistry"; -import { getEntityPermissions } from "../../utils/permissionUtils"; +import { useApplyDataSource } from "../../queries/mutations/useDataSourceMutations"; import BatchSourcePropertiesView from "./BatchSourcePropertiesView"; import FeatureViewEdgesList from "../entities/FeatureViewEdgesList"; import RequestDataSourceSchemaTable from "./RequestDataSourceSchemaTable"; import useLoadDataSource from "./useLoadDataSource"; import { useUIVersion } from "../../contexts/UIVersionContext"; -const buildEditFormData = (ds: feast.core.IDataSource): DataSourceFormData => { - const tags = ds.tags - ? Object.entries(ds.tags).map(([key, value]) => ({ key, value })) +const buildEditFormData = (ds: any): DataSourceFormData => { + const spec = ds.spec || ds; + const tags = spec.tags + ? Object.entries(spec.tags).map(([key, value]) => ({ + key, + value: value as string, + })) : []; return { - name: ds.name || "", - description: ds.description || "", - owner: ds.owner || "", - sourceType: String(ds.type ?? 0), - timestampField: ds.timestampField || "", - createdTimestampColumn: ds.createdTimestampColumn || "", + name: spec.name || ds.name || "", + description: spec.description || ds.description || "", + owner: spec.owner || ds.owner || "", + sourceType: String(spec.type ?? ds.type ?? 0), + timestampField: spec.timestampField || ds.timestampField || "", + createdTimestampColumn: + spec.createdTimestampColumn || ds.createdTimestampColumn || "", tags, - fileUri: ds.fileOptions?.uri || "", - bigqueryTable: ds.bigqueryOptions?.table || "", - bigqueryQuery: ds.bigqueryOptions?.query || "", - snowflakeTable: ds.snowflakeOptions?.table || "", - snowflakeDatabase: ds.snowflakeOptions?.database || "", - snowflakeSchema: ds.snowflakeOptions?.schema || "", - redshiftTable: ds.redshiftOptions?.table || "", - redshiftDatabase: ds.redshiftOptions?.database || "", - redshiftSchema: ds.redshiftOptions?.schema || "", - kafkaBootstrapServers: ds.kafkaOptions?.kafkaBootstrapServers || "", - kafkaTopic: ds.kafkaOptions?.topic || "", - sparkTable: ds.sparkOptions?.table || "", - sparkPath: ds.sparkOptions?.path || "", + fileUri: spec.fileOptions?.uri || ds.fileOptions?.uri || "", + bigqueryTable: + spec.bigqueryOptions?.table || ds.bigqueryOptions?.table || "", + bigqueryQuery: + spec.bigqueryOptions?.query || ds.bigqueryOptions?.query || "", + snowflakeTable: + spec.snowflakeOptions?.table || ds.snowflakeOptions?.table || "", + snowflakeDatabase: + spec.snowflakeOptions?.database || ds.snowflakeOptions?.database || "", + snowflakeSchema: + spec.snowflakeOptions?.schema || ds.snowflakeOptions?.schema || "", + redshiftTable: + spec.redshiftOptions?.table || ds.redshiftOptions?.table || "", + redshiftDatabase: + spec.redshiftOptions?.database || ds.redshiftOptions?.database || "", + redshiftSchema: + spec.redshiftOptions?.schema || ds.redshiftOptions?.schema || "", + kafkaBootstrapServers: + spec.kafkaOptions?.kafkaBootstrapServers || + ds.kafkaOptions?.kafkaBootstrapServers || + "", + kafkaTopic: spec.kafkaOptions?.topic || ds.kafkaOptions?.topic || "", + sparkTable: spec.sparkOptions?.table || ds.sparkOptions?.table || "", + sparkPath: spec.sparkOptions?.path || ds.sparkOptions?.path || "", }; }; +const formDataToPayload = (formData: DataSourceFormData, project: string) => { + const payload: Record = { + name: formData.name, + project, + type: parseInt(formData.sourceType, 10), + timestamp_field: formData.timestampField, + created_timestamp_column: formData.createdTimestampColumn, + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + }; + + const st = formData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + payload.file_options = { uri: formData.fileUri }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + payload.bigquery_options = { + table: formData.bigqueryTable, + query: formData.bigqueryQuery, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE)) { + payload.snowflake_options = { + table: formData.snowflakeTable, + database: formData.snowflakeDatabase, + schema_: formData.snowflakeSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { + payload.redshift_options = { + table: formData.redshiftTable, + database: formData.redshiftDatabase, + schema_: formData.redshiftSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + payload.kafka_options = { + kafka_bootstrap_servers: formData.kafkaBootstrapServers, + topic: formData.kafkaTopic, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { + payload.spark_options = { + table: formData.sparkTable, + path: formData.sparkPath, + }; + } + + return payload; +}; + const DataSourceOverviewTab = () => { - let { dataSourceName, projectName } = useParams(); - const registryUrl = useContext(RegistryPathContext); - const registryQuery = useLoadRegistry(registryUrl, projectName); + const { dataSourceName, projectName } = useParams(); const dsName = dataSourceName === undefined ? "" : dataSourceName; const { isLoading, isSuccess, isError, data, consumingFeatureViews } = @@ -74,16 +133,32 @@ const DataSourceOverviewTab = () => { const { isV2 } = useUIVersion(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyDataSource = useApplyDataSource(); const handleEditSubmit = (formData: DataSourceFormData) => { - console.log("Data source edit payload:", formData); - setIsEditModalOpen(false); - setSuccessMessage( - `Changes to "${formData.name}" are ready to apply. Backend integration coming soon.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); + const payload = formDataToPayload(formData, projectName || ""); + applyDataSource.mutate(payload as any, { + onSuccess: () => { + setIsEditModalOpen(false); + setErrorMessage(null); + setSuccessMessage( + `Data source "${formData.name}" updated successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); }; + const spec = data?.spec || data; + const sourceType = spec?.type; + return ( {isLoading && ( @@ -106,6 +181,17 @@ const DataSourceOverviewTab = () => { )} + {errorMessage && ( + <> + + + + )} {isV2 && ( <> @@ -130,16 +216,16 @@ const DataSourceOverviewTab = () => {

Properties

- {data.fileOptions || data.bigqueryOptions ? ( - - ) : data.type ? ( + {spec?.fileOptions || spec?.bigqueryOptions ? ( + + ) : sourceType ? ( Source Type - {feast.core.DataSource.SourceType[data.type]} + {sourceType} @@ -152,7 +238,7 @@ const DataSourceOverviewTab = () => { - {data.requestDataOptions ? ( + {spec?.requestDataOptions ? (

Request Source Schema

@@ -206,9 +292,7 @@ const DataSourceOverviewTab = () => { )} /> ) : ( - - No permissions defined for this data source. - + No consuming feature views )}
diff --git a/ui/src/pages/data-sources/DataSourcesListingTable.tsx b/ui/src/pages/data-sources/DataSourcesListingTable.tsx index c314a4dfb94..5096f5d0bf9 100644 --- a/ui/src/pages/data-sources/DataSourcesListingTable.tsx +++ b/ui/src/pages/data-sources/DataSourcesListingTable.tsx @@ -32,7 +32,8 @@ const DatasourcesListingTable = ({ name: "Type", field: "type", sortable: true, - render: (valueType: feast.core.DataSource.SourceType) => { + render: (valueType: feast.core.DataSource.SourceType | string) => { + if (typeof valueType === "string") return valueType; return feast.core.DataSource.SourceType[valueType]; }, }, diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 38c408e3ad4..5f74fda8ebf 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -23,6 +23,7 @@ import ExportButton from "../../components/ExportButton"; import DataSourceFormModal, { DataSourceFormData, } from "../../components/DataSourceFormModal"; +import { useApplyDataSource } from "../../queries/mutations/useDataSourceMutations"; import { useUIVersion } from "../../contexts/UIVersionContext"; import useResourceQuery, { dataSourceListPath, @@ -42,23 +43,78 @@ const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => { let filteredByTags = data; if (searchTokens.length) { - return filteredByTags.filter((entry) => { + return data.filter((entry) => { + const name = entry.name || entry.spec?.name || ""; return searchTokens.find((token) => { - return ( - token.length >= 3 && entry.name && entry.name.indexOf(token) >= 0 - ); + return token.length >= 3 && name.indexOf(token) >= 0; }); }); } - return filteredByTags; + return data; +}; + +const formDataToPayload = (formData: DataSourceFormData, project: string) => { + const payload: Record = { + name: formData.name, + project, + type: parseInt(formData.sourceType, 10), + timestamp_field: formData.timestampField, + created_timestamp_column: formData.createdTimestampColumn, + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), + }; + + const st = formData.sourceType; + if (st === String(feast.core.DataSource.SourceType.BATCH_FILE)) { + payload.file_options = { uri: formData.fileUri }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_BIGQUERY)) { + payload.bigquery_options = { + table: formData.bigqueryTable, + query: formData.bigqueryQuery, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SNOWFLAKE)) { + payload.snowflake_options = { + table: formData.snowflakeTable, + database: formData.snowflakeDatabase, + schema_: formData.snowflakeSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_REDSHIFT)) { + payload.redshift_options = { + table: formData.redshiftTable, + database: formData.redshiftDatabase, + schema_: formData.redshiftSchema, + }; + } else if (st === String(feast.core.DataSource.SourceType.STREAM_KAFKA)) { + payload.kafka_options = { + kafka_bootstrap_servers: formData.kafkaBootstrapServers, + topic: formData.kafkaTopic, + }; + } else if (st === String(feast.core.DataSource.SourceType.BATCH_SPARK)) { + payload.spark_options = { + table: formData.sparkTable, + path: formData.sparkPath, + }; + } + + return payload; }; const Index = () => { - const { isLoading, isSuccess, isError, data } = useLoadDatasources(); + const { projectName } = useParams(); const { isV2 } = useUIVersion(); + + const { isLoading, isSuccess, isError, data: queryData } = + useLoadDataSourcesREST(projectName || ""); + const data = queryData?.dataSources; + const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyDataSource = useApplyDataSource(); useDocumentTitle(`Data Sources | Feast`); @@ -67,12 +123,23 @@ const Index = () => { const filterResult = data ? filterFn(data, searchTokens) : data; const handleCreateSubmit = (formData: DataSourceFormData) => { - console.log("Data source create payload:", formData); - setIsModalOpen(false); - setSuccessMessage( - `Data source "${formData.name}" is ready to be created. Backend integration coming soon.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); + const payload = formDataToPayload(formData, projectName || ""); + applyDataSource.mutate(payload as any, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(null); + setSuccessMessage( + `Data source "${formData.name}" created successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); }; return ( @@ -114,6 +181,17 @@ const Index = () => { )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading diff --git a/ui/src/pages/entities/EntitiesListingTable.tsx b/ui/src/pages/entities/EntitiesListingTable.tsx index d5c28b0ea33..b3f929e4d9e 100644 --- a/ui/src/pages/entities/EntitiesListingTable.tsx +++ b/ui/src/pages/entities/EntitiesListingTable.tsx @@ -33,7 +33,8 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => { name: "Type", field: "spec.valueType", sortable: true, - render: (valueType: feast.types.ValueType.Enum) => { + render: (valueType: feast.types.ValueType.Enum | string) => { + if (typeof valueType === "string") return valueType; return feast.types.ValueType.Enum[valueType]; }, }, diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index 14582023640..a423a7b08dc 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -50,13 +50,9 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const v1Query = useLoadEntitiesV1(); - const v2Query = useLoadEntitiesREST(projectName || ""); - - const isLoading = isV2 ? v2Query.isLoading : v1Query.isLoading; - const isSuccess = isV2 ? v2Query.isSuccess : v1Query.isSuccess; - const isError = isV2 ? v2Query.isError : v1Query.isError; - const data = isV2 ? v2Query.data?.entities : v1Query.data; + const { isLoading, isSuccess, isError, data: queryData } = + useLoadEntitiesREST(projectName || ""); + const data = queryData?.entities; const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index d5fd14d1f98..a77c2761157 100644 --- a/ui/src/pages/feature-views/Index.tsx +++ b/ui/src/pages/feature-views/Index.tsx @@ -21,7 +21,11 @@ import { useSearchQuery, useTagsWithSuggestions, } from "../../hooks/useSearchInputWithTags"; -import { genericFVType, regularFVInterface } from "../../parsers/mergedFVTypes"; +import { + FEAST_FV_TYPES, + genericFVType, + regularFVInterface, +} from "../../parsers/mergedFVTypes"; import { useDocumentTitle } from "../../hooks/useDocumentTitle"; import FeatureViewIndexEmptyState from "./FeatureViewIndexEmptyState"; import { useFeatureViewTagsAggregation } from "../../hooks/useTagsAggregation"; @@ -30,6 +34,7 @@ import ExportButton from "../../components/ExportButton"; import FeatureViewFormModal, { FeatureViewFormData, } from "../../components/FeatureViewFormModal"; +import { useApplyFeatureView } from "../../queries/mutations/useFeatureViewMutations"; import { useUIVersion } from "../../contexts/UIVersionContext"; import useResourceQuery, { featureViewListPath, @@ -51,13 +56,12 @@ const shouldIncludeFVsGivenTokenGroups = ( tagTokenGroups: Record, ) => { return Object.entries(tagTokenGroups).every(([key, values]) => { - const entryTagValue = entry?.object?.spec!.tags - ? entry.object.spec.tags[key] - : undefined; + const tags = entry?.object?.spec?.tags; + const entryTagValue = tags ? (tags as any)[key] : undefined; if (entryTagValue) { return values.every((value) => { - return value.length > 0 ? entryTagValue.indexOf(value) >= 0 : true; // Don't filter if the string is empty + return value.length > 0 ? entryTagValue.indexOf(value) >= 0 : true; }); } else { return false; @@ -65,7 +69,10 @@ const shouldIncludeFVsGivenTokenGroups = ( }); }; -const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { +const filterFn = ( + data: genericFVType[], + filterInput: filterInputInterface, +) => { let filteredByTags = data; if (Object.keys(filterInput.tagTokenGroups).length) { @@ -76,7 +83,7 @@ const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { filterInput.tagTokenGroups, ); } else { - return false; // ODFVs don't have tags yet + return false; } }); } @@ -92,12 +99,70 @@ const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { return filteredByTags; }; +const TTL_UNITS: Record = { + days: 86400, + hours: 3600, + minutes: 60, + seconds: 1, +}; + +const formDataToPayload = ( + formData: FeatureViewFormData, + project: string, +) => ({ + name: formData.name, + project, + entities: formData.entities, + features: formData.features.map((f) => ({ + name: f.name, + value_type: parseInt(f.valueType, 10), + })), + batch_source: formData.batchSource, + ttl_seconds: formData.ttlValue * (TTL_UNITS[formData.ttlUnit] || 1), + online: formData.online, + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), + ), +}); + const Index = () => { - const { isLoading, isSuccess, isError, data } = useLoadFeatureViews(); + const { projectName } = useParams(); const { isV2 } = useUIVersion(); + + const { isLoading, isSuccess, isError, data: queryData } = + useLoadFeatureViewsREST(projectName || ""); + + const data = queryData?.featureViews + ? queryData.featureViews.map(mapRestFvToGenericType) + : undefined; + + const entitiesQuery = useLoadEntitiesREST(projectName || ""); + const dataSourcesQuery = useLoadDataSourcesREST(projectName || ""); + const tagAggregationQuery = useFeatureViewTagsAggregation(); const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [prereqWarning, setPrereqWarning] = useState(null); + const applyFeatureView = useApplyFeatureView(); + + const handleCreateClick = () => { + const missingDeps: string[] = []; + const entities = entitiesQuery.data?.entities || []; + const dataSources = dataSourcesQuery.data?.dataSources || []; + + if (entities.length === 0) missingDeps.push("entities"); + if (dataSources.length === 0) missingDeps.push("data sources"); + + if (missingDeps.length > 0) { + setPrereqWarning( + `Feature views require at least one entity and one data source. Missing: ${missingDeps.join(" and ")}. You can still proceed — the form will let you create them inline.`, + ); + } + setIsModalOpen(true); + }; useDocumentTitle(`Feature Views | Feast`); @@ -120,12 +185,24 @@ const Index = () => { : data; const handleCreateSubmit = (formData: FeatureViewFormData) => { - console.log("Feature view create payload:", formData); - setIsModalOpen(false); - setSuccessMessage( - `Feature view "${formData.name}" is ready to be created. Backend integration coming soon.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); + const payload = formDataToPayload(formData, projectName || ""); + applyFeatureView.mutate(payload, { + onSuccess: () => { + setIsModalOpen(false); + setErrorMessage(null); + setPrereqWarning(null); + setSuccessMessage( + `Feature view "${formData.name}" created successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); }; return ( @@ -140,7 +217,7 @@ const Index = () => { setIsModalOpen(true)} + onClick={handleCreateClick} key="create" > Create Feature View @@ -156,6 +233,19 @@ const Index = () => { ]} /> + {prereqWarning && ( + <> + +

{prereqWarning}

+
+ + + )} {successMessage && ( <> { )} + {errorMessage && ( + <> + + + + )} {isLoading && (

Loading @@ -212,7 +313,10 @@ const Index = () => { {isModalOpen && ( setIsModalOpen(false)} + onClose={() => { + setIsModalOpen(false); + setPrereqWarning(null); + }} onSubmit={handleCreateSubmit} /> )} diff --git a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx index 7db1a9ad161..396c9856433 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx @@ -24,6 +24,7 @@ import { encodeSearchQueryString } from "../../hooks/encodeSearchQueryString"; import { EntityRelation } from "../../parsers/parseEntityRelationships"; import { FEAST_FCO_TYPES } from "../../parsers/types"; import useLoadRelationshipData from "../../queries/useLoadRelationshipsData"; +import { useApplyFeatureView } from "../../queries/mutations/useFeatureViewMutations"; import { getEntityPermissions } from "../../utils/permissionUtils"; import BatchSourcePropertiesView from "../data-sources/BatchSourcePropertiesView"; import ConsumingFeatureServicesList from "./ConsumingFeatureServicesList"; @@ -117,14 +118,52 @@ const RegularFeatureViewOverviewTab = ({ const { isV2 } = useUIVersion(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const applyFeatureView = useApplyFeatureView(); + + const TTL_UNITS: Record = { + days: 86400, + hours: 3600, + minutes: 60, + seconds: 1, + }; const handleEditSubmit = (formData: FeatureViewFormData) => { - console.log("Feature view edit payload:", formData); - setIsEditModalOpen(false); - setSuccessMessage( - `Changes to "${formData.name}" are ready to apply. Backend integration coming soon.`, - ); - setTimeout(() => setSuccessMessage(null), 5000); + const payload = { + name: formData.name, + project: projectName || "", + entities: formData.entities, + features: formData.features.map((f) => ({ + name: f.name, + value_type: parseInt(f.valueType, 10), + })), + batch_source: formData.batchSource, + ttl_seconds: formData.ttlValue * (TTL_UNITS[formData.ttlUnit] || 1), + online: formData.online, + description: formData.description, + owner: formData.owner, + tags: Object.fromEntries( + formData.tags + .filter((t) => t.key.trim()) + .map((t) => [t.key, t.value]), + ), + }; + applyFeatureView.mutate(payload, { + onSuccess: () => { + setIsEditModalOpen(false); + setErrorMessage(null); + setSuccessMessage( + `Feature view "${formData.name}" updated successfully.`, + ); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 8000); + }, + }); }; return ( @@ -140,6 +179,17 @@ const RegularFeatureViewOverviewTab = ({ )} + {errorMessage && ( + <> + + + + )} {isV2 && ( <> diff --git a/ui/src/queries/mutations/useDataSourceMutations.ts b/ui/src/queries/mutations/useDataSourceMutations.ts new file mode 100644 index 00000000000..019431278c5 --- /dev/null +++ b/ui/src/queries/mutations/useDataSourceMutations.ts @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface ApplyDataSourcePayload { + name: string; + project: string; + type?: number; + timestamp_field?: string; + created_timestamp_column?: string; + description?: string; + tags?: Record; + owner?: string; + file_options?: { uri: string }; + bigquery_options?: { table: string; query: string }; + snowflake_options?: { table: string; database: string; schema_: string }; + redshift_options?: { table: string; database: string; schema_: string }; + kafka_options?: { kafka_bootstrap_servers: string; topic: string }; + spark_options?: { table: string; path: string }; +} + +interface DeleteDataSourcePayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyDataSource = async ( + payload: ApplyDataSourcePayload, +): Promise => { + const response = await fetch(`${API_BASE}/data_sources`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to apply data source: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteDataSource = async ( + payload: DeleteDataSourcePayload, +): Promise => { + const response = await fetch( + `${API_BASE}/data_sources/${encodeURIComponent(payload.name)}?project=${encodeURIComponent(payload.project)}`, + { method: "DELETE" }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to delete data source: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyDataSource = () => { + const queryClient = useQueryClient(); + + return useMutation(applyDataSource, { + onSuccess: () => { + queryClient.invalidateQueries(["data-sources-rest"]); + queryClient.invalidateQueries(["data-source-rest"]); + }, + }); +}; + +const useDeleteDataSource = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteDataSource, { + onSuccess: () => { + queryClient.invalidateQueries(["data-sources-rest"]); + queryClient.invalidateQueries(["data-source-rest"]); + }, + }); +}; + +export { useApplyDataSource, useDeleteDataSource }; +export type { ApplyDataSourcePayload, DeleteDataSourcePayload }; diff --git a/ui/src/queries/mutations/useFeatureViewMutations.ts b/ui/src/queries/mutations/useFeatureViewMutations.ts new file mode 100644 index 00000000000..47b9eb8fbef --- /dev/null +++ b/ui/src/queries/mutations/useFeatureViewMutations.ts @@ -0,0 +1,98 @@ +import { useMutation, useQueryClient } from "react-query"; + +interface FeaturePayload { + name: string; + value_type: number; +} + +interface ApplyFeatureViewPayload { + name: string; + project: string; + entities?: string[]; + features?: FeaturePayload[]; + batch_source?: string; + ttl_seconds?: number; + online?: boolean; + description?: string; + tags?: Record; + owner?: string; +} + +interface DeleteFeatureViewPayload { + name: string; + project: string; +} + +interface MutationResult { + name: string; + project: string; + status: string; +} + +const API_BASE = "/api/v1"; + +const applyFeatureView = async ( + payload: ApplyFeatureViewPayload, +): Promise => { + const response = await fetch(`${API_BASE}/feature_views`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to apply feature view: ${response.status}`, + ); + } + + return response.json(); +}; + +const deleteFeatureView = async ( + payload: DeleteFeatureViewPayload, +): Promise => { + const response = await fetch( + `${API_BASE}/feature_views/${encodeURIComponent(payload.name)}?project=${encodeURIComponent(payload.project)}`, + { method: "DELETE" }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: response.statusText })); + throw new Error( + error.detail || `Failed to delete feature view: ${response.status}`, + ); + } + + return response.json(); +}; + +const useApplyFeatureView = () => { + const queryClient = useQueryClient(); + + return useMutation(applyFeatureView, { + onSuccess: () => { + queryClient.invalidateQueries(["feature-views-rest"]); + queryClient.invalidateQueries(["feature-view-rest"]); + }, + }); +}; + +const useDeleteFeatureView = () => { + const queryClient = useQueryClient(); + + return useMutation(deleteFeatureView, { + onSuccess: () => { + queryClient.invalidateQueries(["feature-views-rest"]); + queryClient.invalidateQueries(["feature-view-rest"]); + }, + }); +}; + +export { useApplyFeatureView, useDeleteFeatureView }; +export type { ApplyFeatureViewPayload, DeleteFeatureViewPayload }; diff --git a/ui/src/queries/useLoadDataSourcesREST.ts b/ui/src/queries/useLoadDataSourcesREST.ts new file mode 100644 index 00000000000..5d3890cc503 --- /dev/null +++ b/ui/src/queries/useLoadDataSourcesREST.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { fetchApi } from "./restApi"; + +interface DataSourceListResponse { + dataSources: any[]; + pagination: Record; + relationships?: Record; +} + +const useLoadDataSourcesREST = (project: string) => { + return useQuery( + ["data-sources-rest", project], + () => + fetchApi("/data_sources", { + project, + allow_cache: "false", + }), + { + enabled: !!project, + staleTime: 30000, + }, + ); +}; + +const useLoadDataSourceREST = (name: string, project: string) => { + return useQuery( + ["data-source-rest", name, project], + () => + fetchApi(`/data_sources/${encodeURIComponent(name)}`, { + project, + include_relationships: "true", + allow_cache: "false", + }), + { + enabled: !!name && !!project, + staleTime: 30000, + }, + ); +}; + +export { useLoadDataSourcesREST, useLoadDataSourceREST }; diff --git a/ui/src/queries/useLoadFeatureViewsREST.ts b/ui/src/queries/useLoadFeatureViewsREST.ts new file mode 100644 index 00000000000..0b67b960e11 --- /dev/null +++ b/ui/src/queries/useLoadFeatureViewsREST.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; +import { fetchApi } from "./restApi"; + +interface FeatureViewListResponse { + featureViews: any[]; + pagination: Record; + relationships?: Record; +} + +const useLoadFeatureViewsREST = (project: string) => { + return useQuery( + ["feature-views-rest", project], + () => + fetchApi("/feature_views", { + project, + allow_cache: "false", + }), + { + enabled: !!project, + staleTime: 30000, + }, + ); +}; + +const useLoadFeatureViewREST = (name: string, project: string) => { + return useQuery( + ["feature-view-rest", name, project], + () => + fetchApi(`/feature_views/${encodeURIComponent(name)}`, { + project, + include_relationships: "true", + allow_cache: "false", + }), + { + enabled: !!name && !!project, + staleTime: 30000, + }, + ); +}; + +export { useLoadFeatureViewsREST, useLoadFeatureViewREST }; From 9ef8d7b999b26e28dd52893af26b91b163927cef Mon Sep 17 00:00:00 2001 From: Rohit Bharmal Date: Mon, 18 May 2026 13:41:52 +0530 Subject: [PATCH 5/6] refactor: Clean up formatting and improve readability in various components Signed-off-by: Rohit Bharmal --- ui/src/components/DataSourceFormModal.tsx | 13 ++++++------- ui/src/components/EntityFormModal.tsx | 4 +--- .../forms/NameDescriptionOwnerFields.tsx | 4 +--- ui/src/contexts/UIVersionContext.tsx | 7 ++++++- ui/src/pages/data-sources/Index.tsx | 8 ++++++-- ui/src/pages/entities/EntityOverviewTab.tsx | 7 +++---- ui/src/pages/entities/Index.tsx | 8 ++++++-- ui/src/pages/feature-views/Index.tsx | 18 ++++++++---------- .../RegularFeatureViewOverviewTab.tsx | 6 ++---- 9 files changed, 39 insertions(+), 36 deletions(-) diff --git a/ui/src/components/DataSourceFormModal.tsx b/ui/src/components/DataSourceFormModal.tsx index 4eff32b9a61..6f39ac088a2 100644 --- a/ui/src/components/DataSourceFormModal.tsx +++ b/ui/src/components/DataSourceFormModal.tsx @@ -223,7 +223,10 @@ const DataSourceFormModal: React.FC = ({ placeholder="project:dataset.table" /> - + updateField("bigqueryQuery", e.target.value)} @@ -252,9 +255,7 @@ const DataSourceFormModal: React.FC = ({ - updateField("snowflakeDatabase", e.target.value) - } + onChange={(e) => updateField("snowflakeDatabase", e.target.value)} placeholder="MY_DATABASE" /> @@ -282,9 +283,7 @@ const DataSourceFormModal: React.FC = ({ - updateField("redshiftDatabase", e.target.value) - } + onChange={(e) => updateField("redshiftDatabase", e.target.value)} placeholder="my_database" /> diff --git a/ui/src/components/EntityFormModal.tsx b/ui/src/components/EntityFormModal.tsx index 0439fc9b378..a6f313c4a5d 100644 --- a/ui/src/components/EntityFormModal.tsx +++ b/ui/src/components/EntityFormModal.tsx @@ -198,9 +198,7 @@ const EntityFormModal: React.FC = ({ value={key} onChange={(e) => updateJoinKey(index, e.target.value)} placeholder={ - index === 0 - ? "e.g. customer_id" - : "e.g. timestamp_field" + index === 0 ? "e.g. customer_id" : "e.g. timestamp_field" } compressed isInvalid={!!errors.joinKeys && !key.trim()} diff --git a/ui/src/components/forms/NameDescriptionOwnerFields.tsx b/ui/src/components/forms/NameDescriptionOwnerFields.tsx index 0817a0d8e24..46d21c369ba 100644 --- a/ui/src/components/forms/NameDescriptionOwnerFields.tsx +++ b/ui/src/components/forms/NameDescriptionOwnerFields.tsx @@ -15,9 +15,7 @@ interface NameDescriptionOwnerFieldsProps { descriptionPlaceholder?: string; } -const NameDescriptionOwnerFields: React.FC< - NameDescriptionOwnerFieldsProps -> = ({ +const NameDescriptionOwnerFields: React.FC = ({ name, description, owner, diff --git a/ui/src/contexts/UIVersionContext.tsx b/ui/src/contexts/UIVersionContext.tsx index 548692ea194..a680601ef39 100644 --- a/ui/src/contexts/UIVersionContext.tsx +++ b/ui/src/contexts/UIVersionContext.tsx @@ -34,7 +34,12 @@ export const UIVersionProvider: React.FC<{ children: React.ReactNode }> = ({ return ( {children} diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 5f74fda8ebf..4246496ceed 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -107,8 +107,12 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const { isLoading, isSuccess, isError, data: queryData } = - useLoadDataSourcesREST(projectName || ""); + const { + isLoading, + isSuccess, + isError, + data: queryData, + } = useLoadDataSourcesREST(projectName || ""); const data = queryData?.dataSources; const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/ui/src/pages/entities/EntityOverviewTab.tsx b/ui/src/pages/entities/EntityOverviewTab.tsx index 74a587b8a93..0eecf059366 100644 --- a/ui/src/pages/entities/EntityOverviewTab.tsx +++ b/ui/src/pages/entities/EntityOverviewTab.tsx @@ -85,13 +85,12 @@ const EntityOverviewTab = () => { onSuccess: () => { setIsEditModalOpen(false); setErrorMessage(null); - setSuccessMessage( - `Entity "${formData.name}" updated successfully.`, - ); + setSuccessMessage(`Entity "${formData.name}" updated successfully.`); setTimeout(() => setSuccessMessage(null), 5000); }, onError: (err: unknown) => { - const message = err instanceof Error ? err.message : "An unexpected error occurred."; + const message = + err instanceof Error ? err.message : "An unexpected error occurred."; setErrorMessage(message); setTimeout(() => setErrorMessage(null), 8000); }, diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index a423a7b08dc..3cc4299ff49 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -50,8 +50,12 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const { isLoading, isSuccess, isError, data: queryData } = - useLoadEntitiesREST(projectName || ""); + const { + isLoading, + isSuccess, + isError, + data: queryData, + } = useLoadEntitiesREST(projectName || ""); const data = queryData?.entities; const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index a77c2761157..4fb513ae2bf 100644 --- a/ui/src/pages/feature-views/Index.tsx +++ b/ui/src/pages/feature-views/Index.tsx @@ -69,10 +69,7 @@ const shouldIncludeFVsGivenTokenGroups = ( }); }; -const filterFn = ( - data: genericFVType[], - filterInput: filterInputInterface, -) => { +const filterFn = (data: genericFVType[], filterInput: filterInputInterface) => { let filteredByTags = data; if (Object.keys(filterInput.tagTokenGroups).length) { @@ -106,10 +103,7 @@ const TTL_UNITS: Record = { seconds: 1, }; -const formDataToPayload = ( - formData: FeatureViewFormData, - project: string, -) => ({ +const formDataToPayload = (formData: FeatureViewFormData, project: string) => ({ name: formData.name, project, entities: formData.entities, @@ -131,8 +125,12 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const { isLoading, isSuccess, isError, data: queryData } = - useLoadFeatureViewsREST(projectName || ""); + const { + isLoading, + isSuccess, + isError, + data: queryData, + } = useLoadFeatureViewsREST(projectName || ""); const data = queryData?.featureViews ? queryData.featureViews.map(mapRestFvToGenericType) diff --git a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx index 396c9856433..7c9fe5439df 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx @@ -65,7 +65,7 @@ const buildEditFormData = ( const secs = typeof fv.spec.ttl.seconds === "number" ? fv.spec.ttl.seconds - : (fv.spec.ttl.seconds as any).toNumber?.() ?? 0; + : ((fv.spec.ttl.seconds as any).toNumber?.() ?? 0); if (secs > 0 && secs % 86400 === 0) { ttlValue = secs / 86400; ttlUnit = "days"; @@ -143,9 +143,7 @@ const RegularFeatureViewOverviewTab = ({ description: formData.description, owner: formData.owner, tags: Object.fromEntries( - formData.tags - .filter((t) => t.key.trim()) - .map((t) => [t.key, t.value]), + formData.tags.filter((t) => t.key.trim()).map((t) => [t.key, t.value]), ), }; applyFeatureView.mutate(payload, { From 8f5ea8a6b8c5c365b179c69e9281737589282432 Mon Sep 17 00:00:00 2001 From: Rohit Bharmal Date: Wed, 20 May 2026 15:36:31 +0530 Subject: [PATCH 6/6] refactor: Remove unused proxy setting and streamline data loading in data sources and other Signed-off-by: Rohit Bharmal --- ui/package.json | 1 - .../data-sources/DataSourceOverviewTab.tsx | 18 ----------- ui/src/pages/data-sources/Index.tsx | 10 ++---- ui/src/pages/entities/Index.tsx | 8 +---- ui/src/pages/feature-views/Index.tsx | 31 ++++++++++--------- 5 files changed, 20 insertions(+), 48 deletions(-) diff --git a/ui/package.json b/ui/package.json index 5347cca0a69..354334df406 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,7 +22,6 @@ "optional": true } }, - "proxy": "http://localhost:6572", "dependencies": { "@elastic/datemath": "^5.0.3", "@elastic/eui": "^95.12.0", diff --git a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx index 1617f8bad4d..609c7ca1f8a 100644 --- a/ui/src/pages/data-sources/DataSourceOverviewTab.tsx +++ b/ui/src/pages/data-sources/DataSourceOverviewTab.tsx @@ -277,24 +277,6 @@ const DataSourceOverviewTab = () => { No consuming feature views )} - - - -

Permissions

- - - {registryQuery.data?.permissions ? ( - - ) : ( - No consuming feature views - )} -
diff --git a/ui/src/pages/data-sources/Index.tsx b/ui/src/pages/data-sources/Index.tsx index 4246496ceed..1f361b720e9 100644 --- a/ui/src/pages/data-sources/Index.tsx +++ b/ui/src/pages/data-sources/Index.tsx @@ -39,7 +39,7 @@ const useLoadDatasources = () => { }); }; -const filterFn = (data: feast.core.IDataSource[], searchTokens: string[]) => { +const filterFn = (data: any[], searchTokens: string[]) => { let filteredByTags = data; if (searchTokens.length) { @@ -107,13 +107,7 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const { - isLoading, - isSuccess, - isError, - data: queryData, - } = useLoadDataSourcesREST(projectName || ""); - const data = queryData?.dataSources; + const { isLoading, isSuccess, isError, data } = useLoadDatasources(); const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); diff --git a/ui/src/pages/entities/Index.tsx b/ui/src/pages/entities/Index.tsx index 3cc4299ff49..459758e262b 100644 --- a/ui/src/pages/entities/Index.tsx +++ b/ui/src/pages/entities/Index.tsx @@ -50,13 +50,7 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const { - isLoading, - isSuccess, - isError, - data: queryData, - } = useLoadEntitiesREST(projectName || ""); - const data = queryData?.entities; + const { isLoading, isSuccess, isError, data } = useLoadEntities(); const [isModalOpen, setIsModalOpen] = useState(false); const [successMessage, setSuccessMessage] = useState(null); diff --git a/ui/src/pages/feature-views/Index.tsx b/ui/src/pages/feature-views/Index.tsx index 4fb513ae2bf..272c2d3cba9 100644 --- a/ui/src/pages/feature-views/Index.tsx +++ b/ui/src/pages/feature-views/Index.tsx @@ -39,6 +39,8 @@ import { useUIVersion } from "../../contexts/UIVersionContext"; import useResourceQuery, { featureViewListPath, restFeatureViewsToMergedList, + entityListPath, + dataSourceListPath, } from "../../queries/useResourceQuery"; const useLoadFeatureViews = () => { @@ -125,19 +127,20 @@ const Index = () => { const { projectName } = useParams(); const { isV2 } = useUIVersion(); - const { - isLoading, - isSuccess, - isError, - data: queryData, - } = useLoadFeatureViewsREST(projectName || ""); - - const data = queryData?.featureViews - ? queryData.featureViews.map(mapRestFvToGenericType) - : undefined; + const { isLoading, isSuccess, isError, data } = useLoadFeatureViews(); - const entitiesQuery = useLoadEntitiesREST(projectName || ""); - const dataSourcesQuery = useLoadDataSourcesREST(projectName || ""); + const entitiesQuery = useResourceQuery({ + resourceType: "entities-list-fv-prereq", + project: projectName, + restPath: entityListPath(projectName), + restSelect: (d) => d.entities, + }); + const dataSourcesQuery = useResourceQuery({ + resourceType: "data-sources-list-fv-prereq", + project: projectName, + restPath: dataSourceListPath(projectName), + restSelect: (d) => d.dataSources, + }); const tagAggregationQuery = useFeatureViewTagsAggregation(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -148,8 +151,8 @@ const Index = () => { const handleCreateClick = () => { const missingDeps: string[] = []; - const entities = entitiesQuery.data?.entities || []; - const dataSources = dataSourcesQuery.data?.dataSources || []; + const entities = entitiesQuery.data || []; + const dataSources = dataSourcesQuery.data || []; if (entities.length === 0) missingDeps.push("entities"); if (dataSources.length === 0) missingDeps.push("data sources");