How to Build a Survey Form from Question Tables in Oracle APEX

Store surveys, questions and options in tables, draw the form with a Dynamic Content region, validate the answers on the server and show the results in a chart.

A survey with fixed page items needs a developer every time a question changes. A better design keeps the questions in tables. The page then reads them at runtime and shows a text box, a set of choices or a 1 to 5 rating for each one.

This guide builds that design in Oracle APEX. It creates the survey tables, renders the questions with a Dynamic Content region, saves the answers with one process, enforces required questions and one response per user, and shows the results in a report and a bar chart.

The example is a training course feedback survey; the same steps work for any questionnaire, poll or checklist. Questions, options and surveys are plain rows, so you can add a new survey without changing the pages.

Step 1: Create the Tables

Five tables hold the surveys, the questions and the answers:

  • SURVEYS has one row per survey, with an active flag.
  • SURVEY_QUESTIONS holds the questions of a survey, their order, a type (TEXT, CHOICE or RATING) and a required flag.
  • SURVEY_OPTIONS holds the answer options of the CHOICE questions.
  • SURVEY_RESPONSES has one row per user who answered a survey.
  • SURVEY_ANSWERS holds one answer per response and question.

The rules are declared in the tables. A unique key on SURVEY_ID and RESPONDENT allows one response per user. RESPONDENT gets the APEX user through DEFAULT ON NULL, so the save process does not have to set it.

An answer stores an option, a rating or a text. A check constraint makes sure exactly one of the three is filled, and another limits ratings to 1 to 5. The foreign key on QUESTION_ID and OPTION_ID makes sure a chosen option belongs to the question it answers.

Example:

create table surveys (
  survey_id     number generated by default on null as identity,
  title         varchar2(100 char) not null,
  description   varchar2(400 char),
  is_active     varchar2(1 char) default on null 'Y' not null,
  created_on    date default on null sysdate not null,
  constraint surveys_pk primary key (survey_id),
  constraint surveys_title_uk unique (title),
  constraint surveys_active_ck check (is_active in ('Y', 'N'))
);

create table survey_questions (
  question_id    number generated by default on null as identity,
  survey_id      number not null,
  display_seq    number not null,
  question_text  varchar2(400 char) not null,
  question_type  varchar2(10 char) default on null 'TEXT' not null,
  is_required    varchar2(1 char) default on null 'Y' not null,
  constraint survey_questions_pk primary key (question_id),
  constraint survey_questions_seq_uk unique (survey_id, display_seq),
  constraint survey_questions_survey_fk foreign key (survey_id)
    references surveys (survey_id) on delete cascade,
  constraint survey_questions_type_ck check (question_type in ('TEXT', 'CHOICE', 'RATING')),
  constraint survey_questions_req_ck check (is_required in ('Y', 'N'))
);

create table survey_options (
  option_id     number generated by default on null as identity,
  question_id   number not null,
  display_seq   number not null,
  option_text   varchar2(200 char) not null,
  constraint survey_options_pk primary key (option_id),
  constraint survey_options_seq_uk unique (question_id, display_seq),
  constraint survey_options_q_uk unique (question_id, option_id),
  constraint survey_options_question_fk foreign key (question_id)
    references survey_questions (question_id) on delete cascade
);

create table survey_responses (
  response_id   number generated by default on null as identity,
  survey_id     number not null,
  respondent    varchar2(255 char)
                default on null coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                                         sys_context('USERENV', 'SESSION_USER')) not null,
  submitted_on  date default on null sysdate not null,
  constraint survey_responses_pk primary key (response_id),
  constraint survey_responses_user_uk unique (survey_id, respondent),
  constraint survey_responses_survey_fk foreign key (survey_id)
    references surveys (survey_id)
);

create table survey_answers (
  answer_id     number generated by default on null as identity,
  response_id   number not null,
  question_id   number not null,
  option_id     number,
  rating_value  number(1),
  answer_text   varchar2(4000 char),
  constraint survey_answers_pk primary key (answer_id),
  constraint survey_answers_uk unique (response_id, question_id),
  constraint survey_answers_response_fk foreign key (response_id)
    references survey_responses (response_id) on delete cascade,
  constraint survey_answers_question_fk foreign key (question_id)
    references survey_questions (question_id),
  constraint survey_answers_option_fk foreign key (question_id, option_id)
    references survey_options (question_id, option_id),
  constraint survey_answers_rating_ck check (rating_value between 1 and 5),
  constraint survey_answers_one_value_ck check (nvl2(option_id, 1, 0) + nvl2(rating_value, 1, 0) + nvl2(answer_text, 1, 0) = 1)
);

create index survey_answers_question_ix on survey_answers (question_id, option_id);

insert into surveys (survey_id, title, description)
values (1, 'Training Course Feedback', 'Tell us what you thought of the Oracle APEX training course.');
insert into surveys (survey_id, title, description)
values (2, 'Team Lunch Poll', 'Help us pick a day for the monthly team lunch.');

