2021/11/24

建立一款問卷調查系統的 React 應用程式


在學習 React 時,最快樂的莫過遇到淺白易吸收的好文章。

Joe Morgan 所寫的【How To Add Login Authentication to React Applications】就是一例。


參考它的流程,讓我想到可以利用它來設計一款問卷調整系統,App 架構和路由經整理後如下圖所示:

 


整體建立流程和原文相同,我僅列出額外延伸的程式說明。

Router v6 的 Switch 被 Routes 取代

把【切換】變更為【多路由】更為合乎其語意。

Router v6 的 component 被 element 取代

不論是【頁面 (Page, View render)還是【元件 (Component)】,在 HTML 裡都是【元素 (Element)】,這也是朝合乎語意的方向修改。

React v6 和原始文章的差異處
React v6 和原始文章的差異處

建構問卷頁面

參考許多問卷頁面,通常一個頁面裡只會有一個問題,所以設計上只需要一個表單頁面 Survey,內容 Survey-Container 元件則是一次填入一個問題,使用者答題後把答案記錄後再換下一個問題,以此類推。

先建立目錄:

 \>mkdir src/components

使用 Visual Studio Code 建立 survey-container.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ./src/components/survey-container.js

import React from "react"

export default function SurveyContainer(props){
    return (<>
        <div>
            <h2>{props.title}</h2>
        </div>
    </>)
}

使用 Visual Studio Code 建立 survey.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ./src/views/survey.js

import React from "react"
import SurveyContainer from "../components/survey-container"

export default function Survey() {
    return <>
        <form>
            <SurveyContainer title="Topic"/>
        </form>
    </>
}

存檔後網站會進行熱更新,此時你在登入後會看到問卷頁面:


接下來,建立模擬來自後端的問卷題目清單,並且添加一個流水號變數依序提交給 SurveyContainer 元件,key 在這裡是必要的,因為 React 會視 key 值變化而刷新元件,就可以達到表單內容刷新的效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// ./src/views/survey.js

import React from "react"
import SurveyContainer from "../components/survey-container"

const surveyItems = [
    {id:"1",title:"Topic1",itemOptions:"optionA;optionB;optionC;optionD"},
    {id:"2",title:"Topic2",itemOptions:"optionA;optionB;optionC;optionD"},
    {id:"3",title:"Topic3",itemOptions:"optionA;optionB;optionC;optionD"}
]

export default function Survey() {
    const [groupNum, setGroupNum] = React.useState(1)

    return <>
        <form>
            <SurveyContainer key={groupNum} {...surveyItems[groupNum - 1]} />
        </form>
    </>
}

關閉 survey.js,對 SurveyContainer 元件添加對題目、選項的渲染,底下加上 <button> 做下一題的切換:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// ./src/views/survey-container.js

import React from "react"

export default function SurveyContainer(props){
    const {id, title} = props
    const itemOptions = props.itemOptions.split(";")
    return (<>
        <div>
            <h2>{title}</h2>
            {itemOptions.map( (element, index) => {
                return (
                    <div key={element+index}>
                        <label><input type="radio" name={id} value={index+1} />{element}</label>
                    </div>
                )
            })}
            <button>Next</button>
        </div>
    </>)
}

SurveyContainer 點擊 <button> 後會使 Survey 觸發變更題號事件,可以在 Survey 建立和 onClick 事件給 SurveyContainer 元件使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// ./src/views/survey.js

export default function Survey() {
    const [groupNum, setGroupNum] = React.useState(1)

    const handleNextClick = (e) => {
        e.preventDefault()

        setGroupNum(prevGroupNum=>{
            const newGroupNum = prevGroupNum+1
            return newGroupNum
        })
    }

    return <>
        <form>
            <SurveyContainer key={groupNum} handleNextClick={handleNextClick} {...surveyItems[groupNum - 1]} />
        </form>
    </>
}

SurveyContainer 元件則可以在 props 取出 handleNextClick 並掛載使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// ./src/views/survey-container.js

import React from "react"

export default function SurveyContainer(props){
    const {id, title, handleNextClick} = props
    const itemOptions = props.itemOptions.split(";")
    return (<>
        <div>
            <h2>{title}</h2>
            {itemOptions.map( (element, index) => {
                return (
                    <div key={element+index}>
                        <label><input type="radio" name={id} value={index+1} />{element}</label>
                    </div>
                )
            })}
            <button onClick={handleNextClick}>Next</button>
        </div>
    </>)
}

