# Task — Issue #988: requirement-feature quick toggle in `projectsView`
Modernization of the **Test Project Management** screen
(`gui/templates/projectsView.html`) restoring the legacy
**"Requirement Feature"** quick toggle that the 2.0.1 rewrite dropped.
## What legacy did
`gui/templates/dashio/project/projectView.tpl` gave the project list a dedicated
column whose entire content was a click-to-change toggle icon:
- header — `projectView.tpl:92`
```smarty
{$labels.th_requirement_feature} |
```
Position: after Code Tracker, before Active. `icon_cell` +
`{#NOT_SORTABLE#}` and **no** `{#SMART_SEARCH#}` — deliberately not sortable
and not filterable.
- body — `projectView.tpl:126-134`
```smarty
{if $testproject.opt->requirementsEnabled}
{else}
{/if}
|
```
- controller — `lib/project/projectEdit.php:87-88` and `:118-119` dispatch the
`doAction` value to the manager method of the same name, then set
`$reloadType = 'reloadNavBar'` to redraw the list.
- manager — `lib/functions/testproject.class.php:3935-3951`
```php
function enableRequirements($id) {
$opt = $this->getOptions(intval($id));
$opt->requirementsEnabled = 1;
$this->setOptions($safeID, $opt);
}
```
Reads the stored `testprojects.options` blob, flips **only**
`requirementsEnabled`, writes it back. No AUDIT event on this path.
Labels: `locale/en_US/strings.txt:413` `th_requirement_feature`,
`:45` `active_click_to_change = 'Active (click to set inactive)'`,
`:62` `inactive_click_to_change = 'Inactive (click to set active)'`.
## What the modern screen was missing
The 2.0.1 rewrite collapsed legacy's two icon columns ("Requirement Feature" and
"Active") plus the "Public" flag into a single text **Status** badge
(`projectsView.html` `projectRow()`), and the requirement flag survived only in
the edit modal's "Enable Requirements" checkbox. There was no column, no
per-row affordance and no BFF route for it.
## What was implemented
### 1. BFF — `api/projects/index.php`
New route `POST /api/projects//requirements` with body `{"enabled":0|1}`.
Because the id sits one segment *before* the action word
(`/api/projects/6/requirements`), the generic `ctype_digit(end($parts))` id
resolution cannot see it. The dispatcher detects `last === 'requirements'`,
clears `$projectId` and re-reads it from `$parts[count($parts) - 2]`, so a
malformed path falls through to a `Project ID required` 400 instead of silently
reaching `createProject()`.
The handler calls `$tprojectMgr->enableRequirements($id)` /
`disableRequirements($id)` **verbatim** and re-reads the flag through
`getOptions()` so the response reports what actually landed in the DB.
A dedicated route rather than the pre-existing
`PUT /api/projects/` with `{optReq:n}`: `updateProject()` re-runs
`crossChecksBFF()` (duplicate name/prefix validation), rewrites
name/prefix/notes/color, calls `activate()` and `applyTrackers()`, and
unconditionally logs `audit_testproject_saved`. Legacy's toggle does none of
that.
Auth model unchanged: the session check, `bffSameOriginGuard()` and the
`mgt_modify_product` gate at the top of the file already cover the new route, so
a role-3 "no rights" user gets the same `403` as every other projects operation.
Status codes: `404` unknown project, `400` missing/invalid `enabled` or malformed
path, `200 {"success":true,"id":N,"optReq":0|1}` on success.
**Route shape is matched exactly.** `parts === ['api','projects',,'requirements']`.
Detection is by the presence of the literal `requirements` segment anywhere in the
path, so `…/1/requirements/extra`, `…/requirements/1` and `…/1/2/requirements` are
all rejected with `400` and can never fall through to `createProject()` — verified
by firing all of them and confirming the project count is unchanged.
**`"enabled"` is coerced strictly.** `(int)(bool)"false"` is `1`, so a JSON client
sending the *string* `"false"` would have silently enabled the feature. The value
must now be one of `0, 1, false, true, "0", "1"`, otherwise `400`.
**The write is verified.** `testproject::setOptions()` only issues its UPDATE when
the stored blob already contained a decodable object (it iterates the *stored*
object), so a row whose `testprojects.options` is `NULL`/empty — exactly how the
shipped sample data inserts it — can never be written by that method. The route
re-reads the flag afterwards and returns `500 Failed to store the requirements
feature flag` instead of claiming `success:true`, and the client alerts rather than
redrawing a list that disagrees with the click. Legacy was silent here, so this is
deliberately stricter than legacy.
## Known limitation
The toggle cannot write to a project whose `testprojects.options` is `NULL`/empty:
`testproject::setOptions()` (`lib/functions/testproject.class.php:4000-4024`)
iterates the **stored** object and runs its UPDATE only when the loop body executed
at least once, so an empty stored object is a structural dead end. The route
surfaces that as a `500` instead of a silent no-op. The repair path is the edit
modal (or `PUT /api/projects/`), which writes `options` unconditionally —
confirmed: a `PUT` on a `NULL`-blob project restores all four flags and answers
`200`. Legacy had the same dead end, silently; fixing `setOptions()` itself affects
every caller and is filed as a separate issue.
### 2. Screen — `gui/templates/projectsView.html`
- New header cell between "Code Tracker" and "Status", `data-i18n="proj.requirementFeature"`,
**no** `data-col-filter` (legacy has no `{#SMART_SEARCH#}` for this column).
- Loading row `colspan` 8 → 9.
- `requirementFeature` cell: `fa-toggle-on` / `fa-toggle-off` with the
`proj.activeClickToChange` / `proj.inactiveClickToChange` tooltips, plus
`role="button"`, `tabindex="0"` and `aria-pressed` (the Dashio screens'
keyboard convention; legacy had none).
- `columnDefs`: added `{ targets: 6, orderable: false }` (legacy
`{#NOT_SORTABLE#}`) and moved the actions entry 7 → 8.
- `toggleRequirements(id, enable)` POSTs to the new route and calls
`loadProjects()` on success (legacy `reloadNavBar`); on failure it alerts
`proj.msg.errorToggleRequirements`.
- Delegated `keydown` handler on `#projectsTable .req-toggle` maps Enter/Space to
a click — delegation is required because `renderProjects()` destroys and
re-creates the tbody on every reload.
- CSS for `.req-toggle` (teal on / grey off, pointer cursor, focus ring).
### 3. i18n — all 10 locale bundles
New keys: `proj.requirementFeature`, `proj.activeClickToChange`,
`proj.inactiveClickToChange`, `proj.msg.errorToggleRequirements`.
Wording comes from the legacy locale strings, not invented:
`th_requirement_feature` for the header and
`active_click_to_change` / `inactive_click_to_change` for the tooltips. `ro_RO`,
`it_IT` and `ru_RU` have no `th_requirement_feature` string and fall back to
their own existing `proj.enableRequirements` wording, so the column header
matches the modal checkbox in the same language.
Keys are inserted **inside** the existing `proj.*` / `proj.msg.*` runs in local
alphabetical position — the bundles are grouped by namespace but not globally
sorted, and sorting the whole file produces a ~2900-line diff of pure reordering
that would bury the change in every concurrent-agent merge.
## Verification
14-case suite appended to `tmp/TLU_Test_Cases.md` — all PASS. Highlights:
- off→on and on→off both persist, including across a cache-ignoring reload
(`GET /api/projects` → `1:1 2:0 3:0`).
- The other three option flags and name/prefix/notes/active/is_public are
byte-identical before and after a toggle (checked in SQL).
- **Zero** new rows in the `events` table across three toggles — legacy logs no
event for this path, and neither does the new route. No Error/Warning entries.
- 404 / 400 / 403 paths all return the expected JSON, and the 403 attempt left
the row untouched.
- DataTables sort, global search, per-column filtering and the length menu all
still work with the extra column; the new column is neither sortable nor
filterable, as in legacy.
### Screenshot

## Related
- **#1633** — found while testing: the per-column filter boxes display
`[object Object]` after any list re-render, because DataTables 1.13's
`state.columns[i].search` is an object, not a string. Pre-existing (reproduced
on the pre-change revision), filed separately, not fixed here.