Bläddra i källkod

Phase 10: multi-select owner filter + column visibility toggle

Task-list toolbar on /sprints/{id} gains two UX affordances from
spec §6.4 that Phase 6 deferred. Both are pure client-side — no
routes, no audit, no backend work.

1) Multi-select owner filter
   - Replaced the single <select data-owner-filter> with a
     dropdown of checkboxes: "No owner" + every worker in
     ownerChoices.
   - Empty set = show all (matches the previous default).
     Any selection narrows to matching rows only.
   - Clear button wipes the set.
   - Trigger button shows a "(N)" count when N > 0 selected.
   - State persists in localStorage under
     sp:<sprint-id>:ownerFilter so reload preserves the filter.

2) Column visibility toggle
   - New "Columns" dropdown in the toolbar. Checkboxes for
     Owner, Prio, Tot and every sprint-worker column (Task
     column is always visible).
   - Hidden columns apply Tailwind's .hidden to both the <th>
     and every matching <td> via a new data-col="<key>"
     attribute.
   - State persists in localStorage under
     sp:<sprint-id>:hiddenCols. Applied BEFORE applyFilters()
     on boot to avoid a flash of visible-then-hidden layout.

Both dropdowns open exclusively (opening one closes the other)
and close when you click anywhere outside them. No framework;
straight jQuery delegation.

View (views/sprints/show.php):
- New toolbar blocks for the two dropdowns.
- Every sortable <th> now also carries data-col matching its
  sort key, and every row's owner/prio/tot/sw-<id> <td> carries
  the same attribute.
- The old <select data-owner-filter> is removed.

JS (public/assets/js/sprint-planner.js):
- applyFilters() now uses ownerFilterSet (Set<string>) instead
  of a single <select> value. "" (no owner) is keyed as
  "__none__" inside the set.
- New applyColumnVisibility() toggles .hidden on every
  [data-col] matching the persisted hidden set.
- updateOwnerFilterUi() + applyColumnVisibility() run at boot
  after the capacity recompute.

Verified:
- php -l + node --check clean.
- Render test confirms every new data-* hook is emitted and
  the old <select data-owner-filter> is gone.
- Full PHPUnit suite still green: 74 tests, 138 assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 veckor sedan
förälder
incheckning
c35a93427d
2 ändrade filer med 188 tillägg och 24 borttagningar
  1. 119 9
      public/assets/js/sprint-planner.js
  2. 69 15
      views/sprints/show.php

+ 119 - 9
public/assets/js/sprint-planner.js

@@ -612,12 +612,63 @@
         });
     }
 
