Skip to content

Commit cbfbef5

Browse files
authored
Merge branch 'main' into matifali/move-refrences
2 parents 664d474 + 76722a7 commit cbfbef5

17 files changed

+258
-80
lines changed

coderd/appearance/appearance.go

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,61 @@ package appearance
22

33
import (
44
"context"
5+
"fmt"
6+
"strings"
57

8+
"github.com/coder/coder/v2/buildinfo"
69
"github.com/coder/coder/v2/codersdk"
710
)
811

912
type Fetcher interface {
1013
Fetch(ctx context.Context) (codersdk.AppearanceConfig, error)
1114
}
1215

13-
var DefaultSupportLinks = []codersdk.LinkConfig{
14-
{
15-
Name: "Documentation",
16-
Target: "https://coder.com/docs/coder-oss",
17-
Icon: "docs",
18-
},
19-
{
20-
Name: "Report a bug",
21-
Target: "https://github.com/coder/coder/issues/new?labels=needs+grooming&body={CODER_BUILD_INFO}",
22-
Icon: "bug",
23-
},
24-
{
25-
Name: "Join the Coder Discord",
26-
Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer",
27-
Icon: "chat",
28-
},
29-
{
30-
Name: "Star the Repo",
31-
Target: "https://github.com/coder/coder",
32-
Icon: "star",
33-
},
16+
func DefaultSupportLinks(docsURL string) []codersdk.LinkConfig {
17+
version := buildinfo.Version()
18+
if docsURL == "" {
19+
docsURL = "https://coder.com/docs/@" + strings.Split(version, "-")[0]
20+
}
21+
buildInfo := fmt.Sprintf("Version: [`%s`](%s)", version, buildinfo.ExternalURL())
22+
23+
return []codersdk.LinkConfig{
24+
{
25+
Name: "Documentation",
26+
Target: docsURL,
27+
Icon: "docs",
28+
},
29+
{
30+
Name: "Report a bug",
31+
Target: "https://github.com/coder/coder/issues/new?labels=needs+grooming&body=" + buildInfo,
32+
Icon: "bug",
33+
},
34+
{
35+
Name: "Join the Coder Discord",
36+
Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer",
37+
Icon: "chat",
38+
},
39+
{
40+
Name: "Star the Repo",
41+
Target: "https://github.com/coder/coder",
42+
Icon: "star",
43+
},
44+
}
3445
}
3546

36-
type AGPLFetcher struct{}
47+
type AGPLFetcher struct {
48+
docsURL string
49+
}
3750

38-
func (AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) {
51+
func (f AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) {
3952
return codersdk.AppearanceConfig{
4053
AnnouncementBanners: []codersdk.BannerConfig{},
41-
SupportLinks: DefaultSupportLinks,
54+
SupportLinks: DefaultSupportLinks(f.docsURL),
4255
}, nil
4356
}
4457

45-
var DefaultFetcher Fetcher = AGPLFetcher{}
58+
func NewDefaultFetcher(docsURL string) Fetcher {
59+
return &AGPLFetcher{
60+
docsURL: docsURL,
61+
}
62+
}

coderd/coderd.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,8 @@ func New(options *Options) *API {
475475
dbRolluper: options.DatabaseRolluper,
476476
}
477477

478-
api.AppearanceFetcher.Store(&appearance.DefaultFetcher)
478+
f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String())
479+
api.AppearanceFetcher.Store(&f)
479480
api.PortSharer.Store(&portsharing.DefaultPortSharer)
480481
buildInfo := codersdk.BuildInfoResponse{
481482
ExternalURL: buildinfo.ExternalURL(),

enterprise/coderd/appearance.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
4444
type appearanceFetcher struct {
4545
database database.Store
4646
supportLinks []codersdk.LinkConfig
47+
docsURL string
48+
coderVersion string
4749
}
4850

49-
func newAppearanceFetcher(store database.Store, links []codersdk.LinkConfig) agpl.Fetcher {
51+
func newAppearanceFetcher(store database.Store, links []codersdk.LinkConfig, docsURL, coderVersion string) agpl.Fetcher {
5052
return &appearanceFetcher{
5153
database: store,
5254
supportLinks: links,
55+
docsURL: docsURL,
56+
coderVersion: coderVersion,
5357
}
5458
}
5559

@@ -90,7 +94,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
9094
ApplicationName: applicationName,
9195
LogoURL: logoURL,
9296
AnnouncementBanners: []codersdk.BannerConfig{},
93-
SupportLinks: agpl.DefaultSupportLinks,
97+
SupportLinks: agpl.DefaultSupportLinks(f.docsURL),
9498
}
9599

96100
if announcementBannersJSON != "" {

enterprise/coderd/appearance_test.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"net/http"
7+
"net/url"
78
"testing"
89

910
"github.com/stretchr/testify/require"
@@ -229,6 +230,26 @@ func TestCustomSupportLinks(t *testing.T) {
229230
require.Equal(t, supportLinks, appr.SupportLinks)
230231
}
231232

233+
func TestDefaultSupportLinksWithCustomDocsUrl(t *testing.T) {
234+
t.Parallel()
235+
236+
// Don't need to set the license, as default links are passed without it.
237+
testURLRawString := "http://google.com"
238+
testURL, err := url.Parse(testURLRawString)
239+
require.NoError(t, err)
240+
cfg := coderdtest.DeploymentValues(t)
241+
cfg.DocsURL = *serpent.URLOf(testURL)
242+
adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true, Options: &coderdtest.Options{DeploymentValues: cfg}})
243+
anotherClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
244+
245+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
246+
defer cancel()
247+
248+
appr, err := anotherClient.Appearance(ctx)
249+
require.NoError(t, err)
250+
require.Equal(t, appearance.DefaultSupportLinks(testURLRawString), appr.SupportLinks)
251+
}
252+
232253
func TestDefaultSupportLinks(t *testing.T) {
233254
t.Parallel()
234255

@@ -241,5 +262,5 @@ func TestDefaultSupportLinks(t *testing.T) {
241262

242263
appr, err := anotherClient.Appearance(ctx)
243264
require.NoError(t, err)
244-
require.Equal(t, appearance.DefaultSupportLinks, appr.SupportLinks)
265+
require.Equal(t, appearance.DefaultSupportLinks(""), appr.SupportLinks)
245266
}

enterprise/coderd/coderd.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"sync"
1313
"time"
1414

15+
"github.com/coder/coder/v2/buildinfo"
1516
"github.com/coder/coder/v2/coderd/appearance"
1617
"github.com/coder/coder/v2/coderd/database"
1718
agplportsharing "github.com/coder/coder/v2/coderd/portsharing"
@@ -791,10 +792,13 @@ func (api *API) updateEntitlements(ctx context.Context) error {
791792
f := newAppearanceFetcher(
792793
api.Database,
793794
api.DeploymentValues.Support.Links.Value,
795+
api.DeploymentValues.DocsURL.String(),
796+
buildinfo.Version(),
794797
)
795798
api.AGPL.AppearanceFetcher.Store(&f)
796799
} else {
797-
api.AGPL.AppearanceFetcher.Store(&appearance.DefaultFetcher)
800+
f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String())
801+
api.AGPL.AppearanceFetcher.Store(&f)
798802
}
799803
}
800804

