Day36 | WebView Snippets管理页面设计与开发

哈罗,大家好,我是韦恩。今天的文章是系列文的第三十六篇。我们会把完整snippet的元件与routing的部分设定好,份量会有点多。并会於下一篇系列文开始处理WebView与extension资料通信的部分。

Snippets管理页面设计概览


今天我们会处理好snippet的CategoriesPage与SettingsPage的页面,辅助使用者设定snippet。

CategoriesPage作为入口,会展示各类在CodeSnippetWorkspace的程序码片段,使用者将可以在这个页面设计管理Snippet并进行个人配置。SettingsPage则会用於编辑snippet的程序码片段与相关设定。

在设计初期我们会尽量保持简单,着重在功能性与跟VSCode的整合上,让我们开始处理WebView的路由的部分吧!

WebView路由元件设定


在前面文章里,我们设定了简单的router,用以导航snippets与extenions页面。现在我们的snippets页面里,又有category与settings页面需要导航,因此我们需要在/snippets路径底下再接着设定子路由。

在React Router里,主要有两种方式可以达成我们的需求,一个是透过路由表的设定(Route Config),另外我们也可以透过嵌套路由(Nesting Route)实现。

因为我们的页面相对简单,这里我会使用嵌套路由的方式,让我们开始吧!

让我们到WebView专案底下的src/router/router.tsx底下,对原本的RouterPage做改动,全部改动如下:

import React from 'react';
import {
  MemoryRouter as Router,
  Switch,
  Route,
  Redirect
} from "react-router-dom";
import { WhiteSpace, WingBlank } from 'antd-mobile';
import SegmentedNavigator from '../components/navigator';
import SnippetsRouter from './snippets';
import ExtensionsRouter from './extensions';
import './router.css';

export default function AppRouter() {
  return (
    <Router>
      <WingBlank size="lg" className="wing-blank">
        <nav><SegmentedNavigator /></nav>
        <WhiteSpace size={'sm'}/>
        <Switch>
          <Route path="/snippets"  component={SnippetsRouter}></Route>
          <Route path="/extensions" component={ExtensionsRouter}></Route>
        </Switch>
      </WingBlank>
    </Router>
  );
}

这里我们将RouterPage元件重新命名为AppRouter,并对会出元件的index.ts名称做修改。并将path为/snippets跟/extensions的路由中使用的元件重新命名为SnippetRouter与ExtenionsRouter,并将元件透过component这个prop属性传递给route使用。

另外将原本使用的HashRouter换成MemoryRouter,因为在使用HashRouter时,我们无法直接透过hisotry的push方法传递state参数到导航的元件中,对我们的应用而言会有点不方便。但在BrowserRouter跟MemoryRouter里是可以做到这件事的。BrowerRouter在Webview里无法使用,因此我们改为使用MemoryRouter。

在使用MemoryRouter时,不会修改到网址列的url,这个特性对Webview的使用者不会有影响,因为vscode并未公开webview的网址列给user读写,读者们可以放心使用。

因为使用者并不会处理到根路由,接着我们也将<Route exact path="/">...</Route>移除。

接着,让我们在以下路径src/router/snippets创建一个folder,放置snippet-router元件,完成後的router资料夹结构如下:

.
├── index.ts
├── router.css
├── router.tsx
└── snippets
|   ├── index.ts
|   └── snippets.tsx
├── extensions
    ├── extensions.tsx
    └── index.ts

在snippets.tsx下面,我们会实作SnippetsRouter元件。在子router元件里,我们可以拿到 parent router传递过来的match属性,并拿到parent的url,接着我们可以结合parent的path设定底下的path,并传入对应的category与settings的component。

在react router里,另外也提供了useRouteMatch这个hook,帮助我们取得parent的url,是另一种取得parent的url的方式。

import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import CategoryPage from '../../components/snippets/category';
import SettingsPage from '../../components/snippets/settings';

export default function SnippetsRouter({ match: { url } }) {
 return (
  <>
   <Route path={`${url}/categories`} component={CategoryPage}></Route>
   <Route path={`${url}/settings`} component={SettingsPage}></Route>
  </>
 );
}

