iT邦幫忙

2022 iThome 鐵人賽

DAY 18
0
Modern Web

LV的全端開發體驗系列 第 18

Day18 提升一下操作體驗(一)-vue-final-modal

  • 分享至 

  • xImage
  •  

做到現在,都在忙功能,操作體驗不是太好,因為接下來的功能都是差不多的套路,所以是時候來提升一下操作體驗了。

modal是很常見的應用,以前我都是自己手刻一套來自己用,但這會讓我開發的準備期比較久,就像後台明明有不少套件可以用,但我不自覺得就自己做了。

這次前端的modal想來使用國人開發的一個開源專案 vue-final-modal 有滿完整的 中文官方文件可以參考,應該不會太難上手吧?

安裝

npm install vue-final-modal@next

在專案中註冊vue-final-modal

resources\js\app.js

import { vfmPlugin } from 'vue-final-modal';

const appName = window.document.getElementsByTagName('title')[0]?.innerText || 'Laravel';
const pinia = createPinia()
createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
    setup({ el, app, props, plugin }) {
        return createApp({ render: () => h(app, props) })
            .use(plugin)
            .use(ZiggyVue, Ziggy)
            .use(pinia)
            .use(vfmPlugin)  //註冊vue-final-modal
            .mount(el);
    },
});

做個小測試

resources\js\Pages\Home.vue

.....略
import { VueFinalModal } from "vue-final-modal";

const show=ref(false)

.....略
    //在按鈕上註冊click事件來改變顯示狀態
  <button class="px-4 py-2 border rounded-lg bg-sky-300 text-blue-900"
          @click="show=true">modal</button>

  //利用組件提供的屬性對內部的各區塊進行class設定,像是置中或大小設定等等
  <vue-final-modal v-model="show"  classes="flex justify-center items-center">
    <div class="bg-white h-32 w-32 border rounded-lg text-center p-6">
        modal test
    </div>
  </vue-final-modal>
</FrontLayout>

可想而知,如果要直接在每個頁面都寫這麼一大串modal的內容,那日後的閱讀和維護又會是一個痛點,所以我會把需要使用的modal再包成一個組件來使用。

modal組件

這邊還有另一個問題是 InertiaSPA 的支援上,對於 Modal 的應用並不是很友善,所以如果不會使用到Modal的話,那透過 Inertia 的確是可以不用寫Api,但如果會有 Modal 的應用的話,那透過 Apiaxios之類的 ajax 套件反而比較方便。

我們來嘗試把後台的增改刪都加上 Modal 的應用,讓後台的換頁次數可以少一點。

由於modal 會有子父元件互相傳值的問題,所以我們再次出動 pinia 來處理,建立一個給 modal 專用的 Store :
resources\js\Stores\ModalStore.js

import { defineStore } from "pinia";

export const modalStore=defineStore('modal',{
    state:()=>({
        show:false,
    }),
    actions:{
        modal(action){
            switch(action){
                case 'show':
                    this.show=true;
                break;
                case 'hide':
                    this.show=false;
                break;
            }
        },
    },
})

把原本的 BankForm 頁面,去掉外部的 Layout ,做成一個元件,並引入 Store,以前用 emit 的方式來傳遞關閉的事件到父層去,現在改成是由 pinia 來通知 show 變數改變,接著父組件會被通知到 show 改變了,然後關閉modal:
resources\js\QuizComponents\BankForm.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";
import { modalStore } from "@/Stores/ModalStore.js";
import { storeToRefs } from "pinia";

const props = defineProps({
  header: { type: String, default: "新增題庫" },
  button: { type: String, default: "新增" },
  bank: {
    type: Object,
    default: {
      code: "",
      name: "",
      level: "A",
    },
  },
});

//以下為Store的引入
const store = modalStore();
const { show } = storeToRefs(store);
const { modal } = store;

const form = useForm(props.bank);

