Skip to content
Snippets Groups Projects
Unverified Commit 4e343c1d authored by Ryan Laurie's avatar Ryan Laurie Committed by GitHub
Browse files

Add Release Channel selection in-product (#48126)

* add update channels in product

* support for changing release notes to show beta and nightly info

* dont export setting

* obey the linter and add tests

* export setting

* update e2e tests

* clojure magic

* clojure-foo

* better localization

* sorry mr linter

* add more tests :muscle:
parent 313fde98
No related branches found
No related tags found
No related merge requests found
Showing
with 318 additions and 90 deletions
......@@ -1278,3 +1278,100 @@ describe("notifications", { tags: "@external" }, () => {
cy.findByRole("heading", { name: "Add a webhook" }).should("exist");
});
});
describe("admin > settings > updates", () => {
// we're mocking this so it can be stable for tests
const versionInfo = {
latest: {
version: "v1.86.76",
released: "2022-10-14",
rollout: 60,
highlights: ["New latest feature", "Another new feature"],
},
beta: {
version: "v1.86.75.309",
released: "2022-10-15",
rollout: 70,
highlights: ["New beta feature", "Another new feature"],
},
nightly: {
version: "v1.86.75.311",
released: "2022-10-16",
rollout: 80,
highlights: ["New nightly feature", "Another new feature"],
},
older: [
{
version: "v1.86.75",
released: "2022-10-10",
rollout: 100,
highlights: ["Some old feature", "Another old feature"],
},
],
};
const currentVersion = "v1.86.70";
beforeEach(() => {
restore();
cy.signInAsAdmin();
cy.visit("/admin/settings/updates");
cy.intercept("GET", "/api/session/properties", (req, res) => {
req.continue(res => {
res.body["version-info"] = versionInfo;
res.body.version.tag = currentVersion;
return res.body;
});
});
});
it("should show the updates page", () => {
cy.findByLabelText("Check for updates").should("be.visible");
cy.findByTestId("update-channel-setting")
.findByText("Update Channel")
.should("be.visible");
cy.findByTestId("settings-updates").within(() => {
cy.findByText("Metabase 1.86.76 is available. You're running 1.86.70");
cy.findByText("Some old feature").should("be.visible");
});
cy.log("hide most things if updates are turned off");
cy.findByLabelText("Check for updates").click();
cy.findByTestId("settings-updates").within(() => {
cy.findByText("Update Channel").should("not.exist");
cy.findByText("Some old feature").should("not.exist");
});
});
it("should change release notes based on the selected update channel", () => {
cy.findByTestId("settings-updates").within(() => {
cy.findByText(/Metabase 1\.86\.76 is available/).should("be.visible");
cy.findByText("Some old feature").should("be.visible");
cy.findByText("New latest feature").should("be.visible");
cy.findByText("Stable").click();
});
popover().findByText("Beta").click();
cy.findByTestId("settings-updates").within(() => {
cy.findByText(/Metabase 1\.86\.75\.309 is available/).should(
"be.visible",
);
cy.findByText("New beta feature").should("be.visible");
cy.findByText("Beta").click();
});
popover().findByText("Nightly").click();
cy.findByTestId("settings-updates").within(() => {
cy.findByText(/Metabase 1\.86\.75\.311 is available/).should(
"be.visible",
);
cy.findByText("New nightly feature").should("be.visible");
});
});
});
......@@ -256,5 +256,7 @@ export const createMockSettings = (
"notebook-native-preview-shown": false,
"notebook-native-preview-sidebar-width": null,
"query-analysis-enabled": false,
"check-for-updates": true,
"update-channel": "latest",
...opts,
});
......@@ -112,6 +112,8 @@ export interface VersionInfoRecord {
}
export interface VersionInfo {
nightly?: VersionInfoRecord;
beta?: VersionInfoRecord;
latest?: VersionInfoRecord;
older?: VersionInfoRecord[];
}
......@@ -181,12 +183,15 @@ export type PasswordComplexity = {
export type SessionCookieSameSite = "lax" | "strict" | "none";
export type UpdateChannel = "latest" | "beta" | "nightly";
export interface SettingDefinition {
key: string;
env_name?: string;
is_env_setting: boolean;
value?: unknown;
default?: unknown;
description?: string;
}
export interface OpenAiModel {
......@@ -259,7 +264,6 @@ interface AdminSettings {
"setup-license-active-at-setup": boolean;
"store-url": string;
}
interface SettingsManagerSettings {
"bcc-enabled?": boolean;
"ee-openai-api-key"?: string;
......@@ -283,6 +287,7 @@ interface PublicSettings {
"application-name": string;
"available-fonts": string[];
"available-locales": LocaleData[] | null;
"check-for-updates": boolean;
"cloud-gateway-ips": string[] | null;
"custom-formatting": FormattingSettings;
"custom-homepage": boolean;
......@@ -323,6 +328,7 @@ interface PublicSettings {
"snowplow-url": string;
"start-of-week": DayOfWeekId;
"token-features": TokenFeatures;
"update-channel": UpdateChannel;
version: Version;
"version-info-last-checked": string | null;
}
......
import cx from "classnames";
import PropTypes from "prop-types";
import { UpsellHostingUpdates } from "metabase/admin/upsells";
import { useSetting } from "metabase/common/hooks";
import CS from "metabase/css/core/index.css";
import { Flex } from "metabase/ui";
import { SettingsSetting } from "../SettingsSetting";
import { VersionUpdateNotice } from "./VersionUpdateNotice/VersionUpdateNotice";
export default function SettingsUpdatesForm({ elements, updateSetting }) {
const settings = elements.map((setting, index) => (
<SettingsSetting
key={setting.key}
setting={setting}
onChange={value => updateSetting(setting, value)}
autoFocus={index === 0}
/>
));
const isHosted = useSetting("is-hosted");
return (
<Flex justify="space-between">
<div style={{ width: "585px" }}>
{!isHosted && <ul>{settings}</ul>}
<div className={CS.px2}>
<div
className={cx(CS.pt3, {
[CS.borderTop]: !isHosted,
})}
>
<VersionUpdateNotice />
</div>
</div>
</div>
<div>
<UpsellHostingUpdates source="settings-updates-migrate_to_cloud" />
</div>
</Flex>
);
}
SettingsUpdatesForm.propTypes = {
elements: PropTypes.array,
updateSetting: PropTypes.func,
};
import cx from "classnames";
import { c } from "ttag";
import { UpsellHostingUpdates } from "metabase/admin/upsells";
import { useSetting } from "metabase/common/hooks";
import CS from "metabase/css/core/index.css";
import { Flex } from "metabase/ui";
import type { SettingDefinition, UpdateChannel } from "metabase-types/api";
import { SettingsSetting } from "../SettingsSetting";
import { VersionUpdateNotice } from "./VersionUpdateNotice/VersionUpdateNotice";
const updateChannelSetting = {
key: "update-channel",
display_name: "Update Channel",
type: "select",
description:
"Metabase will notify you when a new release is available for the channel you select.",
defaultValue: "latest",
options: [
{ name: c("describes a software version").t`Stable`, value: "latest" },
{ name: c("describes a software version").t`Beta`, value: "beta" },
{ name: c("describes a software version").t`Nightly`, value: "nightly" },
],
};
export function SettingsUpdatesForm({
elements,
updateSetting,
}: {
elements: SettingDefinition[];
// typing this properly is a fool's errand without a major settings refactor
updateSetting: (
setting: Partial<SettingDefinition> & Record<string, any>,
newValue: unknown,
) => void;
}) {
const [setting] = elements;
const isHosted = useSetting("is-hosted?");
const checkForUpdates = useSetting("check-for-updates");
const updateChannelValue = useSetting("update-channel");
return (
<Flex justify="space-between" data-testid="settings-updates">
<div style={{ width: "585px" }}>
{!isHosted && (
<ul>
<SettingsSetting
key={setting.key}
setting={setting}
onChange={(value: boolean) => updateSetting(setting, value)}
/>
{checkForUpdates && (
<li>
<SettingsSetting
key="update-channel"
setting={{
...updateChannelSetting,
value: updateChannelValue,
}}
onChange={(newValue: UpdateChannel) =>
updateSetting(updateChannelSetting, newValue)
}
/>
</li>
)}
</ul>
)}
{checkForUpdates && (
<div className={CS.px2}>
<div
className={cx(CS.pt3, {
[CS.borderTop]: !isHosted,
})}
>
<VersionUpdateNotice />
</div>
</div>
)}
</div>
<div>
<UpsellHostingUpdates source="settings-updates-migrate_to_cloud" />
</div>
</Flex>
);
}
......@@ -8,12 +8,13 @@ import {
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import SettingsUpdatesForm from "./SettingsUpdatesForm";
import { SettingsUpdatesForm } from "./SettingsUpdatesForm";
const elements = [
{
key: "key",
widget: "span",
key: "check-for-updates",
display_name: "Check for updates",
type: "boolean",
},
];
......@@ -22,6 +23,9 @@ function setup({
isPaid = false,
currentVersion = "v1.0.0",
latestVersion = "v2.0.0",
nightlyVersion = "v1.2.1",
betaVersion = "v1.3.0",
channel = "latest",
} = {}) {
const version = currentVersion
? createMockVersion({ tag: currentVersion })
......@@ -30,6 +34,8 @@ function setup({
const versionInfo = currentVersion
? createMockVersionInfo({
latest: createMockVersionInfoRecord({ version: latestVersion }),
nightly: createMockVersionInfoRecord({ version: nightlyVersion }),
beta: createMockVersionInfoRecord({ version: betaVersion }),
})
: null;
......@@ -37,7 +43,9 @@ function setup({
"is-hosted?": isHosted,
version,
"version-info": versionInfo,
"check-for-updates": true,
"token-status": createMockTokenStatus({ valid: isPaid }),
"update-channel": channel,
});
const state = createMockState({
......@@ -45,9 +53,12 @@ function setup({
currentUser: { is_superuser: true },
});
renderWithProviders(<SettingsUpdatesForm elements={elements} />, {
storeInitialState: state,
});
renderWithProviders(
<SettingsUpdatesForm elements={elements} updateSetting={() => {}} />,
{
storeInitialState: state,
},
);
}
describe("SettingsUpdatesForm", () => {
......@@ -58,6 +69,18 @@ describe("SettingsUpdatesForm", () => {
).toBeInTheDocument();
});
it("shows check for updates toggle", async () => {
setup({ currentVersion: "v1.0.0", latestVersion: "v1.0.0" });
expect(await screen.findByText(/Check for updates/i)).toBeInTheDocument();
});
it("shows release channel selection", async () => {
setup({ currentVersion: "v1.0.0", latestVersion: "v1.0.0" });
expect(await screen.findByText("Update Channel")).toBeInTheDocument();
});
it("shows correct message when latest version is installed", async () => {
setup({ currentVersion: "v1.0.0", latestVersion: "v1.0.0" });
expect(
......@@ -72,6 +95,41 @@ describe("SettingsUpdatesForm", () => {
).toBeInTheDocument();
});
it("shows upgrade call to action on the stable channel", () => {
setup({
currentVersion: "v1.0.0",
latestVersion: "v1.7.0",
nightlyVersion: "v1.7.1",
betaVersion: "v1.7.2",
channel: "latest",
});
expect(screen.getByText(/Metabase 1.7.0 is available/)).toBeInTheDocument();
});
it("shows upgrade call to action on the nightly channel", () => {
setup({
currentVersion: "v1.0.0",
latestVersion: "v1.7.0",
nightlyVersion: "v1.7.1",
betaVersion: "v1.7.2",
channel: "nightly",
});
expect(screen.getByText(/Metabase 1.7.1 is available/)).toBeInTheDocument();
});
it("shows upgrade call to action on the beta channel", () => {
setup({
currentVersion: "v1.0.0",
latestVersion: "v1.7.0",
nightlyVersion: "v1.7.1",
betaVersion: "v1.7.2-beta",
channel: "beta",
});
expect(
screen.getByText(/Metabase 1.7.2-beta is available/),
).toBeInTheDocument();
});
it("shows upgrade call-to-action if not in Enterprise plan", () => {
setup({ currentVersion: "v1.0.0", latestVersion: "v1.0.0" });
expect(screen.getByText("Get automatic updates")).toBeInTheDocument();
......
......@@ -5,11 +5,11 @@ import {
getCurrentVersion,
getLatestVersion,
} from "metabase/admin/settings/selectors";
import { useSetting } from "metabase/common/hooks";
import ExternalLink from "metabase/core/components/ExternalLink";
import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import MetabaseSettings from "metabase/lib/settings";
import { newVersionAvailable, versionIsLatest } from "metabase/lib/utils";
import { getIsHosted } from "metabase/setup/selectors";
import type { VersionInfoRecord } from "metabase-types/api";
......@@ -71,8 +71,11 @@ function DefaultUpdateMessage({ currentVersion }: { currentVersion: string }) {
}
function NewVersionAvailable({ currentVersion }: { currentVersion: string }) {
const latestVersion = MetabaseSettings.latestVersion();
const versionInfo = MetabaseSettings.versionInfo();
const versionInfo = useSetting("version-info");
const updateChannel = useSetting("update-channel");
const latestVersion = useSelector(getLatestVersion);
const lastestVersionInfo = versionInfo?.[updateChannel];
return (
<div>
......@@ -109,26 +112,28 @@ function NewVersionAvailable({ currentVersion }: { currentVersion: string }) {
</ExternalLink>
</NewVersionContainer>
<div
className={cx(
CS.textMedium,
CS.bordered,
CS.rounded,
CS.p2,
CS.mt2,
CS.overflowYScroll,
)}
style={{ height: 330 }}
>
<h3 className={cx(CS.pb3, CS.textUppercase)}>{t`What's Changed:`}</h3>
{versionInfo && (
<div
className={cx(
CS.textMedium,
CS.bordered,
CS.rounded,
CS.p2,
CS.mt2,
CS.overflowYScroll,
)}
style={{ height: 330 }}
>
<h3 className={cx(CS.pb3, CS.textUppercase)}>{t`What's Changed:`}</h3>
{versionInfo.latest && <Version version={versionInfo.latest} />}
{lastestVersionInfo && <Version version={lastestVersionInfo} />}
{versionInfo.older &&
versionInfo.older.map((version, index) => (
<Version key={index} version={version} />
))}
</div>
{versionInfo.older &&
versionInfo.older.map((version, index) => (
<Version key={index} version={version} />
))}
</div>
)}
</div>
);
}
......@@ -140,10 +145,7 @@ function Version({ version }: { version: VersionInfoRecord }) {
return (
<div className={CS.pb3}>
<h3 className={CS.textMedium}>
{formatVersion(version.version)}{" "}
{version.patch ? "(" + t`patch release` + ")" : null}
</h3>
<h3 className={CS.textMedium}>{formatVersion(version.version)}</h3>
<ul style={{ listStyleType: "disc", listStylePosition: "inside" }}>
{version.highlights &&
version.highlights.map((highlight, index) => (
......
......@@ -24,7 +24,7 @@ import { CloudPanel } from "./components/CloudPanel";
import { BccToggleWidget } from "./components/Email/BccToggleWidget";
import { SettingsEmailForm } from "./components/Email/SettingsEmailForm";
import SettingsLicense from "./components/SettingsLicense";
import SettingsUpdatesForm from "./components/SettingsUpdatesForm/SettingsUpdatesForm";
import { SettingsUpdatesForm } from "./components/SettingsUpdatesForm/SettingsUpdatesForm";
import { UploadSettings } from "./components/UploadSettings";
import CustomGeoJSONWidget from "./components/widgets/CustomGeoJSONWidget";
import {
......@@ -631,7 +631,8 @@ export const getCurrentVersion = createSelector(
export const getLatestVersion = createSelector(
getDerivedSettingValues,
settings => {
return settings["version-info"]?.latest?.version;
const updateChannel = settings["update-channel"] ?? "latest";
return settings["version-info"]?.[updateChannel]?.version;
},
);
......
......@@ -270,14 +270,6 @@ class MetabaseSettings {
return version.tag;
}
/**
* @deprecated use getSetting(state, "version-info")
*/
latestVersion() {
const { latest } = this.versionInfo();
return latest && latest.version;
}
/**
* @deprecated use getSetting(state, "is-metabot-enabled")
*/
......
......@@ -73,6 +73,24 @@
:audit :getter
:default true)
(defn- set-update-channel! [new-channel]
(let [valid-channels #{"latest" "beta" "nightly"}]
(when-not (valid-channels new-channel)
(throw (IllegalArgumentException.
(tru "Invalid update channel ''{0}''. Valid channels are: {1}"
new-channel valid-channels))))
(setting/set-value-of-type! :string :update-channel new-channel)))
(defsetting update-channel
(deferred-tru "Metabase will notify you when a new release is available for the channel you select.")
:visibility :admin
:type :string
:encryption :no
:export? true
:audit :getter
:setter set-update-channel!
:default "latest")
(defsetting site-uuid
;; Don't i18n this docstring because it's not user-facing! :)
"Unique identifier used for this instance of {0}. This is set once and only once the first time it is fetched via
......
......@@ -437,3 +437,14 @@
(testing "rollout is a decimal"
(let [modified (update version-info :latest assoc :rollout 0.2)]
(is (= modified (info modified {:current-major 51 :upgrade-threshold-value 25}))))))))
(deftest update-channel-test
(testing "we can set the update channel"
(mt/discard-setting-changes [update-channel]
(public-settings/update-channel! "nightly")
(is (= "nightly" (public-settings/update-channel)))))
(testing "we can't set the update channel to an invalid value"
(mt/discard-setting-changes [update-channel]
(is (thrown?
IllegalArgumentException
(public-settings/update-channel! "millennially"))))))
......@@ -51,3 +51,4 @@ start-of-week: null
subscription-allowed-domains: null
synchronous-batch-updates: null
unaggregated-query-row-limit: null
update-channel: null
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment