This is an automated email from the ASF dual-hosted git repository. gstein pushed a commit to branch trunk in repository https://gitbox.apache.org/repos/asf/steve.git
commit e02bb65af88fb6e4b0eff915277e769c7ded09a6 Author: Greg Stein <[email protected]> AuthorDate: Fri Oct 17 00:53:03 2025 -0500 Add Issue management for elections. * manage.ezt: - "Add Issue" button - "Expand/Collapse All" descriptions of existing issues - model for add/edit an issue. - each issue gets a twiddle for the description, and icons to edit or delete the issue. - initial JS code for issue management - pages.py: - .get_issue() returns a tuple (fix!); turn into edict - endpoints that mutate should use 303 (See Other) for their redirect, rather than telling the browser to retry $method at a new Location (eg. do a GET at new loc, not DELETE at new loc) * steve.css: small tweak to support issue management buttons --- v3/server/pages.py | 15 ++-- v3/server/static/css/steve.css | 1 + v3/server/templates/manage.ezt | 172 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/v3/server/pages.py b/v3/server/pages.py index 912e965..557101b 100644 --- a/v3/server/pages.py +++ b/v3/server/pages.py @@ -176,7 +176,10 @@ def load_election_issue(func): raise_404(T_BAD_IID, result) # NOTREACHED - return await func(e, i) + ### get_issue() should return an edict. fix it here. + issue = edict(iid=iid, title=i[0], description=i[1], type=i[2], kv=i[3]) + + return await func(e, issue) return loader @@ -283,7 +286,7 @@ async def do_open_endpoint(election): await flash_success(f'Opened election: {title}') # Return to the management page for this Election. - return quart.redirect(f'/manage/{election.eid}') + return quart.redirect(f'/manage/{election.eid}', code=303) @APP.get('/do-close/<eid>') @@ -303,7 +306,7 @@ async def do_close_endpoint(election): await flash_success(f'Closed election: {title}') # Return to the management page for this Election. - return quart.redirect(f'/manage/{election.eid}') + return quart.redirect(f'/manage/{election.eid}', code=303) @APP.post('/do-add-issue/<eid>') @@ -320,7 +323,7 @@ async def do_add_issue_endpoint(election): f' to election[E:{election.eid}]') # Return to the management page for this Election. - return quart.redirect(f'/manage/{election.eid}') + return quart.redirect(f'/manage/{election.eid}', code=303) @APP.post('/do-edit-issue/<eid>/<iid>') @@ -337,7 +340,7 @@ async def do_edit_issue_endpoint(election, issue): f' in election[E:{election.eid}]') # Return to the management page for this Election. - return quart.redirect(f'/manage/{election.eid}') + return quart.redirect(f'/manage/{election.eid}', code=303) @APP.delete('/do-delete-issue/<eid>/<iid>') @@ -354,7 +357,7 @@ async def do_delete_issue_endpoint(election, issue): f' from election[E:{election.eid}]') # Return to the management page for this Election. - return quart.redirect(f'/manage/{election.eid}') + return quart.redirect(f'/manage/{election.eid}', code=303) @APP.get('/profile') diff --git a/v3/server/static/css/steve.css b/v3/server/static/css/steve.css index 6118779..f29aedb 100644 --- a/v3/server/static/css/steve.css +++ b/v3/server/static/css/steve.css @@ -52,6 +52,7 @@ /* vote-on.ezt */ .issue-item { margin-bottom: 1rem; } +.action-buttons { margin-left: 1rem; } .vote-radio { margin-left: 1rem; } .description { display: none; } .description.show { display: block; } diff --git a/v3/server/templates/manage.ezt b/v3/server/templates/manage.ezt index 956c437..de0768f 100644 --- a/v3/server/templates/manage.ezt +++ b/v3/server/templates/manage.ezt @@ -42,7 +42,7 @@ </div> </div> </div> - </div> + </div>[# id=openConfirmModal ] <!-- Close Confirmation Modal --> <div class="modal fade" id="closeConfirmModal" tabindex="-1" aria-labelledby="closeConfirmModalLabel" aria-hidden="true"> @@ -61,8 +61,37 @@ </div> </div> </div> - </div> + </div>[# id=closeConfirmModal ] + <!-- Edit/Add Issue Modal --> + <div class="modal fade" id="issueModal" tabindex="-1" aria-labelledby="issueModalLabel" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title" id="issueModalLabel">Edit Issue</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <form id="issueForm"> + <input type="hidden" id="issueId" value=""> + <div class="mb-3"> + <label for="issueTitle" class="form-label">Title</label> + <input type="text" class="form-control" id="issueTitle" required> + <div class="invalid-feedback">Title is required.</div> + </div> + <div class="mb-3"> + <label for="issueDescription" class="form-label">Description</label> + <textarea class="form-control" id="issueDescription" rows="4"></textarea> + </div> + </form> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-primary" onclick="saveIssue()">Save</button> + </div> + </div>[# modal-content ] + </div>[# modal-dialog ] + </div>[# id=issueModal ] <div> State: [e_state] @@ -73,11 +102,41 @@ <div class="mb-3"> Estimate to close: <input type="date"> </div> - <ul> + + <div class="mb-4"> + <button type="button" class="btn btn-primary" onclick="openAddIssueModal()">Add Issue</button> + <button type="button" class="btn btn-outline-secondary ms-2" onclick="toggleAllDescriptions()"> + <span id="toggle-all-text">Expand All</span> + </button> + </div> + + <div id="issues-list" class="list-group"> [for issues] - <li>[issues.title]</li> - [end] - </ul> + <div class="list-group-item issue-item"> + <div class="d-flex justify-content-between align-items-center"> + <div> + <span class="twiddle bi bi-caret-right-fill me-2" onclick="toggleDescription('[issues.iid]')"></span> + <strong>[issues.title]</strong> + </div> + <div class="action-buttons"> + <button type="button" class="btn btn-outline-primary btn-sm me-1" + onclick="openEditIssueModal('[issues.iid]', + '[format "js,html"][issues.title][end]', + '[format "js,html"][issues.description][end]')" + aria-label="Edit Issue"> + <i class="bi bi-pencil"></i> + </button> + <button type="button" class="btn btn-outline-danger btn-sm" + onclick="deleteIssue('[issues.iid]')" aria-label="Delete Issue"> + <i class="bi bi-trash"></i> + </button> + </div> + </div> + <div id="description-[issues.iid]" class="description mt-2">[issues.description]</div> + </div> + [end] + </div> + </div> [include "footer.ezt"] @@ -109,4 +168,105 @@ closeModal.hide(); window.location.href = '/do-close/[eid]'; }); + + // Toggle individual description + function toggleDescription(issueId) { + const desc = document.getElementById(`description-${issueId}`); + const twiddle = desc.previousElementSibling.querySelector('.twiddle'); + if (desc && twiddle) { + desc.classList.toggle('show'); + twiddle.classList.toggle('bi-caret-right-fill'); + twiddle.classList.toggle('bi-caret-down-fill'); + } + } + + + // Expand/Collapse All descriptions + let allExpanded = false; + function toggleAllDescriptions() { + allExpanded = !allExpanded; + document.querySelectorAll('.description').forEach(desc => { + desc.classList.toggle('show', allExpanded); + }); + document.querySelectorAll('.twiddle').forEach(twiddle => { + twiddle.classList.toggle('bi-caret-right-fill', !allExpanded); + twiddle.classList.toggle('bi-caret-down-fill', allExpanded); + }); + document.getElementById('toggle-all-text').textContent = allExpanded ? 'Collapse All' : 'Expand All'; + } + + // Open modal for adding issue + function openAddIssueModal() { + document.getElementById('issueModalLabel').textContent = 'Add Issue'; + document.getElementById('issueId').value = ''; + document.getElementById('issueTitle').value = ''; + document.getElementById('issueDescription').value = ''; + document.getElementById('issueTitle').classList.remove('is-invalid'); + const modal = new bootstrap.Modal(document.getElementById('issueModal')); + modal.show(); + } + + // Open modal for editing issue + function openEditIssueModal(issueId, title, description) { + document.getElementById('issueModalLabel').textContent = 'Edit Issue'; + document.getElementById('issueId').value = issueId; + document.getElementById('issueTitle').value = title; + document.getElementById('issueDescription').value = description; + document.getElementById('issueTitle').classList.remove('is-invalid'); + const modal = new bootstrap.Modal(document.getElementById('issueModal')); + modal.show(); + } + + // Save issue (add or edit) + function saveIssue() { + const issueId = document.getElementById('issueId').value; + const title = document.getElementById('issueTitle').value.trim(); + const description = document.getElementById('issueDescription').value.trim(); + const titleInput = document.getElementById('issueTitle'); + + // Client-side validation + if (!title) { + titleInput.classList.add('is-invalid'); + return; + } + titleInput.classList.remove('is-invalid'); + + const url = issueId ? `/do-edit-issue/[eid]/${issueId}` : `/do-add-issue/[eid]`; + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, description }), + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json(); + }) + .then(data => { + bootstrap.Modal.getInstance(document.getElementById('issueModal')).hide(); + // Server will refresh page with flash messages + }) + .catch(error => { + // Server will handle error and flash message + }); + } + + // Delete issue + function deleteIssue(issueId) { + if (!confirm('Are you sure you want to delete this issue?')) return; + + fetch(`/do-delete-issue/[eid]/${issueId}`, { + method: 'DELETE', + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json(); + }) + .then(data => { + // Server will refresh page with flash messages + }) + .catch(error => { + // Server will handle error and flash message + }); + } + </script>