+    // --- Multi-select owner filter (persisted in localStorage) -------------
+
+    const ownerFilterKey = 'sp:' + sprintId + ':ownerFilter';
+    /** @type {Set<string>} */
+    const ownerFilterSet = (function () {
+        try {
+            const raw = window.localStorage.getItem(ownerFilterKey);
+            if (raw) {
+                const arr = JSON.parse(raw);
+                if (Array.isArray(arr)) { return new Set(arr.map(String)); }
+            }
+        } catch (_) { /* ignore */ }
+        return new Set();
+    })();
+
+    function persistOwnerFilter() {
+        try {
+            window.localStorage.setItem(ownerFilterKey, JSON.stringify(Array.from(ownerFilterSet)));
+        } catch (_) { /* ignore quota / private mode */ }
+    }
+
+    function updateOwnerFilterUi() {
+        // Reflect state back into the checkboxes.
+        $root.find('[data-owner-filter-opt]').each(function () {
+            $(this).prop('checked', ownerFilterSet.has(String($(this).val())));
+        });
+        // Count label on the trigger.
+        const n = ownerFilterSet.size;
+        $root.find('[data-owner-filter-count]').text(n === 0 ? '' : '(' + n + ')');
+    }
+
+    $root.on('change', '[data-owner-filter-opt]', function () {
+        const v = String($(this).val());
+        if ($(this).is(':checked')) { ownerFilterSet.add(v); } else { ownerFilterSet.delete(v); }
+        persistOwnerFilter();
+        updateOwnerFilterUi();
+        applyFilters();
+    });
+
+    $root.on('click', '[data-owner-filter-clear]', function () {
+        ownerFilterSet.clear();
+        persistOwnerFilter();
+        updateOwnerFilterUi();
+        applyFilters();
+    });
+
+    $root.on('click', '[data-owner-filter-trigger]', function (ev) {
+        ev.stopPropagation();
+        $root.find('[data-columns-dropdown]').addClass('hidden');
+        $root.find('[data-owner-filter-dropdown]').toggleClass('hidden');
+    });
+
     // --- Filters (search / prio / owner) ----------------------------------
 
     function applyFilters() {
-        const q         = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
-        const prio      = String($root.find('[data-prio-filter]').val() || '');
-        const owner     = String($root.find('[data-owner-filter]').val() || '');
+        const q    = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
+        const prio = String($root.find('[data-prio-filter]').val() || '');
         let visibleCount = 0;
 
         $taskTbody.children('tr[data-task-row]').each(function () {
@@ -625,15 +676,13 @@
             const title     = String($row.find('[data-title]').val() || $row.find('[data-title]').text() || '').toLowerCase();
             const rowPrio   = String($row.attr('data-prio'));
             const rowOwner  = String($row.attr('data-owner') || '');
+            // The filter set treats "" (no owner) as the special "__none__" key.
+            const ownerKey  = rowOwner === '' ? '__none__' : rowOwner;
 
             let ok = true;
             if (q !== '' && !title.includes(q)) { ok = false; }
             if (prio !== '' && rowPrio !== prio) { ok = false; }
-            if (owner === '__none__') {
-                if (rowOwner !== '') { ok = false; }
-            } else if (owner !== '' && rowOwner !== owner) {
-                ok = false;
-            }
+            if (ownerFilterSet.size > 0 && !ownerFilterSet.has(ownerKey)) { ok = false; }
 
             $row.toggle(ok);
             if (ok) { visibleCount++; }
@@ -648,7 +697,63 @@
         clearTimeout(searchDebounce);
         searchDebounce = setTimeout(applyFilters, 120);
     });
-    $root.on('change', '[data-prio-filter], [data-owner-filter]', applyFilters);
+    $root.on('change', '[data-prio-filter]', applyFilters);
+
+    // --- Column visibility toggle (persisted in localStorage) --------------
+
+    const columnsKey = 'sp:' + sprintId + ':hiddenCols';
+    /** @type {Set<string>} */
+    const hiddenCols = (function () {
+        try {
+            const raw = window.localStorage.getItem(columnsKey);
+            if (raw) {
+                const arr = JSON.parse(raw);
+                if (Array.isArray(arr)) { return new Set(arr.map(String)); }
+            }
+        } catch (_) { /* ignore */ }
+        return new Set();
+    })();
+
+    function persistHiddenCols() {
+        try {
+            window.localStorage.setItem(columnsKey, JSON.stringify(Array.from(hiddenCols)));
+        } catch (_) { /* ignore */ }
+    }
+
+    function applyColumnVisibility() {
+        // Tailwind's .hidden is display:none; works on table cells.
+        $root.find('[data-col]').each(function () {
+            const col = String($(this).attr('data-col'));
+            $(this).toggleClass('hidden', hiddenCols.has(col));
+        });
+        // Reflect state into the Columns dropdown checkboxes.
+        $root.find('[data-column-opt]').each(function () {
+            $(this).prop('checked', !hiddenCols.has(String($(this).val())));
+        });
+    }
+
+    $root.on('change', '[data-column-opt]', function () {
+        const v = String($(this).val());
+        if ($(this).is(':checked')) { hiddenCols.delete(v); } else { hiddenCols.add(v); }
+        persistHiddenCols();
+        applyColumnVisibility();
+    });
+
+    $root.on('click', '[data-columns-trigger]', function (ev) {
+        ev.stopPropagation();
+        $root.find('[data-owner-filter-dropdown]').addClass('hidden');
+        $root.find('[data-columns-dropdown]').toggleClass('hidden');
+    });
+
+    // Close either dropdown when clicking outside.
+    $(document).on('click', function (ev) {
+        if ($(ev.target).closest('[data-owner-filter-root]').length === 0) {
+            $root.find('[data-owner-filter-dropdown]').addClass('hidden');
+        }
+        if ($(ev.target).closest('[data-columns-root]').length === 0) {
+            $root.find('[data-columns-dropdown]').addClass('hidden');
+        }
+    });
 
     // --- Column sort (client-side) ----------------------------------------
 
@@ -726,6 +831,11 @@
         recomputeRow(parseInt($(this).data('sw-id'), 10));
     });
     recomputeSumMax();
+
+    // Restore persisted task-list UI state BEFORE applyFilters so hidden
+    // columns don't briefly flash in before being toggled off.
+    updateOwnerFilterUi();
+    applyColumnVisibility();
     applyFilters();
 
 })(jQuery);

+ 69 - 15
views/sprints/show.php

