Skip to content

fix: fix workspace actions options (#13572) #14071

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions enterprise/coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/coder/coder/v2/codersdk"
)

const TimeFormatHHMM = "15:04"

func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Entitlement must be enabled.
Expand Down Expand Up @@ -66,7 +68,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request)
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
UserCanSet: opts.UserCanSet,
Time: opts.Schedule.TimeParsed().Format("15:40"),
Time: opts.Schedule.TimeParsed().Format(TimeFormatHHMM),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})
Expand Down Expand Up @@ -118,7 +120,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
UserCanSet: opts.UserCanSet,
Time: opts.Schedule.TimeParsed().Format("15:40"),
Time: opts.Schedule.TimeParsed().Format(TimeFormatHHMM),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})
Expand Down
17 changes: 11 additions & 6 deletions enterprise/coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)

const TimeFormatHHMM = coderd.TimeFormatHHMM

func TestUserQuietHours(t *testing.T) {
t.Parallel()

Expand All @@ -41,15 +44,17 @@ func TestUserQuietHours(t *testing.T) {

t.Run("OK", func(t *testing.T) {
t.Parallel()

defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *"
// Using 10 for minutes lets us test a format bug in which values greater
// than 5 were causing the API to explode because the time was returned
// incorrectly
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 10 1 * * *"
defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
if time.Until(nextTime) < time.Hour {
// Use a different default schedule instead, because we want to avoid
// the schedule "ticking over" during this test run.
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 13 * * *"
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 10 13 * * *"
defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
}
Expand Down Expand Up @@ -78,7 +83,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
require.False(t, sched1.UserSet)
require.Equal(t, defaultScheduleParsed.TimeParsed().Format("15:40"), sched1.Time)
require.Equal(t, defaultScheduleParsed.TimeParsed().Format(TimeFormatHHMM), sched1.Time)
require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone)
require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second)

Expand All @@ -101,7 +106,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
require.True(t, sched2.UserSet)
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched2.Time)
require.Equal(t, customScheduleParsed.TimeParsed().Format(TimeFormatHHMM), sched2.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second)

Expand All @@ -110,7 +115,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
require.True(t, sched3.UserSet)
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched3.Time)
require.Equal(t, customScheduleParsed.TimeParsed().Format(TimeFormatHHMM), sched3.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)

Expand Down
24 changes: 18 additions & 6 deletions site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
);
};

export const UpdateAndStartButton: FC<ActionButtonProps> = ({
handleAction,
}) => {
return (
<Tooltip title="This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version.">
<TopbarButton
startIcon={<PlayCircleOutlineIcon />}
onClick={() => handleAction()}
>
Update and start&hellip;
</TopbarButton>
</Tooltip>
);
};

export const StopButton: FC<ActionButtonProps> = ({
handleAction,
loading,
Expand Down Expand Up @@ -146,16 +161,13 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
);
};

