前端

實體載入器

如果您正在開發新功能,或通常需要在前端取得一些應用程式資料,實體載入器會是您的好幫手。它們可以抽象化 API 呼叫、處理載入和錯誤狀態、快取先前載入的物件、使快取失效(在某些情況下),並讓您輕鬆執行更新或建立新項目。

實體載入器的良好用途

  • 我需要取得特定的 X (使用者、資料庫等) 並顯示它。
  • 我需要取得 X (資料庫、問題等) 的列表並顯示它。

目前可用的實體

  • 問題、儀表板、脈衝
  • 集合
  • 資料庫、表格、欄位、區隔、指標
  • 使用者、群組
  • 完整的實體列表在此處:https://github.com/metabase/metabase/tree/master/frontend/src/metabase/entities

有兩種使用載入器的方式,一種是作為 React "render prop" 元件,另一種是作為 React 元件類別裝飾器 ("高階元件")。

物件載入

在此範例中,我們將為新頁面載入有關特定資料庫的資訊。

import React from "react";
import Databases from "metabase/entities/databases";

@Databases.load({ id: 4 })
class MyNewPage extends React.Component {
  render() {
    const { database } = this.props;
    return (
      <div>
        <h1>{database.name}</h1>
      </div>
    );
  }
}

此範例使用類別裝飾器來要求並顯示 ID 為 4 的資料庫。如果您想改用 render prop 元件,您的程式碼會像這樣。

import React from "react";
import Databases from "metabase/entities/databases";

class MyNewPage extends React.Component {
  render() {
    const { database } = this.props;
    return (
      <div>
        <Databases.Loader id={4}>
          {({ database }) => <h1>{database.name}</h1>}
        </Databases.Loader>
      </div>
    );
  }
}

現在您很可能不只想顯示一個靜態項目,因此在某些情況下,您可能需要動態值,您可以使用函式來取得 props 並傳回您需要的值。如果您使用元件方法,您可以像平常一樣傳遞 props 作為動態值。

@Databases.load({
  id: (state, props) => props.params.databaseId
}))

列表載入

載入項目列表就像套用 loadList 裝飾器一樣簡單。

import React from "react";
import Users from "metabase/entities/users";

@Users.loadList()
class MyList extends React.Component {
  render() {
    const { users } = this.props;
    return <div>{users.map(u => u.first_name)}</div>;
  }
}

類似於物件載入器的 id 引數,您也可以傳遞 query 物件(如果 API 支援)。

@Users.loadList({
  query: (state, props) => ({ archived: props.showArchivedOnly })
})

控制載入和錯誤狀態

預設情況下,EntityObjectEntityList 載入器都會使用底層的 LoadingAndErrorWrapper 為您處理載入狀態。如果您因故想自行處理載入,可以透過設定 loadingAndErrorWrapper: false 來停用此行為。

包裝物件

