March 17 2026: form editor - add pass/fail element

This commit is contained in:
2026-03-17 12:09:40 -04:00
parent 46df092a61
commit 33166bbc6b
7 changed files with 85 additions and 8 deletions
+14 -3
View File
@@ -30,7 +30,7 @@ ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
INPUT_FIELD_TYPES = { INPUT_FIELD_TYPES = {
'text', 'textarea', 'number', 'date', 'email', 'text', 'textarea', 'number', 'date', 'email',
'checkbox', 'checkbox_group', 'radio', 'select', 'checkbox', 'checkbox_group', 'radio', 'select',
'rating', 'signature', 'image', 'table' 'rating', 'pass_fail', 'signature', 'image', 'table'
} }
@@ -115,7 +115,7 @@ def _compute_score_from_form(form_fields, responses):
Derive an overall score from rating fields and checkbox pass/fail fields. Derive an overall score from rating fields and checkbox pass/fail fields.
Returns a float 0100 or None if the form has no scoreable fields. Returns a float 0100 or None if the form has no scoreable fields.
""" """
scoreable = [f for f in form_fields if f['type'] in ('rating', 'checkbox', 'radio')] scoreable = [f for f in form_fields if f['type'] in ('rating', 'checkbox', 'radio', 'pass_fail')]
if not scoreable: if not scoreable:
return None return None
@@ -144,6 +144,13 @@ def _compute_score_from_form(form_fields, responses):
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'): if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
earned += 1 earned += 1
elif field['type'] == 'pass_fail':
if not val:
continue
total += 1
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
earned += 1
return round((earned / total) * 100, 2) if total else None return round((earned / total) * 100, 2) if total else None
@@ -478,7 +485,7 @@ def view(inspection_id):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
scoreable_types = ('rating', 'checkbox', 'radio') scoreable_types = ('rating', 'checkbox', 'radio', 'pass_fail')
# Collect all text/textarea fields per row, keyed by (col, fid) # Collect all text/textarea fields per row, keyed by (col, fid)
# so we can pick the leftmost non-empty value as the item name. # so we can pick the leftmost non-empty value as the item name.
@@ -557,6 +564,10 @@ def view(inspection_id):
return 100.0 if val == 'true' else 0.0 return 100.0 if val == 'true' else 0.0
if ft == 'radio': if ft == 'radio':
return None if not val else 100.0 return None if not val else 100.0
if ft == 'pass_fail':
if not val:
return None
return 100.0 if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant') else 0.0
return None return None
cur_pct = _field_pct(cur_val, ftype) cur_pct = _field_pct(cur_val, ftype)
+1 -1
View File
@@ -229,7 +229,7 @@ def save_form_schema(template_id):
'label': str(field.get('label', 'Untitled'))[:255], 'label': str(field.get('label', 'Untitled'))[:255],
'placeholder': str(field.get('placeholder', ''))[:255], 'placeholder': str(field.get('placeholder', ''))[:255],
'required': bool(field.get('required', False)), 'required': bool(field.get('required', False)),
'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select') else [], 'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select', 'pass_fail') else [],
'help_text': str(field.get('help_text', ''))[:500], 'help_text': str(field.get('help_text', ''))[:500],
'order': int(field.get('order', 0)), 'order': int(field.get('order', 0)),
# Grid position & size # Grid position & size
+24
View File
@@ -330,6 +330,30 @@
{% endfor %} {% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %} {% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Pass / Fail ── #}
{% elif field.type == 'pass_fail' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% set pf_options = field.options if field.options else ['Pass', 'Fail'] %}
<div class="pf-group" style="display:flex;gap:.5rem;flex-wrap:wrap;">
{% for opt in pf_options %}
{% set is_pass = opt.lower() in ('pass','yes','ok','good') %}
<label class="pf-btn{{ ' active' if saved == opt }}" data-color="{{ '#16a34a' if is_pass else '#dc2626' }}"
style="display:flex;align-items:center;gap:.35rem;cursor:pointer;
padding:.4rem .85rem;border-radius:20px;font-size:.84rem;font-weight:600;
border:2px solid {{ '#16a34a' if is_pass else '#dc2626' }};
color:{{ ('#fff' if saved == opt else ('#16a34a' if is_pass else '#dc2626')) }};
background:{{ (('#16a34a' if is_pass else '#dc2626') if saved == opt else 'transparent') }};
transition:background .15s,color .15s;">
<input type="radio" name="field_{{ fid }}" value="{{ opt }}"
{{ 'checked' if saved == opt }}
style="display:none;"
onchange="var g=this.closest('.pf-group');g.querySelectorAll('.pf-btn').forEach(function(l){l.style.background='';l.style.color=l.dataset.color;});this.parentElement.style.background=this.parentElement.dataset.color;this.parentElement.style.color='#fff';">
<span>{{ opt }}</span>
</label>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Select / Dropdown ── #} {# ── Select / Dropdown ── #}
{% elif field.type == 'select' %} {% elif field.type == 'select' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label> <label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
+12
View File
@@ -559,6 +559,18 @@
{% endif %} {% endif %}
</div> </div>
{% elif ftype == 'pass_fail' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val">
{% if val and val.lower() in ('pass','yes','ok','good','acceptable','compliant') %}
<span class="text-success"><i class="bi bi-check-circle-fill"></i> {{ val }}</span>
{% elif val %}
<span class="text-danger"><i class="bi bi-x-circle-fill"></i> {{ val }}</span>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not answered</span>
{% endif %}
</div>
{% elif ftype == 'checkbox' %} {% elif ftype == 'checkbox' %}
<span class="field-lbl">{{ field.label }}</span> <span class="field-lbl">{{ field.label }}</span>
<div class="field-val"> <div class="field-val">
+11 -4
View File
@@ -402,6 +402,7 @@
('checkbox_group', 'bi-ui-checks', 'Checkbox Group'), ('checkbox_group', 'bi-ui-checks', 'Checkbox Group'),
('radio', 'bi-ui-radios', 'Radio Group'), ('radio', 'bi-ui-radios', 'Radio Group'),
('select', 'bi-menu-button-wide', 'Dropdown'), ('select', 'bi-menu-button-wide', 'Dropdown'),
('pass_fail', 'bi-check2-circle', 'Pass / Fail'),
] %} ] %}
<div class="pal-item" draggable="true" data-ftype="{{ t }}" data-flabel="{{ lb }}"> <div class="pal-item" draggable="true" data-ftype="{{ t }}" data-flabel="{{ lb }}">
<i class="bi {{ ic }}"></i> {{ lb }} <i class="bi {{ ic }}"></i> {{ lb }}
@@ -480,7 +481,7 @@ const DEF_SIZE = {
number: [3, 2], date: [4, 2], number: [3, 2], date: [4, 2],
email: [6, 2], checkbox: [4, 2], email: [6, 2], checkbox: [4, 2],
checkbox_group: [5, 4], radio: [5, 4], checkbox_group: [5, 4], radio: [5, 4],
select: [5, 2], image: [6, 4], select: [5, 2], pass_fail: [4, 2], image: [6, 4],
signature: [6, 3], rating: [5, 2], signature: [6, 3], rating: [5, 2],
section: [12, 1], table: [12, 5], section: [12, 1], table: [12, 5],
label: [6, 1], label: [6, 1],
@@ -490,7 +491,7 @@ const DEF_SIZE = {
const LABELS = { const LABELS = {
text:'Text Input', textarea:'Text Area', number:'Number', date:'Date', text:'Text Input', textarea:'Text Area', number:'Number', date:'Date',
email:'Email', checkbox:'Checkbox', checkbox_group:'Checkbox Group', email:'Email', checkbox:'Checkbox', checkbox_group:'Checkbox Group',
radio:'Radio Group', select:'Dropdown', image:'Image Upload', radio:'Radio Group', select:'Dropdown', pass_fail:'Pass / Fail', image:'Image Upload',
signature:'Signature', rating:'Rating (15)', section:'Section Header', signature:'Signature', rating:'Rating (15)', section:'Section Header',
table:'Table', label:'Label / Text', table:'Table', label:'Label / Text',
button_submit:'Submit Button', button_print:'Print Button', button_email:'Email Button', button_submit:'Submit Button', button_print:'Print Button', button_email:'Email Button',
@@ -586,7 +587,7 @@ function mkField(type, col, row) {
placeholder: '', placeholder: '',
required: false, required: false,
help_text: '', help_text: '',
options: ['radio','checkbox_group','select'].includes(type) ? ['Option 1','Option 2'] : [], options: type === 'pass_fail' ? ['Pass','Fail'] : ['radio','checkbox_group','select'].includes(type) ? ['Option 1','Option 2'] : [],
table_cols: isTable ? 3 : undefined, table_cols: isTable ? 3 : undefined,
table_rows: isTable ? 3 : undefined, table_rows: isTable ? 3 : undefined,
col_headers: isTable ? ['Column 1','Column 2','Column 3'] : undefined, col_headers: isTable ? ['Column 1','Column 2','Column 3'] : undefined,
@@ -745,6 +746,12 @@ function mkPreview(f) {
return `${lbl}<div class="mock-sig">Sign here…</div>`; return `${lbl}<div class="mock-sig">Sign here…</div>`;
case 'rating': case 'rating':
return `${lbl}<div style="font-size:1.2rem;color:#f59e0b;letter-spacing:.15rem;">★★★★★</div>`; return `${lbl}<div style="font-size:1.2rem;color:#f59e0b;letter-spacing:.15rem;">★★★★★</div>`;
case 'pass_fail': {
const pfOpts = f.options || ['Pass','Fail'];
return `${lbl}<div style="display:flex;gap:.35rem;">${pfOpts.map(o =>
`<span style="display:inline-block;padding:.15rem .55rem;border-radius:20px;font-size:.72rem;font-weight:600;border:1.5px solid ${o.toLowerCase()==='pass'?'#16a34a':'#dc2626'};color:${o.toLowerCase()==='pass'?'#16a34a':'#dc2626'};">${esc(o)}</span>`
).join('')}</div>`;
}
case 'section': case 'section':
return `<div style="border-top:2px solid #e2e8f0;padding-top:.4rem;"> return `<div style="border-top:2px solid #e2e8f0;padding-top:.4rem;">
<strong style="font-size:.86rem;color:#374151;">${esc(f.label)}</strong> <strong style="font-size:.86rem;color:#374151;">${esc(f.label)}</strong>
@@ -815,7 +822,7 @@ function renderProperties() {
const f = fields.find(f => f.id === selectedId); const f = fields.find(f => f.id === selectedId);
if (!f) return; if (!f) return;
empty.style.display = 'none'; content.style.display = ''; empty.style.display = 'none'; content.style.display = '';
const hasOpts = ['radio','checkbox_group','select'].includes(f.type); const hasOpts = ['radio','checkbox_group','select','pass_fail'].includes(f.type);
const isSec = f.type === 'section'; const isSec = f.type === 'section';
const isLabel = f.type === 'label'; const isLabel = f.type === 'label';
const isBtn = f.type.startsWith('button_'); const isBtn = f.type.startsWith('button_');
+13
View File
@@ -248,6 +248,19 @@
{% endfor %} {% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %} {% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'pass_fail' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="text-danger">*</span>{% endif %}</label>
{% set pf_options = field.options if field.options else ['Pass', 'Fail'] %}
<div style="display:flex;gap:.4rem;margin-top:.2rem;">
{% for opt in pf_options %}
{% set is_pass = opt.lower() in ('pass','yes','ok','good') %}
<span style="padding:.25rem .65rem;border-radius:20px;font-size:.78rem;font-weight:600;
border:1.5px solid {{ '#16a34a' if is_pass else '#dc2626' }};
color:{{ '#16a34a' if is_pass else '#dc2626' }};">{{ opt }}</span>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'select' %} {% elif field.type == 'select' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label> <label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<select class="form-select"> <select class="form-select">
+10
View File
@@ -386,6 +386,8 @@ def _form_fields_section(form_fields, form_data, static_folder):
if ftype == 'checkbox': if ftype == 'checkbox':
# stored as 'true'/'false' by _collect_form_responses # stored as 'true'/'false' by _collect_form_responses
return val not in ('yes', 'true') return val not in ('yes', 'true')
if ftype == 'pass_fail':
return not val
if ftype == 'checkbox_group': if ftype == 'checkbox_group':
return not val or not isinstance(val, list) or len(val) == 0 return not val or not isinstance(val, list) or len(val) == 0
if ftype == 'table': if ftype == 'table':
@@ -429,6 +431,14 @@ def _form_fields_section(form_fields, form_data, static_folder):
elif ftype == 'checkbox': elif ftype == 'checkbox':
val_p = Paragraph('Yes', STYLES['FieldValue']) val_p = Paragraph('Yes', STYLES['FieldValue'])
elif ftype == 'pass_fail':
is_pass = str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant')
colour = C_GREEN if is_pass else C_RED
val_p = Paragraph(
f'<font color="{colour.hexval()}">{str(val)}</font>',
STYLES['FieldValue'],
)
elif ftype == 'checkbox_group': elif ftype == 'checkbox_group':
val_p = Paragraph(', '.join(val), STYLES['FieldValue']) val_p = Paragraph(', '.join(val), STYLES['FieldValue'])