export const UpdateAndStartButton: FC<ActionButtonProps> = ({
export const UpdateAndRestartButton: FC<ActionButtonProps> = ({
handleAction,
}) => {
return (
<Tooltip title="This template requires automatic updates on workspace startup. Contact your administrator if you want to preserve the template version.">
<TopbarButton
startIcon={<PlayCircleOutlineIcon />}
onClick={() => handleAction()}
>
Update and start&hellip;
<TopbarButton startIcon={<ReplayIcon />} onClick={() => handleAction()}>
Update and restart&hellip;
</TopbarButton>
</Tooltip>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,93 +26,128 @@ export const Running: Story = {
},
};

export const Stopping: Story = {
export const RunningUpdateAvailable: Story = {
name: "Running (Update available)",
args: {
workspace: Mocks.MockStoppingWorkspace,
workspace: {
...Mocks.MockWorkspace,
outdated: true,
},
},
};

export const Stopped: Story = {
export const RunningRequireActiveVersion: Story = {
name: "Running (No required update)",
args: {
workspace: Mocks.MockStoppedWorkspace,
workspace: {
...Mocks.MockWorkspace,
template_require_active_version: true,
},
},
};

export const Canceling: Story = {
export const RunningUpdateRequired: Story = {
name: "Running (Update Required)",
args: {
workspace: Mocks.MockCancelingWorkspace,
workspace: {
...Mocks.MockWorkspace,
template_require_active_version: true,
outdated: true,
},
},
};

export const Canceled: Story = {
export const Stopping: Story = {
args: {
workspace: Mocks.MockCanceledWorkspace,
workspace: Mocks.MockStoppingWorkspace,
},
};

export const Deleting: Story = {
export const Stopped: Story = {
args: {
workspace: Mocks.MockDeletingWorkspace,
workspace: Mocks.MockStoppedWorkspace,
},
};

export const Deleted: Story = {
export const StoppedUpdateAvailable: Story = {
name: "Stopped (Update available)",
args: {
workspace: Mocks.MockDeletedWorkspace,
workspace: {
...Mocks.MockStoppedWorkspace,
outdated: true,
},
},
};

export const Outdated: Story = {
export const StoppedRequireActiveVersion: Story = {
name: "Stopped (No required update)",
args: {
workspace: {
...Mocks.MockStoppedWorkspace,
template_require_active_version: true,
},
},
};

export const StoppedUpdateRequired: Story = {
name: "Stopped (Update Required)",
args: {
workspace: {
...Mocks.MockStoppedWorkspace,
template_require_active_version: true,
outdated: true,
},
},
};

export const Updating: Story = {
args: {
workspace: Mocks.MockOutdatedWorkspace,
isUpdating: true,
},
};

export const Failed: Story = {
export const Restarting: Story = {
args: {
workspace: Mocks.MockFailedWorkspace,
workspace: Mocks.MockStoppingWorkspace,
isRestarting: true,
},
};

export const FailedWithDebug: Story = {
export const Canceling: Story = {
args: {
workspace: Mocks.MockFailedWorkspace,
canDebug: true,
workspace: Mocks.MockCancelingWorkspace,
},
};

export const Updating: Story = {
export const Deleting: Story = {
args: {
isUpdating: true,
workspace: Mocks.MockOutdatedWorkspace,
workspace: Mocks.MockDeletingWorkspace,
},
};

export const RequireActiveVersionStarted: Story = {
export const Deleted: Story = {
args: {
workspace: Mocks.MockOutdatedRunningWorkspaceRequireActiveVersion,
canChangeVersions: false,
workspace: Mocks.MockDeletedWorkspace,
},
};

export const RequireActiveVersionStopped: Story = {
export const Outdated: Story = {
args: {
workspace: Mocks.MockOutdatedStoppedWorkspaceRequireActiveVersion,
canChangeVersions: false,
workspace: Mocks.MockOutdatedWorkspace,
},
};

export const AlwaysUpdateStarted: Story = {
export const Failed: Story = {
args: {
workspace: Mocks.MockOutdatedRunningWorkspaceAlwaysUpdate,
canChangeVersions: true,
workspace: Mocks.MockFailedWorkspace,
},
};

export const AlwaysUpdateStopped: Story = {
export const FailedWithDebug: Story = {
args: {
workspace: Mocks.MockOutdatedStoppedWorkspaceAlwaysUpdate,
canChangeVersions: true,
workspace: Mocks.MockFailedWorkspace,
canDebug: true,
},
};

Expand All @@ -125,6 +160,7 @@ export const CancelShownForOwner: Story = {
isOwner: true,
},
};

export const CancelShownForUser: Story = {
args: {
workspace: Mocks.MockStartingWorkspace,
Expand Down
43 changes: 15 additions & 28 deletions site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ActivateButton,
FavoriteButton,
UpdateAndStartButton,
UpdateAndRestartButton,
} from "./Buttons";
import { type ActionType, abilitiesByWorkspaceStatus } from "./constants";
import { DebugButton } from "./DebugButton";
Expand Down Expand Up @@ -85,12 +86,12 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({

const mustUpdate = mustUpdateWorkspace(workspace, canChangeVersions);
const tooltipText = getTooltipText(workspace, mustUpdate, canChangeVersions);
const canBeUpdated = workspace.outdated && canAcceptJobs;

// A mapping of button type to the corresponding React component
const buttonMapping: Record<ActionType, ReactNode> = {
update: <UpdateButton handleAction={handleUpdate} />,
updateAndStart: <UpdateAndStartButton handleAction={handleUpdate} />,
updateAndRestart: <UpdateAndRestartButton handleAction={handleUpdate} />,
updating: <UpdateButton loading handleAction={handleUpdate} />,
start: (
<StartButton
Expand Down Expand Up @@ -148,43 +149,29 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
enableBuildParameters={workspace.latest_build.transition === "start"}
/>
),
toggleFavorite: (
<FavoriteButton
workspaceID={workspace.id}
isFavorite={workspace.favorite}
onToggle={handleToggleFavorite}
/>
),
};

return (
<div
css={{ display: "flex", alignItems: "center", gap: 8 }}
data-testid="workspace-actions"
>
{canBeUpdated && (
<>
{isUpdating
? buttonMapping.updating
: workspace.template_require_active_version
? buttonMapping.updateAndStart
: buttonMapping.update}
</>
)}

{!canBeUpdated &&
workspace.template_require_active_version &&
buttonMapping.start}

{isRestarting
? buttonMapping.restarting
: actions.map((action) => (
<Fragment key={action}>{buttonMapping[action]}</Fragment>
))}
{/* Restarting must be handled separately, because it otherwise would appear as stopping */}
{isUpdating
? buttonMapping.updating
: isRestarting
? buttonMapping.restarting
: actions.map((action) => (
<Fragment key={action}>{buttonMapping[action]}</Fragment>
))}

{showCancel && <CancelButton handleAction={handleCancel} />}

{buttonMapping.toggleFavorite}
<FavoriteButton
workspaceID={workspace.id}
isFavorite={workspace.favorite}
onToggle={handleToggleFavorite}
/>

<MoreMenu>
<MoreMenuTrigger>
Expand Down
Loading
Loading