完成後,让我们将router元件汇出。

import SnippetsRouter from './snippets';

export default SnippetsRouter;

好的,这样一来,我们就完成了子路由的设定。

我们再简单的将Extensions元件改名为ExtensionsRouter,放置於对应的资料夹,暂时不做较大的修改。

import React from 'react';
import { useHistory } from "react-router-dom";

export default function ExtensionsRouter() {
 const history = useHistory();
 return (
  <h2 onClick={() => history.push('/')}>Extensions</h2>
 );
}

完成後,一样在同一层的index.ts中将元件汇出

import ExtensionsRouter from './extensions';

export default ExtensionsRouter;

SnippetStorage资料格式定义


上面我们设定完了Router,现在我们需要在snippets页面会展示与编辑我们的snippets资料。

在前面系列文里,我们订定了CodeSnippet的Workspace里的资料夹结构

.
└── code-manager-snippets
    ├── global.code-snippets
    ├── nodejs
    │   └── nodejs.code-snippets
    ├── javascript
    │   └── javascript.code-snippets
    └── typescript
        └── typescript.code-snippets

当使用者设定code-manager-snippets为snippet的工作区。他可以在global.code-snippet里设定global范围的snippet,也可以使用vscode支援的程序语言id为资料夹名称,在资料夹下面指定各别语言里使用的code snippet。

这些snippet的设定档会在我们的extension载入时被我们开发的套件读取,储存在extension的工作区。

在typescript里面,我们可以使用interface定义这些储存的资料的格式。因此让我们在webview专案的srcfolder下面建立一个model资料夹,并於model/snippets.ts中放置我们会用到的程序码片端相关interface与type定义。

export interface SnippetSetting {
 title: string;
 prefix: string;
 body: string[];
 description: string;
}

export interface SnippetStorageItem {
 category: string; 
 settings: SnippetSetting[];
};

export type SnippetStorage = SnippetStorageItem[];

在上面的类型定义里,全部的snippet资料SnippetStorage会是一个包含多个SnippetStorageItem的阵列。我们使用SnippetStorageItem介面描述不同种类语言的程序码片段资料格式,SnippetStorageItem的category分类为程序码片段的指定程序语言分类,并在settings阵列属性里放置各个程序码片段的设定。

SnippetStorage描述的具体资料格式如下所示:

export const data: SnippetStorage = [
  {
    "category": "global",
    "settings": [
      {
        "title": "Print Global to console",
        "prefix": "global log",
        "body": [
          "console.log('$1');"
        ],
        "description": "Log output to console"
      }
    ]
  },
  {
    "category": "nodejs",
    "settings": [
      {
        "title": "Print Nodejs to console",
        "prefix": "Nodejs log",
        "body": [
          "console.log('$1');"
        ],
        "description": "Log output to console"
      }
    ]
  },
  {
    "category": "javascript",
    "settings": [
      {
        "title": "Print Javascript to console",
        "prefix": "js-log",
        "body": [
          "console.log('$1');"
        ],
        "description": "Log output to console"
      }
    ]
  },
  {
    "category": "typescript",
    "settings": [
      {
        "title": "Print Typescript to console",
        "prefix": "ts log",
        "body": [
          "console.log('$1');"
        ],
        "description": "Log output to console"
      }
    ]
  }
];

上面的模拟资料会被用於今天我们会以元件的资料展示,我们会先使用上面给定的资料,并於下一篇介绍webview跟extension通信的系列文章进行实际资料的串接。

Snippet管理元件开发


好的,现在,我们已经有了展示用的资料。让我们来看一下今天开发的元件资料夹架构吧!

先前在src/components资料夹里面,我们已经写好了navigator元件,使用SegmentedControl这个页面切换用的元件。下面我们创建一个snippets资料夹,在底下创建category与setttings两个资料夹放置对应的CategoryPage与SettingsPage两个元件。同时,我们也将模拟用的资料写於资料夹底下的data.ts,供snippet元件使用。src/component底下的档案结构如下所示:

