From 73b6ee7d890cfea97ec68de957e0ab1b58698f47 Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Thu, 5 Jan 2023 18:22:49 +0000
Subject: [PATCH] Merge MetabaseSettings and metabase-types settings type
 (#27528)

* Add missing fields to `Settings` type

* Make `MetabaseSettings` use up-to-date setting types

* Fix type errors
---
 .../components/FontWidget/FontWidget.tsx      |   2 +-
 .../src/metabase-types/api/mocks/settings.ts  |  43 +++++-
 frontend/src/metabase-types/api/settings.ts   |  45 ++++++-
 .../ModelCachingControl.tsx                   |   2 +-
 frontend/src/metabase/lib/pulse.ts            |   5 +-
 frontend/src/metabase/lib/settings.ts         | 124 ++++--------------
 frontend/src/metabase/lib/time.ts             |   8 +-
 frontend/src/metabase/setup/actions.ts        |   2 +-
 .../containers/LanguageStep/LanguageStep.tsx  |   2 +-
 9 files changed, 118 insertions(+), 115 deletions(-)

diff --git a/enterprise/frontend/src/metabase-enterprise/whitelabel/components/FontWidget/FontWidget.tsx b/enterprise/frontend/src/metabase-enterprise/whitelabel/components/FontWidget/FontWidget.tsx
index 3de0e814ed2..e2946491f4f 100644
--- a/enterprise/frontend/src/metabase-enterprise/whitelabel/components/FontWidget/FontWidget.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/whitelabel/components/FontWidget/FontWidget.tsx
@@ -15,7 +15,7 @@ export interface FontWidgetProps {
 const FontWidget = ({
   setting,
   settingValues,
-  availableFonts = MetabaseSettings.get("available-fonts"),
+  availableFonts = MetabaseSettings.get("available-fonts") || [],
   onChange,
   onChangeSetting,
 }: FontWidgetProps): JSX.Element => {
diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts
index c6e135944fb..3133f5a1ed9 100644
--- a/frontend/src/metabase-types/api/mocks/settings.ts
+++ b/frontend/src/metabase-types/api/mocks/settings.ts
@@ -7,6 +7,8 @@ import {
   Settings,
   TokenFeatures,
   Version,
+  VersionInfo,
+  VersionInfoRecord,
 } from "metabase-types/api";
 
 export const createMockEngine = (opts?: Partial<Engine>): Engine => ({
@@ -64,6 +66,24 @@ export const createMockVersion = (opts?: Partial<Version>): Version => ({
   ...opts,
 });
 
+export const createMockVersionInfoRecord = (
+  opts?: Partial<VersionInfoRecord>,
+): VersionInfoRecord => ({
+  version: "v1",
+  released: "2021-01-01",
+  patch: true,
+  highlights: ["Bug fix"],
+  ...opts,
+});
+
+export const createMockVersionInfo = (
+  opts?: Partial<VersionInfo>,
+): VersionInfo => ({
+  latest: createMockVersionInfoRecord(),
+  older: [createMockVersionInfoRecord()],
+  ...opts,
+});
+
 export const createMockTokenStatus = () => ({
   status: "Token is Valid.",
   valid: true,
@@ -108,19 +128,29 @@ export const createMockSettingDefinition = (
 });
 
 export const createMockSettings = (opts?: Partial<Settings>): Settings => ({
+  "admin-email": "admin@metabase.test",
+  "anon-tracking-enabled": false,
   "application-font": "Lato",
   "application-font-files": [],
+  "application-name": "Metabase",
   "available-fonts": [],
   "available-locales": null,
+  "cloud-gateway-ips": null,
   "custom-formatting": {},
   "deprecation-notice-version": undefined,
   "email-configured?": false,
   "enable-embedding": false,
+  "enable-enhancements?": false,
   "enable-nested-queries": true,
   "enable-query-caching": undefined,
+  "enable-password-login": true,
   "enable-public-sharing": false,
   "enable-xrays": false,
   "experimental-enable-actions": false,
+  engines: createMockEngines(),
+  "has-user-setup": true,
+  "hide-embed-branding?": true,
+  "ga-enabled": false,
   "google-auth-auto-create-accounts-domain": null,
   "google-auth-client-id": null,
   "google-auth-configured": false,
@@ -131,11 +161,18 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({
   "ldap-configured?": false,
   "ldap-enabled": false,
   "loading-message": "doing-science",
+  "other-sso-enabled?": null,
+  "password-complexity": { total: 6, digit: 1 },
   "persisted-models-enabled": false,
+  "premium-embedding-token": null,
   "report-timezone-short": "UTC",
   "saml-configured": false,
   "saml-enabled": false,
+  "snowplow-url": "",
+  "search-typeahead-enabled": true,
+  "setup-token": null,
   "session-cookies": null,
+  "snowplow-enabled": false,
   "show-database-syncing-modal": false,
   "show-homepage-data": false,
   "show-homepage-pin-message": false,
@@ -144,13 +181,17 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({
   "show-metabot": true,
   "site-locale": "en",
   "site-url": "http://localhost:3000",
+  "site-uuid": "1234",
   "slack-app-token": null,
   "slack-files-channel": null,
   "slack-token": null,
   "slack-token-valid?": false,
+  "subscription-allowed-domains": null,
   "token-features": createMockTokenFeatures(),
   "token-status": null,
-  engines: createMockEngines(),
+  "user-locale": null,
   version: createMockVersion(),
+  "version-info": createMockVersionInfo(),
+  "version-info-last-checked": null,
   ...opts,
 });
diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts
index 489f6d80a75..9e51ab3c6e8 100644
--- a/frontend/src/metabase-types/api/settings.ts
+++ b/frontend/src/metabase-types/api/settings.ts
@@ -100,7 +100,19 @@ export interface FontFile {
 export type FontFormat = "woff" | "woff2" | "truetype";
 
 export interface Version {
-  tag: string;
+  tag?: string;
+}
+
+export interface VersionInfoRecord {
+  version?: string; // tag
+  released?: string; // year-month-day
+  patch?: boolean;
+  highlights?: string[];
+}
+
+export interface VersionInfo {
+  latest?: VersionInfoRecord;
+  older?: VersionInfoRecord[];
 }
 
 export type LocaleData = [string, string];
@@ -128,43 +140,65 @@ export interface TokenFeatures {
   whitelabel: boolean;
 }
 
+export type PasswordComplexity = {
+  total?: number;
+  digit?: number;
+};
+
 export interface SettingDefinition {
   key: string;
   env_name?: string;
   is_env_setting: boolean;
-  value: unknown;
+  value?: unknown;
 }
 
 export interface Settings {
+  "admin-email": string;
+  "anon-tracking-enabled": boolean;
   "application-font": string;
   "application-font-files": FontFile[] | null;
+  "application-name": string;
   "available-fonts": string[];
   "available-locales": LocaleData[] | null;
+  "cloud-gateway-ips": string[] | null;
   "custom-formatting": FormattingSettings;
   "deprecation-notice-version"?: string;
   "email-configured?": boolean;
   "embedding-secret-key"?: string;
   "enable-embedding": boolean;
+  "enable-enhancements?": boolean;
   "enable-nested-queries": boolean;
   "enable-query-caching"?: boolean;
+  "enable-password-login": boolean;
   "enable-public-sharing": boolean;
   "enable-xrays": boolean;
+  engines: Record<string, Engine>;
   "experimental-enable-actions": boolean;
+  "ga-enabled": boolean;
   "google-auth-auto-create-accounts-domain": string | null;
   "google-auth-client-id": string | null;
   "google-auth-configured": boolean;
   "google-auth-enabled": boolean;
+  "has-user-setup": boolean;
+  "hide-embed-branding?": boolean;
   "is-hosted?": boolean;
   "jwt-enabled"?: boolean;
   "jwt-configured"?: boolean;
   "ldap-configured?": boolean;
   "ldap-enabled": boolean;
   "loading-message": LoadingMessage;
+  "other-sso-enabled?": boolean | null;
+  "password-complexity": PasswordComplexity;
   "persisted-models-enabled": boolean;
+  "premium-embedding-token": string | null;
   "report-timezone-short": string;
   "saml-configured"?: boolean;
   "saml-enabled"?: boolean;
+  "search-typeahead-enabled": boolean;
+  "setup-token": string | null;
   "session-cookies": boolean | null;
+  "snowplow-enabled": boolean;
+  "snowplow-url": string;
   "show-database-syncing-modal": boolean;
   "show-homepage-data": boolean;
   "show-homepage-pin-message": boolean;
@@ -172,15 +206,20 @@ export interface Settings {
   "show-lighthouse-illustration": boolean;
   "show-metabot": boolean;
   "site-locale": string;
+  "site-uuid": string;
   "site-url": string;
   "slack-app-token": string | null;
   "slack-files-channel": string | null;
   "slack-token": string | null;
   "slack-token-valid?": boolean;
+  "subscription-allowed-domains": string | null;
   "token-features": TokenFeatures;
   "token-status": TokenStatus | null;
-  engines: Record<string, Engine>;
+  "user-locale": string | null;
   version: Version;
+  "version-info": VersionInfo | null;
+  "version-info-last-checked": string | null;
 }
 
 export type SettingKey = keyof Settings;
+0;
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelCachingControl/ModelCachingControl.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelCachingControl/ModelCachingControl.tsx
index c6a04b1abef..a1ad7f44d99 100644
--- a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelCachingControl/ModelCachingControl.tsx
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelCachingControl/ModelCachingControl.tsx
@@ -59,7 +59,7 @@ function ModelCachingControl({ database }: Props) {
     ? t`Turn model caching off`
     : t`Turn model caching on`;
 
-  const siteUUID = MetabaseSettings.get("site-uuid");
+  const siteUUID = MetabaseSettings.get("site-uuid") || "";
   const cacheSchemaName = getModelCacheSchemaName(databaseId, siteUUID);
 
   const handleCachingChange = async () => {
diff --git a/frontend/src/metabase/lib/pulse.ts b/frontend/src/metabase/lib/pulse.ts
index 7354dd6c9dd..1a8dc5a54ca 100644
--- a/frontend/src/metabase/lib/pulse.ts
+++ b/frontend/src/metabase/lib/pulse.ts
@@ -102,7 +102,10 @@ export function recipientIsValid(recipient: NotificationRecipient) {
 
   const recipientDomain = MetabaseUtils.getEmailDomain(recipient.email);
   const allowedDomains = MetabaseSettings.subscriptionAllowedDomains();
-  return _.isEmpty(allowedDomains) || allowedDomains.includes(recipientDomain);
+  return (
+    _.isEmpty(allowedDomains) ||
+    (recipientDomain && allowedDomains.includes(recipientDomain))
+  );
 }
 
 export function pulseIsValid(pulse: Pulse, channelSpecs: ChannelSpecs) {
diff --git a/frontend/src/metabase/lib/settings.ts b/frontend/src/metabase/lib/settings.ts
index ae4df6eb8b7..01cfa20f10a 100644
--- a/frontend/src/metabase/lib/settings.ts
+++ b/frontend/src/metabase/lib/settings.ts
@@ -1,9 +1,12 @@
 import _ from "underscore";
 import { t, ngettext, msgid } from "ttag";
 import moment from "moment-timezone";
+
 import { parseTimestamp } from "metabase/lib/time";
 import MetabaseUtils from "metabase/lib/utils";
 
+import { PasswordComplexity, SettingKey, Settings } from "metabase-types/api";
+
 const n2w = (n: number) => MetabaseUtils.numberToWord(n);
 
 const PASSWORD_COMPLEXITY_CLAUSES = {
@@ -50,85 +53,21 @@ const PASSWORD_COMPLEXITY_CLAUSES = {
   },
 };
 
-// TODO: dump this from backend settings definitions
-export type SettingName =
-  | "application-name"
-  | "admin-email"
-  | "analytics-uuid"
-  | "anon-tracking-enabled"
-  | "site-locale"
-  | "user-locale"
-  | "available-locales"
-  | "available-timezones"
-  | "custom-formatting"
-  | "custom-geojson"
-  | "email-configured?"
-  | "enable-embedding"
-  | "enable-enhancements?"
-  | "enable-public-sharing"
-  | "enable-xrays"
-  | "experimental-enable-actions"
-  | "persisted-models-enabled"
-  | "engines"
-  | "ga-code"
-  | "ga-enabled"
-  | "google-auth-enabled"
-  | "google-auth-client-id"
-  | "has-sample-database?"
-  | "has-user-setup"
-  | "hide-embed-branding?"
-  | "is-hosted?"
-  | "ldap-enabled"
-  | "ldap-configured?"
-  | "other-sso-enabled?"
-  | "enable-password-login"
-  | "map-tile-server-url"
-  | "password-complexity"
-  | "persisted-model-refresh-interval-hours"
-  | "premium-features"
-  | "search-typeahead-enabled"
-  | "setup-token"
-  | "site-url"
-  | "site-uuid"
-  | "token-status"
-  | "types"
-  | "version-info-last-checked"
-  | "version-info"
-  | "version"
-  | "subscription-allowed-domains"
-  | "cloud-gateway-ips"
-  | "snowplow-enabled"
-  | "snowplow-url"
-  | "deprecation-notice-version"
-  | "show-database-syncing-modal"
-  | "premium-embedding-token"
-  | "metabase-store-managed"
-  | "application-colors"
-  | "application-font"
-  | "available-fonts"
-  | "enable-query-caching"
-  | "start-of-week"
-  | "report-timezone-short";
-
-type SettingsMap = Record<SettingName, any>; // provides access to Metabase application settings
-
 type SettingListener = (value: any) => void;
 
-class Settings {
-  _settings: Partial<SettingsMap>;
-  _listeners: Partial<Record<SettingName, SettingListener[]>> = {};
+class MetabaseSettings {
+  _settings: Partial<Settings>;
+  _listeners: Partial<{ [key: string]: SettingListener[] }> = {};
 
-  constructor(settings: Partial<SettingsMap> = {}) {
+  constructor(settings: Partial<Settings> = {}) {
     this._settings = settings;
   }
 
-  get(key: SettingName, defaultValue: any = null) {
-    return this._settings[key] !== undefined
-      ? this._settings[key]
-      : defaultValue;
+  get<T extends SettingKey>(key: T): Partial<Settings>[T] {
+    return this._settings[key];
   }
 
-  set(key: SettingName, value: any) {
+  set<T extends SettingKey>(key: T, value: Settings[T]) {
     if (this._settings[key] !== value) {
       this._settings[key] = value;
       const listeners = this._listeners[key];
@@ -143,15 +82,15 @@ class Settings {
     }
   }
 
-  setAll(settings: SettingsMap) {
-    const keys = Object.keys(settings) as SettingName[];
+  setAll(settings: Settings) {
+    const keys = Object.keys(settings) as SettingKey[];
 
     keys.forEach(key => {
       this.set(key, settings[key]);
     });
   }
 
-  on(key: SettingName, callback: SettingListener) {
+  on(key: SettingKey, callback: SettingListener) {
     this._listeners[key] = this._listeners[key] || [];
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     this._listeners[key]!.push(callback);
@@ -166,12 +105,12 @@ class Settings {
     return this.get("enable-enhancements?");
   }
 
-  isEmailConfigured() {
-    return this.get("email-configured?");
+  isEmailConfigured(): boolean {
+    return !!this.get("email-configured?");
   }
 
   isHosted(): boolean {
-    return this.get("is-hosted?");
+    return !!this.get("is-hosted?");
   }
 
   cloudGatewayIps(): string[] {
@@ -267,7 +206,7 @@ class Settings {
   }
 
   docsUrl(page = "", anchor = "") {
-    let { tag } = this.get("version", {});
+    let { tag } = this.get("version") || {};
     const matches = tag && tag.match(/v[01]\.(\d+)(?:\.\d+)?(-.*)?/);
 
     if (matches) {
@@ -330,27 +269,13 @@ class Settings {
     return result != null && result >= 0;
   }
 
-  /*
-    We expect the versionInfo to take on the JSON structure detailed below.
-    The 'older' section should contain only the last 5 previous versions, we don't need to go on forever.
-    The highlights for a version should just be text and should be limited to 5 items tops.
-    type VersionInfo = {
-      latest: Version,
-      older: Version[]
-    };
-    type Version = {
-      version: string, // e.x. "v0.17.1"
-      released: ISO8601Time,
-      patch: bool,
-      highlights: string[]
-    };
-  */
   versionInfo() {
-    return this.get("version-info", {});
+    return this.get("version-info") || {};
   }
 
   currentVersion() {
-    return this.get("version", {}).tag;
+    const version = this.get("version") || {};
+    return version.tag;
   }
 
   latestVersion() {
@@ -366,9 +291,8 @@ class Settings {
     return this.isHosted() || this.isEnterprise();
   }
 
-  // returns a map that looks like {total: 6, digit: 1}
-  passwordComplexityRequirements() {
-    return this.get("password-complexity", {});
+  passwordComplexityRequirements(): PasswordComplexity {
+    return this.get("password-complexity") || {};
   }
 
   /**
@@ -399,7 +323,7 @@ class Settings {
     }
   }
 
-  subscriptionAllowedDomains() {
+  subscriptionAllowedDomains(): string[] {
     const setting = this.get("subscription-allowed-domains") || "";
     return setting ? setting.split(",") : [];
   }
@@ -414,4 +338,4 @@ function makeRegexTest(property: string, regex: RegExp) {
 const initValues =
   typeof window !== "undefined" ? _.clone(window.MetabaseBootstrap) : null;
 
-export default new Settings(initValues);
+export default new MetabaseSettings(initValues);
diff --git a/frontend/src/metabase/lib/time.ts b/frontend/src/metabase/lib/time.ts
index ad9d4f9a618..ee04d4d39ea 100644
--- a/frontend/src/metabase/lib/time.ts
+++ b/frontend/src/metabase/lib/time.ts
@@ -1,9 +1,5 @@
 import { t } from "ttag";
-import moment, {
-  DurationInputArg1,
-  DurationInputArg2,
-  MomentInput,
-} from "moment-timezone";
+import moment, { DurationInputArg2, MomentInput } from "moment-timezone";
 
 import MetabaseSettings from "metabase/lib/settings";
 
@@ -110,7 +106,7 @@ export function getDefaultTimezone() {
 
 export function getNumericDateStyleFromSettings() {
   const dateStyle = getDateStyleFromSettings();
-  return /\//.test(dateStyle) ? dateStyle : "M/D/YYYY";
+  return dateStyle && /\//.test(dateStyle) ? dateStyle : "M/D/YYYY";
 }
 
 export function getRelativeTime(timestamp: string) {
diff --git a/frontend/src/metabase/setup/actions.ts b/frontend/src/metabase/setup/actions.ts
index b323447d1fa..4b18b567d92 100644
--- a/frontend/src/metabase/setup/actions.ts
+++ b/frontend/src/metabase/setup/actions.ts
@@ -52,7 +52,7 @@ export const LOAD_LOCALE_DEFAULTS = "metabase/setup/LOAD_LOCALE_DEFAULTS";
 export const loadLocaleDefaults = createThunkAction(
   LOAD_LOCALE_DEFAULTS,
   () => async (dispatch: any) => {
-    const data = MetabaseSettings.get("available-locales");
+    const data = MetabaseSettings.get("available-locales") || [];
     const locale = getDefaultLocale(getLocales(data));
     await dispatch(setLocale(locale));
   },
diff --git a/frontend/src/metabase/setup/containers/LanguageStep/LanguageStep.tsx b/frontend/src/metabase/setup/containers/LanguageStep/LanguageStep.tsx
index 8641aae459a..cafd4ce45c9 100644
--- a/frontend/src/metabase/setup/containers/LanguageStep/LanguageStep.tsx
+++ b/frontend/src/metabase/setup/containers/LanguageStep/LanguageStep.tsx
@@ -14,7 +14,7 @@ import {
 
 const mapStateToProps = (state: State) => ({
   locale: getLocale(state),
-  localeData: Settings.get("available-locales"),
+  localeData: Settings.get("available-locales") || [],
   isStepActive: isStepActive(state, LANGUAGE_STEP),
   isStepCompleted: isStepCompleted(state, LANGUAGE_STEP),
   isSetupCompleted: isSetupCompleted(state),
-- 
GitLab