如果您將 wrapped: true 傳遞給載入器,則物件或物件將會用輔助類別包裝,讓您可以執行 user.getName()user.delete()user.update({ name: "new name" ) 等操作。動作已自動繫結至 dispatch

如果物件很多,這可能會導致效能降低。

在實體的 objectSelectorsobjectActions 中定義的任何其他選取器和動作都會顯示為包裝物件的方法。

進階用法

您也可以直接使用 Redux 動作和選取器,例如 dispatch(Users.actions.loadList())Users.selectors.getList(state)

風格指南

設定 Prettier

我們使用 Prettier 來格式化我們的 JavaScript 程式碼,並由 CI 強制執行。我們建議您將編輯器設定為「儲存時格式化」。您也可以使用 yarn prettier 格式化程式碼,並使用 yarn lint-prettier 驗證是否已正確格式化。

我們使用 ESLint 來強制執行其他規則。它已整合到 Webpack 建置中,或者您可以手動執行 yarn lint-eslint 進行檢查。

React 和 JSX 風格指南

在大多數情況下,我們遵循 Airbnb React/JSX 風格指南。ESLint 和 Prettier 應處理 Airbnb 風格指南中的大多數規則。例外情況將在本文件中註明。

  • 偏好使用 React 函式元件而非類別元件
  • 避免在 containers 資料夾中建立新元件,因為此方法已被棄用。相反地,將已連接和檢視元件都儲存在 components 資料夾中,以獲得更統一和有效率的組織。如果已連接元件的大小大幅增加,並且您需要提取檢視元件,請選擇使用 View 後綴。
  • 對於控制元件,我們通常使用 valueonChange。具有選項的控制項(例如 RadioSelect)通常採用物件的 options 陣列,其中包含 namevalue 屬性。
  • 名稱類似 FooModalFooPopover 的元件通常指的是應在 Modal/ModalWithTriggerPopover/PopoverWithTrigger 內使用的 modal/popover *內容*。
  • 名稱類似 FooWidget 的元件通常在 PopoverWithTrigger 內包含 FooPopover,並帶有一些觸發元素,通常是 FooName

  • 如果您需要在類別中繫結方法(而不是建構函式中的 this.method = this.method.bind(this);),請使用箭頭函式實例屬性,但前提是函式需要繫結(例如,如果您將其作為 prop 傳遞給 React 元件)。
class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    // NO:
    this.handleChange = this.handleChange.bind(this);
  }
  // YES:
  handleChange = e => {
    // ...
  };
  // no need to bind:
  componentDidMount() {}
  render() {
    return <input onChange={this.handleChange} />;
  }
}
  • 對於樣式化元件,我們目前混合使用 styled-components"atomic" / "utility-first" CSS 類別
  • 偏好使用 grid-styledBoxFlex 元件,而不是原始 div
  • 元件通常應將其 className prop 傳遞給元件的根元素。可以使用 classnames 套件中的 cx 函式將其與其他類別合併。
  • 為了使元件更具可重複使用性,元件應僅將類別或樣式套用至元件的根元素,這會影響其自身內容的版面配置/樣式,但 *不* 會影響其自身在其父容器內的版面配置。例如,它可以包含 padding 或 flex 類別,但不應包含 margin 或 flex-fullfullabsolutespread 等。這些應由元件的使用者透過 classNamestyle props 傳遞,使用者知道元件應如何在其自身內定位。
  • 避免在單個元件中將 JSX 分解為單獨的方法呼叫。偏好內聯 JSX,以便您可以更好地了解 render 方法傳回的 JSX 與元件的 stateprops 中的內容之間的關係。透過內聯 JSX,您還可以更好地了解哪些部分應該且不應該是單獨的元件。

// don't do this
render () {
  return (
    <div>
      {this.renderThing1()}
      {this.renderThing2()}
      {this.state.thing3Needed && this.renderThing3()}
    </div>
  );
}

// do this
render () {
  return (
    <div>
      <button onClick={this.toggleThing3Needed}>toggle</button>
      <Thing2 randomProp={this.props.foo} />
      {this.state.thing3Needed && <Thing3 randomProp2={this.state.bar} />}
    </div>
  );
}