.
├── navigator
│   ├── index.ts
│   └── navigator.tsx
└── snippets
    ├── category
    │   ├── CategoryPage.css
    │   ├── CategoryPage.tsx
    │   └── index.ts
    ├── data.ts
    └── settings
        ├── SettingsPage.css
        ├── SettingsPage.tsx
        └── index.ts

Category元件开发


在category的地方,我们会在元件载入时取得snippetStorage的资料,这里我们会先直接使用模拟的资料。接着,我们使用手风琴(Accordion)的元件展示各个category分类,并再category里展示各个列表(List)元件。因此这里我们会用两层的map来将snippetStorage里的阵列资料展示出来。

import React from 'react';
import { Accordion, List } from 'antd-mobile';
import { data } from '../data';
import { SnippetSetting, SnippetStorage } from '../../../model';

export default function CategoryPage() {

 const snippetStorage: SnippetStorage = data;

 return (
    <Accordion accordion style={{ borderTop: 0, textTransform: 'capitalize' }}>
     {
      snippetStorage.map((snippets, i) => (
       <Accordion.Panel
        key={snippets.category}
        header={snippets.category}
        style={{ marginBottom: 16 }}
       >
        <List key={snippets.category}>
         {
          snippets.settings.map((setting) => (
           <List.Item
            key={setting.title}
            arrow="horizontal"
            onClick={() => {}}
           >
            { setting.title}
           </List.Item>
          ))
         }
        </List>
       </Accordion.Panel>
      ))
     }
    </Accordion>
 )
}

在react里面,我们会给定使用map渲染的列表一个唯一的key,以提高元件绘制的效能(详见),因此我们在Accordion.Panel与List上都给定对应的key值。

好的,这样一来手风琴元件顺利的展示snippetStorage里的category与里面的settings阵列,结果如下所示。

接着,我们对元件稍做调整,在Accordion上面指定defaultActiveKey属性值,这样元件在载入时预设就会展开对应的Panel。这里我们指定第一个显示出来的分类snippetStorage[0].category,为了预防null值,这里底下我们使用Optional Chaining的语法,让snipeptStorage没有资料时,可以安全的让snippetStorage?.[0].category回传undefined,不会让程序直接产生错误。

  <Accordion accordion defaultActiveKey={snippetStorage?.[0].category} style={{...}}>
     {
      ...
     }
  </Accordion>

接下来,我们宣告onSnippetItemClick这个callback函式,在List元件的点击事件发生时触发。在元件点击时,将导航页面到settings编辑页面。同时,也传递对应的资料到Setting页面。

import React from 'react';
import { Accordion, List, WhiteSpace } from 'antd-mobile';
import { data } from '../data';
import { useHistory } from 'react-router-dom';
import { SnippetSetting, SnippetStorage } from '../../../model';

export default function CategoryPage() {

 const snippetStorage: SnippetStorage = data;

 const history = useHistory();

 const onSnippetItemClick = (setting: SnippetSetting) => {
  history.push('/snippets/settings', { setting });
 }

 return (
  <>
    ...
    <Accordion accordion defaultActiveKey={snippetStorage?.[0].category} style={{...}}>
     {
      snippetStorage.map((snippets, i) => (
       <Accordion.Panel
        ...
       >
        <List key={snippets.category}>
         {
          snippets.settings.map((setting) => (
           <List.Item
            ...
            onClick={() => onSnippetItemClick(setting)}
            ...
           >
            { setting.title}
           </List.Item>
          ))
         }
        </List>
       </Accordion.Panel>
      ))
     }
    </Accordion>
  </>
 )
}

好的,以上我们简单的开发完基本的CategoryPage元件功能。上面的onSnippetItemClick函式,我们还可以再配合react的useCallback使用来减少不必要的元件渲染。限於篇幅,我们无法对每个react效能优化的部分做介绍,有兴趣的读者可以参考官方网站的useCallback对应说明

Snippet Settings元件开发


前面我们透过location.push简单的传递资料到SettingsPage。在SettingsPage,我们可以使用useLocation这个hook取得一个location物件,location物件内含有当前url的各种资讯,其结构如下官方网站的范例资料所示:

{
  key: 'ac3df4', // not with HashHistory!
  pathname: '/somewhere',
  search: '?some=search-string',
  hash: '#howdy',
  state: {
    [userDefined]: true
  }
}

我们可以在location的state属性里取得先前传递给SettingsPage的资料,在下面我们使用Javascript的解构赋值语法取得setting资料。

import { useHistory, useLocation } from 'react-router-dom';

export default function SettingsPage() {
 const { state: { setting } } = useLocation<any();
 return (
  ...
 );
} 

接下来,我们配置好antd-mobile的List与InputItem与TextareaItem,用於展示与修改资料。同时,我们配置两个不同样式的Button,Save与Back,用於点击後保存资料与回到上一页。


import React from 'react';
import { Button, InputItem, List, TextareaItem } from 'antd-mobile';
import { useHistory, useLocation } from 'react-router-dom';
import './SettingsPage.css';

export default function SettingsPage() {
 const { state: { setting } } = useLocation<any>();
 const history = useHistory();
 return (
   <>
    <List renderHeader={() => 'Title'}>
     <InputItem
      name="title"
      type="text"
      placeholder="Input code-snippet title"
      value={setting.title}
     >
     </InputItem>
    </List>
    <List renderHeader={() => 'Prefix'}>
     <InputItem
      name="prefix"
      type="text"
      placeholder="Input code-snippet prefix"
      value={setting.prefix}
     >
     </InputItem>
    </List>
    <List renderHeader={() => 'Description'}>
     <InputItem
      name="description"
      type="text"
      placeholder="Input code-snippet description"
      value={setting.description}
     >
     </InputItem>
    </List>
    <List renderHeader={() => 'Body'}>
     <TextareaItem
      autoHeight={true}
      rows={8}
      value={setting.body.join('\n')}
     >
     </TextareaItem>
    </List>
    <Button type="primary" onClick={() => {})}>Save</Button>
    <Button type="ghost" onClick={() => history.goBack()}>Back</Button>
   </>
 );
} 

全部完成後,将样式稍作调整,使用Ant-Mobile提供的Whitespace元件,或在两个Button中加上css的margin-top属性,将button元件之间的空间留白。这样一来,样式的调整就完成了。

让我们在VSCode里打开Webview,进入SettingsPage页面,查看元件跟VSCode整合起来的状况。

Wow,整体看起来蛮赞的,不是吗? Webview的layout与vscode editor放在一起,并不显得突兀。同时,ant-mobile的表单与Button元件有效分配了元件使用的空间,使整体看起来相当舒服。

值得留意的是,这里的元件我们还是使用接近Desktop的表单元件的风格,并未特别用到ant-mobile一些为手机设计的行为,毕竟Extension的操作者还是在Desktop上操作元件。

加上表单元件的验证功能


好的,上面我们大致完成了UI元件的布局,让我们为SettingsPage元件加上表单验证功能。Ant-Mobile的官方网站范例是使用rc-form这个套件做示范,笔者这里则是使用Formik这个流行的套件辅助表单验证。

首先,让我们安装Formik

yarn add formil

接着,我们安装表单验证用的套件yup

yarn add yup

现在我们就可以在SettingsPage引入套件提供的useFormik的hook使用表单验证的功能。

import { useFormik } from 'formik';
import * as Yup from 'yup';

让我们先传入对应的表单栏位设定(title, prefix, description, bodyString)到useFormik的表单初始值属性(initialValues)中,设定初始化的表单栏位值,也在下面设定对应提交表单的callback函式。

 const formik = useFormik({
  initialValues: {
   title: setting.title,
   prefix: setting.prefix,
   description: setting.description,
   bodyString: setting.body.join('\n'),
  },
  onSubmit: (values) => {
   console.log({
     ...values,
     body: values.bodyString.split('\n')
   });
  }
);

接着,我们使用yup设定各表单栏位验证的设定,让我们先引入yup。

import * as Yup from 'yup';

接着,就可以在formik的validateSchema中使用yup验证对应的栏位,这里我们会验证各表单栏位是否填入,使用yup提供的required属性来验证表单值是否为空值。

 const formik = useFormik({
      ...
      validationSchema: Yup.object().shape({
        title: Yup.string().required('Snippet title is required'),
        prefix: Yup.string().required('Snippet prefix is required'),
        description: Yup.string().required('Snippet description is required'),
        bodyString: Yup.string().required('Snippet body code string is required'),
      }),
      ...
 });

useFormik设定好之後,我们会在元件的最外层加上html的form标签,并绑定表单提交的事件onSubmit,在提交表单时,即触发formik的handleSubmit方法。接着,我们在Save按钮上绑定onClick时会触发formik的submitForm方法。这样只要按save按钮,formik就会触发我们在useFormik里的onSubmit函式。

export default function SettingsPage() {
 ...
 return (
    <form onSubmit={formik.handleSubmit}>
       ...
       <Button type="primary" onClick={() => formik.submitForm()}>Save</Button>
       <Button type="ghost" onClick={() => history.goBack()}>Back</Button>
    </form>
 );
}

注: Ant Mobile不支援submit的button类型,因此这里我们使用onClick处理表单提交。

接着,让我们绑定表单元件与formik事件吧。

在formik和rc-form这些表单套件里,都有提供getFieldProps('表单栏位')的方法,让我们快速绑定表单的各个事件到套件提供的事件。底下这里我们也使用getFieldProps的方式绑定表单。

 <List renderHeader={() => 'Title'}>
     <InputItem
      {...formik.getFieldProps('title')}
      name="title"
      type="text"
      placeholder="Input code-snippet title"
     >
     </InputItem>
 </List>

但只使用这样绑定完後实际上还是有一些问题,因为Formik跟Ant Mobile整合上并不好,使用getFieldProps後我们可以不用直接在元件设置value={formik.values.title}的属性,但在onChange事件与error属性的绑定上会失效,因此这里我们需手动配置。

在绑定onChange时,也需注意要特别使用formik的setFieldValue方法,事件绑定才会成功。

<List renderHeader={() => 'Title'}>
     <InputItem
      {...formik.getFieldProps('title')}
      ...
      onChange={(value) =>formik.setFieldValue('title', value)}
      error={!!formik.errors.title}
     >
 </InputItem>
</List>

注:Formik官方范例的formik.handleChange函式跟Ant Mobile的onChange绑定不起来,这里我们使用formik的setFieldValue方法。

现在我们将表单值清空就会跳出验证错误的Icon了。

绑定好表单的onChang事件与error值後,Ant-Mobile还提供一个onErrorClick的方法,
让使用点击验证错误时呈现的icon後跳出验证的错误讯息提示。

<List renderHeader={() => 'Title'}>
     <InputItem
      {...formik.getFieldProps('title')}
      ...
      onErrorClick={() => onErrorClick('title')}
     >
 </InputItem>
</List>

因此我们再提供一个onErrorClick函式,在点击icon後使用ant mobile的Toast元件方法跳出通知。

 const onErrorClick = (ctrl: string) => {
    Toast.info(formik.errors[ctrl]);
 };

好的,全部完成後的程序码大致如下。

import React from 'react';
import { Button, InputItem, List, TextareaItem, Toast, WhiteSpace } from 'antd-mobile';
import { useHistory, useLocation } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import './SettingsPage.css';

export default function SettingsPage() {
 ...
 return (
     <form onSubmit={formik.handleSubmit}>
    <List renderHeader={() => 'Title'}>
     <InputItem
      {...formik.getFieldProps('title')}
      name="title"
      type="text"
      placeholder="Input code-snippet title"
      onChange={(value) =>formik.setFieldValue('title', value)}
      error={!!formik.errors.title}
      onErrorClick={() => onErrorClick('title')}
     >
     </InputItem>
    </List>
    <List renderHeader={() => 'Prefix'}>
     <InputItem
      {...formik.getFieldProps('prefix')}
      name="prefix"
      type="text"
      placeholder="Input code-snippet prefix"
      onChange={(value) =>formik.setFieldValue('prefix', value)}
      error={!!formik.errors.prefix}
      onErrorClick={() => onErrorClick('prefix')}
     >
     </InputItem>
    </List>
    <List renderHeader={() => 'Description'}>
     <InputItem
      {...formik.getFieldProps('description')}
      type="text"
      placeholder="Input code-snippet description"
      onChange={(value) =>formik.setFieldValue('description', value)}
      error={!!formik.errors.description}
      onErrorClick={() => onErrorClick('description')}
     >
     </InputItem>
    </List>
    <List renderHeader={() => 'Body'}>
     <TextareaItem
      {...formik.getFieldProps('bodyString')}
      autoHeight={true}
      rows={8}
      onChange={(value) =>formik.setFieldValue('bodyString', value)}
      error={!!formik.errors.bodyString}
      onErrorClick={() => onErrorClick('bodyString')}
     >
     </TextareaItem>
    </List>
    <Button type="primary" onClick={() => formik.submitForm()}>Save</Button>
    <Button type="ghost" onClick={() => history.goBack()}>Back</Button>
   </form>
 );
}

好的,现在我们来看一下跳出验证通知讯息效果吧!

这里可以看到验证讯息跟VSCode的整体看起来不会那麽自然,勉强可以接受。更好的方式会是验证失败时在输入框底下秀出红色的错误讯息。因此之後我们将会再对表单的行为做调整。上面是个很好的例子,在设计使用的元件时,我们可以时时检视元件在vscode的整体感觉与行为,让用户拥有更好的使用者体验。

好的,现在让我们在表单的List底下的显示错误的验证讯息,Ant Mobile的List提供了renderFooter这个属性方便我们提供render在List footer的文字,这里我们直接提供formik的对应错误讯息即可,如下所示:

<List 
  renderHeader={() => 'Title'} 
  renderFooter={() => formik.errors.title }
>
...
</List>

接着,让我们在SettingsPage.css里指定对应的style

.am-list .am-list-footer {
 color: red;
}

因为Ant Mobile的renderFooter在函式回传undefined时也会render出外层的renderFooter,会占用一些空间,我们使用css的设定,在产生验证错误时,帮List元件加上formik-error这个class。

<List 
    renderHeader={() => 'Title'} 
    renderFooter={() => formik.errors.title }
    className={ formik.errors.description ? 'formik-error' : null }
>
...
</List>

并在css档案里指定当list元件的footer没有formik-error时,会display为none,让renderFooter在没有验证错误时不会占用元件间的空间。

.am-list:not(.formik-error) .am-list-footer {
 display: none;
}

现在再让我们检视下错误讯息的呈现方式,是不是自然许多呢?

结语


好的,今天的元件开发就到此为止了,相信资讯量比先前一些文章大上许多。

这篇的范例程序其实好几天前就完成了,反而是在文字说明的部分消耗了笔者不少时间。

原则上,笔者会希望尽量用较简单的方式让读者理解,并能在开发相关功能时找到对应的参考资源。

希望读者有机会亲自动动手实作相关功能,并研究文件了解相关的原理,不要因为已经有了范例程序参考,就直接断定完成这些功能会很简单。实际上我们许多程序开发的成本是被许多热心分享的文章作者降低的。

下一篇文章我们会开始设计元件的状态管理与WebView跟Extension之间的互动。

我们下一篇系列文见,掰掰。

参考资源



<<:  SAML Assertion and OIDC Claim

>>:  [PHP][Laravel][Blade]利用asset()设定JS及CSS来源档案却无法使用?先看看...

Day16:【TypeScript 学起来】新增任意属性的好方法:Index Signatures 索引签名

在之前 interface 那篇文章, 认识到可以使用 Index Signatures, 发现他...

#1-连结Hover动起来!(CSS 伪元素)

网站必备!连结动态 连结的Hover动态算是网页动态最基本款, 一个好的动态绝对可以帮网页 点击率(...

Day14 Combine 01 - 简介

Apple 在 WWDC 2019 介绍了全新的 SwiftUI,一个以宣告式结合响应式编程 (FR...

Day 17 - useReducer + useContext = Redux?

如果有错误,欢迎留言指教~ Q_Q 上篇 Day 16 - 用 useReducer 取代 Red...

.NET Core第11天_Controller定义_附加属性_资料接收方式_返回View方式

藉由前几篇简单操作得知网址路由寻访 可以跳至Controller做相应Action Method执行...