snapshot: preserve builder deletion and onboarding ux improvements

This commit is contained in:
Md Bayazid Bostame
2026-03-27 17:02:06 +01:00
parent 30877ed8ee
commit b0cc5bda78
9 changed files with 300 additions and 140 deletions

View File

@@ -977,6 +977,14 @@ body {
gap: 14px;
}
.builder-card-head-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.builder-entity-card-head strong {
color: #142033;
font-size: 15px;
@@ -1003,6 +1011,7 @@ body {
.builder-switch-stack {
display: grid;
gap: 8px;
justify-items: end;
}
.builder-switch input[type='checkbox'],
@@ -1031,61 +1040,62 @@ body {
gap: 10px;
}
.section-rule-grid.drag-over {
outline: 1px dashed #9db4d2;
outline-offset: 6px;
}
.section-rule-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 13px 14px;
padding: 14px 16px;
border: 1px solid #d6e0ec;
border-radius: 14px;
background: linear-gradient(180deg, #f9fbff, #ffffff);
cursor: move;
border-radius: 16px;
background: linear-gradient(180deg, #fbfdff, #ffffff);
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
width: 100%;
}
.section-rule-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06);
border-color: #b8cae0;
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.07);
border-color: #b2c6df;
}
.section-rule-card.is-locked {
background: linear-gradient(180deg, #f4f7fb, #fafcff);
background: linear-gradient(180deg, #f5f8fc, #fbfdff);
}
.section-rule-card.dragging {
opacity: 0.58;
}
.section-rule-card.manual-dragging {
opacity: 0.72;
border-color: #8fb1d8;
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.10);
}
.section-rule-drag {
color: #8aa0be;
font-size: 18px;
letter-spacing: -2px;
user-select: none;
align-self: stretch;
.section-rule-actions {
display: inline-flex;
align-items: center;
gap: 6px;
padding-right: 2px;
cursor: grab;
}
body.builder-dragging,
body.builder-dragging * {
cursor: grabbing !important;
user-select: none !important;
.section-move-btn {
width: 34px;
height: 34px;
border: 1px solid #cdd9e8;
border-radius: 11px;
background: linear-gradient(180deg, #ffffff, #f5f9ff);
color: #274264;
font-size: 15px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(15, 23, 42, 0.04);
transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease;
}
.section-move-btn:hover {
transform: translateY(-1px);
border-color: #9db4d2;
background: linear-gradient(180deg, #ffffff, #eef5ff);
box-shadow: 0 10px 16px rgba(15, 23, 42, 0.07);
}
.section-move-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.section-rule-copy {
@@ -1096,7 +1106,8 @@ body.builder-dragging * {
.section-rule-copy strong {
color: #0f172a;
font-size: 14px;
font-size: 15px;
font-weight: 700;
overflow-wrap: anywhere;
}

View File

@@ -192,6 +192,10 @@ h1 {
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
border-bottom: 1px dashed #dde4f1;
padding-bottom: 8px;
@@ -210,6 +214,9 @@ h1 {
}
.section-itsetup .section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin: -14px -14px 16px;
padding: 12px 14px;
border-bottom: 1px solid #d5e2f9;
@@ -348,6 +355,11 @@ h1 {
transform: translateY(1px);
}
.section-toggle-btn {
flex-shrink: 0;
margin-left: auto;
}
.itsetup-checklist-body {
padding: 0;
background: #ffffff;

View File

@@ -148,92 +148,36 @@
const sectionRuleGrid = document.getElementById('section-rule-grid');
if (sectionRuleGrid) {
let draggingSectionCard = null;
let manualDraggingSectionCard = null;
function getSectionInsertBeforeNode(mouseY) {
const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.dragging)'));
return cards.find((card) => {
const box = card.getBoundingClientRect();
return mouseY < box.top + box.height / 2;
function updateSectionMoveButtons() {
const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card'));
cards.forEach((card, index) => {
const upBtn = card.querySelector('[data-move-section="up"]');
const downBtn = card.querySelector('[data-move-section="down"]');
if (upBtn) upBtn.disabled = index === 0;
if (downBtn) downBtn.disabled = index === cards.length - 1;
});
}
function getManualSectionInsertBeforeNode(mouseY) {
const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.manual-dragging)'));
return cards.find((card) => {
const box = card.getBoundingClientRect();
return mouseY < box.top + box.height / 2;
});
}
function onManualSectionMove(event) {
if (!manualDraggingSectionCard) return;
event.preventDefault();
sectionRuleGrid.classList.add('drag-over');
const beforeNode = getManualSectionInsertBeforeNode(event.clientY);
if (beforeNode) {
sectionRuleGrid.insertBefore(manualDraggingSectionCard, beforeNode);
} else {
sectionRuleGrid.appendChild(manualDraggingSectionCard);
}
}
function endManualSectionDrag() {
if (!manualDraggingSectionCard) return;
manualDraggingSectionCard.classList.remove('manual-dragging');
manualDraggingSectionCard = null;
sectionRuleGrid.classList.remove('drag-over');
document.body.classList.remove('builder-dragging');
document.removeEventListener('mousemove', onManualSectionMove);
document.removeEventListener('mouseup', endManualSectionDrag);
}
sectionRuleGrid.querySelectorAll('.section-rule-card').forEach((card) => {
card.addEventListener('dragstart', (event) => {
draggingSectionCard = card;
card.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', card.dataset.sectionKey || '');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
sectionRuleGrid.classList.remove('drag-over');
draggingSectionCard = null;
});
const handle = card.querySelector('.section-rule-drag');
if (handle) {
handle.addEventListener('mousedown', (event) => {
event.preventDefault();
manualDraggingSectionCard = card;
card.classList.add('manual-dragging');
sectionRuleGrid.classList.add('drag-over');
document.body.classList.add('builder-dragging');
document.addEventListener('mousemove', onManualSectionMove);
document.addEventListener('mouseup', endManualSectionDrag);
});
sectionRuleGrid.addEventListener('click', (event) => {
const button = event.target.closest('[data-move-section]');
if (!button) return;
const card = button.closest('.section-rule-card');
if (!card) return;
const direction = button.dataset.moveSection;
if (direction === 'up') {
const previousCard = card.previousElementSibling;
if (previousCard) {
sectionRuleGrid.insertBefore(card, previousCard);
}
} else if (direction === 'down') {
const nextCard = card.nextElementSibling;
if (nextCard) {
sectionRuleGrid.insertBefore(nextCard, card);
}
}
updateSectionMoveButtons();
});
sectionRuleGrid.addEventListener('dragover', (event) => {
event.preventDefault();
sectionRuleGrid.classList.add('drag-over');
if (!draggingSectionCard) return;
const beforeNode = getSectionInsertBeforeNode(event.clientY);
if (beforeNode) {
sectionRuleGrid.insertBefore(draggingSectionCard, beforeNode);
} else {
sectionRuleGrid.appendChild(draggingSectionCard);
}
});
sectionRuleGrid.addEventListener('dragleave', () => {
sectionRuleGrid.classList.remove('drag-over');
});
sectionRuleGrid.addEventListener('drop', (event) => {
event.preventDefault();
sectionRuleGrid.classList.remove('drag-over');
});
updateSectionMoveButtons();
}
})();

View File

@@ -222,6 +222,45 @@
});
}
function setupSectionCheckboxToggles() {
document.querySelectorAll('[data-section-checkbox-toggle]').forEach(function (button) {
const sectionCard = button.closest('.section-card');
if (!sectionCard) return;
const getCheckboxes = function () {
return Array.from(sectionCard.querySelectorAll('.custom-section-checkbox input[type="checkbox"]'));
};
const refreshButtonLabel = function () {
const checkboxes = getCheckboxes();
if (!checkboxes.length) {
button.classList.add('hidden');
return;
}
button.classList.remove('hidden');
const allChecked = checkboxes.every(function (box) { return box.checked; });
button.textContent = allChecked ? (button.dataset.labelClear || 'Auswahl aufheben') : (button.dataset.labelSelect || 'Alle auswählen');
};
button.addEventListener('click', function () {
const checkboxes = getCheckboxes();
if (!checkboxes.length) return;
const shouldCheck = checkboxes.some(function (box) { return !box.checked; });
checkboxes.forEach(function (box) {
box.checked = shouldCheck;
box.dispatchEvent(new Event('change', { bubbles: true }));
});
refreshButtonLabel();
});
getCheckboxes().forEach(function (box) {
box.addEventListener('change', refreshButtonLabel);
});
refreshButtonLabel();
});
}
function setupChecklistColumns() {
document.querySelectorAll('.itsetup-checklist-body > [id^="id_"]').forEach(function (container) {
const itemCount = container.querySelectorAll(':scope > div').length;
@@ -271,6 +310,7 @@
setupWorkEmailAutofill();
setupBusinessCardAutofill();
setupChecklistToggles();
setupSectionCheckboxToggles();
setupChecklistColumns();
jumpToFirstErrorPage();
updateStep();

View File

@@ -98,11 +98,17 @@
{% for section in section_rule_items %}
<article
class="section-rule-card{% if section.locked %} is-locked{% endif %}"
draggable="true"
data-section-key="{{ section.key }}"
>
<input type="hidden" name="section_order" value="{{ section.key }}" />
<div class="section-rule-drag" aria-hidden="true" title="{% trans 'Zum Verschieben ziehen' %}">⋮⋮</div>
<div class="section-rule-actions">
<button class="section-move-btn" type="button" data-move-section="up" aria-label="{% trans 'Nach oben' %}" title="{% trans 'Nach oben' %}">
<span aria-hidden="true"></span>
</button>
<button class="section-move-btn" type="button" data-move-section="down" aria-label="{% trans 'Nach unten' %}" title="{% trans 'Nach unten' %}">
<span aria-hidden="true"></span>
</button>
</div>
<div class="section-rule-copy">
<strong>{{ section.title }}</strong>
<span>{% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %}</span>
@@ -491,10 +497,13 @@
<strong>{{ item.title }}</strong>
<div class="entity-meta">{{ item.section_key }}</div>
</div>
<label class="builder-switch">
<input type="checkbox" name="custom_section_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} />
<span>{% trans "Aktiv" %}</span>
</label>
<div class="builder-card-head-actions">
<label class="builder-switch">
<input type="checkbox" name="custom_section_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} />
<span>{% trans "Aktiv" %}</span>
</label>
<button class="btn btn-secondary" type="submit" name="delete_custom_section_id" value="{{ item.id }}" data-confirm="{% trans 'Eigenen Abschnitt wirklich löschen? Zugehörige eigene Felder werden ebenfalls entfernt.' %}">{% trans "Löschen" %}</button>
</div>
</div>
<div class="builder-entity-grid">
<label class="builder-entity-control">
@@ -613,7 +622,7 @@
<strong>{{ item.label }}</strong>
<div class="entity-meta">{{ item.field_key }}</div>
</div>
<div class="builder-switch-stack">
<div class="builder-switch-stack">
<label class="builder-switch">
<input type="checkbox" name="custom_is_required_{{ item.id }}" {% if item.is_required %}checked{% endif %} />
<span>{% trans "Pflicht" %}</span>
@@ -622,8 +631,9 @@
<input type="checkbox" name="custom_is_active_{{ item.id }}" {% if item.is_active %}checked{% endif %} />
<span>{% trans "Aktiv" %}</span>
</label>
</div>
</div>
<button class="btn btn-secondary" type="submit" name="delete_custom_field_id" value="{{ item.id }}" data-confirm="{% trans 'Eigenes Feld wirklich löschen?' %}">{% trans "Löschen" %}</button>
</div>
<div class="builder-entity-grid">
<label class="builder-entity-control">
<span>{% trans "Abschnitt" %}</span>
@@ -670,9 +680,6 @@
<textarea name="custom_select_options_en_{{ item.id }}" rows="2">{{ item.select_options_en }}</textarea>
</label>
</div>
<div class="builder-entity-card-actions">
<button class="btn btn-secondary" type="submit" name="delete_custom_field_id" value="{{ item.id }}" data-confirm="{% trans 'Eigenes Feld wirklich löschen?' %}">{% trans "Löschen" %}</button>
</div>
</article>
{% empty %}
<div class="builder-empty-state">{% trans "Keine eigenen Felder vorhanden." %}</div>

View File

@@ -50,8 +50,19 @@
<section class="page {% if forloop.first %}active{% endif %}" data-step="{{ forloop.counter }}">
<div class="section-card section-{{ section.key }}">
<div class="section-head">
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
<div>
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
{% if section.has_custom_checkbox_fields %}
<button
type="button"
class="checklist-toggle-btn section-toggle-btn"
data-section-checkbox-toggle
data-label-select="{% trans 'Alle auswählen' %}"
data-label-clear="{% trans 'Auswahl aufheben' %}"
>{% trans "Alle auswählen" %}</button>
{% endif %}
</div>
<div class="grid-2">
{% for block in section.blocks %}
@@ -78,7 +89,7 @@
</div>
</div>
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %} {% if field.name|slice:':8' == 'custom__' and field.field.widget.input_type == 'checkbox' %}custom-section-checkbox{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}
@@ -121,7 +132,7 @@
</div>
</div>
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %}">
<div class="field inline-check field-full {% if section.key == 'abschluss' %}finish-check{% endif %} {% if field.name|slice:':8' == 'custom__' and field.field.widget.input_type == 'checkbox' %}custom-section-checkbox{% endif %}">
{{ field }} {{ field.label_tag }}
{% if field.help_text %}<div class="hint">{{ field.help_text }}</div>{% endif %}
{{ field.errors }}

View File

@@ -331,6 +331,60 @@ class FormBuilderAdminTests(TestCase):
self.assertEqual(section.title, 'Benefits')
self.assertEqual(section.sort_order, 5)
def test_staff_can_delete_custom_field(self):
self.client.force_login(self.staff)
field = FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='laptop_tag',
section_key='itsetup',
sort_order=0,
field_type='text',
label='Laptop-Tag',
)
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={'delete_custom_field_id': str(field.id)},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists())
def test_staff_can_delete_custom_section_and_its_fields(self):
self.client.force_login(self.staff)
section = FormCustomSectionConfig.objects.create(
form_type='onboarding',
section_key='benefits',
sort_order=0,
title='Benefits',
)
field = FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='meal_allowance',
section_key='benefits',
sort_order=0,
field_type='checkbox',
label='Essenszuschuss',
)
FormConditionalRuleConfig.objects.create(
form_type='onboarding',
target_key='custom__meal_allowance',
clauses=[{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}],
is_active=True,
)
response = self.client.post(
'/admin-tools/form-builder/?form_type=onboarding&option_category=device',
data={'delete_custom_section_id': str(section.id)},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
self.assertFalse(FormCustomSectionConfig.objects.filter(id=section.id).exists())
self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists())
self.assertFalse(FormConditionalRuleConfig.objects.filter(target_key='custom__meal_allowance').exists())
def test_save_order_accepts_custom_section_column(self):
self.client.force_login(self.staff)
FormCustomSectionConfig.objects.create(

View File

@@ -226,6 +226,41 @@ class OnboardingFlowTests(TestCase):
self.assertIn('Benefits', html)
self.assertIn('Essenszuschuss', html)
def test_onboarding_custom_section_with_checkbox_fields_shows_section_select_all(self):
FormCustomSectionConfig.objects.create(
form_type='onboarding',
section_key='benefits',
sort_order=10,
title='Benefits',
is_active=True,
)
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='meal_allowance',
section_key='benefits',
sort_order=0,
field_type='checkbox',
is_active=True,
label='Essenszuschuss',
)
FormCustomFieldConfig.objects.create(
form_type='onboarding',
field_key='parking_spot',
section_key='benefits',
sort_order=1,
field_type='checkbox',
is_active=True,
label='Parkplatz',
)
response = self.client.get('/onboarding/new/', HTTP_HOST='localhost')
html = response.content.decode('utf-8')
self.assertEqual(response.status_code, 200)
self.assertIn('data-section-checkbox-toggle', html)
self.assertIn('Essenszuschuss', html)
self.assertIn('Parkplatz', html)
@patch('workflows.views.process_onboarding_request.delay')
def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay):
FormCustomFieldConfig.objects.create(

View File

@@ -610,16 +610,33 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str],
section_key = 'abschluss'
grouped[section_key].append(block)
visible_keys = visible_section_keys or set(section_order)
return [
{
'key': key,
'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)),
'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''),
'blocks': grouped[key],
}
for key in section_order
if key in visible_keys
]
sections = []
custom_section_keys = {item['key'] for item in section_defs if item.get('is_custom')}
for key in section_order:
if key not in visible_keys:
continue
blocks_for_section = grouped[key]
has_custom_checkbox_fields = False
for block in blocks_for_section:
candidate_fields = [block['field']] if block['kind'] == 'field' else (block.get('fields') or [])
for bound_field in candidate_fields:
widget_type = getattr(getattr(bound_field.field, 'widget', None), 'input_type', '')
if bound_field.name.startswith('custom__') and widget_type == 'checkbox':
has_custom_checkbox_fields = True
break
if has_custom_checkbox_fields:
break
sections.append(
{
'key': key,
'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)),
'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''),
'blocks': blocks_for_section,
'is_custom': key in custom_section_keys,
'has_custom_checkbox_fields': has_custom_checkbox_fields,
}
)
return sections
OFFBOARDING_SECTION_META = {
@@ -2182,6 +2199,7 @@ def form_builder_page(request):
if request.method == 'POST':
delete_option_id = request.POST.get('delete_option_id', '').strip()
delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip()
delete_custom_section_id = request.POST.get('delete_custom_section_id', '').strip()
if delete_option_id:
option = FormOption.objects.filter(id=delete_option_id).first()
if not option:
@@ -2205,6 +2223,34 @@ def form_builder_page(request):
_audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label)
messages.success(request, 'Benutzerdefiniertes Feld wurde gelöscht.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content")
if delete_custom_section_id:
custom_section = FormCustomSectionConfig.objects.filter(id=delete_custom_section_id, form_type=form_type).first()
if not custom_section:
messages.error(request, 'Benutzerdefinierter Abschnitt nicht gefunden.')
else:
deleted_label = custom_section.title
deleted_id = custom_section.id
section_key = custom_section.section_key
custom_fields = list(FormCustomFieldConfig.objects.filter(form_type=form_type, section_key=section_key))
deleted_field_count = len(custom_fields)
if custom_fields:
field_keys = [item.field_key for item in custom_fields]
FormConditionalRuleConfig.objects.filter(
form_type=form_type,
target_key__in=[f'custom__{field_key}' for field_key in field_keys],
).delete()
FormCustomFieldConfig.objects.filter(id__in=[item.id for item in custom_fields]).delete()
custom_section.delete()
_audit(
request,
'form_custom_section_deleted',
target_type='form_custom_section',
target_id=deleted_id,
target_label=deleted_label,
details={'section_key': section_key, 'deleted_field_count': deleted_field_count},
)
messages.success(request, 'Benutzerdefinierter Abschnitt wurde gelöscht.')
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-sections#builder-content")
action = request.POST.get('builder_action', '')
if action == 'add_option':