//原本的表單動作為完成後回到頁面(redirect())
//現在改成關閉modal
const submit = () => {
  if (typeof form.id !== "undefined") {
    form.put(route("bank.update", form.id),{
      onFinish:()=>{modal('hide')}
    });
  } else {
    form.post(route("bank.store"),{
      onFinish:()=>{ modal('hide') }
    });
  }
};
</script>

<template>
    <!--CSS的部份做了一些調整來適應modal-->
    <div class="p-4 bg-white rounded-lg shadow-xl">
      <h2 class="text-xl font-bold">{{ header }}</h2>
      <div>
        <div>
          <label>題庫代碼:</label>
          <input type="text" name="code" id="code" v-model="form.code" />
        </div>
        <div>
          <label>題庫名稱:</label>
          <input type="text" name="name" id="name" v-model="form.name" />
        </div>
        <div>
          <label>題庫級別:</label>
          <select name="level" id="level" v-model="form.level">
            <option value="A">甲級</option>
            <option value="B">乙級</option>
            <option value="C">丙級</option>
            <option value="D">單一級</option>
          </select>
        </div>
        <button @click="submit" class="my-2 border px-6 py-2 rounded-xl shadow">
          {{ button }}
        </button>
      </div>
    </div>
</template>

原本的題庫主頁一樣引入Store及Modal:
resources\js\Pages\Backstage\Banks.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link } from "@inertiajs/inertia-vue3";
import { VueFinalModal } from 'vue-final-modal';
import  QuizBankForm  from "@/QuizComponents/BankForm.vue";
import { ref } from "vue";
import { modalStore } from "@/Stores/ModalStore.js";
import { storeToRefs } from "pinia";

const props = defineProps({ banks: Array, count: Number ,errors:Object});

//引入Store來使用
const store = modalStore();
const { show } = storeToRefs(store);
const { modal } = store;

</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
    </template>

    <div class="w-[calc(100%_-_12rem)] p-4">
        
      <!--原本的Inertia Link 改成一般的Button,並綁定Store中的modal action-->
      <button class="inline-block py-2 px-3 border rounded-xl bg-blue-700 text-blue-100 my-4"
              @click="modal('show')">
          新增題庫
      </button>

      <div class="w-full px-4 py-2">題庫總數:{{ count }}</div>
      <div v-if="errors.bank" 
           class="text-red-100 px-3 py-1.5 rounded-lg bg-red-500 my-2">
               {{ errors.bank }}
      </div>
      <div v-for="bank in banks"
           :key="bank.id"
           class="w-full border rounded-xl flex p-4 bg-green-400 justify-between">
        <Link :href="route('bank.subjects', bank.id)">
          {{ bank.code }}.{{ bank.name }}{{ bank.levelC }}級
        </Link>
        <div>
          <Link :href="route('bank.edit', bank.id)">編輯</Link>
          /
          <Link :href="route('bank.destroy', bank.id)" 
                method="delete" 
                as="button">刪除</Link>
        </div>
      </div>
    </div>
  </AuthenticatedLayout>

    <!--把Modal放在頁面外部,並綁定Store的show 狀態-->
    <vue-final-modal v-model="show"
      classes="flex justify-center items-center">
        <QuizBankForm />
    </vue-final-modal>    
</template>

因為新增題庫的功能不再需要向後端請求頁面,而是前端直接呼叫出來,所以原本的 bank.create 路由可以刪除
routes\web.php

//可以刪除
Route::get('/bank/create',[BankController::class,'create'])->name('bank.create');

routes\web.php

//可以刪除
Route::get('/bank/create',[BankController::class,'create'])->name('bank.create');

BankController 中的 create 也可以刪除

新增的功能是沒什麼問題,照原本的機制走就可以了,但是編輯會出問題,因為編輯是要帶入資料的,但是 InertiaModal 的支援上是有點問題的,作者表示他目前也沒有什麼好的解決方案,所以建議 Modal 的一些操作還是使用原本的 xhr 就可以了。

所以編輯的按鈕我們會由 InertiaLink 改回 button
resources\js\Pages\Backstage\Banks.vue