insert into survey_questions (question_id, survey_id, display_seq, question_text, question_type, is_required)
values (1, 1, 10, 'How would you rate the course overall?', 'RATING', 'Y');
insert into survey_questions (question_id, survey_id, display_seq, question_text, question_type, is_required)
values (2, 1, 20, 'Which session was the most useful?', 'CHOICE', 'Y');
insert into survey_questions (question_id, survey_id, display_seq, question_text, question_type, is_required)
values (3, 1, 30, 'Would you recommend this course to a colleague?', 'CHOICE', 'Y');
insert into survey_questions (question_id, survey_id, display_seq, question_text, question_type, is_required)
values (4, 1, 40, 'What should we improve?', 'TEXT', 'N');
insert into survey_questions (question_id, survey_id, display_seq, question_text, question_type, is_required)
values (5, 2, 10, 'Which day suits you best?', 'CHOICE', 'Y');

insert into survey_options (option_id, question_id, display_seq, option_text) values (1, 2, 10, 'SQL Basics');
insert into survey_options (option_id, question_id, display_seq, option_text) values (2, 2, 20, 'Page Designer');
insert into survey_options (option_id, question_id, display_seq, option_text) values (3, 2, 30, 'Dynamic Actions');
insert into survey_options (option_id, question_id, display_seq, option_text) values (4, 2, 40, 'REST Data Sources');
insert into survey_options (option_id, question_id, display_seq, option_text) values (5, 3, 10, 'Yes');
insert into survey_options (option_id, question_id, display_seq, option_text) values (6, 3, 20, 'No');
insert into survey_options (option_id, question_id, display_seq, option_text) values (7, 3, 30, 'Not sure');
insert into survey_options (option_id, question_id, display_seq, option_text) values (8, 5, 10, 'Tuesday');
insert into survey_options (option_id, question_id, display_seq, option_text) values (9, 5, 20, 'Thursday');
insert into survey_options (option_id, question_id, display_seq, option_text) values (10, 5, 30, 'Friday');

insert into survey_responses (response_id, survey_id, respondent) values (1, 1, 'ANITA');
insert into survey_answers (response_id, question_id, rating_value) values (1, 1, 5);
insert into survey_answers (response_id, question_id, option_id) values (1, 2, 3);
insert into survey_answers (response_id, question_id, option_id) values (1, 3, 5);
insert into survey_answers (response_id, question_id, answer_text) values (1, 4, 'More time for the hands-on labs.');

insert into survey_responses (response_id, survey_id, respondent) values (2, 1, 'MARCO');
insert into survey_answers (response_id, question_id, rating_value) values (2, 1, 4);
insert into survey_answers (response_id, question_id, option_id) values (2, 2, 2);
insert into survey_answers (response_id, question_id, option_id) values (2, 3, 5);

commit;

alter table surveys modify survey_id generated by default on null as identity (start with limit value);
alter table survey_questions modify question_id generated by default on null as identity (start with limit value);
alter table survey_options modify option_id generated by default on null as identity (start with limit value);
alter table survey_responses modify response_id generated by default on null as identity (start with limit value);

column survey format a26
column question format a45
column answer format a30
select s.title as survey,
       q.display_seq as seq,
       q.question_text as question,
       q.question_type as type,
       (select count(*) from survey_options o where o.question_id = q.question_id) as options
from   surveys s
join   survey_questions q on q.survey_id = s.survey_id
order by s.survey_id, q.display_seq;

Output:

Table SURVEYS created.

Table SURVEY_QUESTIONS created.

Table SURVEY_OPTIONS created.

Table SURVEY_RESPONSES created.

Table SURVEY_ANSWERS created.

Index SURVEY_ANSWERS_QUESTION_IX created.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

Table SURVEYS altered.

Table SURVEY_QUESTIONS altered.

Table SURVEY_OPTIONS altered.

Table SURVEY_RESPONSES altered.

SURVEY                         SEQ QUESTION                                           TYPE         OPTIONS
___________________________ ______ __________________________________________________ _________ __________
Training Course Feedback        10 How would you rate the course overall?             RATING             0
Training Course Feedback        20 Which session was the most useful?                 CHOICE             4
Training Course Feedback        30 Would you recommend this course to a colleague?    CHOICE             3
Training Course Feedback        40 What should we improve?                            TEXT               0
Team Lunch Poll                 10 Which day suits you best?                          CHOICE             3

The sample data has two surveys. Training Course Feedback has a rating question, two choice questions and an optional text question. Two people, ANITA and MARCO, have already answered it.

In a first test, the "exactly one value" check used the NUM_NONNULLS function. Oracle rejected it with ORA-00904: "NUM_NONNULLS": invalid identifier, so the constraint adds up NVL2 results instead.

