前端

实体加载器

如果您正在开发新功能,或者只是通常需要在前端获取一些应用程序数据,那么实体加载器将是您的好帮手。它们抽象了调用 API、处理加载和错误状态、缓存先前加载的对象、使缓存失效(在某些情况下),并让您轻松执行更新或创建新项目。

实体加载器的良好用途

  • 我需要获取特定的 X(用户、数据库等)并显示它。
  • 我需要获取 X 列表(数据库、问题等)并显示它。

当前可用的实体

  • 问题、仪表板、脉冲
  • 集合
  • 数据库、表、字段、分段、指标
  • 用户、群组
  • 此处提供实体完整列表:https://github.com/metabase/metabase/tree/master/frontend/src/metabase/entities

有两种使用加载器的方法,一种是作为 React “渲染属性”组件,另一种是作为 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 的数据库。如果您改为使用渲染属性组件,您的代码将如下所示。

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)通常采用包含具有 namevalue 属性的对象的 options 数组。
  • 名为 FooModalFooPopover 的组件通常是指模态/弹出窗口内容,该内容应在 Modal/ModalWithTriggerPopover/PopoverWithTrigger 内使用
  • 名为 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“原子化”/“实用至上”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 的 libentitiesservices、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 中创建一个新的 issue 来记录这些。理想情况下,代码应该以能够清晰地解释自身的方式编写。如果代码无法做到这一点,你应该首先尝试重写代码。如果由于任何原因你无法清晰地编写代码,请添加注释来解释“为什么”。

// 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);

当处理业务逻辑时,你不想关心语言的具体细节。与其编写 const query = new Question(card).query(); 这样需要实例化一个新的 Question 实例并在该实例上调用 query 方法的代码,不如引入一个像 getQueryFromCard(card) 这样的函数,以便实现者可以避免考虑如何从卡片中获取 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 模块(已弃用)

: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

Popovers 是弹出窗口或模态框。

在 Metabase core 中,它们在视觉上是响应式的:它们出现在触发其出现的元素上方或下方。它们的高度会自动计算,以使其适应屏幕。

在用户旅程中哪里可以找到 Popovers

当创建自定义问题时

  1. 从主页,点击 New,然后点击 Question
  2. 👀 自动在 Pick your starting data 旁边打开的选项选择器是一个 <Popover />
  3. 选择 Sample Database,如果尚未选择
  4. 选择任何表格,例如 People

在这里,点击以下内容将打开 <Popover /> 组件

  • Pick columnsFieldsPicker 控件右侧的箭头,在标记为 Data 的部分中)
  • 灰色网格图标,下方带有 + 号,位于标记为 Data 的部分下方
  • 添加过滤器以缩小你的答案范围
  • 选择你想看到的指标
  • 选择一个列进行分组
  • Sort 图标,带有向上和向下箭头,位于 Visualize 按钮上方

单元测试

Setup 模式

我们使用以下模式来单元测试组件

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 的文档。