diff --git a/e2e/test/scenarios/admin-2/settings.cy.spec.js b/e2e/test/scenarios/admin-2/settings.cy.spec.js index ed57e240bf4b5201b6b47e7dbfdbf805d7039448..d652a383482746bc2d2ce3dd96cb5ba92a0310bd 100644 --- a/e2e/test/scenarios/admin-2/settings.cy.spec.js +++ b/e2e/test/scenarios/admin-2/settings.cy.spec.js @@ -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"); + }); + }); +}); diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 072c2d3c744557c4cf1fe1c09101457ce8de7187..037dafe0791398a1d76abc9c4513ed8aa3ca83d8 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -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, }); diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 894e6242a023a3c511202f2d7b597f6dfcd275bb..89aa32c3823a67a3f31be03a355040e0fdfb8144 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -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; } diff --git a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.jsx deleted file mode 100644 index bc762d67724af3617fd399c38da9d16278ede4f6..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.jsx +++ /dev/null @@ -1,48 +0,0 @@ -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, -}; diff --git a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7717cff05504aa054436084770ab0618062d647e --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx @@ -0,0 +1,88 @@ +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> + ); +} diff --git a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.unit.spec.js b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.unit.spec.jsx similarity index 53% rename from frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.unit.spec.js rename to frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.unit.spec.jsx index 61497463edae20338edafec30ec8a0d7dbb46689..cbaa466bc7012efd0b53e18dfcfa44ad4784d94c 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.unit.spec.js +++ b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.unit.spec.jsx @@ -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(); diff --git a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/VersionUpdateNotice/VersionUpdateNotice.tsx b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/VersionUpdateNotice/VersionUpdateNotice.tsx index 519eeb3cd26959235be8036bedf5907f27a7bf7a..d6d3155173b7c51bd09b969d0321681946abbf3a 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/VersionUpdateNotice/VersionUpdateNotice.tsx +++ b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/VersionUpdateNotice/VersionUpdateNotice.tsx @@ -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) => ( diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index 69d2353bc2db018a4462b8addc0f2280ec6e8f28..6ca2a7fe76283f32037043f30c71507e38e9a00a 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -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; }, ); diff --git a/frontend/src/metabase/lib/settings.ts b/frontend/src/metabase/lib/settings.ts index a32c1628b42118202a4f1c2a9bbb577e6dd57d51..2829d4d05125f69c9e5c15ed8d5768803efbc2a4 100644 --- a/frontend/src/metabase/lib/settings.ts +++ b/frontend/src/metabase/lib/settings.ts @@ -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") */ diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index fa86f870dbbdc2383f3999cfc927f4fb62bb8f3d..23a7b68eb8309e69e77acfee6ae2235fe3cba56c 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -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 diff --git a/test/metabase/public_settings_test.clj b/test/metabase/public_settings_test.clj index 223c9930d1e8562d9672e17e53a6e9acaeb319b0..a73f9de42a30c67e2816e4d2df353f5ab60e8238 100644 --- a/test/metabase/public_settings_test.clj +++ b/test/metabase/public_settings_test.clj @@ -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")))))) diff --git a/test_resources/serialization_baseline/settings.yaml b/test_resources/serialization_baseline/settings.yaml index 4b9cc89cc6550b9429cc0f48346befbad908a3cf..bc270501de90f97cfbcafe74c09360f19b0c49b5 100644 --- a/test_resources/serialization_baseline/settings.yaml +++ b/test_resources/serialization_baseline/settings.yaml @@ -51,3 +51,4 @@ start-of-week: null subscription-allowed-domains: null synchronous-batch-updates: null unaggregated-query-row-limit: null +update-channel: null