Visitar URL original
feat: Allow CRUD on entities, data sources, and feature views from UI by rohit-bharmal · Pull Request #6412 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: Add UI REST integration for entity management
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Rohit Bharmal <rohitbharmal123@gmail.com>
  • Loading branch information
rohit-bharmal and cursoragent committed Jun 8, 2026
commit c787209e4d95d492dd2549d3a119d4d3d42418e7
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"optional": true
}
},
"proxy": "http://localhost:6572",
"dependencies": {
"@elastic/datemath": "^5.0.3",
"@elastic/eui": "^95.12.0",
Expand Down
46 changes: 40 additions & 6 deletions ui/src/pages/entities/EntityOverviewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,14 +74,36 @@ const EntityOverviewTab = () => {

const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const applyEntity = useApplyEntity();

const handleEditSubmit = (formData: EntityFormData) => {
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 (
Expand All @@ -105,6 +128,17 @@ const EntityOverviewTab = () => {
<EuiSpacer size="m" />
</>
)}
{errorMessage && (
<>
<EuiCallOut
title={errorMessage}
color="danger"
iconType="alert"
size="s"
/>
<EuiSpacer size="m" />
</>
)}
{isV2 && (
<>
<EuiFlexGroup justifyContent="flexEnd">
Expand Down
59 changes: 51 additions & 8 deletions ui/src/pages/entities/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
Expand Down Expand Up @@ -90,6 +122,17 @@ const Index = () => {
<EuiSpacer size="m" />
</>
)}
{errorMessage && (
<>
<EuiCallOut
title={errorMessage}
color="danger"
iconType="alert"
size="s"
/>
<EuiSpacer size="m" />
</>
)}
{isLoading && (
<p>
<EuiLoadingSpinner size="m" /> Loading
Expand Down
90 changes: 90 additions & 0 deletions ui/src/queries/mutations/useEntityMutations.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<MutationResult> => {
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<MutationResult> => {
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 };
19 changes: 19 additions & 0 deletions ui/src/queries/restApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const API_BASE = "/api/v1";

export async function fetchApi<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
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();
}
41 changes: 41 additions & 0 deletions ui/src/queries/useLoadEntitiesREST.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useQuery } from "react-query";
import { fetchApi } from "./restApi";

interface EntityListResponse {
entities: any[];
pagination: Record<string, any>;
relationships?: Record<string, any[]>;
}

const useLoadEntitiesREST = (project: string) => {
return useQuery(
["entities-rest", project],
() =>
fetchApi<EntityListResponse>("/entities", {
project,
allow_cache: "false",
}),
{
enabled: !!project,
staleTime: 30000,
},
);
};

const useLoadEntityREST = (name: string, project: string) => {
return useQuery(
["entity-rest", name, project],
() =>
fetchApi<any>(`/entities/${encodeURIComponent(name)}`, {
project,
include_relationships: "true",
allow_cache: "false",
}),
{
enabled: !!name && !!project,
staleTime: 30000,
},
);
};

export { useLoadEntitiesREST, useLoadEntityREST };