chore: add "user_configs" db table (#16564)

This commit is contained in:
ケイラ
2025-03-05 10:46:03 -07:00
committed by GitHub
parent cc946f199d
commit 9041646b81
47 changed files with 786 additions and 394 deletions
+47 -46
View File
@@ -9,53 +9,54 @@
-->
<head>
<meta charset="utf-8" />
<title>Coder</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#17172E" />
<meta name="application-name" content="{{ .ApplicationName }}" />
<meta property="og:type" content="website" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta property="build-info" content="{{ .BuildInfo }}" />
<meta property="user" content="{{ .User }}" />
<meta property="entitlements" content="{{ .Entitlements }}" />
<meta property="appearance" content="{{ .Appearance }}" />
<meta property="experiments" content="{{ .Experiments }}" />
<meta property="regions" content="{{ .Regions }}" />
<meta property="docs-url" content="{{ .DocsURL }}" />
<meta property="logo-url" content="{{ .LogoURL }}" />
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-light.png"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-light.svg"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-dark.png"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-dark.svg"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
<meta charset="utf-8" />
<title>Coder</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#17172E" />
<meta name="application-name" content="{{ .ApplicationName }}" />
<meta property="og:type" content="website" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta property="build-info" content="{{ .BuildInfo }}" />
<meta property="user" content="{{ .User }}" />
<meta property="entitlements" content="{{ .Entitlements }}" />
<meta property="appearance" content="{{ .Appearance }}" />
<meta property="userAppearance" content="{{ .UserAppearance }}" />
<meta property="experiments" content="{{ .Experiments }}" />
<meta property="regions" content="{{ .Regions }}" />
<meta property="docs-url" content="{{ .DocsURL }}" />
<meta property="logo-url" content="{{ .LogoURL }}" />
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-light.png"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-light.svg"
media="(prefers-color-scheme: dark)"
data-react-helmet="true"
/>
<link
rel="alternate icon"
type="image/png"
href="/favicons/favicon-dark.png"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
<link
rel="icon"
type="image/svg+xml"
href="/favicons/favicon-dark.svg"
media="(prefers-color-scheme: light)"
data-react-helmet="true"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
+29 -7
View File
@@ -292,13 +292,14 @@ type htmlState struct {
ApplicationName string
LogoURL string
BuildInfo string
User string
Entitlements string
Appearance string
Experiments string
Regions string
DocsURL string
BuildInfo string
User string
Entitlements string
Appearance string
UserAppearance string
Experiments string
Regions string
DocsURL string
}
type csrfState struct {
@@ -426,12 +427,22 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
var eg errgroup.Group
var user database.User
var themePreference string
orgIDs := []uuid.UUID{}
eg.Go(func() error {
var err error
user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
return err
})
eg.Go(func() error {
var err error
themePreference, err = h.opts.Database.GetUserAppearanceSettings(ctx, apiKey.UserID)
if errors.Is(err, sql.ErrNoRows) {
themePreference = ""
return nil
}
return err
})
eg.Go(func() error {
memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID})
if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 {
@@ -455,6 +466,17 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
}
}()
wg.Add(1)
go func() {
defer wg.Done()
userAppearance, err := json.Marshal(codersdk.UserAppearanceSettings{
ThemePreference: themePreference,
})
if err == nil {
state.UserAppearance = html.EscapeString(string(userAppearance))
}
}()
if h.Entitlements != nil {
wg.Add(1)
go func() {
+8 -6
View File
@@ -1340,14 +1340,16 @@ class ApiMethods {
return response.data;
};
getAppearanceSettings =
async (): Promise<TypesGen.UserAppearanceSettings> => {
const response = await this.axios.get("/api/v2/users/me/appearance");
return response.data;
};
updateAppearanceSettings = async (
userId: string,
data: TypesGen.UpdateUserAppearanceSettingsRequest,
): Promise<TypesGen.User> => {
const response = await this.axios.put(
`/api/v2/users/${userId}/appearance`,
data,
);
): Promise<TypesGen.UserAppearanceSettings> => {
const response = await this.axios.put("/api/v2/users/me/appearance", data);
return response.data;
};
+20 -16
View File
@@ -8,8 +8,8 @@ import type {
UpdateUserPasswordRequest,
UpdateUserProfileRequest,
User,
UserAppearanceSettings,
UsersRequest,
ValidateUserPasswordRequest,
} from "api/typesGenerated";
import {
type MetadataState,
@@ -224,35 +224,39 @@ export const updateProfile = (userId: string) => {
};
};
const myAppearanceKey = ["me", "appearance"];
export const appearanceSettings = (
metadata: MetadataState<UserAppearanceSettings>,
) => {
return cachedQuery({
metadata,
queryKey: myAppearanceKey,
queryFn: API.getAppearanceSettings,
});
};
export const updateAppearanceSettings = (
userId: string,
queryClient: QueryClient,
): UseMutationOptions<
User,
UserAppearanceSettings,
unknown,
UpdateUserAppearanceSettingsRequest,
unknown
> => {
return {
mutationFn: (req) => API.updateAppearanceSettings(userId, req),
mutationFn: (req) => API.updateAppearanceSettings(req),
onMutate: async (patch) => {
// Mutate the `queryClient` optimistically to make the theme switcher
// more responsive.
const me: User | undefined = queryClient.getQueryData(meKey);
if (userId === "me" && me) {
queryClient.setQueryData(meKey, {
...me,
theme_preference: patch.theme_preference,
});
}
queryClient.setQueryData(myAppearanceKey, {
theme_preference: patch.theme_preference,
});
},
onSuccess: async () => {
onSuccess: async () =>
// Could technically invalidate more, but we only ever care about the
// `theme_preference` for the `me` query.
if (userId === "me") {
await queryClient.invalidateQueries(meKey);
}
},
await queryClient.invalidateQueries(myAppearanceKey),
};
};
+6 -1
View File
@@ -1970,7 +1970,7 @@ export interface ReducedUser extends MinimalUser {
readonly last_seen_at: string;
readonly status: UserStatus;
readonly login_type: LoginType;
readonly theme_preference: string;
readonly theme_preference?: string;
}
// From codersdk/workspaceproxy.go
@@ -2805,6 +2805,11 @@ export interface UserActivityInsightsResponse {
readonly report: UserActivityInsightsReport;
}
// From codersdk/users.go
export interface UserAppearanceSettings {
readonly theme_preference: string;
}
// From codersdk/insights.go
export interface UserLatency {
readonly template_ids: readonly string[];
@@ -1,20 +1,18 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { ThemeProvider } from "contexts/ThemeProvider";
import { fireEvent, screen } from "@testing-library/react";
import { renderComponent } from "testHelpers/renderHelpers";
import { FileUpload } from "./FileUpload";
test("accepts files with the correct extension", async () => {
const onUpload = jest.fn();
render(
<ThemeProvider>
<FileUpload
isUploading={false}
onUpload={onUpload}
removeLabel="Remove file"
title="Upload file"
extensions={["tar", "zip"]}
/>
</ThemeProvider>,
renderComponent(
<FileUpload
isUploading={false}
onUpload={onUpload}
removeLabel="Remove file"
title="Upload file"
extensions={["tar", "zip"]}
/>,
);
const dropZone = screen.getByTestId("drop-zone");
+9 -7
View File
@@ -7,26 +7,27 @@ import {
StyledEngineProvider,
// biome-ignore lint/nursery/noRestrictedImports: we extend the MUI theme
} from "@mui/material/styles";
import { appearanceSettings } from "api/queries/users";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import {
type FC,
type PropsWithChildren,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useQuery } from "react-query";
import themes, { DEFAULT_THEME, type Theme } from "theme";
import { AuthContext } from "./auth/AuthProvider";
/**
*
*/
export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
// We need to use the `AuthContext` directly, rather than the `useAuth` hook,
// because Storybook and many tests depend on this component, but do not provide
// an `AuthProvider`, and `useAuth` will throw in that case.
const user = useContext(AuthContext)?.user;
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const themeQuery = useMemo(
() => window.matchMedia?.("(prefers-color-scheme: light)"),
[],
@@ -53,7 +54,8 @@ export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
}, [themeQuery]);
// We might not be logged in yet, or the `theme_preference` could be an empty string.
const themePreference = user?.theme_preference || DEFAULT_THEME;
const themePreference =
appearanceSettingsQuery.data?.theme_preference || DEFAULT_THEME;
// The janky casting here is find because of the much more type safe fallback
// We need to support `themePreference` being wrong anyway because the database
// value could be anything, like an empty string.
+4 -3
View File
@@ -11,7 +11,8 @@
*/
import { act, renderHook, screen } from "@testing-library/react";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { ThemeProvider } from "contexts/ThemeProvider";
import { ThemeOverride } from "contexts/ThemeProvider";
import themes, { DEFAULT_THEME } from "theme";
import {
COPY_FAILED_MESSAGE,
HTTP_FALLBACK_DATA_ID,
@@ -121,10 +122,10 @@ function renderUseClipboard<TInput extends UseClipboardInput>(inputs: TInput) {
initialProps: inputs,
wrapper: ({ children }) => (
// Need ThemeProvider because GlobalSnackbar uses theme
<ThemeProvider>
<ThemeOverride theme={themes[DEFAULT_THEME]}>
{children}
<GlobalSnackbar />
</ThemeProvider>
</ThemeOverride>
),
},
);
@@ -6,6 +6,7 @@ import {
MockEntitlements,
MockExperiments,
MockUser,
MockUserAppearanceSettings,
} from "testHelpers/entities";
import {
DEFAULT_METADATA_KEY,
@@ -38,6 +39,7 @@ const mockDataForTags = {
entitlements: MockEntitlements,
experiments: MockExperiments,
user: MockUser,
userAppearance: MockUserAppearanceSettings,
regions: MockRegions,
} as const satisfies Record<MetadataKey, MetadataValue>;
@@ -66,6 +68,10 @@ const emptyMetadata: RuntimeHtmlMetadata = {
available: false,
value: undefined,
},
userAppearance: {
available: false,
value: undefined,
},
};
const populatedMetadata: RuntimeHtmlMetadata = {
@@ -93,6 +99,10 @@ const populatedMetadata: RuntimeHtmlMetadata = {
available: true,
value: MockUser,
},
userAppearance: {
available: true,
value: MockUserAppearanceSettings,
},
};
function seedInitialMetadata(metadataKey: string): () => void {
+4
View File
@@ -5,6 +5,7 @@ import type {
Experiments,
Region,
User,
UserAppearanceSettings,
} from "api/typesGenerated";
import { useMemo, useSyncExternalStore } from "react";
@@ -25,6 +26,7 @@ type AvailableMetadata = Readonly<{
user: User;
experiments: Experiments;
appearance: AppearanceConfig;
userAppearance: UserAppearanceSettings;
entitlements: Entitlements;
regions: readonly Region[];
"build-info": BuildInfoResponse;
@@ -83,6 +85,8 @@ export class MetadataManager implements MetadataManagerApi {
this.metadata = {
user: this.registerValue<User>("user"),
appearance: this.registerValue<AppearanceConfig>("appearance"),
userAppearance:
this.registerValue<UserAppearanceSettings>("userAppearance"),
entitlements: this.registerValue<Entitlements>("entitlements"),
experiments: this.registerValue<Experiments>("experiments"),
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
@@ -34,7 +34,7 @@ describe("appearance page", () => {
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toBeCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith("me", {
expect(API.updateAppearanceSettings).toHaveBeenCalledWith({
theme_preference: "light",
});
});
@@ -1,19 +1,34 @@
import CircularProgress from "@mui/material/CircularProgress";
import { updateAppearanceSettings } from "api/queries/users";
import { appearanceSettings } from "api/queries/users";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Section } from "../Section";
import { AppearanceForm } from "./AppearanceForm";
export const AppearancePage: FC = () => {
const { user: me } = useAuthenticated();
const queryClient = useQueryClient();
const updateAppearanceSettingsMutation = useMutation(
updateAppearanceSettings("me", queryClient),
updateAppearanceSettings(queryClient),
);
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
if (appearanceSettingsQuery.isLoading) {
return <Loader />;
}
if (!appearanceSettingsQuery.data) {
return <ErrorAlert error={appearanceSettingsQuery.error} />;
}
return (
<>
<Section
@@ -30,7 +45,9 @@ export const AppearancePage: FC = () => {
<AppearanceForm
isUpdating={updateAppearanceSettingsMutation.isLoading}
error={updateAppearanceSettingsMutation.error}
initialValues={{ theme_preference: me.theme_preference }}
initialValues={{
theme_preference: appearanceSettingsQuery.data.theme_preference,
}}
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
/>
</Section>
@@ -1,15 +1,13 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { ThemeProvider } from "contexts/ThemeProvider";
import dayjs from "dayjs";
import { http, HttpResponse } from "msw";
import type { FC } from "react";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import { useQuery } from "react-query";
import { MockTemplate, MockWorkspace } from "testHelpers/entities";
import { render } from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
@@ -45,16 +43,7 @@ const renderScheduleControls = async () => {
});
}),
);
render(
<ThemeProvider>
<QueryClientProvider client={new QueryClient()}>
<RouterProvider
router={createMemoryRouter([{ path: "/", element: <Wrapper /> }])}
/>
</QueryClientProvider>
<GlobalSnackbar />
</ThemeProvider>,
);
render(<Wrapper />);
await screen.findByTestId("schedule-controls");
expect(screen.getByText("Stop in 3 hours")).toBeInTheDocument();
};
+4 -3
View File
@@ -495,7 +495,6 @@ export const MockUser: TypesGen.User = {
avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
last_seen_at: "",
login_type: "password",
theme_preference: "",
name: "",
};
@@ -516,7 +515,6 @@ export const MockUser2: TypesGen.User = {
avatar_url: "",
last_seen_at: "2022-09-14T19:12:21Z",
login_type: "oidc",
theme_preference: "",
name: "Mock User The Second",
};
@@ -532,10 +530,13 @@ export const SuspendedMockUser: TypesGen.User = {
avatar_url: "",
last_seen_at: "",
login_type: "password",
theme_preference: "",
name: "",
};
export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
theme_preference: "dark",
};
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
organization_id: MockOrganization.id,
user_id: MockUser.id,
+3
View File
@@ -162,6 +162,9 @@ export const handlers = [
http.get("/api/v2/users/me", () => {
return HttpResponse.json(M.MockUser);
}),
http.get("/api/v2/users/me/appearance", () => {
return HttpResponse.json(M.MockUserAppearanceSettings);
}),
http.get("/api/v2/users/me/keys", () => {
return HttpResponse.json(M.MockAPIKey);
}),
+5 -2
View File
@@ -5,7 +5,7 @@ import {
} from "@testing-library/react";
import { AppProviders } from "App";
import type { ProxyProvider } from "contexts/ProxyContext";
import { ThemeProvider } from "contexts/ThemeProvider";
import { ThemeOverride } from "contexts/ThemeProvider";
import { RequireAuth } from "contexts/auth/RequireAuth";
import { DashboardLayout } from "modules/dashboard/DashboardLayout";
import type { DashboardProvider } from "modules/dashboard/DashboardProvider";
@@ -19,6 +19,7 @@ import {
RouterProvider,
createMemoryRouter,
} from "react-router-dom";
import themes, { DEFAULT_THEME } from "theme";
import { MockUser } from "./entities";
export function createTestQueryClient() {
@@ -245,6 +246,8 @@ export const waitForLoaderToBeRemoved = async (): Promise<void> => {
export const renderComponent = (component: React.ReactElement) => {
return testingLibraryRender(component, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
wrapper: ({ children }) => (
<ThemeOverride theme={themes[DEFAULT_THEME]}>{children}</ThemeOverride>
),
});
};