diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx index e9ce8559a8803..66ca469919c88 100644 --- a/site/src/components/Alert/Alert.tsx +++ b/site/src/components/Alert/Alert.tsx @@ -1,12 +1,11 @@ -import { useState, FC, ReactNode, PropsWithChildren } from "react" +import { useState, FC, ReactNode } from "react" import Collapse from "@mui/material/Collapse" // eslint-disable-next-line no-restricted-imports -- It is the base component import MuiAlert, { AlertProps as MuiAlertProps } from "@mui/material/Alert" import Button from "@mui/material/Button" import Box from "@mui/material/Box" -export interface AlertProps extends PropsWithChildren { - severity: MuiAlertProps["severity"] +export type AlertProps = MuiAlertProps & { actions?: ReactNode dismissible?: boolean onRetry?: () => void @@ -20,12 +19,14 @@ export const Alert: FC = ({ dismissible, severity, onDismiss, + ...alertProps }) => { const [open, setOpen] = useState(true) return ( diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index ac64018358e17..3afcc927624a5 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -1,19 +1,56 @@ import { action } from "@storybook/addon-actions" -import { Story } from "@storybook/react" +import { Meta, StoryObj } from "@storybook/react" import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata" import { ProvisionerJobLog } from "api/typesGenerated" import * as Mocks from "testHelpers/entities" -import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace" +import { Workspace, WorkspaceErrors } from "./Workspace" import { withReactContext } from "storybook-react-context" import EventSource from "eventsourcemock" import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext" import { DashboardProviderContext } from "components/Dashboard/DashboardProvider" -export default { +const MockedAppearance = { + config: Mocks.MockAppearance, + preview: false, + setPreview: () => null, + save: () => null, +} + +const meta: Meta = { title: "components/Workspace", component: Workspace, - argTypes: {}, decorators: [ + (Story) => ( + + { + return + }, + setProxy: () => { + return + }, + refetchProxyLatencies: () => { + return + }, + }} + > + + + + ), withReactContext({ Context: WatchAgentMetadataContext, initialState: (_: string): EventSource => { @@ -23,172 +60,149 @@ export default { }), ], } +export default meta +type Story = StoryObj -const MockedAppearance = { - config: Mocks.MockAppearance, - preview: false, - setPreview: () => null, - save: () => null, -} - -const Template: Story = (args) => ( - - { - return - }, - setProxy: () => { - return - }, - refetchProxyLatencies: () => { - return - }, - }} - > - - - -) - -export const Running = Template.bind({}) -Running.args = { - scheduleProps: { - onDeadlineMinus: () => { - // do nothing, this is just for storybook - }, - onDeadlinePlus: () => { - // do nothing, this is just for storybook - }, - maxDeadlineDecrease: 0, - maxDeadlineIncrease: 24, +export const Running: Story = { + args: { + scheduleProps: { + onDeadlineMinus: () => { + // do nothing, this is just for storybook + }, + onDeadlinePlus: () => { + // do nothing, this is just for storybook + }, + maxDeadlineDecrease: 0, + maxDeadlineIncrease: 24, + }, + workspace: Mocks.MockWorkspace, + handleStart: action("start"), + handleStop: action("stop"), + resources: [ + Mocks.MockWorkspaceResource, + Mocks.MockWorkspaceResource2, + Mocks.MockWorkspaceResource3, + ], + builds: [Mocks.MockWorkspaceBuild], + canUpdateWorkspace: true, + workspaceErrors: {}, + buildInfo: Mocks.MockBuildInfo, + template: Mocks.MockTemplate, }, - workspace: Mocks.MockWorkspace, - handleStart: action("start"), - handleStop: action("stop"), - resources: [ - Mocks.MockWorkspaceResource, - Mocks.MockWorkspaceResource2, - Mocks.MockWorkspaceResource3, - ], - builds: [Mocks.MockWorkspaceBuild], - canUpdateWorkspace: true, - workspaceErrors: {}, - buildInfo: Mocks.MockBuildInfo, - template: Mocks.MockTemplate, } -export const WithoutUpdateAccess = Template.bind({}) -WithoutUpdateAccess.args = { - ...Running.args, - canUpdateWorkspace: false, +export const WithoutUpdateAccess: Story = { + args: { + ...Running.args, + canUpdateWorkspace: false, + }, } -export const Starting = Template.bind({}) -Starting.args = { - ...Running.args, - workspace: Mocks.MockStartingWorkspace, +export const Starting: Story = { + args: { + ...Running.args, + workspace: Mocks.MockStartingWorkspace, + }, } -export const Stopped = Template.bind({}) -Stopped.args = { - ...Running.args, - workspace: Mocks.MockStoppedWorkspace, +export const Stopped: Story = { + args: { + ...Running.args, + workspace: Mocks.MockStoppedWorkspace, + }, } -export const Stopping = Template.bind({}) -Stopping.args = { - ...Running.args, - workspace: Mocks.MockStoppingWorkspace, +export const Stopping: Story = { + args: { + ...Running.args, + workspace: Mocks.MockStoppingWorkspace, + }, } -export const Failed = Template.bind({}) -Failed.args = { - ...Running.args, - workspace: Mocks.MockFailedWorkspace, - workspaceErrors: { - [WorkspaceErrors.BUILD_ERROR]: Mocks.mockApiError({ - message: "A workspace build is already active.", - }), +export const Failed: Story = { + args: { + ...Running.args, + workspace: Mocks.MockFailedWorkspace, + workspaceErrors: { + [WorkspaceErrors.BUILD_ERROR]: Mocks.mockApiError({ + message: "A workspace build is already active.", + }), + }, }, } -export const FailedWithLogs = Template.bind({}) -FailedWithLogs.args = { - ...Running.args, - workspace: { - ...Mocks.MockFailedWorkspace, - latest_build: { - ...Mocks.MockFailedWorkspace.latest_build, - job: { - ...Mocks.MockFailedWorkspace.latest_build.job, - error: - "recv workspace provision: plan terraform: terraform plan: exit status 1", +export const FailedWithLogs: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockFailedWorkspace, + latest_build: { + ...Mocks.MockFailedWorkspace.latest_build, + job: { + ...Mocks.MockFailedWorkspace.latest_build.job, + error: + "recv workspace provision: plan terraform: terraform plan: exit status 1", + }, }, }, + failedBuildLogs: makeFailedBuildLogs(), }, - failedBuildLogs: makeFailedBuildLogs(), } -export const Deleting = Template.bind({}) -Deleting.args = { - ...Running.args, - workspace: Mocks.MockDeletingWorkspace, +export const Deleting: Story = { + args: { + ...Running.args, + workspace: Mocks.MockDeletingWorkspace, + }, } -export const Deleted = Template.bind({}) -Deleted.args = { - ...Running.args, - workspace: Mocks.MockDeletedWorkspace, +export const Deleted: Story = { + args: { + ...Running.args, + workspace: Mocks.MockDeletedWorkspace, + }, } -export const Canceling = Template.bind({}) -Canceling.args = { - ...Running.args, - workspace: Mocks.MockCancelingWorkspace, +export const Canceling: Story = { + args: { + ...Running.args, + workspace: Mocks.MockCancelingWorkspace, + }, } -export const Canceled = Template.bind({}) -Canceled.args = { - ...Running.args, - workspace: Mocks.MockCanceledWorkspace, +export const Canceled: Story = { + args: { + ...Running.args, + workspace: Mocks.MockCanceledWorkspace, + }, } -export const Outdated = Template.bind({}) -Outdated.args = { - ...Running.args, - workspace: Mocks.MockOutdatedWorkspace, +export const Outdated: Story = { + args: { + ...Running.args, + workspace: Mocks.MockOutdatedWorkspace, + }, } -export const GetBuildsError = Template.bind({}) -GetBuildsError.args = { - ...Running.args, - workspaceErrors: { - [WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.mockApiError({ - message: "There is a problem fetching builds.", - }), +export const GetBuildsError: Story = { + args: { + ...Running.args, + workspaceErrors: { + [WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.mockApiError({ + message: "There is a problem fetching builds.", + }), + }, }, } -export const CancellationError = Template.bind({}) -CancellationError.args = { - ...Failed.args, - workspaceErrors: { - [WorkspaceErrors.CANCELLATION_ERROR]: Mocks.mockApiError({ - message: "Job could not be canceled.", - }), +export const CancellationError: Story = { + args: { + ...Failed.args, + workspaceErrors: { + [WorkspaceErrors.CANCELLATION_ERROR]: Mocks.mockApiError({ + message: "Job could not be canceled.", + }), + }, }, } @@ -683,8 +697,9 @@ function makeFailedBuildLogs(): ProvisionerJobLog[] { ] } -export const WithDeprecatedParameters = Template.bind({}) -WithDeprecatedParameters.args = { - ...Running.args, - templateWarnings: ["DEPRECATED_PARAMETERS"], +export const WithDeprecatedParameters: Story = { + args: { + ...Running.args, + templateWarnings: ["DEPRECATED_PARAMETERS"], + }, } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 194b3a6d94314..f874b606658ff 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -2,7 +2,7 @@ import MenuItem from "@mui/material/MenuItem" import Menu from "@mui/material/Menu" import { makeStyles } from "@mui/styles" import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined" -import { FC, ReactNode, useRef, useState } from "react" +import { FC, Fragment, ReactNode, useRef, useState } from "react" import { WorkspaceStatus } from "api/typesGenerated" import { ActionLoadingButton, @@ -102,7 +102,10 @@ export const WorkspaceActions: FC = ({ ? buttonMapping[ButtonTypesEnum.updating] : buttonMapping[ButtonTypesEnum.update])} {isRestarting && buttonMapping[ButtonTypesEnum.restarting]} - {!isRestarting && actionsByStatus.map((action) => buttonMapping[action])} + {!isRestarting && + actionsByStatus.map((action) => ( + {buttonMapping[action]} + ))} {canCancel && }
{ const now = useRef(new Date()) @@ -35,45 +37,31 @@ export const WorkspacePage: FC = () => { username: string workspace: string } + const orgId = useOrganizationId() const [workspaceState, workspaceSend] = useMachine(workspaceMachine, { context: { + orgId, workspaceName, username, }, }) - const { - workspace, - getWorkspaceError, - getTemplateWarning, - getTemplateParametersWarning, - checkPermissionsError, - } = workspaceState.context + const { workspace, error } = workspaceState.context const [quotaState] = useMachine(quotaMachine, { context: { username } }) const { getQuotaError } = quotaState.context - const styles = useStyles() const failedBuildLogs = useFailedBuildLogs(workspace) + const pageError = error ?? getQuotaError return ( - -
- {Boolean(getWorkspaceError) && ( - - )} - {Boolean(getTemplateWarning) && ( - - )} - {Boolean(getTemplateParametersWarning) && ( - - )} - {Boolean(checkPermissionsError) && ( - - )} - {Boolean(getQuotaError) && } -
+ + + + { ) } -const useStyles = makeStyles((theme) => ({ - error: { - margin: theme.spacing(2), - }, -})) - export default WorkspacePage diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 790404a040297..4b1b37091c474 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -1,6 +1,6 @@ /* eslint-disable eslint-comments/disable-enable-pair -- ignore */ /* eslint-disable @typescript-eslint/no-explicit-any -- We don't care about any here */ -import { ComponentMeta, Story } from "@storybook/react" +import { Meta, StoryObj } from "@storybook/react" import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils" import dayjs from "dayjs" import uniqueId from "lodash/uniqueId" @@ -16,11 +16,9 @@ import { MockEntitlementsWithScheduling, MockExperiments, MockUser, + mockApiError, } from "testHelpers/entities" -import { - WorkspacesPageView, - WorkspacesPageViewProps, -} from "./WorkspacesPageView" +import { WorkspacesPageView } from "./WorkspacesPageView" import { DashboardProviderContext } from "components/Dashboard/DashboardProvider" import { action } from "@storybook/addon-actions" import { ComponentProps } from "react" @@ -102,49 +100,62 @@ const defaultFilterProps = { }, } as ComponentProps["filterProps"] -export default { +const meta: Meta = { title: "pages/WorkspacesPageView", component: WorkspacesPageView, args: { limit: DEFAULT_RECORDS_PER_PAGE, filterProps: defaultFilterProps, }, -} as ComponentMeta + decorators: [ + (Story) => ( + + + + ), + ], +} -const Template: Story = (args) => ( - - - -) +export default meta +type Story = StoryObj -export const AllStates = Template.bind({}) -AllStates.args = { - workspaces: allWorkspaces, - count: allWorkspaces.length, +export const AllStates: Story = { + args: { + workspaces: allWorkspaces, + count: allWorkspaces.length, + }, } -export const OwnerHasNoWorkspaces = Template.bind({}) -OwnerHasNoWorkspaces.args = { - workspaces: [], - count: 0, +export const OwnerHasNoWorkspaces: Story = { + args: { + workspaces: [], + count: 0, + }, } -export const NoSearchResults = Template.bind({}) -NoSearchResults.args = { - workspaces: [], - filterProps: { - ...defaultFilterProps, - filter: { - ...defaultFilterProps.filter, - query: "searchwithnoresults", +export const NoSearchResults: Story = { + args: { + workspaces: [], + filterProps: { + ...defaultFilterProps, + filter: { + ...defaultFilterProps.filter, + query: "searchwithnoresults", + }, }, + count: 0, + }, +} + +export const Error: Story = { + args: { + error: mockApiError({ message: "Something went wrong" }), }, - count: 0, } diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index ee5bf164eaf64..12f39c86694e3 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -9,7 +9,6 @@ import { displayError, displaySuccess, } from "../../components/GlobalSnackbar/utils" -import { AxiosError } from "axios" const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => { // Cloning builds to not change the origin object with the sort() @@ -41,29 +40,22 @@ const moreBuildsAvailable = ( return event.data.latest_build.updated_at !== latestBuildInTimeline.updated_at } -const Language = { - getTemplateWarning: - "Error updating workspace: latest template could not be fetched.", - getTemplateParametersWarning: - "Error updating workspace: template parameters could not be fetched.", - buildError: "Workspace action failed.", -} - type Permissions = Record, boolean> export interface WorkspaceContext { // Initial data + orgId: string username: string workspaceName: string + + error?: unknown // our server side events instance eventSource?: EventSource workspace?: TypesGen.Workspace template?: TypesGen.Template + permissions?: Permissions templateVersion?: TypesGen.TemplateVersion build?: TypesGen.WorkspaceBuild - getWorkspaceError?: AxiosError - getTemplateWarning: Error | unknown - getTemplateParametersWarning: Error | unknown // Builds builds?: TypesGen.WorkspaceBuild[] getBuildsError?: Error | unknown @@ -72,9 +64,6 @@ export interface WorkspaceContext { buildError?: Error | unknown cancellationMessage?: Types.Message cancellationError?: Error | unknown - // permissions - permissions?: Permissions - checkPermissionsError?: Error | unknown // debug createBuildLogLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"] // SSH Config @@ -152,14 +141,8 @@ export const workspaceMachine = createMachine( context: {} as WorkspaceContext, events: {} as WorkspaceEvent, services: {} as { - getWorkspace: { - data: TypesGen.Workspace - } - getTemplate: { - data: TypesGen.Template - } - getTemplateVersion: { - data: TypesGen.TemplateVersion + loadInitialWorkspaceData: { + data: Awaited> } getTemplateParameters: { data: TypesGen.TemplateVersionParameter[] @@ -188,98 +171,26 @@ export const workspaceMachine = createMachine( getBuilds: { data: TypesGen.WorkspaceBuild[] } - checkPermissions: { - data: TypesGen.AuthorizationResponse - } getSSHPrefix: { data: TypesGen.SSHConfigResponse } }, }, - initial: "gettingWorkspace", + initial: "loadInitialData", states: { - gettingWorkspace: { + loadInitialData: { entry: ["clearContext"], invoke: { - src: "getWorkspace", - id: "getWorkspace", - onDone: [ - { - actions: ["assignWorkspace", "clearGetWorkspaceError"], - target: "gettingTemplate", - }, - ], - onError: [ - { - actions: "assignGetWorkspaceError", - target: "error", - }, - ], - }, - tags: "loading", - }, - gettingTemplate: { - invoke: { - src: "getTemplate", - id: "getTemplate", - onDone: [ - { - actions: ["assignTemplate", "clearGetTemplateWarning"], - target: "gettingTemplateVersion", - }, - ], - onError: [ - { - actions: [ - "assignGetTemplateWarning", - "displayGetTemplateWarning", - ], - target: "error", - }, - ], - }, - tags: "loading", - }, - gettingTemplateVersion: { - invoke: { - src: "getTemplateVersion", - id: "getTemplateVersion", - onDone: [ - { - actions: ["assignTemplateVersion", "clearGetTemplateWarning"], - target: "gettingPermissions", - }, - ], - onError: [ - { - actions: [ - "assignGetTemplateWarning", - "displayGetTemplateWarning", - ], - target: "error", - }, - ], - }, - tags: "loading", - }, - gettingPermissions: { - invoke: { - src: "checkPermissions", - id: "checkPermissions", - onDone: [ - { - actions: ["assignPermissions", "clearGetPermissionsError"], - target: "ready", - }, - ], + src: "loadInitialWorkspaceData", + id: "loadInitialWorkspaceData", + onDone: [{ target: "ready", actions: ["assignInitialData"] }], onError: [ { - actions: "assignGetPermissionsError", + actions: "assignError", target: "error", }, ], }, - tags: "loading", }, ready: { type: "parallel", @@ -572,30 +483,14 @@ export const workspaceMachine = createMachine( permissions: undefined, eventSource: undefined, }), - assignWorkspace: assign({ - workspace: (_, event) => event.data, + assignInitialData: assign({ + workspace: (_, event) => event.data.workspace, + template: (_, event) => event.data.template, + templateVersion: (_, event) => event.data.templateVersion, + permissions: (_, event) => event.data.permissions as Permissions, }), - assignGetWorkspaceError: assign({ - getWorkspaceError: (_, event) => event.data as AxiosError, - }), - clearGetWorkspaceError: (context) => - assign({ ...context, getWorkspaceError: undefined }), - assignTemplate: assign({ - template: (_, event) => event.data, - }), - assignTemplateVersion: assign({ - templateVersion: (_, event) => event.data, - }), - assignPermissions: assign({ - // Setting event.data as Permissions to be more stricted. So we know - // what permissions we asked for. - permissions: (_, event) => event.data as Permissions, - }), - assignGetPermissionsError: assign({ - checkPermissionsError: (_, event) => event.data, - }), - clearGetPermissionsError: assign({ - checkPermissionsError: (_) => undefined, + assignError: assign({ + error: (_, event) => event.data, }), assignBuild: assign({ build: (_, event) => event.data, @@ -637,15 +532,6 @@ export const workspaceMachine = createMachine( logWatchWorkspaceWarning: (_, event) => { console.error("Watch workspace error:", event) }, - assignGetTemplateWarning: assign({ - getTemplateWarning: (_, event) => event.data, - }), - displayGetTemplateWarning: () => { - displayError(Language.getTemplateWarning) - }, - clearGetTemplateWarning: assign({ - getTemplateWarning: (_) => undefined, - }), // Timeline assignBuilds: assign({ builds: (_, event) => event.data, @@ -706,27 +592,7 @@ export const workspaceMachine = createMachine( Boolean(templateVersionIdToChange), }, services: { - getWorkspace: async ({ username, workspaceName }) => { - return await API.getWorkspaceByOwnerAndName(username, workspaceName, { - include_deleted: true, - }) - }, - getTemplate: async (context) => { - if (context.workspace) { - return await API.getTemplate(context.workspace.template_id) - } else { - throw Error("Cannot get template without workspace") - } - }, - getTemplateVersion: async (context) => { - if (context.template) { - return await API.getTemplateVersion( - context.template.active_version_id, - ) - } else { - throw Error("Cannot get template version without template") - } - }, + loadInitialWorkspaceData, updateWorkspace: ({ workspace }, { buildParameters }) => async (send) => { @@ -846,17 +712,6 @@ export const workspaceMachine = createMachine( throw Error("Cannot get builds without id") } }, - checkPermissions: async ({ workspace, template }) => { - if (!workspace) { - throw new Error("Workspace is not set") - } - if (!template) { - throw new Error("Template is not set") - } - return await API.checkAuthorization({ - checks: permissionsToCheck(workspace, template), - }) - }, scheduleBannerMachine: workspaceScheduleBannerMachine, getSSHPrefix: async () => { return API.getDeploymentSSHConfig() @@ -864,3 +719,31 @@ export const workspaceMachine = createMachine( }, }, ) + +async function loadInitialWorkspaceData({ + orgId, + username, + workspaceName, +}: WorkspaceContext) { + const workspace = await API.getWorkspaceByOwnerAndName( + username, + workspaceName, + { + include_deleted: true, + }, + ) + const template = await API.getTemplateByName(orgId, workspace.template_name) + const [templateVersion, permissions] = await Promise.all([ + API.getTemplateVersion(template.active_version_id), + API.checkAuthorization({ + checks: permissionsToCheck(workspace, template), + }), + ]) + + return { + workspace, + template, + templateVersion, + permissions, + } +}