Skip to main content

Overview

The ION Job Application web form dynamically loads and renders screening questions based on the selected Job Opening. This provides a seamless application experience while capturing structured data for scoring and evaluation.
The web form is configured in ion_career/ion_career/web_form/ion_job_application/ion_job_application.json

How It Works

The dynamic form system consists of three main components:
  1. Web Form Configuration: Defines the base fields and layout
  2. Client-Side JavaScript: Handles dynamic question loading and validation
  3. Server API: Fetches questions for the selected job opening

Form Structure

The base web form includes standard fields:
job_title
Link
Links to Job Opening. Once selected, becomes read-only to prevent confusion.
applicant_name
Data
required
Full name of the applicant.
email_id
Data
required
Email address with built-in validation.
phone_number
Data
Optional contact number.
country
Link
Links to Country doctype.
cover_letter
Text
Optional cover letter text.
resume_attachment
Attach
Resume file upload.
source
Link
How the applicant found the job (links to Job Applicant Source).
Screening Questions
HTML
Container where dynamic questions are rendered.

Dynamic Question Rendering

The client-side JavaScript dynamically loads and renders questions when a Job Opening is selected.

Lifecycle Hooks

frappe.web_form.after_load = function () {
    if (frappe.web_form.get_value('job_title')) {
        load_job_questions();
    }
};
Once a job is selected, the field becomes read-only to prevent accidental changes that would invalidate the loaded questions.

Loading Questions

The load_job_questions() function fetches questions via API call:
function load_job_questions() {
    const job_opening = frappe.web_form.get_value('job_title');

    if (!job_opening) {
        clear_questions();
        return;
    }

    frappe.call({
        method: "ion_career.api.get_job_questions",
        args: { job_opening },
        freeze: true,
        callback: function (r) {
            current_questions = r.message || [];
            render_questions(current_questions);
        }
    });
}

Rendering Questions

Questions are rendered as HTML based on their input_type:
function render_questions(questions) {
    const container = get_questions_container();
    if (!container) return;

    if (!questions.length) {
        container.innerHTML = "";
        return;
    }

    let html = `<div class="job-questions-wrapper">`;

    questions.forEach(q => {
        html += `
            <div class="form-group job-question-item">
                <label class="control-label">
                    ${frappe.utils.escape_html(q.question)}
                    ${q.required ? '<span class="text-danger">*</span>' : ''}
                </label>
        `;

        if (q.input_type === "Checkbox") {
            html += `
                <input type="checkbox"
                       class="job-question-input"
                       data-fieldname="${q.fieldname}">
            `;
        } else {
            html += `
                <select class="form-control job-question-input"
                        data-fieldname="${q.fieldname}">
                    <option value="">Select</option>
                    <option value="Yes">Yes</option>
                    <option value="No">No</option>
                </select>
            `;
        }

        html += `</div>`;
    });

    html += `</div>`;
    container.innerHTML = html;
    
    // Attach event listeners
    container.querySelectorAll('.job-question-input')
        .forEach(el => el.addEventListener('change', sync_answers_to_hidden_field));

    // Initial sync
    sync_answers_to_hidden_field();
}
Questions are HTML-escaped using frappe.utils.escape_html() to prevent XSS attacks.

Client-Side Validation

The form implements comprehensive client-side validation before submission.

Answer Synchronization

As users interact with questions, their answers are synced to a hidden JSON field:
function sync_answers_to_hidden_field() {
    let answers = {};

    document.querySelectorAll('.job-question-input').forEach(el => {
        const key = el.dataset.fieldname;
        if (!key) return;

        if (el.type === 'checkbox') {
            answers[key] = el.checked ? 1 : 0;
        } else {
            answers[key] = el.value;
        }
    });

    frappe.web_form.set_value(
        'custom_job_question_answers',
        JSON.stringify(answers)
    );
}

Required Field Validation

Before form submission, all required questions are validated:
frappe.web_form.validate = function () {
    let answers = JSON.parse(
        frappe.web_form.get_value('custom_job_question_answers') || '{}'
    );

    current_questions.forEach(q => {
        if (q.required && !answers[q.fieldname]) {
            frappe.throw(
                __('Please answer the required question: {0}', [q.question])
            );
        }
    });
};
If a required question is not answered, the form will not submit and an error message will be displayed.

Input Types

The form supports two input types:
Renders as a dropdown with three options:
  • Empty (no selection)
  • Yes
  • No
<select class="form-control job-question-input" data-fieldname="field_abc123">
    <option value="">Select</option>
    <option value="Yes">Yes</option>
    <option value="No">No</option>
</select>

Data Flow

1

User Selects Job Opening

The job_title field triggers the load_job_questions() function.
2

API Call

JavaScript calls ion_career.api.get_job_questions with the job opening name.
3

Server Response

Server returns array of questions with their properties (question text, fieldname, input_type, required).
4

Dynamic Rendering

Questions are rendered as HTML and inserted into the Screening Questions container.
5

User Interaction

As user answers questions, the sync_answers_to_hidden_field() function updates the hidden JSON field.
6

Validation

On submit, frappe.web_form.validate() checks all required questions are answered.
7

Submission

Form data (including JSON answers) is submitted to create a Job Applicant record.

Server Processing

After submission, the process_job_questions handler processes the answers:
@frappe.whitelist()
def process_job_questions(doc, method):
    if not doc.custom_job_question_answers:
        return

    answers = json.loads(doc.custom_job_question_answers)
    job_opening = doc.job_title
    qset_name = frappe.db.get_value(
        "Job Opening", job_opening, "custom_job_question_set"
    )

    if not qset_name:
        return

    qset = frappe.get_doc("Job Question Set", qset_name)

    # Populate answer table and calculate score
    for q in qset.questions:
        doc.append("custom_question_answers", {
            "question": q.question,
            "fieldname": q.fieldname,
            "answer": answers.get(q.fieldname),
            "job_opening": job_opening
        })
See the full implementation in ion_career/handlers.py:4-41

Best Practices

  • Questions are loaded once when job is selected
  • Event listeners are attached in batch after rendering
  • No unnecessary re-renders during answer changes
  • All question text is HTML-escaped to prevent XSS
  • Answers are validated server-side as well
  • Field names are auto-generated to prevent conflicts
  • Job field becomes read-only after selection
  • Required questions are clearly marked with asterisk
  • Validation errors are user-friendly and specific
  • Form freezes during API calls to prevent duplicate submissions

Customization

You can extend the web form by:
  1. Adding Custom Validations: Extend frappe.web_form.validate() with additional logic
  2. Styling: Add CSS classes to .job-questions-wrapper and .job-question-item
  3. New Input Types: Modify render_questions() to support additional field types
  4. Conditional Logic: Show/hide questions based on previous answers

Build docs developers (and LLMs) love