JavaScript 慣例

  • import 應按類型排序,通常是
    1. 外部函式庫(react 通常是第一個,以及 ttagsunderscoreclassnames 等)。
    2. Metabase 的頂層 React 元件和容器 (metabase/components/*, metabase/containers/* 等)。
    3. Metabase 的 React 元件和容器,特定於應用程式的此部分 (metabase/*/components/* 等)。
    4. Metabase 的 libs、entitiesservices、Redux 檔案等。
  • 偏好使用 const 而非 let(並且永遠不要使用 var)。僅當您有重新指派識別符的特定原因時才使用 let(注意:現在由 ESLint 強制執行)。
  • 偏好將 箭頭函式 用於內聯函式,特別是當您需要從父作用域參考 this 時(幾乎永遠不需要執行 const self = this; 等),但通常即使您不這樣做也是如此(例如 array.map(x => x * 2))。
  • 偏好將 function 宣告用於頂層函式,包括 React 函式元件。例外情況是傳回值的單行函式。
// YES:
function MyComponent(props) {
  return <div>...</div>;
}
// NO:
const MyComponent = props => {
  return <div>...</div>;
};
// YES:
const double = n => n * 2;
// ALSO OK:
function double(n) {
  return n * 2;
}
  • 偏好使用原生 Array 方法,而不是 underscore 的方法。我們 polyfill 所有 ES6 功能。對於未在本機實作的功能,請使用 Underscore。
  • 偏好使用 async/await,而不是直接使用 promise.then(...) 等。
  • 您可以使用指派解構或引數解構,但請避免深度巢狀解構,因為它們可能難以閱讀,而且 prettier 有時會使用額外的空格來格式化它們。
    • 避免從類似「實體」的物件解構屬性,例如,不要執行 const { display_name } = column;
    • 不要直接解構 this,例如 const { foo } = this.props; const { bar } = this.state;,而不是 const { props: { foo }, state: { bar } } = this;
  • 避免巢狀三元運算子,因為它們通常會導致程式碼難以閱讀。如果您的程式碼中有邏輯分支取決於字串的值,則偏好使用物件作為多個值的地圖(當評估很簡單時)或 switch 陳述式(當評估更複雜時,例如在分支要傳回哪個 React 元件時)。
// don't do this
const foo = str == 'a' ? 123 : str === 'b' ? 456 : str === 'c' : 789 : 0;

// do this
const foo = {
  a: 123,
  b: 456,
  c: 789,
}[str] || 0;

// or do this
switch (str) {
  case 'a':
    return <ComponentA />;
  case 'b':
    return <ComponentB />;
  case 'c':
    return <ComponentC />;
  case 'd':
  default:
    return <ComponentD />;
}

如果您的巢狀三元運算子採用評估為布林值的述詞形式,則偏好使用 if/if-else/else 陳述式,該陳述式已隔離到單獨的純函式。

const foo = getFoo(a, b);

function getFoo(a, b, c) {
  if (a.includes("foo")) {
    return 123;
  } else if (a === b) {
    return 456;
  } else {
    return 0;
  }
}
  • 對於您新增至程式碼庫的註解要保守。註解不應用作提醒或待辦事項 – 透過在 Github 中建立新問題來記錄這些內容。理想情況下,程式碼應以能夠清楚地自我解釋的方式編寫。當程式碼無法清楚地自我解釋時,您應該首先嘗試重寫程式碼。如果由於任何原因您無法清楚地編寫某些內容,請新增註解以解釋「原因」。

// don't do this--the comment is redundant

// get the native permissions for this db
const nativePermissions = getNativePermissions(perms, groupId, {
  databaseId: database.id,
});

// don't add TODOs -- they quickly become forgotten cruft

isSearchable(): boolean {
  // TODO: this should return the thing instead
  return this.isString();
}

// this is acceptable -- the implementer explains a not-obvious edge case of a third party library

// foo-lib seems to return undefined/NaN occasionally, which breaks things
if (isNaN(x) || isNaN(y)) {
  return;
}

  • 避免在 if 陳述式內使用複雜的邏輯表達式。
// don't do this
if (typeof children === "string" && children.split(/\n/g).length > 1) {
  // ...
}

// do this
const isMultilineText =
  typeof children === "string" && children.split(/\n/g).length > 1;
if (isMultilineText) {
  // ...
}
  • 對於常數使用 ALL_CAPS。
// do this
const MIN_HEIGHT = 200;

// also acceptable
const OBJECT_CONFIG_CONSTANT = {
  camelCaseProps: "are OK",
  abc: 123,
};
  • 偏好具名匯出而非預設匯出。
// this makes it harder to search for Widget
import Foo from "./Widget";
// do this to enforce using the proper name
import { Widget } from "./Widget";
  • 避免使用魔術字串和數字。
// don't do this
const options = _.times(10, () => ...);

// do this in a constants file
export const MAX_NUM_OPTIONS = 10;
const options = _.times(MAX_NUM_OPTIONS,  () => ...);

編寫宣告式程式碼

您應該在編寫程式碼時考慮到其他工程師,因為其他工程師會花費比您編寫(和重寫)更多的時間來閱讀。當程式碼告訴電腦「要做什麼」而不是「如何做」時,程式碼更易於閱讀。避免使用命令式模式,例如 for 迴圈。

// don't do this
let foo = [];
for (let i = 0; i < list.length; i++) {
  if (list[i].bar === false) {
    continue;
  }

  foo.push(list[i]);
}

// do this
const foo = list.filter(entry => entry.bar !== false);

在處理商業邏輯時,您不希望擔心語言的細節。您應該引入類似 getQueryFromCard(card) 的函式,而不是撰寫 const query = new Question(card).query(); 這樣的程式碼,因為這需要實例化一個新的 Question 實例,並在該實例上呼叫 query 方法。如此一來,實作者就可以避免思考如何從卡片中取得 query 值。

元件樣式樹狀結構

經典/全域 CSS 與 BEM 樣式選擇器 (已棄用)

.Button.Button--primary {
  color: -var(--mb-color-brand);
}

原子/工具 CSS (不建議使用)

.text-brand {
  color: -var(--mb-color-brand);
}
const Foo = () => <div className="text-brand" />;

行內樣式 (不建議使用)

const Foo = ({ color ) =>
  <div style={{ color: color }} />

CSS Modules (已棄用)

:local(.primary) {
  color: -var(--mb-color-brand);
}
import style from "./Foo.css";

const Foo = () => <div className={style.primary} />;

Emotion

import styled from "@emotion/styled";

const Foo = styled.div`
  color: ${props => props.color};
`;

const Bar = ({ color }) => <Foo color={color} />;

Popover

Popover 是彈出視窗或對話框。

在 Metabase core 中,它們在視覺上具有響應性:它們會出現在觸發其出現的元素上方或下方。它們的高度會自動計算,以使其符合螢幕大小。

在使用者旅程中何處尋找 Popover

建立自訂問題時

  1. 從首頁點擊 New,然後點擊 Question
  2. 👀 自動在 Pick your starting data 旁邊開啟的選項選擇器是一個 <Popover />
  3. 如果尚未選取,請選擇 Sample Database
  4. 選擇任何表格,例如 People

在此,點擊以下項目將開啟 <Popover /> 元件

  • Pick columns (標示為 Data 的區段中 FieldsPicker 控制項右側的箭頭)
  • 標示為 Data 區段下方帶有 + 號的網格灰色圖示
  • 新增篩選條件以縮小您的答案範圍
  • 選擇您想看到的指標
  • 選擇要分組的欄位
  • 位於 Visualize 按鈕上方的 Sort 圖示,帶有向上和向下箭頭

單元測試

設定模式

我們使用以下模式來單元測試元件

import React from "react";
import userEvent from "@testing-library/user-event";
import { Collection } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
import { renderWithProviders, screen } from "__support__/ui";
import CollectionHeader from "./CollectionHeader";

interface SetupOpts {
  collection: Collection;
}

const setup = ({ collection }: SetupOpts) => {
  const onUpdateCollection = jest.fn();

  renderWithProviders(
    <CollectionHeader
      collection={collection}
      onUpdateCollection={onUpdateCollection}
    />,
  );

  return { onUpdateCollection };
};

describe("CollectionHeader", () => {
  it("should be able to update the name of the collection", () => {
    const collection = createMockCollection({
      name: "Old name",
    });

    const { onUpdateCollection } = setup({
      collection,
    });

    await userEvent.clear(screen.getByDisplayValue("Old name"));
    await userEvent.type(screen.getByPlaceholderText("Add title"), "New title");
    await userEvent.tab();

    expect(onUpdateCollection).toHaveBeenCalledWith({
      ...collection,
      name: "New name",
    });
  });
});

重點

  • setup 函式
  • renderWithProviders 新增應用程式使用的 providers,包括 redux

請求模擬

我們使用 fetch-mock 來模擬請求

import fetchMock from "fetch-mock";
import { setupCollectionsEndpoints } from "__support__/server-mocks";

interface SetupOpts {
  collections: Collection[];
}

const setup = ({ collections }: SetupOpts) => {
  setupCollectionsEndpoints({ collections });

  // renderWithProviders and other setup
};

describe("Component", () => {
  it("renders correctly", async () => {
    setup();
    expect(await screen.findByText("Collection")).toBeInTheDocument();
  });
});

重點

  • setup 函式
  • __support__/server-mocks 呼叫 helpers 以為您的資料設定端點

閱讀其他版本的 Metabase 文件