mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): migrate a few services to react-query used in the DashboardProvider (#9667)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import * as API from "api/api";
|
||||
import { AppearanceConfig } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
|
||||
export const appearance = () => {
|
||||
return {
|
||||
queryKey: ["appearance"],
|
||||
queryFn: async () =>
|
||||
getMetadataAsJSON<AppearanceConfig>("appearance") ?? API.getAppearance(),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateAppearance = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: API.updateAppearance,
|
||||
onSuccess: (newConfig: AppearanceConfig) => {
|
||||
queryClient.setQueryData(["appearance"], newConfig);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as API from "api/api";
|
||||
import { BuildInfoResponse } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
|
||||
export const buildInfo = () => {
|
||||
return {
|
||||
queryKey: ["buildInfo"],
|
||||
queryFn: async () =>
|
||||
getMetadataAsJSON<BuildInfoResponse>("build-info") ?? API.getBuildInfo(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import * as API from "api/api";
|
||||
import { Entitlements } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
|
||||
const ENTITLEMENTS_QUERY_KEY = ["entitlements"];
|
||||
|
||||
export const entitlements = () => {
|
||||
return {
|
||||
queryKey: ENTITLEMENTS_QUERY_KEY,
|
||||
queryFn: async () =>
|
||||
getMetadataAsJSON<Entitlements>("entitlements") ?? API.getEntitlements(),
|
||||
};
|
||||
};
|
||||
|
||||
export const refreshEntitlements = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: API.refreshEntitlements,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ENTITLEMENTS_QUERY_KEY,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as API from "api/api";
|
||||
import { Experiments } from "api/typesGenerated";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
|
||||
export const experiments = () => {
|
||||
return {
|
||||
queryKey: ["experiments"],
|
||||
queryFn: async () =>
|
||||
getMetadataAsJSON<Experiments>("experiments") ?? API.getExperiments(),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { buildInfo } from "api/queries/buildInfo";
|
||||
import { experiments } from "api/queries/experiments";
|
||||
import { entitlements } from "api/queries/entitlements";
|
||||
import {
|
||||
AppearanceConfig,
|
||||
BuildInfoResponse,
|
||||
@@ -6,17 +9,19 @@ import {
|
||||
Experiments,
|
||||
} from "api/typesGenerated";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { createContext, FC, PropsWithChildren, useContext } from "react";
|
||||
import { appearanceMachine } from "xServices/appearance/appearanceXService";
|
||||
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService";
|
||||
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService";
|
||||
import { experimentsMachine } from "xServices/experiments/experimentsMachine";
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
import { appearance } from "api/queries/appearance";
|
||||
|
||||
interface Appearance {
|
||||
config: AppearanceConfig;
|
||||
preview: boolean;
|
||||
isPreview: boolean;
|
||||
setPreview: (config: AppearanceConfig) => void;
|
||||
save: (config: AppearanceConfig) => void;
|
||||
}
|
||||
|
||||
interface DashboardProviderValue {
|
||||
@@ -31,29 +36,16 @@ export const DashboardProviderContext = createContext<
|
||||
>(undefined);
|
||||
|
||||
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [buildInfoState] = useMachine(buildInfoMachine);
|
||||
const [entitlementsState] = useMachine(entitlementsMachine);
|
||||
const [appearanceState, appearanceSend] = useMachine(appearanceMachine);
|
||||
const [experimentsState] = useMachine(experimentsMachine);
|
||||
const { buildInfo } = buildInfoState.context;
|
||||
const { entitlements } = entitlementsState.context;
|
||||
const { appearance, preview } = appearanceState.context;
|
||||
const { experiments } = experimentsState.context;
|
||||
const isLoading = !buildInfo || !entitlements || !appearance || !experiments;
|
||||
|
||||
const setAppearancePreview = (config: AppearanceConfig) => {
|
||||
appearanceSend({
|
||||
type: "SET_PREVIEW_APPEARANCE",
|
||||
appearance: config,
|
||||
});
|
||||
};
|
||||
|
||||
const saveAppearance = (config: AppearanceConfig) => {
|
||||
appearanceSend({
|
||||
type: "SAVE_APPEARANCE",
|
||||
appearance: config,
|
||||
});
|
||||
};
|
||||
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;
|
||||
const [configPreview, setConfigPreview] = useState<AppearanceConfig>();
|
||||
|
||||
if (isLoading) {
|
||||
return <FullScreenLoader />;
|
||||
@@ -62,14 +54,13 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<DashboardProviderContext.Provider
|
||||
value={{
|
||||
buildInfo,
|
||||
entitlements,
|
||||
experiments,
|
||||
buildInfo: buildInfoQuery.data,
|
||||
entitlements: entitlementsQuery.data,
|
||||
experiments: experimentsQuery.data,
|
||||
appearance: {
|
||||
preview,
|
||||
config: appearance,
|
||||
setPreview: setAppearancePreview,
|
||||
save: saveAppearance,
|
||||
config: configPreview ?? appearanceQuery.data,
|
||||
setPreview: setConfigPreview,
|
||||
isPreview: configPreview !== undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServiceBanner: React.FC = () => {
|
||||
<ServiceBannerView
|
||||
message={message}
|
||||
backgroundColor={background_color}
|
||||
preview={appearance.preview}
|
||||
isPreview={appearance.isPreview}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,6 @@ export const Preview: Story = {
|
||||
args: {
|
||||
message: "weeeee",
|
||||
backgroundColor: "#000000",
|
||||
preview: true,
|
||||
isPreview: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,13 +7,13 @@ import { hex } from "color-convert";
|
||||
export interface ServiceBannerViewProps {
|
||||
message: string;
|
||||
backgroundColor: string;
|
||||
preview: boolean;
|
||||
isPreview: boolean;
|
||||
}
|
||||
|
||||
export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
|
||||
message,
|
||||
backgroundColor,
|
||||
preview,
|
||||
isPreview,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
// We don't want anything funky like an image or a heading in the service
|
||||
@@ -34,7 +34,7 @@ export const ServiceBannerView: React.FC<ServiceBannerViewProps> = ({
|
||||
className={styles.container}
|
||||
style={{ backgroundColor: backgroundColor }}
|
||||
>
|
||||
{preview && <Pill text="Preview" type="info" lightBorder />}
|
||||
{isPreview && <Pill text="Preview" type="info" lightBorder />}
|
||||
<div
|
||||
className={styles.centerContent}
|
||||
style={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { embedRedirect } from "../../utils/redirect";
|
||||
import { FullScreenLoader } from "../Loader/FullScreenLoader";
|
||||
import { DashboardProvider } from "components/Dashboard/DashboardProvider";
|
||||
import { ProxyProvider } from "contexts/ProxyContext";
|
||||
import { isApiError } from "api/errors";
|
||||
|
||||
export const RequireAuth: FC = () => {
|
||||
const [authState, authSend] = useAuth();
|
||||
@@ -18,11 +19,11 @@ export const RequireAuth: FC = () => {
|
||||
useEffect(() => {
|
||||
const interceptorHandle = axios.interceptors.response.use(
|
||||
(okResponse) => okResponse,
|
||||
(error) => {
|
||||
(error: unknown) => {
|
||||
// 401 Unauthorized
|
||||
// If we encountered an authentication error, then our token is probably
|
||||
// invalid and we should update the auth state to reflect that.
|
||||
if (error.response.status === 401) {
|
||||
if (isApiError(error) && error.response.status === 401) {
|
||||
authSend("SIGN_OUT");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,16 @@ import {
|
||||
MockBuildInfo,
|
||||
MockEntitlementsWithScheduling,
|
||||
MockExperiments,
|
||||
MockAppearance,
|
||||
MockAppearanceConfig,
|
||||
} from "testHelpers/entities";
|
||||
import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge";
|
||||
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const MockedAppearance = {
|
||||
config: MockAppearance,
|
||||
preview: false,
|
||||
setPreview: () => null,
|
||||
save: () => null,
|
||||
config: MockAppearanceConfig,
|
||||
isPreview: false,
|
||||
setPreview: () => {},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkspaceStatusBadge> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FeatureName } from "api/typesGenerated";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors";
|
||||
import { selectFeatureVisibility } from "utils/entitlements";
|
||||
|
||||
export const useFeatureVisibility = (): Record<FeatureName, boolean> => {
|
||||
const { entitlements } = useDashboard();
|
||||
|
||||
+16
-3
@@ -4,6 +4,10 @@ import { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateAppearance } from "api/queries/appearance";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
|
||||
// ServiceBanner is unlike the other Deployment Settings pages because it
|
||||
// implements a form, whereas the others are read-only. We make this
|
||||
@@ -11,10 +15,12 @@ import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView";
|
||||
// the command line would be a significantly worse user experience.
|
||||
const AppearanceSettingsPage: FC = () => {
|
||||
const { appearance, entitlements } = useDashboard();
|
||||
const queryClient = useQueryClient();
|
||||
const updateAppearanceMutation = useMutation(updateAppearance(queryClient));
|
||||
const isEntitled =
|
||||
entitlements.features["appearance"].entitlement !== "not_entitled";
|
||||
|
||||
const updateAppearance = (
|
||||
const onSaveAppearance = async (
|
||||
newConfig: Partial<UpdateAppearanceConfig>,
|
||||
preview: boolean,
|
||||
) => {
|
||||
@@ -26,7 +32,14 @@ const AppearanceSettingsPage: FC = () => {
|
||||
appearance.setPreview(newAppearance);
|
||||
return;
|
||||
}
|
||||
appearance.save(newAppearance);
|
||||
try {
|
||||
await updateAppearanceMutation.mutateAsync(newAppearance);
|
||||
displaySuccess("Successfully updated appearance settings!");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to update appearance settings."),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -38,7 +51,7 @@ const AppearanceSettingsPage: FC = () => {
|
||||
<AppearanceSettingsPageView
|
||||
appearance={appearance.config}
|
||||
isEntitled={isEntitled}
|
||||
updateAppearance={updateAppearance}
|
||||
onSaveAppearance={onSaveAppearance}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+7
-4
@@ -14,13 +14,16 @@ const meta: Meta<typeof AppearanceSettingsPageView> = {
|
||||
},
|
||||
},
|
||||
isEntitled: false,
|
||||
updateAppearance: () => {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppearanceSettingsPageView>;
|
||||
|
||||
export const Page: Story = {};
|
||||
export const Entitled: Story = {
|
||||
args: {
|
||||
isEntitled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotEntitled: Story = {};
|
||||
|
||||
+7
-7
@@ -25,7 +25,7 @@ import { colors } from "theme/colors";
|
||||
export type AppearanceSettingsPageViewProps = {
|
||||
appearance: UpdateAppearanceConfig;
|
||||
isEntitled: boolean;
|
||||
updateAppearance: (
|
||||
onSaveAppearance: (
|
||||
newConfig: Partial<UpdateAppearanceConfig>,
|
||||
preview: boolean,
|
||||
) => void;
|
||||
@@ -33,7 +33,7 @@ export type AppearanceSettingsPageViewProps = {
|
||||
export const AppearanceSettingsPageView = ({
|
||||
appearance,
|
||||
isEntitled,
|
||||
updateAppearance,
|
||||
onSaveAppearance,
|
||||
}: AppearanceSettingsPageViewProps): JSX.Element => {
|
||||
const styles = useStyles();
|
||||
const theme = useTheme();
|
||||
@@ -43,7 +43,7 @@ export const AppearanceSettingsPageView = ({
|
||||
initialValues: {
|
||||
logo_url: appearance.logo_url,
|
||||
},
|
||||
onSubmit: (values) => updateAppearance(values, false),
|
||||
onSubmit: (values) => onSaveAppearance(values, false),
|
||||
});
|
||||
const logoFieldHelpers = getFormHelpers(logoForm);
|
||||
|
||||
@@ -56,7 +56,7 @@ export const AppearanceSettingsPageView = ({
|
||||
appearance.service_banner.background_color ?? colors.blue[7],
|
||||
},
|
||||
onSubmit: (values) =>
|
||||
updateAppearance(
|
||||
onSaveAppearance(
|
||||
{
|
||||
service_banner: values,
|
||||
},
|
||||
@@ -123,7 +123,7 @@ export const AppearanceSettingsPageView = ({
|
||||
!isEntitled && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateAppearance(
|
||||
onSaveAppearance(
|
||||
{
|
||||
service_banner: {
|
||||
message:
|
||||
@@ -162,7 +162,7 @@ export const AppearanceSettingsPageView = ({
|
||||
...serviceBannerForm.values,
|
||||
enabled: newState,
|
||||
};
|
||||
updateAppearance(
|
||||
onSaveAppearance(
|
||||
{
|
||||
service_banner: newBanner,
|
||||
},
|
||||
@@ -196,7 +196,7 @@ export const AppearanceSettingsPageView = ({
|
||||
"background_color",
|
||||
color.hex,
|
||||
);
|
||||
updateAppearance(
|
||||
onSaveAppearance(
|
||||
{
|
||||
service_banner: {
|
||||
...serviceBannerForm.values,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { getLicenses, removeLicense } from "api/api";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { FC, useEffect } from "react";
|
||||
@@ -7,22 +6,30 @@ import { Helmet } from "react-helmet-async";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import useToggle from "react-use/lib/useToggle";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService";
|
||||
import LicensesSettingsPageView from "./LicensesSettingsPageView";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { entitlements, refreshEntitlements } from "api/queries/entitlements";
|
||||
|
||||
const LicensesSettingsPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [entitlementsState, sendEvent] = useMachine(entitlementsMachine);
|
||||
const { entitlements, getEntitlementsError } = entitlementsState.context;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const success = searchParams.get("success");
|
||||
const [confettiOn, toggleConfettiOn] = useToggle(false);
|
||||
if (getEntitlementsError) {
|
||||
displayError(
|
||||
getErrorMessage(getEntitlementsError, "Failed to fetch entitlements"),
|
||||
);
|
||||
}
|
||||
const entitlementsQuery = useQuery(entitlements());
|
||||
const refreshEntitlementsMutation = useMutation(
|
||||
refreshEntitlements(queryClient),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (entitlementsQuery.error) {
|
||||
displayError(
|
||||
getErrorMessage(
|
||||
entitlementsQuery.error,
|
||||
"Failed to fetch entitlements",
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [entitlementsQuery.error]);
|
||||
|
||||
const { mutate: removeLicenseApi, isLoading: isRemovingLicense } =
|
||||
useMutation(removeLicense, {
|
||||
@@ -59,14 +66,19 @@ const LicensesSettingsPage: FC = () => {
|
||||
<LicensesSettingsPageView
|
||||
showConfetti={confettiOn}
|
||||
isLoading={isLoading}
|
||||
userLimitActual={entitlements?.features.user_limit.actual}
|
||||
userLimitLimit={entitlements?.features.user_limit.limit}
|
||||
isRefreshing={refreshEntitlementsMutation.isLoading}
|
||||
userLimitActual={entitlementsQuery.data?.features.user_limit.actual}
|
||||
userLimitLimit={entitlementsQuery.data?.features.user_limit.limit}
|
||||
licenses={licenses}
|
||||
isRemovingLicense={isRemovingLicense}
|
||||
removeLicense={(licenseId: number) => removeLicenseApi(licenseId)}
|
||||
refreshEntitlements={() => {
|
||||
const x = sendEvent("REFRESH");
|
||||
return !x.context.getEntitlementsError;
|
||||
refreshEntitlements={async () => {
|
||||
try {
|
||||
await refreshEntitlementsMutation.mutateAsync();
|
||||
displaySuccess("Successfully removed license");
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to remove license"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
+8
-11
@@ -12,8 +12,8 @@ import Confetti from "react-confetti";
|
||||
import { Link } from "react-router-dom";
|
||||
import useWindowSize from "react-use/lib/useWindowSize";
|
||||
import MuiLink from "@mui/material/Link";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { LoadingButton } from "components/LoadingButton/LoadingButton";
|
||||
|
||||
type Props = {
|
||||
showConfetti: boolean;
|
||||
@@ -22,8 +22,9 @@ type Props = {
|
||||
userLimitLimit?: number;
|
||||
licenses?: GetLicensesResponse[];
|
||||
isRemovingLicense: boolean;
|
||||
isRefreshing: boolean;
|
||||
removeLicense: (licenseId: number) => void;
|
||||
refreshEntitlements?: () => boolean;
|
||||
refreshEntitlements: () => void;
|
||||
};
|
||||
|
||||
const LicensesSettingsPageView: FC<Props> = ({
|
||||
@@ -33,6 +34,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
userLimitLimit,
|
||||
licenses,
|
||||
isRemovingLicense,
|
||||
isRefreshing,
|
||||
removeLicense,
|
||||
refreshEntitlements,
|
||||
}) => {
|
||||
@@ -69,18 +71,13 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
Add a license
|
||||
</Button>
|
||||
<Tooltip title="Refresh license entitlements. This is done automatically every 10 minutes.">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (refreshEntitlements) {
|
||||
if (refreshEntitlements()) {
|
||||
displaySuccess("Successfully refreshed licenses");
|
||||
}
|
||||
}
|
||||
}}
|
||||
<LoadingButton
|
||||
loading={isRefreshing}
|
||||
onClick={refreshEntitlements}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ConfirmDialog } from "../../../components/Dialogs/ConfirmDialog/Confirm
|
||||
import { Section } from "../../../components/SettingsLayout/Section";
|
||||
import { SSHKeysPageView } from "./SSHKeysPageView";
|
||||
import { regenerateUserSSHKey, userSSHKey } from "api/queries/sshKeys";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
|
||||
export const Language = {
|
||||
title: "SSH keys",
|
||||
@@ -20,18 +21,9 @@ export const SSHKeysPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const userSSHKeyQuery = useQuery(userSSHKey("me"));
|
||||
const regenerateSSHKeyMutationOptions = regenerateUserSSHKey(
|
||||
"me",
|
||||
queryClient,
|
||||
const regenerateSSHKeyMutation = useMutation(
|
||||
regenerateUserSSHKey("me", queryClient),
|
||||
);
|
||||
const regenerateSSHKeyMutation = useMutation({
|
||||
...regenerateSSHKeyMutationOptions,
|
||||
onSuccess: (newKey) => {
|
||||
regenerateSSHKeyMutationOptions.onSuccess(newKey);
|
||||
displaySuccess("SSH Key regenerated successfully.");
|
||||
setIsConfirmingRegeneration(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,7 +46,18 @@ export const SSHKeysPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
confirmLoading={regenerateSSHKeyMutation.isLoading}
|
||||
title={Language.regenerateDialogTitle}
|
||||
confirmText={Language.confirmLabel}
|
||||
onConfirm={regenerateSSHKeyMutation.mutate}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await regenerateSSHKeyMutation.mutateAsync();
|
||||
displaySuccess("SSH Key regenerated successfully.");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to regenerate SSH key"),
|
||||
);
|
||||
} finally {
|
||||
setIsConfirmingRegeneration(false);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsConfirmingRegeneration(false);
|
||||
}}
|
||||
|
||||
@@ -11,10 +11,9 @@ import { DashboardProviderContext } from "components/Dashboard/DashboardProvider
|
||||
import { WorkspaceBuildLogsSection } from "pages/WorkspacePage/WorkspaceBuildLogsSection";
|
||||
|
||||
const MockedAppearance = {
|
||||
config: Mocks.MockAppearance,
|
||||
preview: false,
|
||||
setPreview: () => null,
|
||||
save: () => null,
|
||||
config: Mocks.MockAppearanceConfig,
|
||||
isPreview: false,
|
||||
setPreview: () => {},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof Workspace> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockAppearance,
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockEntitlementsWithScheduling,
|
||||
MockExperiments,
|
||||
@@ -10,10 +10,9 @@ import { WorkspaceStats } from "./WorkspaceStats";
|
||||
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider";
|
||||
|
||||
const MockedAppearance = {
|
||||
config: MockAppearance,
|
||||
preview: false,
|
||||
setPreview: () => null,
|
||||
save: () => null,
|
||||
config: MockAppearanceConfig,
|
||||
isPreview: false,
|
||||
setPreview: () => {},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkspaceStats> = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockAppearance,
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockEntitlementsWithScheduling,
|
||||
MockExperiments,
|
||||
@@ -71,10 +71,9 @@ const allWorkspaces = [
|
||||
];
|
||||
|
||||
const MockedAppearance = {
|
||||
config: MockAppearance,
|
||||
preview: false,
|
||||
setPreview: () => null,
|
||||
save: () => null,
|
||||
config: MockAppearanceConfig,
|
||||
isPreview: false,
|
||||
setPreview: () => {},
|
||||
};
|
||||
|
||||
type FilterProps = ComponentProps<typeof WorkspacesPageView>["filterProps"];
|
||||
|
||||
@@ -2105,7 +2105,7 @@ export const MockDeploymentConfig: DeploymentConfig = {
|
||||
options: [],
|
||||
};
|
||||
|
||||
export const MockAppearance: TypesGen.AppearanceConfig = {
|
||||
export const MockAppearanceConfig: TypesGen.AppearanceConfig = {
|
||||
logo_url: "",
|
||||
service_banner: {
|
||||
enabled: false,
|
||||
|
||||
@@ -324,7 +324,7 @@ export const handlers = [
|
||||
}),
|
||||
|
||||
rest.get("/api/v2/appearance", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockAppearance));
|
||||
return res(ctx.status(200), ctx.json(M.MockAppearanceConfig));
|
||||
}),
|
||||
|
||||
rest.get("/api/v2/deployment/stats", (_, res, ctx) => {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getFeatureVisibility } from "./entitlementsSelectors";
|
||||
import { getFeatureVisibility } from "./entitlements";
|
||||
|
||||
describe("getFeatureVisibility", () => {
|
||||
it("returns empty object if there is no license", () => {
|
||||
@@ -0,0 +1,18 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- It can be any
|
||||
export const getMetadataAsJSON = <T extends Record<string, any>>(
|
||||
property: string,
|
||||
): T | undefined => {
|
||||
const appearance = document.querySelector(`meta[property=${property}]`);
|
||||
if (appearance) {
|
||||
const rawContent = appearance.getAttribute("content");
|
||||
try {
|
||||
return JSON.parse(rawContent as string);
|
||||
} catch (ex) {
|
||||
// In development the metadata is always going to be empty throwing this
|
||||
// error
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.warn(`Failed to parse ${property} metadata`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { assign, createMachine } from "xstate";
|
||||
import * as API from "../../api/api";
|
||||
import { AppearanceConfig } from "../../api/typesGenerated";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
|
||||
export type AppearanceContext = {
|
||||
appearance?: AppearanceConfig;
|
||||
getAppearanceError?: unknown;
|
||||
preview: boolean;
|
||||
};
|
||||
|
||||
export type AppearanceEvent =
|
||||
| { type: "SET_PREVIEW_APPEARANCE"; appearance: AppearanceConfig }
|
||||
| { type: "SAVE_APPEARANCE"; appearance: AppearanceConfig };
|
||||
|
||||
export const appearanceMachine = createMachine(
|
||||
{
|
||||
id: "appearanceMachine",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./appearanceXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as AppearanceContext,
|
||||
events: {} as AppearanceEvent,
|
||||
services: {
|
||||
getAppearance: {
|
||||
data: {} as AppearanceConfig,
|
||||
},
|
||||
setAppearance: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
context: {
|
||||
preview: false,
|
||||
},
|
||||
initial: "gettingAppearance",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
SET_PREVIEW_APPEARANCE: {
|
||||
actions: ["clearGetAppearanceError", "assignPreviewAppearance"],
|
||||
},
|
||||
SAVE_APPEARANCE: "savingAppearance",
|
||||
},
|
||||
},
|
||||
gettingAppearance: {
|
||||
entry: "clearGetAppearanceError",
|
||||
invoke: {
|
||||
id: "getAppearance",
|
||||
src: "getAppearance",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignAppearance"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignGetAppearanceError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
savingAppearance: {
|
||||
invoke: {
|
||||
id: "setAppearance",
|
||||
src: "setAppearance",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignAppearance", "notifyUpdateAppearanceSuccess"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: (_, error) => {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to update appearance settings."),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
assignPreviewAppearance: assign({
|
||||
appearance: (_, event) => event.appearance,
|
||||
preview: (_) => true,
|
||||
}),
|
||||
notifyUpdateAppearanceSuccess: () => {
|
||||
displaySuccess("Successfully updated appearance settings!");
|
||||
},
|
||||
assignAppearance: assign({
|
||||
appearance: (_, event) => event.data as AppearanceConfig,
|
||||
preview: (_) => false,
|
||||
}),
|
||||
assignGetAppearanceError: assign({
|
||||
getAppearanceError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetAppearanceError: assign({
|
||||
getAppearanceError: (_) => undefined,
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getAppearance: async () => {
|
||||
// Appearance is injected by the Coder server into the HTML document.
|
||||
const appearance = document.querySelector("meta[property=appearance]");
|
||||
if (appearance) {
|
||||
const rawContent = appearance.getAttribute("content");
|
||||
try {
|
||||
return JSON.parse(rawContent as string);
|
||||
} catch (ex) {
|
||||
// Ignore this and fetch as normal!
|
||||
}
|
||||
}
|
||||
|
||||
return API.getAppearance();
|
||||
},
|
||||
setAppearance: (_, event) => API.updateAppearance(event.appearance),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,88 +0,0 @@
|
||||
import { assign, createMachine } from "xstate";
|
||||
import * as API from "../../api/api";
|
||||
import * as TypesGen from "../../api/typesGenerated";
|
||||
|
||||
export interface BuildInfoContext {
|
||||
getBuildInfoError?: unknown;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
}
|
||||
|
||||
export const buildInfoMachine = createMachine(
|
||||
{
|
||||
id: "buildInfoState",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./buildInfoXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as BuildInfoContext,
|
||||
services: {} as {
|
||||
getBuildInfo: {
|
||||
data: TypesGen.BuildInfoResponse;
|
||||
};
|
||||
},
|
||||
},
|
||||
context: {
|
||||
buildInfo: undefined,
|
||||
},
|
||||
initial: "gettingBuildInfo",
|
||||
states: {
|
||||
gettingBuildInfo: {
|
||||
invoke: {
|
||||
src: "getBuildInfo",
|
||||
id: "getBuildInfo",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignBuildInfo", "clearGetBuildInfoError"],
|
||||
target: "#buildInfoState.success",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: ["assignGetBuildInfoError", "clearBuildInfo"],
|
||||
target: "#buildInfoState.failure",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
failure: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
getBuildInfo: async () => {
|
||||
// Build info is injected by the Coder server into the HTML document.
|
||||
const buildInfo = document.querySelector("meta[property=build-info]");
|
||||
if (buildInfo) {
|
||||
const rawContent = buildInfo.getAttribute("content");
|
||||
try {
|
||||
return JSON.parse(rawContent as string);
|
||||
} catch (ex) {
|
||||
// Ignore this and fetch as normal!
|
||||
}
|
||||
}
|
||||
|
||||
return API.getBuildInfo();
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignBuildInfo: assign({
|
||||
buildInfo: (_, event) => event.data,
|
||||
}),
|
||||
clearBuildInfo: assign((context: BuildInfoContext) => ({
|
||||
...context,
|
||||
buildInfo: undefined,
|
||||
})),
|
||||
assignGetBuildInfoError: assign({
|
||||
getBuildInfoError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetBuildInfoError: assign((context: BuildInfoContext) => ({
|
||||
...context,
|
||||
getBuildInfoError: undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,106 +0,0 @@
|
||||
import { assign, createMachine } from "xstate";
|
||||
import * as API from "../../api/api";
|
||||
import { Entitlements } from "../../api/typesGenerated";
|
||||
|
||||
export type EntitlementsContext = {
|
||||
entitlements?: Entitlements;
|
||||
getEntitlementsError?: unknown;
|
||||
};
|
||||
|
||||
export const entitlementsMachine = createMachine(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5RgHYBcCWaA2YC2qasAsgIYDGAFhimAHQBOYAZk7JQMQQD2tdNAN24Brek1ZxKAUXRZcBdLADaABgC6iUAAdusLBl6aQAD0QAWAGwAOOgGYAjACYArABoQAT0SOA7AE46C0czP2cVHzNIlRV7HwBfOPdCOXxCEgpqPnE2TjAGBm4GOi1sUjRmQrxGFhyZTBxUxVUNJBAdPUxDVtMESxsHF3cvBFtbAKcQsIiomPjE8FkGhSIyKhp6GDRMFCg6lOXYLl56QRENsDQ9pbTmo3b9LtAe2wtnOmcBi1Hos0c-Hx8Q0QVnsdBCfj8Vh8UKsYVscySi3kaVWmXOWxouyRjSIHDyBSKJTKFQYVU2V2RTXUd10DxQRmer3en2+Kl+-0BnkQsTM7yCZimkTM0ViCUR9UpKwy634EFwHAASlIAGJKgDKAAlbq17p16d1uZCgb1HBZ3n54fZIrCgrZHGKFhKcek1nx8YVFSr1VrqTraXqjMN7KE6CoLT4rWYbY4HJyev5QabnBYzA4LX5XmYEvMUNwIHAjMlropUesaR0DPqnogALRR401s3RaKORwqWyw2Ehe3zIuSl1o6oSdjlukMxDwlR0HwWAFsiz-PwqZz-Y0r3m2AXhaGhPxmHzOB1952lvibbZYp0HUcBg0m42wuwp5wfeHP9s98X7FHSvgYOVgDelbjggKjGiENgrpa1rJjGn6Ot+Ja-vQsAAK7kOQcDwH6FaPCYiBQXQSYpmmYyZsa9gqI4oYDM49gWMGPimrOR7Ygcp70O6DBAXhPSEcRqbBmRzhmBRIZhtBUawbG2ZxEAA */
|
||||
id: "entitlementsMachine",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./entitlementsXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as EntitlementsContext,
|
||||
services: {
|
||||
getEntitlements: {
|
||||
data: {} as Entitlements,
|
||||
},
|
||||
},
|
||||
},
|
||||
initial: "gettingEntitlements",
|
||||
states: {
|
||||
refresh: {
|
||||
invoke: {
|
||||
id: "refreshEntitlements",
|
||||
src: "refreshEntitlements",
|
||||
onDone: {
|
||||
target: "gettingEntitlements",
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: ["assignGetEntitlementsError"],
|
||||
},
|
||||
},
|
||||
entry: "clearGetEntitlementsError",
|
||||
},
|
||||
gettingEntitlements: {
|
||||
entry: "clearGetEntitlementsError",
|
||||
invoke: {
|
||||
id: "getEntitlements",
|
||||
src: "getEntitlements",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignEntitlements"],
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: ["assignGetEntitlementsError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
idle: {
|
||||
on: {
|
||||
REFRESH: "refresh",
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
REFRESH: "refresh",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
assignEntitlements: assign({
|
||||
entitlements: (_, event) => event.data,
|
||||
}),
|
||||
assignGetEntitlementsError: assign({
|
||||
getEntitlementsError: (_, event) => {
|
||||
return event.data;
|
||||
},
|
||||
}),
|
||||
clearGetEntitlementsError: assign({
|
||||
getEntitlementsError: (_) => undefined,
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
refreshEntitlements: async () => {
|
||||
return API.refreshEntitlements();
|
||||
},
|
||||
getEntitlements: async () => {
|
||||
// Entitlements is injected by the Coder server into the HTML document.
|
||||
const entitlements = document.querySelector(
|
||||
"meta[property=entitlements]",
|
||||
);
|
||||
if (entitlements) {
|
||||
const rawContent = entitlements.getAttribute("content");
|
||||
try {
|
||||
return JSON.parse(rawContent as string);
|
||||
} catch (ex) {
|
||||
// Ignore this and fetch as normal!
|
||||
}
|
||||
}
|
||||
|
||||
return API.getEntitlements();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,87 +0,0 @@
|
||||
import { getExperiments } from "api/api";
|
||||
import { Experiment } from "api/typesGenerated";
|
||||
import { createMachine, assign } from "xstate";
|
||||
|
||||
export interface ExperimentsContext {
|
||||
experiments?: Experiment[];
|
||||
getExperimentsError?: unknown;
|
||||
}
|
||||
|
||||
export const experimentsMachine = createMachine(
|
||||
{
|
||||
id: "experimentsState",
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./experimentsMachine.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as ExperimentsContext,
|
||||
services: {} as {
|
||||
getExperiments: {
|
||||
data: Experiment[];
|
||||
};
|
||||
},
|
||||
},
|
||||
initial: "gettingExperiments",
|
||||
states: {
|
||||
gettingExperiments: {
|
||||
invoke: {
|
||||
src: "getExperiments",
|
||||
id: "getExperiments",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignExperiments", "clearGetExperimentsError"],
|
||||
target: "#experimentsState.success",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: ["assignGetExperimentsError", "clearExperiments"],
|
||||
target: "#experimentsState.failure",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
failure: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
getExperiments: async () => {
|
||||
// Experiments is injected by the Coder server into the HTML document.
|
||||
const experiments = document.querySelector(
|
||||
"meta[property=experiments]",
|
||||
);
|
||||
if (experiments) {
|
||||
const rawContent = experiments.getAttribute("content");
|
||||
try {
|
||||
return JSON.parse(rawContent as string);
|
||||
} catch (ex) {
|
||||
// Ignore this and fetch as normal!
|
||||
}
|
||||
}
|
||||
|
||||
return getExperiments();
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignExperiments: assign({
|
||||
experiments: (_, event) => event.data,
|
||||
}),
|
||||
clearExperiments: assign((context: ExperimentsContext) => ({
|
||||
...context,
|
||||
experiments: undefined,
|
||||
})),
|
||||
assignGetExperimentsError: assign({
|
||||
getExperimentsError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetExperimentsError: assign((context: ExperimentsContext) => ({
|
||||
...context,
|
||||
getExperimentsError: undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user