@@ -224,14 +224,67 @@ if (!function_exists('fmt_days')) {
                     <option value="2">Prio 2 only</option>
                 </select>
 
-                <select data-owner-filter
-                        class="rounded border border-slate-300 px-2 py-1 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
-                    <option value="">All owners</option>
-                    <option value="__none__">No owner</option>
-                    <?php foreach ($ownerChoices as $ow): ?>
-                        <option value="<?= (int) $ow->id ?>"><?= e($ow->name) ?></option>
-                    <?php endforeach; ?>
-                </select>
+                <!-- Multi-select owner filter -->
+                <div class="relative" data-owner-filter-root>
+                    <button type="button" data-owner-filter-trigger
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                        Owners <span data-owner-filter-count class="text-slate-500"></span>
+                    </button>
+                    <div data-owner-filter-dropdown
+                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg">
+                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between">
+                            <span>Owner</span>
+                            <button type="button" data-owner-filter-clear
+                                    class="text-blue-700 hover:underline">Clear</button>
+                        </div>
+                        <div class="max-h-64 overflow-y-auto">
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-owner-filter-opt value="__none__"
+                                       class="rounded border-slate-300">
+                                <span class="text-slate-500 italic">No owner</span>
+                            </label>
+                            <?php foreach ($ownerChoices as $ow): ?>
+                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                    <input type="checkbox" data-owner-filter-opt value="<?= (int) $ow->id ?>"
+                                           class="rounded border-slate-300">
+                                    <span><?= e($ow->name) ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Column visibility -->
+                <div class="relative" data-columns-root>
+                    <button type="button" data-columns-trigger
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                        Columns
+                    </button>
+                    <div data-columns-dropdown
+                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg">
+                        <div class="px-3 py-2 text-xs text-slate-500">Show columns</div>
+                        <div class="max-h-64 overflow-y-auto">
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-column-opt value="owner" checked class="rounded border-slate-300">
+                                <span>Owner</span>
+                            </label>
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-column-opt value="prio"  checked class="rounded border-slate-300">
+                                <span>Prio</span>
+                            </label>
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-column-opt value="tot"   checked class="rounded border-slate-300">
+                                <span>Tot</span>
+                            </label>
+                            <?php foreach ($sprintWorkers as $sw): ?>
+                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                    <input type="checkbox" data-column-opt value="sw-<?= (int) $sw->id ?>" checked class="rounded border-slate-300">
+                                    <span><?= e($sw->workerName) ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
 
                 <?php if ($currentUser->isAdmin): ?>
                     <button type="button" data-add-task
@@ -250,14 +303,14 @@ if (!function_exists('fmt_days')) {
                         <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
                             data-sort-col="title">Task <span class="sort-ind opacity-30">↕</span></th>
                         <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
-                            data-sort-col="owner">Owner <span class="sort-ind opacity-30">↕</span></th>
+                            data-sort-col="owner" data-col="owner">Owner <span class="sort-ind opacity-30">↕</span></th>
                         <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
-                            data-sort-col="prio">Prio <span class="sort-ind opacity-30">↕</span></th>
+                            data-sort-col="prio" data-col="prio">Prio <span class="sort-ind opacity-30">↕</span></th>
                         <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
-                            data-sort-col="tot">Tot <span class="sort-ind opacity-30">↕</span></th>
+                            data-sort-col="tot" data-col="tot">Tot <span class="sort-ind opacity-30">↕</span></th>
                         <?php foreach ($sprintWorkers as $sw): ?>
                             <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none whitespace-nowrap"
-                                data-sort-col="sw-<?= (int) $sw->id ?>">
+                                data-sort-col="sw-<?= (int) $sw->id ?>" data-col="sw-<?= (int) $sw->id ?>">
                                 <?= e($sw->workerName) ?>
                                 <span class="sort-ind opacity-30">↕</span>
                             </th>
@@ -297,7 +350,7 @@ if (!function_exists('fmt_days')) {
                                         <span><?= e($t->title) ?></span>
                                     <?php endif; ?>
                                 </td>
-                                <td class="px-2 py-1">
+                                <td class="px-2 py-1" data-col="owner">
                                     <?php if ($currentUser->isAdmin): ?>
                                         <select data-owner-select
                                                 class="w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
@@ -318,7 +371,7 @@ if (!function_exists('fmt_days')) {
                                         ?>
                                     <?php endif; ?>
                                 </td>
-                                <td class="px-2 py-1 text-center">
+                                <td class="px-2 py-1 text-center" data-col="prio">
                                     <?php if ($currentUser->isAdmin): ?>
                                         <select data-prio-select
                                                 class="rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
@@ -330,11 +383,12 @@ if (!function_exists('fmt_days')) {
                                     <?php endif; ?>
                                 </td>
                                 <td class="px-2 py-1 text-center font-mono font-semibold"
-                                    data-task-tot>
+                                    data-col="tot" data-task-tot>
                                     <?= e(fmt_days($tot)) ?>
                                 </td>
                                 <?php foreach ($sprintWorkers as $sw): $d = (float) ($assign[$sw->id] ?? 0.0); ?>
                                     <td class="px-1 py-1 text-center"
+                                        data-col="sw-<?= (int) $sw->id ?>"
                                         data-sort-value-sw-<?= (int) $sw->id ?>="<?= e(number_format($d, 2, '.', '')) ?>">
                                         <?php if ($currentUser->isAdmin): ?>
                                             <input type="number" min="0" step="0.5"