site/site.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ type Options struct {
8484
func New(opts *Options) *Handler {
8585
if opts.AppearanceFetcher == nil {
8686
daf := atomic.Pointer[appearance.Fetcher]{}
87-
daf.Store(&appearance.DefaultFetcher)
87+
f := appearance.NewDefaultFetcher(opts.DocsURL)
88+
daf.Store(&f)
8889
opts.AppearanceFetcher = &daf
8990
}
9091
handler := &Handler{

site/src/modules/dashboard/DashboardProvider.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import type {
1313
import { ErrorAlert } from "components/Alert/ErrorAlert";
1414
import { Loader } from "components/Loader/Loader";
1515
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
16+
import { selectFeatureVisibility } from "./entitlements";
1617

1718
export interface DashboardValue {
1819
entitlements: Entitlements;
1920
experiments: Experiments;
2021
appearance: AppearanceConfig;
2122
organizations: Organization[];
23+
showOrganizations: boolean;
2224
}
2325

2426
export const DashboardContext = createContext<DashboardValue | undefined>(
@@ -52,13 +54,19 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
5254
return <Loader fullscreen />;
5355
}
5456

57+
const hasMultipleOrganizations = organizationsQuery.data.length > 1;
58+
const organizationsEnabled =
59+
experimentsQuery.data.includes("multi-organization") &&
60+
selectFeatureVisibility(entitlementsQuery.data).multiple_organizations;
61+
5562
return (
5663
<DashboardContext.Provider
5764
value={{
5865
entitlements: entitlementsQuery.data,
5966
experiments: experimentsQuery.data,
6067
appearance: appearanceQuery.data,
6168
organizations: organizationsQuery.data,
69+
showOrganizations: hasMultipleOrganizations || organizationsEnabled,
6270
}}
6371
>
6472
{children}

site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
9393
<Divider />
9494
{supportLinks.map((link) => (
9595
<a
96-
href={includeBuildInfo(link.target, buildInfo)}
96+
href={link.target}
9797
key={link.name}
9898
target="_blank"
9999
rel="noreferrer"
@@ -177,18 +177,6 @@ export const GithubStar: FC<SvgIconProps> = (props) => (
177177
</svg>
178178
);
179179

180-
const includeBuildInfo = (
181-
href: string,
182-
buildInfo?: TypesGen.BuildInfoResponse,
183-
): string => {
184-
return href.replace(
185-
"{CODER_BUILD_INFO}",
186-
`${encodeURIComponent(
187-
`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})`,
188-
)}`,
189-
);
190-
};
191-
192180
const styles = {
193181
info: (theme) => [
194182
theme.typography.body2 as CSSObject,

site/src/modules/navigation.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import { useEffectEvent } from "hooks/hookPolyfills";
66
import type { DashboardValue } from "./dashboard/DashboardProvider";
7-
import { selectFeatureVisibility } from "./dashboard/entitlements";
87
import { useDashboard } from "./dashboard/useDashboard";
98

109
type LinkThunk = (state: DashboardValue) => string;
@@ -27,13 +26,7 @@ export const linkToUsers = withFilter("/users", "status:active");
2726

2827
export const linkToTemplate =
2928
(organizationName: string, templateName: string): LinkThunk =>
30-
(dashboard) => {
31-
const hasMultipleOrganizations = dashboard.organizations.length > 1;
32-
const organizationsEnabled =
33-
dashboard.experiments.includes("multi-organization") &&
34-
selectFeatureVisibility(dashboard.entitlements).multiple_organizations;
35-
36-
return hasMultipleOrganizations || organizationsEnabled
29+
(dashboard) =>
30+
dashboard.showOrganizations
3731
? `/templates/${organizationName}/${templateName}`
3832
: `/templates/${templateName}`;
39-
};

site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayou
1313
import { Sidebar } from "./Sidebar";
1414

1515
type OrganizationSettingsValue = {
16-
organizations: Organization[] | undefined;
16+
organizations: Organization[];
1717
};
1818

1919
export const useOrganizationSettings = (): OrganizationSettingsValue => {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { waitFor } from "@testing-library/react";
2+
import { API } from "api/api";
3+
import * as M from "testHelpers/entities";
4+
import { renderWithAuth } from "testHelpers/renderHelpers";
5+
import { TemplateRedirectController } from "./TemplateRedirectController";
6+
7+
const renderTemplateRedirectController = (route: string) => {
8+
return renderWithAuth(<TemplateRedirectController />, {
9+
route,
10+
path: "/templates/:organization?/:template",
11+
});
12+
};
13+
14+
it("redirects from multi-org to single-org", async () => {
15+
const { router } = renderTemplateRedirectController(
16+
`/templates/${M.MockTemplate.organization_name}/${M.MockTemplate.name}`,
17+
);
18+
19+
await waitFor(() =>
20+
expect(router.state.location.pathname).toEqual(
21+
`/templates/${M.MockTemplate.name}`,
22+
),
23+
);
24+
});
25+
26+
it("redirects from single-org to multi-org", async () => {
27+
jest
28+
.spyOn(API, "getOrganizations")
29+
.mockResolvedValueOnce([M.MockDefaultOrganization, M.MockOrganization2]);
30+
31+
const { router } = renderTemplateRedirectController(
32+
`/templates/${M.MockTemplate.name}`,
33+
);
34+
35+
await waitFor(() =>
36+
expect(router.state.location.pathname).toEqual(
37+
`/templates/${M.MockDefaultOrganization.name}/${M.MockTemplate.name}`,
38+
),
39+
);
40+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { FC } from "react";
2+
import { Navigate, Outlet, useLocation, useParams } from "react-router-dom";
3+
import type { Organization } from "api/typesGenerated";
4+
import { useDashboard } from "modules/dashboard/useDashboard";
5+
6+
export const TemplateRedirectController: FC = () => {
7+
const { organizations, showOrganizations } = useDashboard();
8+
const { organization, template } = useParams() as {
9+
organization?: string;
10+
template: string;
11+
};
12+
const location = useLocation();
13+
14+
// We redirect templates without an organization to the default organization,
15+
// as that's likely what any links floating around expect.
16+
if (showOrganizations && !organization) {
17+
const extraPath = removePrefix(location.pathname, `/templates/${template}`);
18+
19+
return (
20+
<Navigate
21+
to={`/templates/${getOrganizationNameByDefault(
22+
organizations,
23+
)}/${template}${extraPath}${location.search}`}
24+
replace
25+
/>
26+
);
27+
}
28+
29+
// `showOrganizations` can only be false when there is a single organization,
30+
// so it's safe to throw away the organization name.
31+
if (!showOrganizations && organization) {
32+
const extraPath = removePrefix(
33+
location.pathname,
34+
`/templates/${organization}/${template}`,
35+
);
36+
37+
return (
38+
<Navigate
39+
to={`/templates/${template}${extraPath}${location.search}`}
40+
replace
41+
/>
42+
);
43+
}
44+
45+
return <Outlet />;
46+
};
47+
48+
const getOrganizationNameByDefault = (organizations: Organization[]) =>
49+
organizations.find((org) => org.is_default)?.name;
50+
51+
// I really hate doing it this way, but React Router does not provide a better way.
52+
const removePrefix = (self: string, prefix: string) =>
53+
self.startsWith(prefix) ? self.slice(prefix.length) : self;

0 commit comments

Comments
 (0)