到這個階段即可順利載入問卷題目及選項,而且按下 Next 也會跳下一題:

接下來你會發現當進行到最後一題時畫面就消失了,這是因為題號超過了題數,預期的流程是最後一題答題後結束問卷並且統計得分。

Survey 頁面設定【最後一題】旗標,讓 SurveyContainer 能依照此屬性變化顯示內容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ./src/views/survey.js

export default function Survey() {
    const [groupNum, setGroupNum] = React.useState(1)
    const [isLast, setIsLast] = React.useState(false)

    const handleNextClick = (e) => {
        e.preventDefault()

        setGroupNum(prevGroupNum=>{
            const newGroupNum = prevGroupNum+1
            if (surveyItems.length === newGroupNum)
                setIsLast(true)
            return newGroupNum
        })
    }

    return <>
        <form>
            <SurveyContainer key={groupNum} handleNextClick={handleNextClick} isLast={isLast} {...surveyItems[groupNum - 1]} />
        </form>
    </>
}

SurveyContainer 可以在最後一題時將 Next 字樣修改為 Finish,讓使用者知道已經到最後一題:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ./src/views/survey-container.js

export default function SurveyContainer(props){
    const {id, title, isLast, handleNextClick} = props
    const itemOptions = props.itemOptions.split(";")
    return (<>
        <div>
            <h2>{title}</h2>
            {itemOptions.map( (element, index) => {
                return (
                    <div key={element+index}>
                        <label><input type="radio" name={id} value={index+1} />{element}</label>
                    </div>
                )
            })}
            <button onClick={handleNextClick}>{isLast?"Finish":"Next"}</button>
        </div>
    </>)
}

此外,使用者在還沒答題前,NextFinish 按鈕不應該出現,應該在使用者選擇答案項目後才能顯示。SurveyContainer 要加入 onChange 事件並對 <button> 進行顯示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ./src/views/survey-container.js

export default function SurveyContainer(props){
    const [showClick, setShowClick] = React.useState(false)
    const {id, title, isLast, handleNextClick} = props
    const itemOptions = props.itemOptions.split(";")
    const handleOptionChange = (e) => {
        if (!showClick)
            setShowClick(!showClick)
        
    }
    return (<>
        <div>
            <h2>{title}</h2>
            {itemOptions.map( (element, index) => {
                return (
                    <div key={element+index}>
                        <label><input type="radio" name={id} value={index+1} onChange={handleOptionChange} />{element}</label>
                    </div>
                )
            })}
            <button style={{display:showClick?"block":"none"}} onClick={handleNextClick}>{isLast?"Finish":"Next"}</button>
        </div>
    </>)
}

此時畫面變化如下:


使用者答題後要將答題內容保存,在 Survey 增加 ansers 狀態及 handleSetAnser 事件 ,並提交給 SurveyContainer 使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ./src/views/survey.js

export default function Survey() {
    const [groupNum, setGroupNum] = React.useState(1)
    const [isLast, setIsLast] = React.useState(false)
    const [ansers, setAnsers] = React.useState([])

    const handleNextClick = (e) => {
        e.preventDefault()

        setGroupNum(prevGroupNum=>{
            const newGroupNum = prevGroupNum+1
            if (surveyItems.length === newGroupNum)
                setIsLast(true)
            return newGroupNum
        })
    }
    const handleSetAnser = (value) => {
        const newAnsers = [...ansers]
        newAnsers[groupNum-1] = value
        setAnsers(newAnsers)
    }

    return <>
        <form>
            <SurveyContainer key={groupNum} handleNextClick={handleNextClick} setAnser={handleSetAnser} isLast={isLast} {...surveyItems[groupNum - 1]} />
        </form>
    </>
}

SurveyContainer 則在使用者選擇項目的當下儲存答案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ./src/views/survey-container.js

export default function SurveyContainer(props){
    const [showClick, setShowClick] = React.useState(false)
    const {id, title, isLast, handleNextClick, setAnser} = props
    const itemOptions = props.itemOptions.split(";")
    const handleOptionChange = (e) => {
        if (!showClick)
            setShowClick(!showClick)
        setAnser(e.target.value)
    }
    return (<>
        <div>
            <h2>{title}</h2>
            {itemOptions.map( (element, index) => {
                return (
                    <div key={element+index}>
                        <label><input type="radio" name={id} value={index+1} onChange={handleOptionChange} />{element}</label>
                    </div>
                )
            })}
            <button style={{display:showClick?"block":"none"}} onClick={handleNextClick}>{isLast?"Finish":"Next"}</button>
        </div>
    </>)
}

答題和使用者介面調整結束後,就要進行統計和顯示成績,Survey 增加結束旗標 finish,使用 useEffect 判斷 finish 後使用 useNavigate 跳轉到成績頁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// ./src/views/survey.js

import React from "react"
import { useNavigate } from "react-router"
import SurveyContainer from "../components/survey-container"

const surveyItems = [
    {id:"1",title:"Topic1",itemOptions:"optionA;optionB;optionC;optionD"},
    {id:"2",title:"Topic2",itemOptions:"optionA;optionB;optionC;optionD"},
    {id:"3",title:"Topic3",itemOptions:"optionA;optionB;optionC;optionD"}
]

export default function Survey() {
    const [groupNum, setGroupNum] = React.useState(1)
    const [isLast, setIsLast] = React.useState(false)
    const [ansers, setAnsers] = React.useState([])
    const [finished, setFinished] = React.useState(false)

    const navigate = useNavigate();

    React.useEffect(()=>{
        if (finished)  {
            // calc score
            navigate('/score/100')
        }
    },[finished])

    const handleNextClick = (e) => {
        e.preventDefault()

        setGroupNum(prevGroupNum=>{
            let newGroupNum = prevGroupNum+1
            if (surveyItems.length === newGroupNum)
                setIsLast(true)
            if (isLast) {
                setFinished(true)
                newGroupNum = prevGroupNum
            }
            return newGroupNum
        })
    }
    const handleSetAnser = (value) => {
        const newAnsers = [...ansers]
        newAnsers[groupNum-1] = value
        setAnsers(newAnsers)
    }

    return <>
        <form>
            <SurveyContainer key={groupNum} handleNextClick={handleNextClick} setAnser={handleSetAnser} isLast={isLast} {...surveyItems[groupNum - 1]} />
        </form>
    </>
}

newGroupNum 變為 let 宣告是因為避免超過總題數造成 SurveyContainer 無法讀取題目的異常,進而無法順利進行頁面跳轉,順利跳轉後如下圖顯示成績:


使用 Delphi DataSnap 建立 Token API

關於 DataSnap 建立的內容有很多,可以參考:

主程式內容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
unit ServerMethodsUnit1;

interface

uses
  SysUtils, Classes, DSServer, DBXJSON;

type
{$METHODINFO ON}
  TServerMethods1 = class(TDataModule)
  private
    { Private declarations }
  public
    { Public declarations }
    function Login(Credentials: TJSONObject): string;
  end;
{$METHODINFO OFF}

implementation

{$R *.dfm}

uses StrUtils;

function TServerMethods1.Login(Credentials: TJSONObject): string;
begin
  Result := '{"token": "test123"}';
end;

end.

後端的程式內容非常簡單,cors 則在 webpack.config.js 就搞定,這裡就不再詳述,而 login.jsloginUser 函式則作了點小小小的異動:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ./src/views/login.js

async function loginUser(credentials){
    return fetch('/datasnap/rest/TServerMethods1/"Login"',{
        method:'POST',
        headers:{
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(credentials)
    })
    .then(response=>response.json())
    .then(resultObj=>resultObj[0])
}

完成後你將可以看到登入畫面以及問卷內容和成績統計:



之後呢?

哈哈,被發現了嗎,本次範例雖然將問卷全流程解析,但並沒有後端計算成績的 API,但對你來說,到了這個階段都是分秒能完成的小事了,留待你的各種發揮 😉

總結

在看完 Joe Morgan 所寫的【How To Add Login Authentication to React Applications】後,真的很開心自己也能透過作者的文章做出更加延伸的應用程式,趁著記憶猶新,趕緊記錄下來。

本次的程式碼會放在 See also 內。

和你分享 😉

See also

沒有留言:

張貼留言