From 7560d99294002c4c4eb7c3618fe88c6392c1a89d Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 11 Sep 2023 17:52:51 +0000 Subject: [PATCH 01/17] Refactor build info --- site/src/api/queries/buildInfo.ts | 24 +++++ .../Dashboard/DashboardProvider.tsx | 10 +-- .../xServices/buildInfo/buildInfoXService.ts | 88 ------------------- 3 files changed, 29 insertions(+), 93 deletions(-) create mode 100644 site/src/api/queries/buildInfo.ts delete mode 100644 site/src/xServices/buildInfo/buildInfoXService.ts diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts new file mode 100644 index 0000000000000..e6b4ed210f077 --- /dev/null +++ b/site/src/api/queries/buildInfo.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import * as API from "api/api"; + +export const useBuildInfo = () => { + return useQuery({ + queryKey: ["buildInfo"], + queryFn: fetchBuildInfo, + }); +}; + +const fetchBuildInfo = 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 (e) { + console.warn("Failed to parse build info from document", e); + } + } + + return API.getBuildInfo(); +}; diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index bfe632d755d9e..b195670ce930e 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -1,4 +1,5 @@ import { useMachine } from "@xstate/react"; +import { useBuildInfo } from "api/queries/buildInfo"; import { AppearanceConfig, BuildInfoResponse, @@ -8,7 +9,6 @@ import { 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"; @@ -31,15 +31,15 @@ export const DashboardProviderContext = createContext< >(undefined); export const DashboardProvider: FC = ({ children }) => { - const [buildInfoState] = useMachine(buildInfoMachine); + const buildInfoQuery = useBuildInfo(); 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 isLoading = + !buildInfoQuery.data || !entitlements || !appearance || !experiments; const setAppearancePreview = (config: AppearanceConfig) => { appearanceSend({ @@ -62,7 +62,7 @@ export const DashboardProvider: FC = ({ children }) => { return ( { - // 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, - })), - }, - }, -); From d34f4cf8fbe897316af088096eaabb8b53313afd Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 11 Sep 2023 19:01:08 +0000 Subject: [PATCH 02/17] Refactor entitlements --- site/src/api/queries/entitlements.ts | 46 +++++++++++++++++++ .../Dashboard/DashboardProvider.tsx | 12 +++-- site/src/hooks/useFeatureVisibility.ts | 2 +- .../LicensesSettingsPage.tsx | 38 +++++++++------ .../LicensesSettingsPageView.tsx | 19 ++++---- .../entitlements.test.ts} | 2 +- .../entitlements.ts} | 0 7 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 site/src/api/queries/entitlements.ts rename site/src/{xServices/entitlements/entitlementsSelectors.test.ts => utils/entitlements.test.ts} (95%) rename site/src/{xServices/entitlements/entitlementsSelectors.ts => utils/entitlements.ts} (100%) diff --git a/site/src/api/queries/entitlements.ts b/site/src/api/queries/entitlements.ts new file mode 100644 index 0000000000000..019cb16458ec1 --- /dev/null +++ b/site/src/api/queries/entitlements.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import * as API from "api/api"; + +const ENTITLEMENTS_QUERY_KEY = ["entitlements"]; + +export const useEntitlements = () => { + return useQuery({ + queryKey: ENTITLEMENTS_QUERY_KEY, + queryFn: fetchEntitlements, + }); +}; + +export const useRefreshEntitlements = ({ + onSuccess, + onError, +}: { + onSuccess: () => void; + onError: (error: unknown) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: API.refreshEntitlements, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ENTITLEMENTS_QUERY_KEY, + }); + onSuccess(); + }, + onError, + }); +}; + +const fetchEntitlements = () => { + // 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 (e) { + console.warn("Failed to parse entitlements from document", e); + } + } + + return API.getEntitlements(); +}; diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index b195670ce930e..1240d6d1ef2f4 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -1,5 +1,6 @@ import { useMachine } from "@xstate/react"; import { useBuildInfo } from "api/queries/buildInfo"; +import { useEntitlements } from "api/queries/entitlements"; import { AppearanceConfig, BuildInfoResponse, @@ -9,7 +10,6 @@ import { import { FullScreenLoader } from "components/Loader/FullScreenLoader"; import { createContext, FC, PropsWithChildren, useContext } from "react"; import { appearanceMachine } from "xServices/appearance/appearanceXService"; -import { entitlementsMachine } from "xServices/entitlements/entitlementsXService"; import { experimentsMachine } from "xServices/experiments/experimentsMachine"; interface Appearance { @@ -32,14 +32,16 @@ export const DashboardProviderContext = createContext< export const DashboardProvider: FC = ({ children }) => { const buildInfoQuery = useBuildInfo(); - const [entitlementsState] = useMachine(entitlementsMachine); + const entitlementsQuery = useEntitlements(); const [appearanceState, appearanceSend] = useMachine(appearanceMachine); const [experimentsState] = useMachine(experimentsMachine); - const { entitlements } = entitlementsState.context; const { appearance, preview } = appearanceState.context; const { experiments } = experimentsState.context; const isLoading = - !buildInfoQuery.data || !entitlements || !appearance || !experiments; + !buildInfoQuery.data || + !entitlementsQuery.data || + !appearance || + !experiments; const setAppearancePreview = (config: AppearanceConfig) => { appearanceSend({ @@ -63,7 +65,7 @@ export const DashboardProvider: FC = ({ children }) => { => { const { entitlements } = useDashboard(); diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 9bfddcc53892f..3688cb7c839ce 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -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,35 @@ 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 { + useEntitlements, + useRefreshEntitlements, +} 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 entitlements = useEntitlements(); + const refreshEntitlements = useRefreshEntitlements({ + onSuccess: () => { + displaySuccess("Successfully refreshed licenses"); + }, + onError: (error) => { + displayError(getErrorMessage(error, "Failed to refresh entitlements")); + }, + }); + + useEffect(() => { + if (entitlements.error) { + displayError( + getErrorMessage(entitlements.error, "Failed to fetch entitlements"), + ); + } + }, [entitlements.error]); const { mutate: removeLicenseApi, isLoading: isRemovingLicense } = useMutation(removeLicense, { @@ -59,14 +71,14 @@ const LicensesSettingsPage: FC = () => { removeLicenseApi(licenseId)} refreshEntitlements={() => { - const x = sendEvent("REFRESH"); - return !x.context.getEntitlementsError; + refreshEntitlements.mutate(); }} /> diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 1c9c6fe295850..eb584189c1a51 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -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 = ({ @@ -33,6 +34,7 @@ const LicensesSettingsPageView: FC = ({ userLimitLimit, licenses, isRemovingLicense, + isRefreshing, removeLicense, refreshEntitlements, }) => { @@ -69,18 +71,13 @@ const LicensesSettingsPageView: FC = ({ Add a license - + diff --git a/site/src/xServices/entitlements/entitlementsSelectors.test.ts b/site/src/utils/entitlements.test.ts similarity index 95% rename from site/src/xServices/entitlements/entitlementsSelectors.test.ts rename to site/src/utils/entitlements.test.ts index f1c3dc56cb46b..b2f473e4a6d9a 100644 --- a/site/src/xServices/entitlements/entitlementsSelectors.test.ts +++ b/site/src/utils/entitlements.test.ts @@ -1,4 +1,4 @@ -import { getFeatureVisibility } from "./entitlementsSelectors"; +import { getFeatureVisibility } from "./entitlements"; describe("getFeatureVisibility", () => { it("returns empty object if there is no license", () => { diff --git a/site/src/xServices/entitlements/entitlementsSelectors.ts b/site/src/utils/entitlements.ts similarity index 100% rename from site/src/xServices/entitlements/entitlementsSelectors.ts rename to site/src/utils/entitlements.ts From fc932a6a11a8fce2ef32b8f08304d3adc8c83e1c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 11 Sep 2023 19:01:43 +0000 Subject: [PATCH 03/17] Improve verbiage --- site/src/components/Dashboard/DashboardProvider.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index 1240d6d1ef2f4..4b77e39ade84c 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -31,17 +31,14 @@ export const DashboardProviderContext = createContext< >(undefined); export const DashboardProvider: FC = ({ children }) => { - const buildInfoQuery = useBuildInfo(); - const entitlementsQuery = useEntitlements(); + const buildInfo = useBuildInfo(); + const entitlements = useEntitlements(); const [appearanceState, appearanceSend] = useMachine(appearanceMachine); const [experimentsState] = useMachine(experimentsMachine); const { appearance, preview } = appearanceState.context; const { experiments } = experimentsState.context; const isLoading = - !buildInfoQuery.data || - !entitlementsQuery.data || - !appearance || - !experiments; + !buildInfo.data || !entitlements.data || !appearance || !experiments; const setAppearancePreview = (config: AppearanceConfig) => { appearanceSend({ @@ -64,8 +61,8 @@ export const DashboardProvider: FC = ({ children }) => { return ( Date: Tue, 12 Sep 2023 19:37:48 +0000 Subject: [PATCH 04/17] Refactor to use factory --- site/src/api/queries/buildInfo.ts | 7 ++-- site/src/api/queries/entitlements.ts | 23 ++++--------- .../Dashboard/DashboardProvider.tsx | 18 ++++++---- .../LicensesSettingsPage.tsx | 33 ++++++++++--------- 4 files changed, 38 insertions(+), 43 deletions(-) diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts index e6b4ed210f077..cab1d2f1b0b67 100644 --- a/site/src/api/queries/buildInfo.ts +++ b/site/src/api/queries/buildInfo.ts @@ -1,11 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; import * as API from "api/api"; -export const useBuildInfo = () => { - return useQuery({ +export const buildInfo = () => { + return { queryKey: ["buildInfo"], queryFn: fetchBuildInfo, - }); + }; }; const fetchBuildInfo = async () => { diff --git a/site/src/api/queries/entitlements.ts b/site/src/api/queries/entitlements.ts index 019cb16458ec1..95a9f92a8b024 100644 --- a/site/src/api/queries/entitlements.ts +++ b/site/src/api/queries/entitlements.ts @@ -1,33 +1,24 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import * as API from "api/api"; const ENTITLEMENTS_QUERY_KEY = ["entitlements"]; -export const useEntitlements = () => { - return useQuery({ +export const entitlements = () => { + return { queryKey: ENTITLEMENTS_QUERY_KEY, queryFn: fetchEntitlements, - }); + }; }; -export const useRefreshEntitlements = ({ - onSuccess, - onError, -}: { - onSuccess: () => void; - onError: (error: unknown) => void; -}) => { - const queryClient = useQueryClient(); - return useMutation({ +export const refreshEntitlements = (queryClient: QueryClient) => { + return { mutationFn: API.refreshEntitlements, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ENTITLEMENTS_QUERY_KEY, }); - onSuccess(); }, - onError, - }); + }; }; const fetchEntitlements = () => { diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index 4b77e39ade84c..78401777cad4c 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -1,6 +1,7 @@ +import { useQuery } from "@tanstack/react-query"; import { useMachine } from "@xstate/react"; -import { useBuildInfo } from "api/queries/buildInfo"; -import { useEntitlements } from "api/queries/entitlements"; +import { buildInfo } from "api/queries/buildInfo"; +import { entitlements } from "api/queries/entitlements"; import { AppearanceConfig, BuildInfoResponse, @@ -31,14 +32,17 @@ export const DashboardProviderContext = createContext< >(undefined); export const DashboardProvider: FC = ({ children }) => { - const buildInfo = useBuildInfo(); - const entitlements = useEntitlements(); + const buildInfoQuery = useQuery(buildInfo()); + const entitlementsQuery = useQuery(entitlements()); const [appearanceState, appearanceSend] = useMachine(appearanceMachine); const [experimentsState] = useMachine(experimentsMachine); const { appearance, preview } = appearanceState.context; const { experiments } = experimentsState.context; const isLoading = - !buildInfo.data || !entitlements.data || !appearance || !experiments; + !buildInfoQuery.data || + !entitlementsQuery.data || + !appearance || + !experiments; const setAppearancePreview = (config: AppearanceConfig) => { appearanceSend({ @@ -61,8 +65,8 @@ export const DashboardProvider: FC = ({ children }) => { return ( { const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const success = searchParams.get("success"); const [confettiOn, toggleConfettiOn] = useToggle(false); - const entitlements = useEntitlements(); - const refreshEntitlements = useRefreshEntitlements({ - onSuccess: () => { + const entitlementsQuery = useQuery(entitlements()); + const refreshEntitlementsMutationOptions = refreshEntitlements(queryClient); + const refreshEntitlementsMutation = useMutation({ + ...refreshEntitlementsMutationOptions, + onSuccess: async () => { displaySuccess("Successfully refreshed licenses"); + await refreshEntitlementsMutationOptions.onSuccess(); }, onError: (error) => { displayError(getErrorMessage(error, "Failed to refresh entitlements")); @@ -29,12 +29,15 @@ const LicensesSettingsPage: FC = () => { }); useEffect(() => { - if (entitlements.error) { + if (entitlementsQuery.error) { displayError( - getErrorMessage(entitlements.error, "Failed to fetch entitlements"), + getErrorMessage( + entitlementsQuery.error, + "Failed to fetch entitlements", + ), ); } - }, [entitlements.error]); + }, [entitlementsQuery.error]); const { mutate: removeLicenseApi, isLoading: isRemovingLicense } = useMutation(removeLicense, { @@ -71,15 +74,13 @@ const LicensesSettingsPage: FC = () => { removeLicenseApi(licenseId)} - refreshEntitlements={() => { - refreshEntitlements.mutate(); - }} + refreshEntitlements={refreshEntitlementsMutation.mutate} /> ); From a75c079a52f394fc832c5609cea752df9c74499c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Sep 2023 14:55:17 +0000 Subject: [PATCH 05/17] Use mutate async --- .../LicensesSettingsPage.tsx | 23 +++++++-------- .../SSHKeysPage/SSHKeysPage.tsx | 29 ++++++++++--------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index db8a1edda83b0..fdf2c2d42f268 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -16,17 +16,9 @@ const LicensesSettingsPage: FC = () => { const success = searchParams.get("success"); const [confettiOn, toggleConfettiOn] = useToggle(false); const entitlementsQuery = useQuery(entitlements()); - const refreshEntitlementsMutationOptions = refreshEntitlements(queryClient); - const refreshEntitlementsMutation = useMutation({ - ...refreshEntitlementsMutationOptions, - onSuccess: async () => { - displaySuccess("Successfully refreshed licenses"); - await refreshEntitlementsMutationOptions.onSuccess(); - }, - onError: (error) => { - displayError(getErrorMessage(error, "Failed to refresh entitlements")); - }, - }); + const refreshEntitlementsMutation = useMutation( + refreshEntitlements(queryClient), + ); useEffect(() => { if (entitlementsQuery.error) { @@ -80,7 +72,14 @@ const LicensesSettingsPage: FC = () => { licenses={licenses} isRemovingLicense={isRemovingLicense} removeLicense={(licenseId: number) => removeLicenseApi(licenseId)} - refreshEntitlements={refreshEntitlementsMutation.mutate} + refreshEntitlements={async () => { + try { + await refreshEntitlementsMutation.mutateAsync(); + displaySuccess("Successfully removed license"); + } catch (error) { + displayError(getErrorMessage(error, "Failed to remove license")); + } + }} /> ); diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx index b35cb2688707f..5f889b4a51f50 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx @@ -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> = () => { 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> = () => { 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); }} From 4ec38be23da3d3e19cb730a48bf31e2a6ae6470a Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Sep 2023 15:00:19 +0000 Subject: [PATCH 06/17] Migrate experiments to react-query --- site/src/api/queries/experiments.ts | 23 +++++ .../Dashboard/DashboardProvider.tsx | 9 +- .../experiments/experimentsMachine.ts | 87 ------------------- 3 files changed, 27 insertions(+), 92 deletions(-) create mode 100644 site/src/api/queries/experiments.ts delete mode 100644 site/src/xServices/experiments/experimentsMachine.ts diff --git a/site/src/api/queries/experiments.ts b/site/src/api/queries/experiments.ts new file mode 100644 index 0000000000000..22aadd980f89c --- /dev/null +++ b/site/src/api/queries/experiments.ts @@ -0,0 +1,23 @@ +import * as API from "api/api"; + +export const experiments = () => { + return { + queryKey: ["experiments"], + queryFn: fetchExperiments, + }; +}; + +const fetchExperiments = 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 API.getExperiments(); +}; diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index 78401777cad4c..2b51da0461dff 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useMachine } from "@xstate/react"; import { buildInfo } from "api/queries/buildInfo"; +import { experiments } from "api/queries/experiments"; import { entitlements } from "api/queries/entitlements"; import { AppearanceConfig, @@ -11,7 +12,6 @@ import { import { FullScreenLoader } from "components/Loader/FullScreenLoader"; import { createContext, FC, PropsWithChildren, useContext } from "react"; import { appearanceMachine } from "xServices/appearance/appearanceXService"; -import { experimentsMachine } from "xServices/experiments/experimentsMachine"; interface Appearance { config: AppearanceConfig; @@ -34,15 +34,14 @@ export const DashboardProviderContext = createContext< export const DashboardProvider: FC = ({ children }) => { const buildInfoQuery = useQuery(buildInfo()); const entitlementsQuery = useQuery(entitlements()); + const experimentsQuery = useQuery(experiments()); const [appearanceState, appearanceSend] = useMachine(appearanceMachine); - const [experimentsState] = useMachine(experimentsMachine); const { appearance, preview } = appearanceState.context; - const { experiments } = experimentsState.context; const isLoading = !buildInfoQuery.data || !entitlementsQuery.data || !appearance || - !experiments; + !experimentsQuery.data; const setAppearancePreview = (config: AppearanceConfig) => { appearanceSend({ @@ -67,7 +66,7 @@ export const DashboardProvider: FC = ({ children }) => { value={{ buildInfo: buildInfoQuery.data, entitlements: entitlementsQuery.data, - experiments, + experiments: experimentsQuery.data, appearance: { preview, config: appearance, diff --git a/site/src/xServices/experiments/experimentsMachine.ts b/site/src/xServices/experiments/experimentsMachine.ts deleted file mode 100644 index d96a59559cf14..0000000000000 --- a/site/src/xServices/experiments/experimentsMachine.ts +++ /dev/null @@ -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, - })), - }, - }, -); From daf251afa6f01223a18af6ae28a874a6c487b5ba Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Sep 2023 15:19:24 +0000 Subject: [PATCH 07/17] Remove entitlements service --- .../entitlements/entitlementsXService.ts | 106 ------------------ 1 file changed, 106 deletions(-) delete mode 100644 site/src/xServices/entitlements/entitlementsXService.ts diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts deleted file mode 100644 index 61420ace5fc72..0000000000000 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ /dev/null @@ -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(); - }, - }, - }, -); From 7bef76c57ae45c65cb5aacf24bb11ae9a66ff2b8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Sep 2023 15:20:31 +0000 Subject: [PATCH 08/17] Improve error handling --- site/src/api/queries/experiments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/api/queries/experiments.ts b/site/src/api/queries/experiments.ts index 22aadd980f89c..a1f025c539978 100644 --- a/site/src/api/queries/experiments.ts +++ b/site/src/api/queries/experiments.ts @@ -14,8 +14,8 @@ const fetchExperiments = async () => { const rawContent = experiments.getAttribute("content"); try { return JSON.parse(rawContent as string); - } catch (ex) { - // Ignore this and fetch as normal! + } catch (e) { + console.warn("Failed to parse experiments from document", e); } } From 8183a340f548a3b730521b4eb4d80f14c66274b0 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Sep 2023 15:23:05 +0000 Subject: [PATCH 09/17] Add storybook --- .../AppearanceSettingsPageView.stories.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index da41824db556c..df522f4f6904f 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -23,4 +23,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Page: Story = {}; +export const Entitled: Story = { + args: { + isEntitled: true, + }, +}; + +export const NotEntitled: Story = {}; From e94af69ed7d68155374057efd5d59eae15caeef1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Sep 2023 15:40:55 +0000 Subject: [PATCH 10/17] Remove appearance service --- site/src/api/queries/appearance.ts | 33 +++++ .../Dashboard/DashboardProvider.tsx | 39 ++---- .../AppearanceSettingsPage.tsx | 19 ++- .../AppearanceSettingsPageView.tsx | 14 +- .../appearance/appearanceXService.ts | 120 ------------------ 5 files changed, 69 insertions(+), 156 deletions(-) create mode 100644 site/src/api/queries/appearance.ts delete mode 100644 site/src/xServices/appearance/appearanceXService.ts diff --git a/site/src/api/queries/appearance.ts b/site/src/api/queries/appearance.ts new file mode 100644 index 0000000000000..9d522ba687f18 --- /dev/null +++ b/site/src/api/queries/appearance.ts @@ -0,0 +1,33 @@ +import { QueryClient } from "@tanstack/react-query"; +import * as API from "api/api"; +import { AppearanceConfig } from "api/typesGenerated"; + +export const appearance = () => { + return { + queryKey: ["appearance"], + queryFn: fetchAppearance, + }; +}; + +export const updateAppearance = (queryClient: QueryClient) => { + return { + mutationFn: API.updateAppearance, + onSuccess: (newConfig: AppearanceConfig) => { + queryClient.setQueryData(["appearance"], newConfig); + }, + }; +}; + +const fetchAppearance = () => { + 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(); +}; diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index 2b51da0461dff..989b2702dfb3c 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -1,5 +1,4 @@ import { useQuery } from "@tanstack/react-query"; -import { useMachine } from "@xstate/react"; import { buildInfo } from "api/queries/buildInfo"; import { experiments } from "api/queries/experiments"; import { entitlements } from "api/queries/entitlements"; @@ -10,14 +9,18 @@ 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 { + createContext, + FC, + PropsWithChildren, + useContext, + useState, +} from "react"; +import { appearance } from "api/queries/appearance"; interface Appearance { config: AppearanceConfig; - preview: boolean; setPreview: (config: AppearanceConfig) => void; - save: (config: AppearanceConfig) => void; } interface DashboardProviderValue { @@ -35,27 +38,13 @@ export const DashboardProvider: FC = ({ children }) => { const buildInfoQuery = useQuery(buildInfo()); const entitlementsQuery = useQuery(entitlements()); const experimentsQuery = useQuery(experiments()); - const [appearanceState, appearanceSend] = useMachine(appearanceMachine); - const { appearance, preview } = appearanceState.context; + const appearanceQuery = useQuery(appearance()); const isLoading = !buildInfoQuery.data || !entitlementsQuery.data || - !appearance || + !appearanceQuery.data || !experimentsQuery.data; - - const setAppearancePreview = (config: AppearanceConfig) => { - appearanceSend({ - type: "SET_PREVIEW_APPEARANCE", - appearance: config, - }); - }; - - const saveAppearance = (config: AppearanceConfig) => { - appearanceSend({ - type: "SAVE_APPEARANCE", - appearance: config, - }); - }; + const [configPreview, setConfigPreview] = useState(); if (isLoading) { return ; @@ -68,10 +57,8 @@ export const DashboardProvider: FC = ({ children }) => { entitlements: entitlementsQuery.data, experiments: experimentsQuery.data, appearance: { - preview, - config: appearance, - setPreview: setAppearancePreview, - save: saveAppearance, + config: configPreview ?? appearanceQuery.data, + setPreview: setConfigPreview, }, }} > diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx index 830fef0fb8684..64ecdf4f887ca 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx @@ -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, 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 = () => { ); diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 313fa5b371be8..363672e63efeb 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -25,7 +25,7 @@ import { colors } from "theme/colors"; export type AppearanceSettingsPageViewProps = { appearance: UpdateAppearanceConfig; isEntitled: boolean; - updateAppearance: ( + onSaveAppearance: ( newConfig: Partial, 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 && (