feat: show version on login page (#13033)

This commit is contained in:
Kayla Washburn-Love 2024-04-23 11:18:56 -06:00 committed by GitHub
parent a69fc657f2
commit 215dd7b152
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 101 additions and 83 deletions

View File

@ -8,13 +8,12 @@ const initialAppearanceData = getMetadataAsJSON<AppearanceConfig>("appearance");
const appearanceConfigKey = ["appearance"] as const;
export const appearance = (): UseQueryOptions<AppearanceConfig> => {
return {
// We either have our initial data or should immediately
// fetch and never again!
...cachedQuery(initialAppearanceData),
// We either have our initial data or should immediately fetch and never again!
return cachedQuery({
initialData: initialAppearanceData,
queryKey: ["appearance"],
queryFn: () => API.getAppearance(),
};
});
};
export const updateAppearance = (queryClient: QueryClient) => {

View File

@ -8,11 +8,10 @@ const initialBuildInfoData = getMetadataAsJSON<BuildInfoResponse>("build-info");
const buildInfoKey = ["buildInfo"] as const;
export const buildInfo = (): UseQueryOptions<BuildInfoResponse> => {
return {
// We either have our initial data or should immediately
// fetch and never again!
...cachedQuery(initialBuildInfoData),
// The version of the app can't change without reloading the page.
return cachedQuery({
initialData: initialBuildInfoData,
queryKey: buildInfoKey,
queryFn: () => API.getBuildInfo(),
};
});
};

View File

@ -8,11 +8,11 @@ const initialEntitlementsData = getMetadataAsJSON<Entitlements>("entitlements");
const entitlementsQueryKey = ["entitlements"] as const;
export const entitlements = (): UseQueryOptions<Entitlements> => {
return {
...cachedQuery(initialEntitlementsData),
return cachedQuery({
initialData: initialEntitlementsData,
queryKey: entitlementsQueryKey,
queryFn: () => API.getEntitlements(),
};
});
};
export const refreshEntitlements = (queryClient: QueryClient) => {

View File

@ -8,11 +8,11 @@ const initialExperimentsData = getMetadataAsJSON<Experiments>("experiments");
const experimentsKey = ["experiments"] as const;
export const experiments = (): UseQueryOptions<Experiments> => {
return {
...cachedQuery(initialExperimentsData),
return cachedQuery({
initialData: initialExperimentsData,
queryKey: experimentsKey,
queryFn: () => API.getExperiments(),
} satisfies UseQueryOptions<Experiments>;
});
};
export const availableExperiments = () => {

View File

@ -129,11 +129,11 @@ const meKey = ["me"];
export const me = (): UseQueryOptions<User> & {
queryKey: QueryKey;
} => {
return {
...cachedQuery(initialUserData),
return cachedQuery({
initialData: initialUserData,
queryKey: meKey,
queryFn: API.getAuthenticatedUser,
};
});
};
export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
@ -144,12 +144,12 @@ export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
}
export const hasFirstUser = (): UseQueryOptions<boolean> => {
return {
return cachedQuery({
// This cannot be false otherwise it will not fetch!
...cachedQuery(typeof initialUserData !== "undefined" ? true : undefined),
initialData: Boolean(initialUserData) || undefined,
queryKey: ["hasFirstUser"],
queryFn: API.hasFirstUser,
};
});
};
export const login = (

View File

@ -1,23 +1,27 @@
import type { UseQueryOptions } from "react-query";
// cachedQuery allows the caller to only make a request
// a single time, and use `initialData` if it is provided.
//
// This is particularly helpful for passing values injected
// via metadata. We do this for the initial user fetch, buildinfo,
// and a few others to reduce page load time.
export const cachedQuery = <T>(initialData?: T): Partial<UseQueryOptions<T>> =>
// Only do this if there is initial data,
// otherwise it can conflict with tests.
initialData
? {
cacheTime: Infinity,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
initialData,
}
: {
initialData,
};
/**
* cachedQuery allows the caller to only make a request a single time, and use
* `initialData` if it is provided. This is particularly helpful for passing
* values injected via metadata. We do this for the initial user fetch,
* buildinfo, and a few others to reduce page load time.
*/
export const cachedQuery = <
TQueryOptions extends UseQueryOptions<TData>,
TData,
>(
options: TQueryOptions,
): TQueryOptions =>
// Only do this if there is initial data, otherwise it can conflict with tests.
({
...(options.initialData
? {
cacheTime: Infinity,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
: {}),
...options,
});

View File

@ -7,7 +7,7 @@ import {
useEffect,
useState,
} from "react";
import { type UseQueryOptions, useQuery } from "react-query";
import { useQuery } from "react-query";
import { getWorkspaceProxies, getWorkspaceProxyRegions } from "api/api";
import { cachedQuery } from "api/queries/util";
import type { Region, WorkspaceProxy } from "api/typesGenerated";
@ -131,11 +131,13 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
error: proxiesError,
isLoading: proxiesLoading,
isFetched: proxiesFetched,
} = useQuery({
...cachedQuery(initialData),
queryKey,
queryFn: query,
} as UseQueryOptions<readonly Region[]>);
} = useQuery(
cachedQuery({
initialData,
queryKey,
queryFn: query,
}),
);
// Every time we get a new proxiesResponse, update the latency check
// to each workspace proxy.

View File

@ -7,12 +7,10 @@ import {
} from "react";
import { useQuery } from "react-query";
import { appearance } from "api/queries/appearance";
import { buildInfo } from "api/queries/buildInfo";
import { entitlements } from "api/queries/entitlements";
import { experiments } from "api/queries/experiments";
import type {
AppearanceConfig,
BuildInfoResponse,
Entitlements,
Experiments,
} from "api/typesGenerated";
@ -27,7 +25,6 @@ interface Appearance {
}
export interface DashboardValue {
buildInfo: BuildInfoResponse;
entitlements: Entitlements;
experiments: Experiments;
appearance: Appearance;
@ -38,16 +35,12 @@ export const DashboardContext = createContext<DashboardValue | undefined>(
);
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const buildInfoQuery = useQuery(buildInfo());
const entitlementsQuery = useQuery(entitlements());
const experimentsQuery = useQuery(experiments());
const appearanceQuery = useQuery(appearance());
const isLoading =
!buildInfoQuery.data ||
!entitlementsQuery.data ||
!appearanceQuery.data ||
!experimentsQuery.data;
!entitlementsQuery.data || !appearanceQuery.data || !experimentsQuery.data;
const [configPreview, setConfigPreview] = useState<AppearanceConfig>();
@ -84,7 +77,6 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
return (
<DashboardContext.Provider
value={{
buildInfo: buildInfoQuery.data,
entitlements: entitlementsQuery.data,
experiments: experimentsQuery.data,
appearance: {

View File

@ -1,4 +1,6 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import { buildInfo } from "api/queries/buildInfo";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useProxy } from "contexts/ProxyContext";
import { useDashboard } from "modules/dashboard/useDashboard";
@ -6,7 +8,8 @@ import { useFeatureVisibility } from "../useFeatureVisibility";
import { NavbarView } from "./NavbarView";
export const Navbar: FC = () => {
const { appearance, buildInfo } = useDashboard();
const { appearance } = useDashboard();
const buildInfoQuery = useQuery(buildInfo());
const { user: me, permissions, signOut } = useAuthenticated();
const featureVisibility = useFeatureVisibility();
const canViewAuditLog =
@ -19,7 +22,7 @@ export const Navbar: FC = () => {
<NavbarView
user={me}
logo_url={appearance.config.logo_url}
buildInfo={buildInfo}
buildInfo={buildInfoQuery.data}
supportLinks={appearance.config.support_links}
onSignOut={signOut}
canViewAuditLog={canViewAuditLog}

View File

@ -1,6 +1,6 @@
import { css, type Interpolation, type Theme, useTheme } from "@emotion/react";
import Badge from "@mui/material/Badge";
import type { FC, ReactNode } from "react";
import type { FC } from "react";
import type * as TypesGen from "api/typesGenerated";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import {
@ -17,7 +17,6 @@ export interface UserDropdownProps {
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
children?: ReactNode;
}
export const UserDropdown: FC<UserDropdownProps> = ({

View File

@ -24,7 +24,7 @@ export const AgentVersion: FC<AgentVersionProps> = ({
);
if (status === agentVersionStatus.Updated) {
return <span>Updated</span>;
return null;
}
return (

View File

@ -1,20 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DashboardContext } from "modules/dashboard/DashboardProvider";
import {
MockAppearanceConfig,
MockBuildInfo,
MockCanceledWorkspace,
MockCancelingWorkspace,
MockDeletedWorkspace,
MockDeletingWorkspace,
MockEntitlementsWithScheduling,
MockExperiments,
MockFailedWorkspace,
MockPendingWorkspace,
MockStartingWorkspace,
MockStoppedWorkspace,
MockStoppingWorkspace,
MockWorkspace,
MockBuildInfo,
MockEntitlementsWithScheduling,
MockExperiments,
MockAppearanceConfig,
} from "testHelpers/entities";
import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge";
@ -27,11 +27,18 @@ const MockedAppearance = {
const meta: Meta<typeof WorkspaceStatusBadge> = {
title: "modules/workspaces/WorkspaceStatusBadge",
component: WorkspaceStatusBadge,
parameters: {
queries: [
{
key: ["buildInfo"],
data: MockBuildInfo,
},
],
},
decorators: [
(Story) => (
<DashboardContext.Provider
value={{
buildInfo: MockBuildInfo,
entitlements: MockEntitlementsWithScheduling,
experiments: MockExperiments,
appearance: MockedAppearance,

View File

@ -2,6 +2,7 @@ import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { buildInfo } from "api/queries/buildInfo";
import { authMethods } from "api/queries/users";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { getApplicationName } from "utils/appearance";
@ -22,6 +23,7 @@ export const LoginPage: FC = () => {
const redirectTo = retrieveRedirect(location.search);
const applicationName = getApplicationName();
const navigate = useNavigate();
const buildInfoQuery = useQuery(buildInfo());
if (isSignedIn) {
// If the redirect is going to a workspace application, and we
@ -65,6 +67,7 @@ export const LoginPage: FC = () => {
authMethods={authMethodsQuery.data}
error={signInError}
isLoading={isLoading || authMethodsQuery.isLoading}
buildInfo={buildInfoQuery.data}
isSigningIn={isSigningIn}
onSignIn={async ({ email, password }) => {
await signIn(email, password);

View File

@ -1,7 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC } from "react";
import { useLocation } from "react-router-dom";
import type { AuthMethods } from "api/typesGenerated";
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
import { CoderIcon } from "components/Icons/CoderIcon";
import { Loader } from "components/Loader/Loader";
import { getApplicationName, getLogoURL } from "utils/appearance";
@ -12,6 +12,7 @@ export interface LoginPageViewProps {
authMethods: AuthMethods | undefined;
error: unknown;
isLoading: boolean;
buildInfo?: BuildInfoResponse;
isSigningIn: boolean;
onSignIn: (credentials: { email: string; password: string }) => void;
}
@ -20,6 +21,7 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
authMethods,
error,
isLoading,
buildInfo,
isSigningIn,
onSignIn,
}) => {
@ -64,7 +66,10 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
/>
)}
<footer css={styles.footer}>
Copyright © {new Date().getFullYear()} Coder Technologies, Inc.
<div>
Copyright &copy; {new Date().getFullYear()} Coder Technologies, Inc.
</div>
<div>{buildInfo?.version}</div>
</footer>
</div>
</div>

View File

@ -27,6 +27,10 @@ const meta: Meta<typeof Workspace> = {
component: Workspace,
parameters: {
queries: [
{
key: ["buildInfo"],
data: Mocks.MockBuildInfo,
},
{
key: ["portForward", Mocks.MockWorkspaceAgent.id],
data: Mocks.MockListeningPortsResponse,
@ -37,7 +41,6 @@ const meta: Meta<typeof Workspace> = {
(Story) => (
<DashboardContext.Provider
value={{
buildInfo: Mocks.MockBuildInfo,
entitlements: Mocks.MockEntitlementsWithScheduling,
experiments: Mocks.MockExperiments,
appearance: MockedAppearance,

View File

@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { MissingBuildParameters, restartWorkspace } from "api/api";
import { getErrorMessage } from "api/errors";
import { buildInfo } from "api/queries/buildInfo";
import { deploymentConfig, deploymentSSHConfig } from "api/queries/deployment";
import { templateVersion, templateVersions } from "api/queries/templates";
import {
@ -27,7 +28,6 @@ import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { pageTitle } from "utils/page";
import { ChangeVersionDialog } from "./ChangeVersionDialog";
@ -50,7 +50,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { buildInfo } = useDashboard();
const buildInfoQuery = useQuery(buildInfo());
const featureVisibility = useFeatureVisibility();
if (workspace === undefined) {
throw Error("Workspace is undefined");
@ -248,7 +248,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
canChangeVersions={canChangeVersions}
hideSSHButton={featureVisibility["browser_only"]}
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
buildInfo={buildInfo}
buildInfo={buildInfoQuery.data}
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
template={template}
buildLogs={

View File

@ -139,11 +139,18 @@ const meta: Meta<typeof WorkspacesPageView> = {
count: 13,
page: 1,
},
parameters: {
queries: [
{
key: ["buildInfo"],
data: MockBuildInfo,
},
],
},
decorators: [
(Story) => (
<DashboardContext.Provider
value={{
buildInfo: MockBuildInfo,
entitlements: MockEntitlementsWithScheduling,
experiments: MockExperiments,
appearance: MockedAppearance,

View File

@ -3,11 +3,7 @@ import type { FC } from "react";
import { withDefaultFeatures } from "api/api";
import type { Entitlements } from "api/typesGenerated";
import { DashboardContext } from "modules/dashboard/DashboardProvider";
import {
MockAppearanceConfig,
MockBuildInfo,
MockEntitlements,
} from "./entities";
import { MockAppearanceConfig, MockEntitlements } from "./entities";
export const withDashboardProvider = (
Story: FC,
@ -30,7 +26,6 @@ export const withDashboardProvider = (
return (
<DashboardContext.Provider
value={{
buildInfo: MockBuildInfo,
entitlements,
experiments,
appearance: {

View File

@ -3,8 +3,8 @@ export const getApplicationName = (): string => {
.querySelector(`meta[name=application-name]`)
?.getAttribute("content");
// Fallback to "Coder" if the application name is not available for some reason.
// We need to check if the content does not look like {{ .ApplicationName}}
// as it means that Coder is running in development mode (port :8080).
// We need to check if the content does not look like `{{ .ApplicationName }}`
// as it means that Coder is running in development mode.
return c && !c.startsWith("{{ .") ? c : "Coder";
};