iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

0
Software Development

自己用的工具自己做! 30天玩轉VS Code Extension之旅系列 第 36

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之間的互動。

我們下一篇系列文見,掰掰。

參考資源



上一篇
Day35 | WebView元件開發 - Webpack打包工具整合地雷陷阱排除
系列文
自己用的工具自己做! 30天玩轉VS Code Extension之旅36

尚未有邦友留言

立即登入留言