developer from Unsplash
在 產品工匠日常:打造全端產品的宏觀程序 中,彙整了從頭到尾開發一個全端產品(web app)所需的各個程序。仔細閱讀後會發現「3.0 開發主要功能」的部分著墨不多,原因是只要以筆記中的五個步驟,基本上大部分的功能應該都能實現,決定速率的通常是對解決特定技術問題的熟練程度。
然而,經過一連串的功能實作後,我發現有些用以強化使用者體驗的功能其實會互相影響。原因是他們都針對同一批資料進行處理,並且產生交集或聯集。本篇將記錄我在整合「排序 sort、篩選 filter 及 搜尋 Search」功能時遇到的問題,及如何尋求協助以解決問題。
本篇筆記將將接續 前篇,提供刻意練習的心得,並將解決以下問題:
誰適合閱讀:
由於在 課程 的上個階段,已經有整合多個處理資料功能的經驗,這次我在實作前就已經安排好順序,並思考過可能發生衝圖及矛盾的地方。印象中,前次就是做到一半才發現功能會互相抵觸,而且資料處理的邏輯散亂在各支函式當中,折騰了一番才梳理出解決方案。
以下整理出我這次的處理邏輯:
在實作這項功能前,我想起之前使用 Yourator 時,對他們的搜尋功能印象深刻,使用體驗也不錯;但由於這份作業時間有限,我只取部分元素來優化。打算在之後的 side project 中再來復刻。
這是一個記帳程式的部分功能,初步的構想是
由於當初只用紙筆畫下需求,這邊提供最終結果的截圖。在首頁的最上方依序排列「依複數類別篩選 filter」、「以名稱搜尋 search」、「按金額排序 sort」等功能,並共用一個確認送出按鍵(submit button)。總金額則是加總篩選結果的金額。
我由左至右依序開發,如此設計的邏輯是:以「資料處理的難易度」由高至低排序。因為隨著進度推進,多個功能開始共同處理資料時,邏輯複雜度會逐步上升;若一開始就把複雜的邏輯處理完,後續僅需稍加處理就能整合新增的功能。
開發各項功能的紀錄可以參考我的 GitHub,這邊提供第一版功能的路由內 MVC 邏輯:
// Set routes to filter, search record
router.get('/', (req, res) => {
const { filter } = req.query // 篩選的類別
const keyword = req.query.keyword.trim() // 搜尋的關鍵字
const sort = req.query.sort // 排序的方式
Category.find()
.lean()
.sort({ _id: 'asc' })
.then(categories => { // 撈出所有類別是為了用在篩選類別的 select options 中
Record.find({ category: filter })
.populate('category')
.lean()
.sort({ amount: sort })
.then(records => {
// search keyword
records = records.filter(record => record.name.toLowerCase().includes(keyword.toLowerCase()))
// checked total amount
let totalAmount = 0
records.forEach(record => totalAmount += record.amount)
// render records
res.render('index', { records, totalAmount, categories, keyword, sort })
})
.catch(error => console.error(error))
})
.catch(error => console.error(error))
})
其實上面這版已經優化部分體驗,如保留關鍵字及排序方式。但由於 Handlebars 的限制,在第一次完成時,始終找不到好的解決方案,以保留使用者選取的類別。
在經過一番觀摩和查詢資料後,我重新設計了篩選器,並且加上了「選取全部」及「清除全部」的兩顆按鈕,方便使用者要更換篩選類別時,不必逐一取消、再逐一開啟。並且區分選取及未選取的類別,分開渲染,已達到保留使用者選取紀錄的目的。
// Set routes to filter, search record
router.get('/', (req, res) => {
const { filter } = req.query
const keyword = req.query.keyword.trim()
const sort = req.query.sort
Category.find()
.lean()
.sort({ _id: 'asc' })
.then(categories => {
// checked select options
let checkedCategories = []
let otherCategories = []
categories.forEach(category => {
if (filter.includes(category._id.toString())) {
checkedCategories.push(category)
} else {
otherCategories.push(category)
}
})
Record.find({ category: filter })
.populate('category')
.lean()
.sort({ amount: sort })
.then(records => {
// search keyword
records = records.filter(record => record.name.toLowerCase().includes(keyword.toLowerCase()))
// checked total amount
let totalAmount = 0
records.forEach(record => totalAmount += record.amount)
// render records
res.render('index', { records, totalAmount, keyword, sort, checkedCategories, otherCategories })
})
.catch(error => console.error(error))
})
.catch(error => console.error(error))
})
{{!-- filter switch --}}
<div class="filter col-md-3 my-1">
<div class="card mb-3" id="category-filter">
<div class="card-header bg-secondary text-light">
篩 選 類 別
</div>
<div class="card card-body container">
<div id="options" class="row">
{{#each checkedCategories}}
<div class="custom-control custom-switch col-4 col-md-6 pb-3">
<input type="checkbox" class="custom-control-input" id="customSwitch{{this._id}}" name="filter"
value="{{this._id}}" checked>
<label class="custom-control-label" for="customSwitch{{this._id}}">{{this.title}}</label>
</div>
{{/each}}
{{#each otherCategories}}
<div class="custom-control custom-switch col-4 col-md-6 pb-3">
<input type="checkbox" class="custom-control-input" id="customSwitch{{this._id}}" name="filter"
value="{{this._id}}">
<label class="custom-control-label" for="customSwitch{{this._id}}">{{this.title}}</label>
</div>
{{/each}}
</div>
<div id="all-none-btn" class="btn-group mt-3" role="group" aria-label="all-none-btn">
<button id="selectAll" type="button" class="btn btn-outline-dark">選擇全部</button>
<button id="clearAll" type="button" class="btn btn-outline-dark">清除全部</button>
</div>
</div>
</div>
</div>
document.querySelector('#selectAll').addEventListener('click', () => {
$('input[type="checkbox"]').prop('checked', true)
})
document.querySelector('#clearAll').addEventListener('click', () => {
$('input[type="checkbox"]').prop('checked', false)
})
關於本系列更多內容及導讀,請閱讀作者於 Medium 個人專欄 【無限賽局玩家 Infinite Gamer | Publication – 】 上的文章 《用 JavaScript 打造全端產品的入門學習筆記》系列指南。