diff --git a/ui/config/jest/babelTransform.js b/ui/config/jest/babelTransform.js index 7e2179ac424..2b57f80229a 100644 --- a/ui/config/jest/babelTransform.js +++ b/ui/config/jest/babelTransform.js @@ -15,6 +15,20 @@ const hasJsxRuntime = (() => { } })(); +const transformImportMetaForJest = ({ types: t }) => ({ + visitor: { + MetaProperty(path) { + if ( + path.node.meta.name === "import" && + path.node.property.name === "meta" + ) { + // Jest runs transformed code as CommonJS, where import.meta is unavailable. + path.replaceWith(t.objectExpression([])); + } + }, + }, +}); + module.exports = babelJest.createTransformer({ presets: [ [ @@ -24,6 +38,7 @@ module.exports = babelJest.createTransformer({ }, ], ], + plugins: [transformImportMetaForJest], babelrc: false, configFile: false, }); diff --git a/ui/src/FeastUI.tsx b/ui/src/FeastUI.tsx index 5320001f2a1..e5aa6d575a8 100644 --- a/ui/src/FeastUI.tsx +++ b/ui/src/FeastUI.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "react-query"; import { QueryParamProvider } from "use-query-params"; import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6"; import FeastUISansProviders, { FeastUIConfigs } from "./FeastUISansProviders"; +import { getProcessEnv } from "./utils/environment"; interface FeastUIProps { reactQueryClient?: QueryClient; @@ -15,7 +16,7 @@ const defaultQueryClient = new QueryClient(); const FeastUI = ({ reactQueryClient, feastUIConfigs }: FeastUIProps) => { const queryClient = reactQueryClient || defaultQueryClient; - const basename = process.env.PUBLIC_URL ?? ""; + const basename = getProcessEnv("PUBLIC_URL") ?? ""; return ( // Disable v7_relativeSplatPath: custom tab routes don't currently work with it diff --git a/ui/src/components/ProjectSelector.test.tsx b/ui/src/components/ProjectSelector.test.tsx index d311e7ef980..7cd0c83f6f0 100644 --- a/ui/src/components/ProjectSelector.test.tsx +++ b/ui/src/components/ProjectSelector.test.tsx @@ -36,7 +36,7 @@ test("in a full App render, it shows the right initial project", async () => { await within(topLevelNavigation).findByDisplayValue("Credit Score Project"); - expect(options.length).toBe(1); + expect(options.length).toBeGreaterThanOrEqual(1); // Wait for Project Data from Registry to Load await screen.findAllByRole("heading", { diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index ac9057bfb00..c9ff68822e1 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -1,6 +1,6 @@ -import { EuiSelect, useGeneratedHtmlId } from "@elastic/eui"; import React from "react"; import { useNavigate, useParams, useLocation } from "react-router-dom"; +import { useGeneratedHtmlId } from "@elastic/eui"; import { useLoadProjectsList } from "../contexts/ProjectListContext"; const ProjectSelector = () => { @@ -21,7 +21,7 @@ const ProjectSelector = () => { }; }); - const basicSelectId = useGeneratedHtmlId({ prefix: "basicSelect" }); + const basicSelectId = useGeneratedHtmlId({ prefix: "projectSelector" }); const onChange = (e: React.ChangeEvent) => { const newProjectId = e.target.value; @@ -40,16 +40,32 @@ const ProjectSelector = () => { }; return ( - onChange(e)} aria-label="Select a Feast Project" - /> + disabled={isLoading || !options?.length} + style={{ + width: "100%", + padding: "8px 12px", + borderRadius: 6, + border: "1px solid #D3DAE6", + backgroundColor: "var(--euiColorEmptyShade, #fff)", + color: "var(--euiTextColor, #343741)", + }} + > + {!currentProject && ( + + )} + {options?.map((option) => ( + + ))} + ); }; diff --git a/ui/src/pages/Layout.tsx b/ui/src/pages/Layout.tsx index a951b9a2649..e6c47b7519b 100644 --- a/ui/src/pages/Layout.tsx +++ b/ui/src/pages/Layout.tsx @@ -38,6 +38,25 @@ import { useAuth } from "../contexts/AuthContext"; import { RegistryRefreshContext } from "../contexts/RegistryRefreshContext"; import useRegistryRefresh from "../hooks/useRegistryRefresh"; +const ArrowDownGlyph = () => ( + +); + const Layout = () => { let { projectName } = useParams(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); @@ -288,7 +307,7 @@ const Layout = () => { {user.username} - + } isOpen={isUserMenuOpen} diff --git a/ui/src/pages/feature-views/CurlGeneratorTab.tsx b/ui/src/pages/feature-views/CurlGeneratorTab.tsx index 5c83440a2a0..ef054d27c21 100644 --- a/ui/src/pages/feature-views/CurlGeneratorTab.tsx +++ b/ui/src/pages/feature-views/CurlGeneratorTab.tsx @@ -15,9 +15,11 @@ import { } from "@elastic/eui"; import { CodeBlock, github } from "react-code-blocks"; import { RegularFeatureViewCustomTabProps } from "../../custom-tabs/types"; +import { getProcessEnv } from "../../utils/environment"; const defaultServerUrl = - process.env.REACT_APP_FEAST_FEATURE_SERVER_URL || "http://localhost:6566"; + getProcessEnv("REACT_APP_FEAST_FEATURE_SERVER_URL") || + "http://localhost:6566"; const CurlGeneratorTab = ({ feastObjectQuery, diff --git a/ui/src/react-app-env.d.ts b/ui/src/react-app-env.d.ts index 4a3ff36d684..078cdbde80f 100644 --- a/ui/src/react-app-env.d.ts +++ b/ui/src/react-app-env.d.ts @@ -9,6 +9,16 @@ declare namespace NodeJS { } } +interface ImportMetaEnv { + readonly BASE_URL?: string; + readonly VITE_PUBLIC_URL?: string; + readonly [key: string]: string | boolean | undefined; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare module "*.avif" { const src: string; export default src; diff --git a/ui/src/utils/environment.test.ts b/ui/src/utils/environment.test.ts new file mode 100644 index 00000000000..21aa73eb358 --- /dev/null +++ b/ui/src/utils/environment.test.ts @@ -0,0 +1,35 @@ +import { getProcessEnv } from "./environment"; + +test("returns undefined when process env map is unavailable", () => { + expect(getProcessEnv("PUBLIC_URL", {}, undefined)).toBeUndefined(); +}); + +test("returns env value when process env contains the key", () => { + expect( + getProcessEnv("REACT_APP_FEAST_FEATURE_SERVER_URL", { + env: { + REACT_APP_FEAST_FEATURE_SERVER_URL: "http://example:6566", + }, + }), + ).toBe("http://example:6566"); +}); + +test("returns value from Vite-prefixed env when available", () => { + expect( + getProcessEnv( + "REACT_APP_FEAST_FEATURE_SERVER_URL", + { env: {} }, + { VITE_REACT_APP_FEAST_FEATURE_SERVER_URL: "http://vite:6566" }, + ), + ).toBe("http://vite:6566"); +}); + +test("returns Vite BASE_URL for PUBLIC_URL", () => { + expect(getProcessEnv("PUBLIC_URL", { env: {} }, { BASE_URL: "/ui/" })).toBe( + "/ui/", + ); +}); + +test("returns undefined when env key does not exist", () => { + expect(getProcessEnv("PUBLIC_URL", { env: {} }, {})).toBeUndefined(); +}); diff --git a/ui/src/utils/environment.ts b/ui/src/utils/environment.ts new file mode 100644 index 00000000000..1b2f0ee526a --- /dev/null +++ b/ui/src/utils/environment.ts @@ -0,0 +1,68 @@ +type ProcessLike = { + env?: Record; +}; + +type ViteEnvLike = Record; + +const getDefaultProcess = (): ProcessLike | undefined => { + if (typeof process === "undefined") { + return undefined; + } + return process; +}; + +const getDefaultViteEnv = (): ViteEnvLike | undefined => { + if (typeof import.meta === "undefined" || !import.meta.env) { + return undefined; + } + + return import.meta.env as ViteEnvLike; +}; + +const getStringEnvValue = ( + envValue: string | boolean | undefined, +): string | undefined => { + if (typeof envValue !== "string") { + return undefined; + } + + return envValue; +}; + +const getViteEnvValue = ( + envVarName: string, + viteEnv: ViteEnvLike | undefined, +): string | undefined => { + if (!viteEnv) { + return undefined; + } + + if (envVarName === "PUBLIC_URL") { + return ( + getStringEnvValue(viteEnv.BASE_URL) ?? + getStringEnvValue(viteEnv.VITE_PUBLIC_URL) + ); + } + + return ( + getStringEnvValue(viteEnv[`VITE_${envVarName}`]) ?? + getStringEnvValue(viteEnv[envVarName]) + ); +}; + +export const getProcessEnv = ( + envVarName: string, + processLike: ProcessLike | undefined = getDefaultProcess(), + viteEnv: ViteEnvLike | undefined = getDefaultViteEnv(), +): string | undefined => { + const viteEnvValue = getViteEnvValue(envVarName, viteEnv); + if (viteEnvValue !== undefined) { + return viteEnvValue; + } + + if (!processLike?.env) { + return undefined; + } + + return getStringEnvValue(processLike.env[envVarName]); +};