<Link :href="route('bank.edit', bank.id)">編輯</Link>

    改成

<button @click="modal('show',bank.id)">編輯</button>

原本 modalStore 中的 action modal() 只是用來改變 show 的值,現在我們加入一個 id 的變數,用來通知表單題庫的 id 值有變化,所以我們來改造一下Store的內容:
resources\js\Stores\ModalStore.js

import { defineStore } from "pinia";

export const modalStore=defineStore('modal',{
    state:()=>({
        show:false,
        bankId:null,  //增加一個bankId的狀態值
    }),
    actions:{
        modal(action,id){
            this.bankId=id??null //根據id的有無來改變bankId的值
            switch(action){
                case 'show':
                    this.show=true;
                break;
                case 'hide':
                    this.show=false;
                break;
            }
        },
    },
})

現在我們可以觸發 BankId 的改變,我們可以讓 BankForm 自己去向後端請求新的 bank 資料,而不是透過 Inertia 的頁面請求;
增加一個 api 路由
routes\api.php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\BankController;

Route::get('/bank/edit/{id}',[BankController::class,'edit'])->name('bank.edit');

原本routes\web.php中同名的路由可以刪了

resources\js\QuizComponents\BankForm.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";
import { modalStore } from "@/Stores/ModalStore.js";
import { storeToRefs } from "pinia";
import { watch, ref } from "vue"; //引入vue的功能
import axios from "axios";

//原本的props用不到了所以刪除,另外建立組件內的變數

const header=ref("新增題庫")
const button=ref("新增")

const store = modalStore();
const { show,bankId } = storeToRefs(store); //引入Store中的bankId
const { modal } = store;

//建立表單初始內容
const form = useForm({ code: "", name: "", level: "A", });

//建立一個重設表單資料的函式,在編輯或新增結束時,重設表單內容
const resetForm=()=>{
    form.code="";
    form.name="";
    form.level="A";
}

//監視 bankId 的變化
watch(bankId,(newId)=>{
  if(newId){  //如果bankId不是null,那表示要進行的是編輯
      
    //使用axios去向後台請求某bankId的資料
    axios.get(route('bank.edit',newId))
      .then((response)=>{
          //根據回傳值資料更新對應的內容
          Object.assign(form,response.data.bank)
          header.value=response.data.header
          button.value=response.data.button
      })
  }
})

const submit = () => {
  if (typeof form.id !== "undefined") {
    form.put(route("bank.update", form.id),{
      onFinish:()=>{
        modal('hide')
        resetForm()  //重設表單
      }
    });
  } else {
    form.post(route("bank.store"),{
      onFinish:()=>{ 
        modal('hide')
        resetForm() //重設表單
      }
    });
  }
};
</script>
.....略

然後原本 BankController 中的 edit 由回傳 Inertia 頁面改成只回傳資料即可
app\Http\Controllers\BankController.php

function edit($id)
{
    return ['header'=>'編輯題庫',
            'button'=>'修改',
            'bank'=>$this->bank->find($id)];
}

改造完成後,原本的頁面 BankForm 被組件 BankForm 取代了,所以少了一個頁面,但多了一個組件。

改用 modal 的方式有時也不單單只是為了提升使用者體驗,在這個範例中,如果所有的功能都是要一個個的獨立頁面來呈現的話,會增加對頁面管理的複雜度。

modal 的使用減少了一些對後端的請求,由整個 inertia 頁面的資料傳遞變成只需要表單的部份資料即可,前端可以更專注在畫面的處理上,後端則是更專注在資料的處理,而改以組件的方式來操作表單,也可以強化組件的獨立性,不再依賴父層元件來傳值,組件自己可以做完的事就自行處理。

當然,沒有什麼方式是絕對的,要看專案的需求如何來決定。


上一篇
Day17 使用Laravel Excel來匯出資料
下一篇
Day19 提升一下操作體驗(二) - SweetAlert
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言