The constraints also stop bad rows that do not come from the page. An option of another survey, two values in one answer, a rating of 9 and a second response by the same user all fail:

Example:

insert into survey_answers (response_id, question_id, option_id) values (2, 4, 8);

insert into survey_answers (response_id, question_id, rating_value, answer_text) values (2, 4, 3, 'Shorter breaks.');

insert into survey_answers (response_id, question_id, rating_value) values (2, 1, 9);

insert into survey_responses (survey_id, respondent) values (1, 'MARCO');

rollback;

Output:

Error starting at line : 1 File @ b57/01-constraint-test.sql
In command -
insert into survey_answers (response_id, question_id, option_id) values (2, 4, 8)
Error report -
ORA-02291: integrity constraint (APXSOL.SURVEY_ANSWERS_OPTION_FK) violated -
parent key not found
Help: https://docs.oracle.com/error-help/db/ora-02291/

Error starting at line : 3 File @ b57/01-constraint-test.sql
In command -
insert into survey_answers (response_id, question_id, rating_value, answer_text) values (2, 4, 3, 'Shorter breaks.')
Error report -
ORA-02290: check constraint (APXSOL.SURVEY_ANSWERS_ONE_VALUE_CK) violated
Help: https://docs.oracle.com/error-help/db/ora-02290/

Error starting at line : 5 File @ b57/01-constraint-test.sql
In command -
insert into survey_answers (response_id, question_id, rating_value) values (2, 1, 9)
Error report -
ORA-02290: check constraint (APXSOL.SURVEY_ANSWERS_RATING_CK) violated
Help: https://docs.oracle.com/error-help/db/ora-02290/

Error starting at line : 7 File @ b57/01-constraint-test.sql
In command -
insert into survey_responses (survey_id, respondent) values (1, 'MARCO')
Error report -
ORA-00001: unique constraint (APXSOL.SURVEY_RESPONSES_USER_UK) violated on table
APXSOL.SURVEY_RESPONSES columns (SURVEY_ID, RESPONDENT)
ORA-03301: (ORA-00001 details) row with column values (SURVEY_ID:1,
RESPONDENT:'MARCO') already exists
Help: https://docs.oracle.com/error-help/db/ora-00001/

Rollback complete.

Step 2: Create the Take Survey Page

In App Builder, open the application, click Create Page and select Blank Page:

Create a Page wizard with the Blank Page tile marked with a red box
Select the Blank Page type.

Enter 540 as the Page Number and Take Survey as the Name. Turn off Use Breadcrumb, keep Use Navigation on, enter fa-list-alt as the Icon and click Create Page:

Create Blank Page screen with page 540, name Take Survey, Use Breadcrumb off, Use Navigation on and icon fa-list-alt marked with red boxes
The page number, name and navigation entry.

Step 3: Add the Page JavaScript and CSS

The questions are plain HTML radio buttons and text areas, not page items. A small JavaScript function collects the answers into one JSON text and puts it into a hidden page item. The page submits that item, and the server reads the answers from it.

In Page Designer, select the page (Page 540: Take Survey). Enter this code in JavaScript > Function and Global Variable Declaration:

Example:

function collectAnswers() {
    var answers = {};
    document.querySelectorAll(".sv-question").forEach(function (q) {
        var id = q.dataset.qid, el;
        if (q.dataset.type === "TEXT") {
            el = q.querySelector("textarea");
            if (el.value.trim() !== "") {
                answers[id] = el.value.trim();
            }
        } else {
            el = q.querySelector("input:checked");
            if (el) {
                answers[id] = el.value;
            }
        }
    });
    apex.item("P540_ANSWERS").setValue(JSON.stringify(answers));
}

Each question is a fieldset with the question ID in data-qid and the question type in data-type. For a TEXT question the function reads the text area. For the other types it reads the checked radio button. Unanswered questions are left out of the JSON.

Enter this code in CSS > Inline to style the questions:

Example:

.sv-question { border: 1px solid #d6d9de; border-radius: 8px; padding: 12px 16px; margin: 0 0 16px; }
.sv-question legend { font-weight: 600; padding: 0 6px; }
.sv-req { color: #c62828; }
.sv-option, .sv-rating { display: inline-flex; align-items: center; gap: 6px; margin: 6px 20px 6px 0; }
.sv-question textarea { width: 100%; }
Page Designer with page 540 selected; Function and Global Variable Declaration and CSS Inline marked with red boxes
The JavaScript function and the CSS of the page.

Step 4: Add the Survey Select List and the Hidden Item

Right-click Body in the Rendering tree and select Create Region. Set Name to Choose Survey and keep the Type Static Content:

Region Choose Survey with Name and Type Static Content marked with red boxes
The region for the survey choice.

Right-click the Choose Survey region and select Create Page Item. Set these properties:

  • Identification > Name: P540_SURVEY_ID
  • Identification > Type: Select List
  • Label > Label: Survey
  • Settings > Page Action on Selection: Redirect and Set Value
  • List of Values > Type: SQL Query, with the query below
  • List of Values > Null Display Value: - Select Survey -

Example:

select title as display_value,
       survey_id as return_value
from   surveys
where  is_active = 'Y'
order by title
Page item P540_SURVEY_ID with Type Select List, Label Survey, Page Action on Selection Redirect and Set Value, SQL Query and Null Display Value marked with red boxes
The survey select list reloads the page with the chosen survey.

Redirect and Set Value reloads the page with the chosen survey in session state, so the questions region can read it.

Create a second item in the same region. Name it P540_ANSWERS, set Type to Hidden and turn off Settings > Value Protected:

Page item P540_ANSWERS with Type Hidden and Value Protected off marked with red boxes
The hidden item that carries the answers as JSON.

Value Protected must be off because JavaScript sets the value in the browser. The server checks the content in a validation instead (Step 9).

Step 5: Render the Questions with a Dynamic Content Region

Create a region below Choose Survey. Set Name to Questions and Type to Dynamic Content. Enter this code in Source > PL/SQL Function Body returning a CLOB:

Example:

declare
    l_html clob;
    l_name varchar2(30);
begin
    for q in (select question_id, question_text, question_type, is_required
              from   survey_questions
              where  survey_id = :P540_SURVEY_ID
              order by display_seq)
    loop
        l_name := 'q' || q.question_id;
        l_html := l_html
            || '<fieldset class="sv-question" data-qid="' || q.question_id
            || '" data-type="' || q.question_type || '">'
            || '<legend>' || apex_escape.html(q.question_text)
            || case when q.is_required = 'Y' then ' <span class="sv-req">*</span>' end
            || '</legend>';

        if q.question_type = 'CHOICE' then
            for o in (select option_id, option_text
                      from   survey_options
                      where  question_id = q.question_id
                      order by display_seq)
            loop
                l_html := l_html
                    || '<label class="sv-option"><input type="radio" name="' || l_name
                    || '" value="' || o.option_id || '">'
                    || apex_escape.html(o.option_text) || '</label>';
            end loop;
        elsif q.question_type = 'RATING' then
            for r in 1 .. 5 loop
                l_html := l_html
                    || '<label class="sv-rating"><input type="radio" name="' || l_name
                    || '" value="' || r || '">' || r || '</label>';
            end loop;
        else
            l_html := l_html
                || '<textarea name="' || l_name || '" rows="3" maxlength="4000"'
                || ' class="textarea"></textarea>';
        end if;

        l_html := l_html || '</fieldset>';
    end loop;

    return l_html;
end;
Region Questions with Type Dynamic Content and the PL/SQL Function Body returning a CLOB marked with red boxes
The Dynamic Content region builds the questions from the tables.

The function loops over the questions of the chosen survey. A CHOICE question gets one radio button per option, with the OPTION_ID as the value. A RATING question gets the values 1 to 5, and a TEXT question gets a text area. APEX_ESCAPE.HTML escapes the question and option texts.

The form should show only when a survey is chosen and the user has not answered it yet. In Server-side Condition, set Type to Rows returned and enter this query:

Example:

select 1
from   surveys s
where  s.survey_id = :P540_SURVEY_ID
and    not exists (select 1
                   from   survey_responses r
                   where  r.survey_id  = s.survey_id
                   and    r.respondent = :APP_USER)
Server-side Condition of region Questions with Type Rows returned and the SQL Query marked with a red box
The region shows only for a survey the user has not answered.

In a first test, this condition was a PL/SQL Expression with NOT EXISTS. The page failed with "Error processing condition." EXISTS works only inside SQL, so the condition uses the Rows returned type.

Step 6: Add the Submit Button

Create a region below Questions. Set Name to Survey Buttons and Appearance > Template to Buttons Container. Give it the same Server-side Condition as the Questions region (Rows returned, same query):

Region Survey Buttons with Template Buttons Container and the Rows returned condition marked with red boxes
A buttons region under the questions.

Right-click Survey Buttons and select Create Button. Set these properties:

  • Identification > Button Name: SUBMIT
  • Identification > Label: Submit Answers
  • Layout > Region: Survey Buttons, Slot: Next
  • Appearance > Hot: on
  • Behavior > Action: Defined by Dynamic Action
Button SUBMIT with label Submit Answers, region Survey Buttons, slot Next, Hot on and Action Defined by Dynamic Action marked with red boxes
The button runs a dynamic action instead of submitting at once.

Step 7: Add the Thank You Region

Create one more region. Set Name to Thank You, keep Type Static Content and enter this in Source > HTML Code:

Example:

<p>You have already answered this survey. Thank you for your feedback.</p>

Set Server-side Condition > Type to Rows returned with this query:

Example:

select 1
from   survey_responses
where  survey_id  = :P540_SURVEY_ID
and    respondent = :APP_USER
Region Thank You with Name, HTML Code and the Rows returned condition marked with red boxes
The message replaces the form after the user has answered.

Step 8: Create the Dynamic Action

Right-click the SUBMIT button and select Create Dynamic Action. Set Name to Submit answers. The When settings are Event Click, Selection Type Button and Button SUBMIT:

Dynamic action Submit answers with Event Click, Selection Type Button and Button SUBMIT marked with red boxes
The dynamic action fires on the button click.

Select the first True action. Set Action to Execute JavaScript Code and enter this in Settings > Code. Fire on Initialization stays off:

Example:

collectAnswers();
True action Execute JavaScript Code with code collectAnswers() and Fire on Initialization off marked with red boxes
The first action copies the answers into P540_ANSWERS.

Right-click True and select Create TRUE Action. Set Action to Submit Page, Request / Button Name to SUBMIT and turn on Show Processing:

True action Submit Page with Request / Button Name SUBMIT and Show Processing on marked with red boxes
The second action submits the page with the request SUBMIT.

Because the request is SUBMIT, the validations and the process can use When Button Pressed: SUBMIT.

Step 9: Add the Validations

Open the Processing tab. Right-click Validations and select Create Validation. Set these properties:

  • Identification > Name: One response per user
  • Validation > Type: No Rows returned, with the query below
  • Error > Error Message: You have already answered this survey.
  • Server-side Condition > When Button Pressed: SUBMIT

Example:

select 1
from   survey_responses
where  survey_id  = :P540_SURVEY_ID
and    respondent = :APP_USER
Validation One response per user with Type No Rows returned, SQL Query, Error Message and When Button Pressed SUBMIT marked with red boxes
A second response by the same user is rejected with a clear message.

The unique key from Step 1 also blocks a second response. The validation runs first and shows a readable message.

Create a second validation named Answers complete and valid. Set Type to Function Body (returning Error Text), When Button Pressed to SUBMIT, and enter this code:

Example:

declare
    l_answers json_object_t;
    l_value   varchar2(4000);
    l_found   pls_integer;
    l_missing varchar2(4000);
begin
    begin
        l_answers := json_object_t.parse(:P540_ANSWERS);
    exception
        when others then
            l_answers := json_object_t();
    end;

    for q in (select question_id, question_text, question_type, is_required
              from   survey_questions
              where  survey_id = :P540_SURVEY_ID
              order by display_seq)
    loop
        l_value := trim(l_answers.get_string(to_char(q.question_id)));

        if l_value is null then
            if q.is_required = 'Y' then
                l_missing := l_missing
                    || case when l_missing is not null then ' ' end
                    || q.question_text;
            end if;
        elsif q.question_type = 'RATING'
              and l_value not in ('1', '2', '3', '4', '5') then
            return 'Choose a rating from 1 to 5 for: ' || q.question_text;
        elsif q.question_type = 'CHOICE' then
            select count(*)
            into   l_found
            from   survey_options
            where  question_id = q.question_id
            and    to_char(option_id) = l_value;

            if l_found = 0 then
                return 'Choose one of the listed answers for: ' || q.question_text;
            end if;
        end if;
    end loop;

    if l_missing is not null then
        return 'Please answer: ' || l_missing;
    end if;
    return null;
end;
Validation Answers complete and valid with Type Function Body returning Error Text, the PL/SQL code and When Button Pressed SUBMIT marked with red boxes
One validation checks required questions and the submitted values.

The function reads the JSON with JSON_OBJECT_T. It collects every required question without an answer and returns them in one message. It also rejects a rating outside 1 to 5 and an option that does not belong to the question, because a user can change the JSON in the browser.

In a first version, the value checks were in the save process with RAISE_APPLICATION_ERROR. The insert was rolled back, but the message showed as "ORA-20001: Choose one of the listed answers...". In the validation, the message is clean and nothing is inserted.

Step 10: Add the Save Process and the Branch

Right-click Processes and select Create Process. Set Name to Save Answers, keep Type Execute Code and enter this PL/SQL Code:

Example:

declare
    l_answers     json_object_t := json_object_t.parse(:P540_ANSWERS);
    l_response_id survey_responses.response_id%type;
    l_value       varchar2(4000);
begin
    insert into survey_responses (survey_id)
    values (:P540_SURVEY_ID)
    returning response_id into l_response_id;

    for q in (select question_id, question_type
              from   survey_questions
              where  survey_id = :P540_SURVEY_ID
              order by display_seq)
    loop
        l_value := trim(l_answers.get_string(to_char(q.question_id)));

        if l_value is null then
            continue;
        elsif q.question_type = 'CHOICE' then
            insert into survey_answers (response_id, question_id, option_id)
            values (l_response_id, q.question_id, to_number(l_value));
        elsif q.question_type = 'RATING' then
            insert into survey_answers (response_id, question_id, rating_value)
            values (l_response_id, q.question_id, to_number(l_value));
        else
            insert into survey_answers (response_id, question_id, answer_text)
            values (l_response_id, q.question_id, substr(l_value, 1, 4000));
        end if;
    end loop;

    :P540_ANSWERS := null;
end;

Set Success Message to Thank you! Your answers have been saved. and Server-side Condition > When Button Pressed to SUBMIT:

Process Save Answers with Type Execute Code, PL/SQL Code, Success Message and When Button Pressed SUBMIT marked with red boxes
One process inserts the response and its answers.

The process inserts the response first; the respondent comes from the DEFAULT ON NULL. It then loops over the questions of the survey and stores each answer in the matching column. The loop reads only this survey's questions, so other keys in the JSON are not used.

Right-click After Processing and select Create Branch. Set Name to Back to page 540, Type to Page or URL (Redirect) and Target to page 540:

Branch Back to page 540 with Type Page or URL Redirect and Target Page 540 marked with red boxes
After saving, the page opens again and shows the Thank You region.

Step 11: Create the Survey Results Page

Create a second Blank Page with the same wizard. Enter 541 as the Page Number and Survey Results as the Name, turn off Use Breadcrumb, enter fa-bar-chart as the Icon and click Create Page:

Create Blank Page screen with page 541, name Survey Results, Use Breadcrumb off, Use Navigation on and icon fa-bar-chart marked with red boxes
The results page.

Results are for administrators. Select the page and set Security > Authorization Scheme to Is Administrator:

Page 541 with Authorization Scheme Is Administrator marked with a red box
Only administrators can open the results.

Step 12: Add the Filter Select Lists

Create a Static Content region named Filters. Add a select list P541_SURVEY_ID with the label Survey, Page Action on Selection Redirect and Set Value, Display Null Value off and this query:

Example:

select title as display_value,
       survey_id as return_value
from   surveys
order by title
Page item P541_SURVEY_ID with Type Select List, Label Survey, Redirect and Set Value, SQL Query and Display Null Value off marked with red boxes
The survey filter.

Add a second select list P541_QUESTION_ID with the label Chart Question and the same settings. Its query lists the CHOICE and RATING questions of the chosen survey:

Example:

select question_text as display_value,
       question_id as return_value
from   survey_questions
where  survey_id = :P541_SURVEY_ID
and    question_type in ('CHOICE', 'RATING')
order by display_seq
Page item P541_QUESTION_ID with Type Select List, Label Chart Question, Redirect and Set Value, SQL Query and Display Null Value off marked with red boxes
The question shown in the chart.

Two computations give the items a value when the page opens. Right-click Pre-Rendering > Before Header and select Create Computation. Set Item Name to P541_SURVEY_ID, Type to SQL Query (return single value) and enter this query:

Example:

select min(survey_id)
from   surveys

Set Server-side Condition > Type to Item is NULL with the item P541_SURVEY_ID, so the computation runs only when no survey is chosen yet:

Computation for P541_SURVEY_ID at Before Header with SQL Query and condition Item is NULL marked with red boxes
The first survey is the default.

In a first test, the default took the first survey by title. That was the empty Team Lunch Poll, so the query now takes the lowest SURVEY_ID.

Create a second computation for P541_QUESTION_ID with this query:

Example:

select min(question_id) keep (dense_rank first order by display_seq)
from   survey_questions
where  survey_id = :P541_SURVEY_ID
and    question_type in ('CHOICE', 'RATING')

Set its Server-side Condition to No Rows returned with this query. It runs when no question is chosen, or when the chosen question belongs to another survey after the survey changed:

Example:

select 1
from   survey_questions
where  question_id = :P541_QUESTION_ID
and    survey_id   = :P541_SURVEY_ID
and    question_type in ('CHOICE', 'RATING')
Computation for P541_QUESTION_ID at Before Header with SQL Query and No Rows returned condition marked with red boxes
The first chart question of the chosen survey is the default.

Step 13: Add the Summary Report

Create a region named Summary with Type Classic Report, Source Type SQL Query and this query:

Example:

select q.display_seq,
       q.question_text,
       initcap(q.question_type) as question_type,
       count(a.answer_id) as answers,
       round(avg(a.rating_value), 1) as average_rating
from   survey_questions q
left join survey_answers a on a.question_id = q.question_id
where  q.survey_id = :P541_SURVEY_ID
group by q.display_seq, q.question_text, q.question_type
order by q.display_seq
Region Summary with Type Classic Report, Source Type SQL Query and the query marked with red boxes
One row per question with its answer count and average rating.

Expand Columns and set DISPLAY_SEQ to Type Hidden. It is only used for the order:

Column DISPLAY_SEQ with Type Hidden marked with a red box
The sort column is hidden.

Set the headings of the other columns to Question, Type, Answers and Average Rating. Align Answers and Average Rating to the end.

Step 14: Add the Answers Chart

Create a region named Answers Chart and set its Type to Chart:

Region Answers Chart with Type Chart marked with red boxes
The chart region.

Open the Attributes tab. Keep the Type Bar, set Orientation to Horizontal and Height to 320:

Chart attributes with Type Bar, Orientation Horizontal and Height 320 marked with red boxes
A horizontal bar chart reads well with long option texts.

Select the series under Series. Set Name to Answers, Source Type to SQL Query and enter this query:

Example:

select o.option_text as answer,
       count(a.answer_id) as answers,
       o.display_seq as seq
from   survey_options o
left join survey_answers a on a.option_id = o.option_id
where  o.question_id = :P541_QUESTION_ID
group by o.option_text, o.display_seq
union all
select r.rating || case when r.rating = 1 then ' star' else ' stars' end,
       count(a.answer_id),
       r.rating
from   (select level as rating from dual connect by level <= 5) r
join   survey_questions q
       on  q.question_id   = :P541_QUESTION_ID
       and q.question_type = 'RATING'
left join survey_answers a
       on  a.question_id  = q.question_id
       and a.rating_value = r.rating
group by r.rating
order by 3

Set Column Mapping > Label to ANSWER and Value to ANSWERS, and turn on Label > Show to print the count on each bar:

Series Answers with SQL Query, Label ANSWER, Value ANSWERS and Label Show on marked with red boxes
The series counts the answers per option or star rating.

The first part of the query counts the answers per option of a CHOICE question. The second part runs only for a RATING question and lists 1 to 5 stars. The outer joins keep options and ratings with no answers at 0, and ORDER BY 3 keeps the display order.

Select the y axis under Axes. Set Value > Decimal Places to 0 and Major Ticks > Minimum Step to 1:

Axis y with Decimal Places 0 and Minimum Step 1 marked with red boxes
Whole numbers only on the value axis.

Without the minimum step, the chart used steps of 0.5 and, with 0 decimal places, showed the axis labels 0, 0, 1, 1, 2, 2.

Step 15: Add the Written Answers Report

Create a Classic Report region named Written Answers with this query. Set the column headings to Question, Answer, Respondent and Submitted On, and the format mask of SUBMITTED_ON to DD-MON-YYYY HH24:MI:

Example:

select q.question_text,
       a.answer_text,
       r.respondent,
       r.submitted_on
from   survey_answers a
join   survey_questions q on q.question_id = a.question_id
join   survey_responses r on r.response_id = a.response_id
where  q.survey_id     = :P541_SURVEY_ID
and    q.question_type = 'TEXT'
order by r.submitted_on desc
Region Written Answers with Type Classic Report and the SQL Query marked with red boxes
The text answers, newest first.

Test the Survey

A Required Question Is Missing

Run page 540, choose Training Course Feedback and pick only a rating of 4. Click Submit Answers. The validation lists both missing required questions:

Take Survey page with the error Please answer: Which session was the most useful? Would you recommend this course to a colleague? marked with a red box
Both missing questions are named in one message.

The rating of 4 is still selected. The page's Reload on Submit setting is Only for Success, so APEX shows validation errors without reloading the page.

Answer the Survey

Sign in as SALES_USER, pick a rating of 4, Dynamic Actions and Yes, enter a suggestion and click Submit Answers:

Take Survey page filled in by SALES_USER with rating 4, Dynamic Actions, Yes and a suggestion; Submit Answers marked with a red box
A complete set of answers.

The success message appears and the Thank You region replaces the questions:

Take Survey page with the message Thank you! Your answers have been saved. and the Thank You region
The answers are saved.

Sign in as ORDER_EDITOR and answer with a rating of 3, REST Data Sources and Not sure, without a suggestion. The optional text question can stay empty. The database now has four responses:

Example:

column respondent format a14
column question format a48
column answer format a42
select r.respondent,
       q.question_text as question,
       coalesce(o.option_text, to_char(a.rating_value), a.answer_text) as answer
from   survey_responses r
join   survey_answers a on a.response_id = r.response_id
join   survey_questions q on q.question_id = a.question_id
left join survey_options o on o.option_id = a.option_id
where  r.survey_id = 1
order by r.response_id, q.display_seq;

Output:

RESPONDENT      QUESTION                                           ANSWER
_______________ __________________________________________________ __________________________________________
ANITA           How would you rate the course overall?             5
ANITA           Which session was the most useful?                 Dynamic Actions
ANITA           Would you recommend this course to a colleague?    Yes
ANITA           What should we improve?                            More time for the hands-on labs.
MARCO           How would you rate the course overall?             4
MARCO           Which session was the most useful?                 Page Designer
MARCO           Would you recommend this course to a colleague?    Yes
SALES_USER      How would you rate the course overall?             4
SALES_USER      Which session was the most useful?                 Dynamic Actions
SALES_USER      Would you recommend this course to a colleague?    Yes
SALES_USER      What should we improve?                            Add a session on charts and dashboards.
ORDER_EDITOR    How would you rate the course overall?             3
ORDER_EDITOR    Which session was the most useful?                 REST Data Sources
ORDER_EDITOR    Would you recommend this course to a colleague?    Not sure

14 rows selected.

The RESPONDENT values came from DEFAULT ON NULL; the process never set them.

One Response per User

SALES_USER no longer sees the button. To test a forced second submit, run this in the browser console on page 540:

Example:

apex.page.submit({ request: "SUBMIT", set: { P540_ANSWERS: '{"1":"1","2":"1","3":"6"}' } });

The validation rejects it:

Take Survey page for SALES_USER with the error You have already answered this survey. marked with a red box
A second response by the same user is blocked.

A Tampered Answer

Sign in as DEMO_DEV and submit option 8 for the session question. Option 8 is Tuesday from the lunch poll:

Example:

apex.page.submit({ request: "SUBMIT", set: { P540_ANSWERS: '{"1":"5","2":"8","3":"5"}' } });
Take Survey page with the error Choose one of the listed answers for: Which session was the most useful? marked with a red box
An option of another question is rejected.

A rating of 9 was rejected with "Choose a rating from 1 to 5 for: How would you rate the course overall?". No response was saved for DEMO_DEV:

Example:

select respondent, count(*) over () as responses
from   survey_responses
where  survey_id = 1
order by response_id;

Output:

RESPONDENT         RESPONSES
_______________ ____________
ANITA                      4
MARCO                      4
SALES_USER                 4
ORDER_EDITOR               4

The Results

Sign in as an administrator and open page 541. The summary shows four answers per required question and an average rating of 4. The chart shows the rating question:

Survey Results page with the summary report, a horizontal bar chart of 1 to 5 stars and the written answers
Ratings: one 3, two 4s and one 5.

Choose Which session was the most useful? in Chart Question. The page reloads and the chart counts the sessions:

Survey Results page with Chart Question Which session was the most useful? marked with a red box and bars for SQL Basics 0, Page Designer 1, Dynamic Actions 2 and REST Data Sources 1
Dynamic Actions got two votes.

The same counts in SQL:

Example:

column question format a48
column answer format a20
select q.question_text as question,
       coalesce(o.option_text, to_char(a.rating_value)) as answer,
       count(*) as answers
from   survey_answers a
join   survey_questions q on q.question_id = a.question_id
left join survey_options o on o.option_id = a.option_id
where  q.survey_id = 1
and    q.question_type in ('CHOICE', 'RATING')
group by q.display_seq, q.question_text, coalesce(o.option_text, to_char(a.rating_value))
order by q.display_seq, answer;

Output:

QUESTION                                           ANSWER                  ANSWERS
__________________________________________________ ____________________ __________
How would you rate the course overall?             3                             1
How would you rate the course overall?             4                             2
How would you rate the course overall?             5                             1
Which session was the most useful?                 Dynamic Actions               2
Which session was the most useful?                 Page Designer                 1
Which session was the most useful?                 REST Data Sources             1
Would you recommend this course to a colleague?    Not sure                      1
Would you recommend this course to a colleague?    Yes                           3

8 rows selected.

When the survey changes to Team Lunch Poll, the chart question switches to that survey's question at once.

A Reader Opens the Results

SALES_USER has the Reader role. Opening page 541 shows the error of the authorization scheme:

Error page Only administrators can use this page. Access denied by Page security check
Readers cannot see the results.

Things to Know

  • The radio buttons and text areas are plain HTML. Only the hidden item P540_ANSWERS reaches the server, so the JavaScript must run before the submit.
  • A hidden item that JavaScript changes needs Value Protected off. Check its content on the server, as the validation here does.
  • Store choice answers by OPTION_ID with a foreign key on QUESTION_ID and OPTION_ID. It also blocks options of other questions when rows come from outside the page.
  • A PL/SQL Expression condition cannot use EXISTS. Use the Rows returned or No Rows returned type with a query.
  • NUM_NONNULLS is not allowed in a check constraint (ORA-00904). Add up NVL2 results instead.
  • Put value checks in a validation. RAISE_APPLICATION_ERROR in a process shows the ORA-20001 prefix to the user.
  • Changing questions after people have answered changes the meaning of old answers. Close a survey (IS_ACTIVE = N) and create a new one instead.
  • The results page shows the respondent's name. For an anonymous survey, leave RESPONDENT out of the reports, or store a hash of the user name.

Related Guides

Conclusion

With the questions in tables, an Oracle APEX survey needs no page change for a new question or a new survey. A Dynamic Content region draws the form, a few lines of JavaScript collect the answers, and one process stores them.

Constraints and validations keep the data clean. Required questions, one response per user and valid options are checked on the server. A summary report and a bar chart show the results to administrators only.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted