코딩 강의/컨텐츠를 만들어 볼까요

웹 언어 코딩으로 심리 테스트 만들기 #5 선다형, 단답형 복합 구현

아미넴 2021. 2. 26.
반응형

이번 시간은 기능을 대폭 강화해 보도록 할게요. 이전, 다음 버튼을 실제로 용도에 맞게 완성시키고 질문, 답변도 좀 더 다양하게 구성할 수 있도록 개선할 예정입니다.

 

웹 언어 코딩으로 심리 테스트 만들기 #4 데이터 입력

 

웹 언어 코딩으로 심리 테스트 만들기 #4 데이터 입력

기다리시는 분이 계셨을 지는 모르겠지만 ㅎㅎ 조금 늦어져서 죄송합니다. 이번에는 깊게 들어가면 복잡한 고급 스킬이지만 단순히 따라하기는 어렵지 않은 내용을 다루어 보겠습니다. 웹 언어

sangminem.tistory.com

 

목차

     

    HTML 구성 변경

    먼저 가장 기본이 되는 화면 구성부터 손 보겠습니다. test.html 파일에 변경된 부분이 좀 많습니다.

    <!DOCTYPE html>
    <html>
        <head>
            <meta name="viewport" content="initial-scale=1.0, width=device-width">
            <link rel="stylesheet" href="./css/test.css">
            <title>샘플 심리 테스트</title>
            <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js"></script>
            <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
        </head>
        <body>
            <div id="test">
                <div id="intro" class="intro-wrap">
                    <!-- 도입부 -->
                    <div class="intro">
                        <div class="intro-story" v-on:click="start">{{intro}}</div>
                    </div>
                </div>
                <div id="main">
                    <!-- 타이틀 -->
                    <div class="title-wrap">
                        <h2 class="title">{{title}}</h2>
                    </div>
    
                    <!-- 질문 -->
                    <div class="question-wrap">
                        <h3 class="question">
                            {{qna[currentIndex].q}} <!-- question[index] -> qna[currentIndex].q 로 변경 -->
                        </h3>
                    </div>
                    
                    <!-- 답변 -->
                    <div class="answer-wrap">
                        <!-- 선다형, 단답형 구현을 위해 변경 시작 -->
                        <div v-for="(qVal, qIdx) in qna" :id="'q'+qIdx">
                            <div v-if="qna[qIdx].a != null">
                                <div v-if="qIdx == currentIndex" v-for="(aVal, aIdx) in qna[qIdx].a" class="answer">
                                    <input type="radio" :name="'q'+qIdx" :id="'q'+qIdx+'a'+aIdx" :value="qna[qIdx].a[aIdx]" v-model="qna[qIdx].r">
                                    <label :for="'q'+qIdx+'a'+aIdx">{{aVal}}</label>
                                </div>
                            </div>
                            <div v-if="qna[qIdx].a == null">
                                <input v-if="qIdx == currentIndex" class="answer-text" type="text" :id="'q'+qIdx+'a0'" v-model="qna[qIdx].r" placeholder="">
                            </div>
                        </div>
                        <!-- 선다형, 단답현 구현을 위해 변경 끝 -->
                    </div>
                    
                    <!-- 하단 버튼 -->
                    <div class="bottom">
                        <div class="controller-wrap">
                            <button class='prev-btn' v-on:click="prev">이전</button>
                            <button class='next-btn' v-on:click="next">다음</button>
                        </div>
                    </div>
                </div>
                
                <!-- 결과 -->
                <div id="result" class="result-wrap">
                    <div class="result">{{result}}</div>
                </div>
            </div>
            <script src="./js/test.js"></script>
        </body>
    </html>

    처음에 따로 question 배열로 선언하여 관리했던 질문 변수를 질문과 답을 하나로 묶어 관리하는 것이 낫겠다고 판단하여 qna라는 배열을 선언해서 그 안에 q라는 필드에 배열 형태로 삽입했습니다. 데이터 구조는 JSON 오브젝트 형태인데 복잡하지는 않으니 잘 모르셔도 일단 따라 작성하시기 바랍니다. 그리고 질문 index 변수명을 currentIndex로 변경하였습니다.

     

    다음에 나오는 선다형, 단답형을 구성하는 부분이 모든 부분 통틀어서 가장 복잡합니다. v-if 부터 v-for, v-model 등 당장 설명하기 어려운 개념들이 많이 등장합니다. 모든 것을 상세히 설명하려면 따로 시간을 내야할 정도로 분량이 많으므로 간단히만 설명하고 넘어가려고 합니다. 좀 더 공부하고 싶은 분들을 위해 참고할 수 있는 아래 링크를 남겨 놓겠습니다.

     

    v-if는 선다형과 단답형 유형을 구분하고 인덱스에 따른 현재 문항을 가져오기 위해 사용하였고, v-for는 선다형의 문항 개수가 여러 개이므로 반복적으로 구성하기 위해 사용하였습니다. v-model은 radio 버튼으로 선택한 값을 저장하기 위한 변수를 대입하는 속성입니다.

     

    v-if - 조건부 렌더링

     

    조건부 렌더링 — Vue.js

    Vue.js - 프로그레시브 자바스크립트 프레임워크

    kr.vuejs.org

    v-for - 리스트 렌더링

     

    리스트 렌더링 — Vue.js

    Vue.js - 프로그레시브 자바스크립트 프레임워크

    kr.vuejs.org

    v-model - 폼 입력 바인딩

     

    폼 입력 바인딩 — Vue.js

    Vue.js - 프로그레시브 자바스크립트 프레임워크

    kr.vuejs.org

     

    질문 답 데이터 입력

    다음으로 test.js 파일에 질문 답 데이터를 입력해 보도록 하겠습니다. 개념을 완벽히 숙지하지 않아도 차근차근 따라가시면 어렵지 않습니다.

    var test = new Vue({
        el: '#test',
        data: {
            intro: '테스트를 시작 합니다',
            title: '샘플 테스트',
            currentIndex: 0, // index 에서 이름 변경
            qna: [], // 새로 선언, question[], answer[], selection[] 제거
            result: ''
        },
        beforeMount: function() {
            // 질문, 답 데이터 입력
            this.insertQna('Q1. 숲 속을 걷고 있는 당신 앞에 불쑥 나타난 동물! 어떤 동물일까?', null, 'text');
            this.insertQna('Q2. 당신의 눈 앞에 보이는 벌레는 몇 마리일까?', [1,2,3,4,5], null);
        },
        mounted: function() {
            // 생략
        },
        methods: {
            // 질문, 답을 입력하기 위한 함수 구현
            insertQna: function(q, a, t) {
                // 질문, 답, 선택, 타입(문자, 숫자 등)으로 구성된 JSON 형태의 데이터 오브젝트
                var item = {
                    q: q,
                    a: a,
                    r: '',
                    t: t
                };
                // qna 배열에 삽입
                this.qna.push(item);
            }
        },
        // 생략
    });

    질문 답을 입력 받기 위해 insertQna라는 메서드를 구현하였습니다. q에는 질문이 들어가고 a에는 답지가 들어가는데 단답형은 답지가 비어 있어야 하므로 null을 넣고 숫자, 문자를 구분하기 위해 t에 타입(text, number 등)을 넣어 주도록 하였습니다. 그리고 선다형일 경우에는 a에 선택 문항을 배열 형태로 넣어주었습니다. 직접 값을 입력하는 것이 아니라 선택하는 것이므로 t에 타입을 지정하지 않고 null을 넣어 주었습니다. 데이터는 화면이 구성되기 전에 미리 입력되어 있어야 하므로 beforeMount 영역에 작성해 주었습니다. vueJS에서 제공하는 기능 중 하나이므로 알고만 넘어가시면 됩니다.

     

     

    이전, 다음 버튼 구현

    이번에는 지난 시간까지 적당히 넘겼던 이전, 다음 버튼을 제대로 구현해 보겠습니다.

    var test = new Vue({
        el: '#test',
        data: {
            // 생략
        },
        beforeMount: function() {
            // 생략
        },
        mounted: function() {
            // 생략
        },
        methods: {
            // 생략
            start: function() {
                $('#intro').hide();
                $('#main').show();
                $('#result').hide();
    
                var self = this;
                setTimeout(function() {
                    if(typeof self.qna[0].t != 'undefined' && self.qna[0].t != null) {
                        $('#q0a0').attr('type', self.qna[0].t);
                        $('#q0a0').focus();
                    }
                }, 200);
            },
            next: function () {
                var self = this;
                if(this.currentIndex < this.qna.length-1) {
                    this.currentIndex++;
                    if(typeof this.qna[this.currentIndex].t != 'undefined' && this.qna[this.currentIndex].t != null) {
                        setTimeout(function() {
                            $('#q'+self.currentIndex+'a0').attr('type', self.qna[self.currentIndex].t);
                            $('#q'+self.currentIndex+'a0').focus();
                        }, 200);                    
                    }
                } else {
                    var check = true;
                    for(var i=0; i < this.qna.length; i++) {
                        if(this.qna[i].r === '') {
                            check = false;
                        }
                    }
                    if(check) {
                        this.showResult();
                    } else {
                        alert("아직 완료되지 않았습니다.");
                    }
                }
            },
            prev: function () {
                var self = this;
                if(this.currentIndex > 0) {
                    this.currentIndex--;
                    if(typeof this.qna[this.currentIndex].t != 'undefined' && this.qna[this.currentIndex].t != null) {
                        setTimeout(function() {
                            $('#q'+self.currentIndex+'a0').attr('type', self.qna[self.currentIndex].t);
                            $('#q'+self.currentIndex+'a0').focus();
                        }, 200);                    
                    }
                } else {
                    alert('첫 질문입니다.');
                }
            }
        }
    });

    다음 버튼을 누를 경우(next 메서드) 전체 질문 수보다 currentIndex 값이 적으면 다음 질문으로 이동하도록 하였고 그 때 선다형인 지 단답형인 지를 판단하여 단답형일 경우 커서 포커스를 입력 박스에 두도록 하였습니다. 더 이상 다음 질문이 없을 경우에는 먼저 모든 답이 입력되었는 지 판단하여 전부 입력되었으면 결과 화면으로 이동하게 하였고 완료되지 않은 문항이 있으면 알림 창을 띄워 주도록 하였습니다.

     

    이전 버튼도 마찬가지로(prev 메서드) 첫 번째 질문이 아닐 경우만 제외하고 제한 없이 이동할 수 있도록 하였고 마찬가지로 단답형일 경우 입력 박스에 포커스를 가져 가도록 했습니다. 이전 버튼 누른 위치가 첫 번째 질문이라면 알림 창을 띄워 주었습니다.

     

    참고로 최초 메인 화면 진입 시(start 메서드) 첫 번째 질문에서도 단답형일 경우 입력 박스 포커스를 두도록 하였습니다.

     

    결과 화면 구성

    마지막으로 답변을 토대로 결과 화면을 구성해 보겠습니다. 이번 시간에는 기능만 간단하게 구현할 생각입니다.

    var test = new Vue({
        el: '#test',
        data: {
            // 생략
        },
        beforeMount: function() {
            // 생략
        },
        mounted: function() {
            // 생략
        },
        methods: {
            // 생략
            showResult: function() {
                this.result =  'Q1. 불쑥 나타난 동물 = '+this.qna[0].r+'\n=> 사람들이 나를 보는 모습\n\n';
                this.result += 'Q2. 눈 앞의 벌레 수 = '+this.qna[1].r+'마리\n=> 나를 화나게 하는 사람 수\n\n';
                $('#main').hide();
                $('#result').show();
            }
        }
    });

    답변과 함께 그 의미를 간단하게 텍스트로 구성하여 result 변수에 담아 보았습니다. 그리고 jQuery를 이용하여 메인 블럭은 숨기고 결과 블럭을 나타나게 하였습니다. 결과 블럭에 구성한 result 변수 내용이 보여지게 됩니다.

     

    CSS 꾸미기

    input 박스를 있는 그대로 사용하면 예쁘지 않아서 새롭게 꾸며보겠습니다.

    input {
        color: rgb(40, 92, 24);
    }
    input:focus {
        color: rgb(40, 92, 24);
    }
    input[type=text],
    input[type=number] {
        background: none;
        border: 0;
        border-bottom: solid 3px rgb(40, 92, 24);
    }
    input::placeholder {
        color:  rgb(40, 92, 24);
        opacity: 0.5;
    }
    input:focus,
    select:focus,
    textarea:focus,
    button:focus {
        outline: none;
    }

    input 태그의 기본 속성을 대부분 제거하고 새롭게 작성하였습니다. 꾸미는 부분은 따로 자세히 설명하지는 않겠습니다. 일단 그대로 작성하시고 본인만의 스타일을 적용하고 싶으신 분은 CSS 문법 공부를 조금만 공부해서 변경해 보세요. 여기저기 쓸모가 많아서 한 번 알아 두시면 손해는 아닐 겁니다.

     

    다음은 새롭게 추가한 단답형 관련 클래스 입니다.

    .answer-text {
        margin-left: 20px;
        padding-bottom: 5px;
    }

    여백, 간격 등만 조절한 코드이므로 특별히 설명할 부분은 없습니다.

     

     

    전체 소스 제공

    설명이 다소 복잡하고 이곳 저곳 많이 고쳐서 헷갈릴 소지가 있으므로 중간 점검 차원에서 현재까지 개발한 소스를 공유하겠습니다.

    test.html 소스 내용

    입력 후 파일 이름을 test.html로 저장해 주시면 됩니다.

    <!DOCTYPE html>
    <html>
        <head>
            <meta name="viewport" content="initial-scale=1.0, width=device-width">
            <link rel="stylesheet" href="./css/test.css">
            <title>샘플 심리 테스트</title>
            <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js"></script>
            <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
        </head>
        <body>
            <div id="test">
                <!-- 도입부 -->
                <div id="intro" class="intro-wrap">
                    <div class="intro">
                        <div class="intro-story" v-on:click="start">{{intro}}</div>
                    </div>
                </div>
                <div id="main">
                    <!-- 타이틀 -->
                    <div class="title-wrap">
                        <h2 class="title">{{title}}</h2>
                    </div>
                    <!-- 질문 -->
                    <div class="question-wrap">
                        <h3 class="question">
                            {{qna[currentIndex].q}}
                        </h3>
                    </div>
                    <!-- 답변 -->
                    <div class="answer-wrap">
                        <div v-for="(qVal, qIdx) in qna" :id="'q'+qIdx">
                            <div v-if="qna[qIdx].a != null">
                                <div v-if="qIdx == currentIndex" v-for="(aVal, aIdx) in qna[qIdx].a" class="answer">
                                    <input type="radio" :name="'q'+qIdx" :id="'q'+qIdx+'a'+aIdx" :value="qna[qIdx].a[aIdx]" v-model="qna[qIdx].r">
                                    <label :for="'q'+qIdx+'a'+aIdx">{{aVal}}</label>
                                </div>
                            </div>
                            <div v-if="qna[qIdx].a == null">
                                <input v-if="qIdx == currentIndex" class="answer-text" type="text" :id="'q'+qIdx+'a0'" v-model="qna[qIdx].r" placeholder="">
                            </div>
                        </div>
                    </div>
                    <!-- 하단 버튼 -->
                    <div class="bottom">
                        <div class="controller-wrap">
                            <button class='prev-btn' v-on:click="prev">이전</button>
                            <button class='next-btn' v-on:click="next">다음</button>
                        </div>
                    </div>
                </div>
                <!-- 결과 -->
                <div id="result" class="result-wrap">
                    <div class="result">{{result}}</div>
                </div>
            </div>
            <script src="./js/test.js"></script>
        </body>
    </html>

     

    test.js 소스 내용

    test.html 파일 저장한 위치에서 js 폴더 하나 만들어서 넣어 주세요.

    var test = new Vue({
        el: '#test',
        data: {
            intro: '테스트를 시작 합니다',
            title: '샘플 테스트',
            currentIndex: 0,
            qna: [],
            result: ''
        },
        beforeMount: function() {
            this.insertQna('Q1. 숲 속을 걷고 있는 당신 앞에 불쑥 나타난 동물! 어떤 동물일까?', null, 'text');
            this.insertQna('Q2. 당신의 눈 앞에 보이는 벌레는 몇 마리일까?', [1,2,3,4,5], null);
        },
        mounted: function() {
            $('#intro').show();
            $('#main').hide();
            $('#result').hide();
        },
        methods: {
            insertQna: function(q, a, t) {
                var item = {
                    q: q,
                    a: a,
                    r: '',
                    t: t
                };
                this.qna.push(item);
            },
            start: function() {
                $('#intro').hide();
                $('#main').show();
                $('#result').hide();
    
                var self = this;
                setTimeout(function() {
                    if(typeof self.qna[0].t != 'undefined' && self.qna[0].t != null) {
                        $('#q0a0').attr('type', self.qna[0].t);
                        $('#q0a0').focus();
                    }
                }, 200);
            },
            next: function () {
                var self = this;
                if(this.currentIndex < this.qna.length-1) {
                    this.currentIndex++;
                    if(typeof this.qna[this.currentIndex].t != 'undefined' && this.qna[this.currentIndex].t != null) {
                        setTimeout(function() {
                            $('#q'+self.currentIndex+'a0').attr('type', self.qna[self.currentIndex].t);
                            $('#q'+self.currentIndex+'a0').focus();
                        }, 200);                    
                    }
                } else {
                    var check = true;
                    for(var i=0; i < this.qna.length; i++) {
                        if(this.qna[i].r === '') {
                            check = false;
                        }
                    }
                    if(check) {
                        this.showResult();
                    } else {
                        alert("아직 완료되지 않았습니다.");
                    }
                }
            },
            prev: function () {
                var self = this;
                if(this.currentIndex > 0) {
                    this.currentIndex--;
                    if(typeof this.qna[this.currentIndex].t != 'undefined' && this.qna[this.currentIndex].t != null) {
                        setTimeout(function() {
                            $('#q'+self.currentIndex+'a0').attr('type', self.qna[self.currentIndex].t);
                            $('#q'+self.currentIndex+'a0').focus();
                        }, 200);                    
                    }
                } else {
                    alert('첫 질문입니다.');
                }
            },
            showResult: function() {
                this.result =  'Q1. 불쑥 나타난 동물 = '+this.qna[0].r+'\n=> 사람들이 나를 보는 모습\n\n';
                this.result += 'Q2. 눈 앞의 벌레 수 = '+this.qna[1].r+'마리\n=> 나를 화나게 하는 사람 수\n\n';
                $('#main').hide();
                $('#result').show();
            }
        }
    });

     

    test.css 소스 내용

    test.html 파일 저장한 위치에서 css 폴더 하나 만들어서 넣어 주세요.

    html, button {
        font-size: 16px;
    }
    button {
        margin: 0;
        padding: 0;
        border: 0;
        background: none;
    }
    input {
        color: rgb(40, 92, 24);
    }
    input:focus {
        color: rgb(40, 92, 24);
    }
    input[type=text],
    input[type=number] {
        background: none;
        border: 0;
        border-bottom: solid 3px rgb(40, 92, 24);
    }
    input::placeholder {
        color:  rgb(40, 92, 24);
        opacity: 0.5;
    }
    input:focus,
    select:focus,
    textarea:focus,
    button:focus {
        outline: none;
    }
    body {
        margin: 0;
        overflow: hidden;
    }
    #main::after {
        content: "";
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height:100%;
        background:red;
        opacity: 0.1;
        z-index: -1;
    }
    
    .title-wrap {
        display: table;
        position: relative;
        width:100vw;
        height:80px;
    }
    
    .title {
        display: table-cell;
        text-align: center;
        vertical-align: middle;
    }
    
    .question-wrap {
        display: table;
        position: relative;
        width:100vw;
        height:100px;
    }
    
    .question {
        display:table-cell;
        vertical-align:middle;
        padding-left: 20px;
    }
    
    .answer-wrap {
        position: relative;
        width:100vw;
        cursor: pointer;
    }
    
    .answer {
        padding: 10px;
        font-weight: bold;
    }
    
    .answer-text {
        margin-left: 20px;
        padding-bottom: 5px;
    }
    
    .bottom {
        position: fixed;
        bottom: 0;
        width: 100%;
    }
    
    .controller-wrap {
        display: table;
        position: relative;
        width:100vw;
        height:70px;
    }
    
    .prev-btn {
        width:50%;
        height: 100%;
        display: table-cell;
        vertical-align:middle;
        text-align:center;
        font-size: 1.2em;
        font-weight: bold;
        cursor: pointer;
    }
    .prev-btn::after {
        content: "";
        position: absolute;
        left: 0;
        top: 0;
        width: 50%;
        height:100%;
        background:yellow;
        opacity: 0.1;
        z-index: -1;
    }
    .next-btn {
        width:50%;
        height: 100%;
        display: table-cell;
        vertical-align:middle;
        text-align:center;
        font-size: 1.2em;
        font-weight: bold;
        cursor: pointer;
    }
    .next-btn::after {
        content: "";
        position: absolute;
        right: 0;
        top: 0;
        width: 50%;
        height:100%;
        background:blue;
        opacity: 0.1;
        z-index: -1;
    }
    
    .intro-wrap {
        display: table;
        position: relative;
        width:100vw;
        height:100vh;
    }
    .intro-wrap::after {
        content: "";
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height:100%;
        background:orange;
        opacity: 0.2;
        z-index: -1;
    }
    .intro {
        display: table-cell;
        text-align: center;
        vertical-align: middle;
    }
    .intro-story {
        font-size: 1.5em;
        font-weight: bold;
        white-space: pre;
    }
    
    .result-wrap {
        display: table;
        position: relative;
        width:100vw;
        height:100vh;
    }
    .result-wrap::after {
        content: "";
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height:100%;
        background:blue;
        opacity: 0.1;
        z-index: -1;
    }
    .result {
        display: table-cell;
        text-align: center;
        vertical-align: middle;
        white-space: pre;
        font-size: 1.2em;
        font-weight: bold;
    }

     

     

    결과 확인

    인트로 부분입니다

    중앙 텍스트를 클릭하면 메인 화면으로 넘어 갑니다.

     

    메인 화면 첫 번째 질문입니다.

     

    이전 버튼을 누르면 다음과 같은 알림 창이 뜹니다.

     

    다음 버튼을 누르니 두 번째 질문이 잘 보이네요. 선다형이 잘 표현 되었습니다.

     

    답을 선택하지 않고 다음 버튼을 누르니 알림 창도 잘 뜹니다.

     

    모든 답을 입력하고 다음 버튼을 눌러 보면 결과까지 잘 표현이 되는 것을 알 수 있습니다.

     

    지금까지 선다형, 단답형 구조까지 필요한 모든 기능이 구현 되었습니다. 앞으로는 이 기능을 활용하여 좀 더 많은 문항을 표현하고 답변을 토대로 결과를 만들어 내는 부분만 변경을 하면 다양한 컨텐츠를 만들어 낼 수 있을 것 같습니다.

     

    다음 시간에는 테스트 시작 전과 결과 표시 전에 필요한 멘트를 좀 더 넣어 보겠습니다. 그리고 페이지 이동 시 애니메이션 효과도 구현하도록 하겠습니다.

     

    감사합니다 :)

    반응형

    댓글

    💲 추천 글