- add API Lookup attribute type with alias, URL, format, match fields, and auto-fill config - update job type create/edit forms to configure API lookup attributes - add authenticated /api/proxy route for external lookup requests - enhance job create/edit flow to load and select API lookup values - rebuild jobs table columns dynamically from job type attributes - add unsaved-changes guard for ct-form forms - include related UI/style updates across job and job-type viewspull/2/head
| @@ -0,0 +1,9 @@ | |||||
| { | |||||
| "permissions": { | |||||
| "allow": [ | |||||
| "Bash(git *)", | |||||
| "Bash(git --no-pager log --oneline -8 2>&1 || true)", | |||||
| "Bash(git --no-pager diff -- app/Controllers/ApiProxyController.php routes/web.php app/Controllers/JobTypeController.php app/V…)" | |||||
| ] | |||||
| } | |||||
| } | |||||
| @@ -13,7 +13,8 @@ | |||||
| "Bash(dir /b /s)", | "Bash(dir /b /s)", | ||||
| "Bash(findstr \"^app\")", | "Bash(findstr \"^app\")", | ||||
| "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", | "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", | ||||
| "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)" | |||||
| "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)", | |||||
| "PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })" | |||||
| ] | ] | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,19 @@ | |||||
| APP_ENV=local | |||||
| APP_DEBUG=true | |||||
| DB_HOST=sqlserver | |||||
| DB_PORT=1433 | |||||
| DB_DATABASE=Campaign_Tracker | |||||
| DB_USERNAME=sa | |||||
| DB_PASSWORD=Dev_Password123! | |||||
| # ── Keycloak ─────────────────────────────────────────────────────────────────── | |||||
| # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | |||||
| # Keycloak 17+ (no /auth prefix): http://localhost:8080 | |||||
| # Keycloak < 17 (has /auth prefix): http://localhost:8080/auth | |||||
| KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/ | |||||
| KEYCLOAK_REALM=KCI | |||||
| KEYCLOAK_CLIENT_ID=canopy-web | |||||
| KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y | |||||
| KEYCLOAK_REDIRECT_URI=http://192.168.1.200:8801/auth/callback | |||||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://192.168.1.200:8801/login | |||||
| @@ -1311,4 +1311,21 @@ class MyDashboardViewModel | |||||
| } | } | ||||
| ``` | ``` | ||||
| Populate it in the controller using private `repo()` methods or inline `new Repository(database())` calls — consistent with how other controllers are written in this project. | |||||
| Populate it in the controller using private `repo()` methods or inline `new Repository(database())` calls — consistent with how other controllers are written in this project. | |||||
| --- | |||||
| ## Clean JavaScript Practices (Distilled) | |||||
| Source: https://medium.com/@onix_react/best-practices-for-writing-clean-javascript-code-a4e5755de69a | |||||
| - Prefer `const` and `let` over `var` to avoid function-scoped surprises. | |||||
| - Keep scope tight and avoid globals to prevent hidden coupling and collisions. | |||||
| - Use small, focused functions and clear names to make intent obvious. | |||||
| - Prefer arrow functions when lexical `this` and concise syntax improve clarity. | |||||
| - Use `async/await` for async flows and handle failures with `try/catch`. | |||||
| - Fail safely: validate inputs, handle exceptions, and log actionable error details. | |||||
| - Use array helpers (`map`, `filter`, `reduce`, `forEach`) where they improve readability. | |||||
| - Minimize direct DOM writes; batch updates, cache selectors, and use delegation when possible. | |||||
| - Keep formatting and naming conventions consistent across the project. | |||||
| - Document non-obvious decisions briefly; avoid redundant comments that restate code. | |||||
| @@ -0,0 +1,48 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| class ApiProxyController extends Controller | |||||
| { | |||||
| public function fetch(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $url = trim((string) ($request->input('url') ?? '')); | |||||
| if ($url === '' || | |||||
| !filter_var($url, FILTER_VALIDATE_URL) || | |||||
| !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true) | |||||
| ) { | |||||
| return Response::json(['error' => 'Invalid or missing URL.'], 400); | |||||
| } | |||||
| $ch = curl_init(); | |||||
| curl_setopt_array($ch, [ | |||||
| CURLOPT_URL => $url, | |||||
| CURLOPT_RETURNTRANSFER => true, | |||||
| CURLOPT_TIMEOUT => 10, | |||||
| CURLOPT_FOLLOWLOCATION => true, | |||||
| CURLOPT_MAXREDIRS => 3, | |||||
| CURLOPT_SSL_VERIFYPEER => true, | |||||
| CURLOPT_USERAGENT => 'CampaignTracker-ApiProxy/1.0', | |||||
| CURLOPT_HTTPHEADER => ['Accept: application/json, application/xml, text/xml, text/plain, */*'], | |||||
| ]); | |||||
| $body = curl_exec($ch); | |||||
| $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); | |||||
| $curlErr = curl_error($ch); | |||||
| curl_close($ch); | |||||
| if ($body === false) { | |||||
| return Response::json(['error' => $curlErr ?: 'Outbound request failed.'], 502); | |||||
| } | |||||
| return Response::json(['body' => (string) $body, 'http_status' => $httpCode]); | |||||
| } | |||||
| } | |||||
| @@ -218,19 +218,42 @@ class JobTypeController extends Controller | |||||
| ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') | ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') | ||||
| ->errors()); | ->errors()); | ||||
| $attributeAliases = (array) ($request->input('attribute_alias') ?? []); | |||||
| $attributeApiUrls = (array) ($request->input('attribute_api_url') ?? []); | |||||
| $attributeApiFormats = (array) ($request->input('attribute_api_format') ?? []); | |||||
| $attributeApiReturnTypes = (array) ($request->input('attribute_api_return_type') ?? []); | |||||
| $attributeApiMatchFields = (array) ($request->input('attribute_api_match_field') ?? []); | |||||
| $attributeApiAutoFills = (array) ($request->input('attribute_api_auto_fill') ?? []); | |||||
| $attributes = []; | $attributes = []; | ||||
| foreach ($attributeNames as $i => $attrName) { | foreach ($attributeNames as $i => $attrName) { | ||||
| $attrName = trim((string) $attrName); | $attrName = trim((string) $attrName); | ||||
| $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | ||||
| if ($attrName === '') continue; | if ($attrName === '') continue; | ||||
| $attributes[] = [ | |||||
| $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text'; | |||||
| $attr = [ | |||||
| 'name' => $attrName, | 'name' => $attrName, | ||||
| 'type' => in_array($attrType, ['text', 'number', 'date', 'boolean'], true) ? $attrType : 'text', | |||||
| 'type' => $validatedType, | |||||
| 'alias' => trim((string) ($attributeAliases[$i] ?? '')), | |||||
| 'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== '' | 'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== '' | ||||
| ? max(1, (int) $attributeOrders[$i]) | ? max(1, (int) $attributeOrders[$i]) | ||||
| : count($attributes) + 1, | : count($attributes) + 1, | ||||
| ]; | ]; | ||||
| if ($validatedType === 'api_lookup') { | |||||
| $rawFormat = trim((string) ($attributeApiFormats[$i] ?? '')); | |||||
| $rawReturnType = trim((string) ($attributeApiReturnTypes[$i] ?? '')); | |||||
| $attr['api_url'] = trim((string) ($attributeApiUrls[$i] ?? '')); | |||||
| $attr['api_format'] = in_array($rawFormat, ['json', 'xml'], true) ? $rawFormat : 'json'; | |||||
| $attr['api_return_type'] = in_array($rawReturnType, ['text', 'number', 'date', 'boolean'], true) ? $rawReturnType : 'text'; | |||||
| $attr['api_match_field'] = trim((string) ($attributeApiMatchFields[$i] ?? '')); | |||||
| $attr['api_auto_fill'] = trim((string) ($attributeApiAutoFills[$i] ?? '')); | |||||
| } | |||||
| $attributes[] = $attr; | |||||
| } | } | ||||
| usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); | usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); | ||||
| @@ -45,30 +45,77 @@ | |||||
| x-on:drop="drop($event, index)" | x-on:drop="drop($event, index)" | ||||
| x-on:dragend="dragEnd()" | x-on:dragend="dragEnd()" | ||||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | ||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" placeholder="e.g. Priority" maxlength="100"> | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button type="button" class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||||
| <div class="attribute-fields"> | |||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" placeholder="e.g. Priority" maxlength="100"> | |||||
| </label> | |||||
| <label class="field attribute-alias-field"> | |||||
| <span>Alias</span> | |||||
| <input class="input" type="text" :name="`attribute_alias[${index}]`" | |||||
| x-model="attr.alias" placeholder="e.g. PROD" maxlength="255"> | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| <option value="api_lookup">API Lookup</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button type="button" class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||||
| </div> | |||||
| </div> | |||||
| <div class="api-lookup-config" x-show="attr.type === 'api_lookup'"> | |||||
| <label class="field api-lookup-url-field"> | |||||
| <span>API URL</span> | |||||
| <input class="input" type="url" | |||||
| :name="`attribute_api_url[${index}]`" | |||||
| x-model="attr.api_url" | |||||
| placeholder="https://example.com/api/value"> | |||||
| </label> | |||||
| <label class="field api-lookup-match-field"> | |||||
| <span>Match field name(s)</span> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_api_match_field[${index}]`" | |||||
| x-model="attr.api_match_field" | |||||
| placeholder="e.g. status or name;code"> | |||||
| </label> | |||||
| <label class="field api-lookup-match-field"> | |||||
| <span>Auto-fill attributes (aliases)</span> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_api_auto_fill[${index}]`" | |||||
| x-model="attr.api_auto_fill" | |||||
| placeholder="e.g. productName;price"> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Response format</span> | |||||
| <select class="input" :name="`attribute_api_format[${index}]`" x-model="attr.api_format"> | |||||
| <option value="json">JSON</option> | |||||
| <option value="xml">XML</option> | |||||
| </select> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Return value type</span> | |||||
| <select class="input" :name="`attribute_api_return_type[${index}]`" x-model="attr.api_return_type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| @@ -52,30 +52,77 @@ | |||||
| x-on:drop="drop($event, index)" | x-on:drop="drop($event, index)" | ||||
| x-on:dragend="dragEnd()" | x-on:dragend="dragEnd()" | ||||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | ||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" placeholder="e.g. Priority" maxlength="100"> | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button type="button" class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||||
| <div class="attribute-fields"> | |||||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||||
| <label class="field attribute-order-field"> | |||||
| <span>Order</span> | |||||
| <input class="input" type="number" | |||||
| :name="`attribute_order[${index}]`" | |||||
| x-model.number="attr.order" min="1"> | |||||
| </label> | |||||
| <label class="field attribute-name-field"> | |||||
| <span>Attribute name</span> | |||||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||||
| x-model="attr.name" placeholder="e.g. Priority" maxlength="100"> | |||||
| </label> | |||||
| <label class="field attribute-alias-field"> | |||||
| <span>Alias</span> | |||||
| <input class="input" type="text" :name="`attribute_alias[${index}]`" | |||||
| x-model="attr.alias" placeholder="e.g. PROD" maxlength="255"> | |||||
| </label> | |||||
| <label class="field attribute-type-field"> | |||||
| <span>Type</span> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| <option value="api_lookup">API Lookup</option> | |||||
| </select> | |||||
| </label> | |||||
| <div class="attribute-remove"> | |||||
| <button type="button" class="button button-danger button-sm" | |||||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||||
| </div> | |||||
| </div> | |||||
| <div class="api-lookup-config" x-show="attr.type === 'api_lookup'"> | |||||
| <label class="field api-lookup-url-field"> | |||||
| <span>API URL</span> | |||||
| <input class="input" type="url" | |||||
| :name="`attribute_api_url[${index}]`" | |||||
| x-model="attr.api_url" | |||||
| placeholder="https://example.com/api/value"> | |||||
| </label> | |||||
| <label class="field api-lookup-match-field"> | |||||
| <span>Match field name(s)</span> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_api_match_field[${index}]`" | |||||
| x-model="attr.api_match_field" | |||||
| placeholder="e.g. status or name;code"> | |||||
| </label> | |||||
| <label class="field api-lookup-match-field"> | |||||
| <span>Auto-fill attributes (aliases)</span> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_api_auto_fill[${index}]`" | |||||
| x-model="attr.api_auto_fill" | |||||
| placeholder="e.g. productName;price"> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Response format</span> | |||||
| <select class="input" :name="`attribute_api_format[${index}]`" x-model="attr.api_format"> | |||||
| <option value="json">JSON</option> | |||||
| <option value="xml">XML</option> | |||||
| </select> | |||||
| </label> | |||||
| <label class="field"> | |||||
| <span>Return value type</span> | |||||
| <select class="input" :name="`attribute_api_return_type[${index}]`" x-model="attr.api_return_type"> | |||||
| <option value="text">Text</option> | |||||
| <option value="number">Number</option> | |||||
| <option value="date">Date</option> | |||||
| <option value="boolean">True/False</option> | |||||
| </select> | |||||
| </label> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| @@ -76,7 +76,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| <div class="form-grid"> | <div class="form-grid"> | ||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | <template x-for="attr in currentAttributes" :key="attr.name"> | ||||
| <label class="field"> | |||||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }"> | |||||
| <span x-text="attr.name"></span> | <span x-text="attr.name"></span> | ||||
| <template x-if="attr.type === 'boolean'"> | <template x-if="attr.type === 'boolean'"> | ||||
| <select class="input" | <select class="input" | ||||
| @@ -87,7 +87,46 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | ||||
| </select> | </select> | ||||
| </template> | </template> | ||||
| <template x-if="attr.type !== 'boolean'"> | |||||
| <template x-if="attr.type === 'api_lookup'"> | |||||
| <div class="api-lookup-field"> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-model="attributeValues[attr.name]" | |||||
| placeholder="Type to search…" | |||||
| autocomplete="off" | |||||
| x-on:focus="openApiLookup(attr.name)" | |||||
| x-on:blur="closeApiLookup(attr.name)" | |||||
| x-on:keydown.escape.prevent="closeApiLookup(attr.name)"> | |||||
| <div class="api-lookup-dropdown" | |||||
| x-show="apiLookupOpen[attr.name]" | |||||
| x-on:mousedown.prevent> | |||||
| <p class="api-lookup-loading" x-show="apiLookupState[attr.name] === 'loading'">Loading…</p> | |||||
| <div class="api-lookup-table" x-show="apiLookupState[attr.name] !== 'loading' && apiLookupState[attr.name] !== 'error'"> | |||||
| <div class="api-lookup-thead" | |||||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`"> | |||||
| <template x-for="f in getApiOptions(attr.name).fields" :key="f"> | |||||
| <span x-text="f"></span> | |||||
| </template> | |||||
| </div> | |||||
| <div class="api-lookup-tbody"> | |||||
| <template x-for="rec in getFilteredRecords(attr.name, attributeValues[attr.name])" :key="rec._primary"> | |||||
| <div class="api-lookup-tr" | |||||
| :class="{ 'is-selected': attributeValues[attr.name] === rec._primary }" | |||||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`" | |||||
| x-on:click="selectApiOption(attr, rec)"> | |||||
| <template x-for="(v, i) in rec._display" :key="i"> | |||||
| <span x-text="v"></span> | |||||
| </template> | |||||
| </div> | |||||
| </template> | |||||
| <div class="api-lookup-empty" x-show="!getFilteredRecords(attr.name, attributeValues[attr.name]).length">No results.</div> | |||||
| </div> | |||||
| </div> | |||||
| <small class="field-error" x-show="apiLookupState[attr.name] === 'error'" x-text="apiLookupError[attr.name] || 'Fetch failed.'"></small> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'"> | |||||
| <input class="input" :type="inputType(attr.type)" | <input class="input" :type="inputType(attr.type)" | ||||
| :name="`attribute_values[${attr.name}]`" | :name="`attribute_values[${attr.name}]`" | ||||
| :value="attributeValues[attr.name] ?? ''" | :value="attributeValues[attr.name] ?? ''" | ||||
| @@ -73,7 +73,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| <div class="form-grid"> | <div class="form-grid"> | ||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | <template x-for="attr in currentAttributes" :key="attr.name"> | ||||
| <label class="field"> | |||||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }"> | |||||
| <span x-text="attr.name"></span> | <span x-text="attr.name"></span> | ||||
| <template x-if="attr.type === 'boolean'"> | <template x-if="attr.type === 'boolean'"> | ||||
| <select class="input" | <select class="input" | ||||
| @@ -84,7 +84,46 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | ||||
| </select> | </select> | ||||
| </template> | </template> | ||||
| <template x-if="attr.type !== 'boolean'"> | |||||
| <template x-if="attr.type === 'api_lookup'"> | |||||
| <div class="api-lookup-field"> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-model="attributeValues[attr.name]" | |||||
| placeholder="Type to search…" | |||||
| autocomplete="off" | |||||
| x-on:focus="openApiLookup(attr.name)" | |||||
| x-on:blur="closeApiLookup(attr.name)" | |||||
| x-on:keydown.escape.prevent="closeApiLookup(attr.name)"> | |||||
| <div class="api-lookup-dropdown" | |||||
| x-show="apiLookupOpen[attr.name]" | |||||
| x-on:mousedown.prevent> | |||||
| <p class="api-lookup-loading" x-show="apiLookupState[attr.name] === 'loading'">Loading…</p> | |||||
| <div class="api-lookup-table" x-show="apiLookupState[attr.name] !== 'loading' && apiLookupState[attr.name] !== 'error'"> | |||||
| <div class="api-lookup-thead" | |||||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`"> | |||||
| <template x-for="f in getApiOptions(attr.name).fields" :key="f"> | |||||
| <span x-text="f"></span> | |||||
| </template> | |||||
| </div> | |||||
| <div class="api-lookup-tbody"> | |||||
| <template x-for="rec in getFilteredRecords(attr.name, attributeValues[attr.name])" :key="rec._primary"> | |||||
| <div class="api-lookup-tr" | |||||
| :class="{ 'is-selected': attributeValues[attr.name] === rec._primary }" | |||||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`" | |||||
| x-on:click="selectApiOption(attr, rec)"> | |||||
| <template x-for="(v, i) in rec._display" :key="i"> | |||||
| <span x-text="v"></span> | |||||
| </template> | |||||
| </div> | |||||
| </template> | |||||
| <div class="api-lookup-empty" x-show="!getFilteredRecords(attr.name, attributeValues[attr.name]).length">No results.</div> | |||||
| </div> | |||||
| </div> | |||||
| <small class="field-error" x-show="apiLookupState[attr.name] === 'error'" x-text="apiLookupError[attr.name] || 'Fetch failed.'"></small> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'"> | |||||
| <input class="input" :type="inputType(attr.type)" | <input class="input" :type="inputType(attr.type)" | ||||
| :name="`attribute_values[${attr.name}]`" | :name="`attribute_values[${attr.name}]`" | ||||
| :value="attributeValues[attr.name] ?? ''" | :value="attributeValues[attr.name] ?? ''" | ||||
| @@ -28,6 +28,14 @@ | |||||
| </div> | </div> | ||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | ||||
| </div> | </div> | ||||
| <div class="skeleton-rows" x-cloak x-show="isLoading"> | |||||
| <div class="skeleton-row"></div> | |||||
| <div class="skeleton-row"></div> | |||||
| <div class="skeleton-row"></div> | |||||
| <div class="skeleton-row"></div> | |||||
| <div class="skeleton-row"></div> | |||||
| </div> | |||||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | |||||
| <div id="job-table" class="tabulator-host"></div> | <div id="job-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -36,11 +36,11 @@ function Get-BaseArgs { | |||||
| } | } | ||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| # Step 1 — copy .env (first password prompt) | |||||
| # Step 1 — copy .env_prod as .env (first password prompt) | |||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| Write-Step "Copying .env to $SSH_USER@$SSH_HOST" | |||||
| Write-Step "Copying .env_prod to $SSH_USER@$SSH_HOST as .env" | |||||
| $scpArgs = Get-BaseArgs | $scpArgs = Get-BaseArgs | ||||
| $scpArgs += ".env", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env" | |||||
| $scpArgs += ".env_prod", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env" | |||||
| scp @scpArgs | scp @scpArgs | ||||
| if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." } | if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." } | ||||
| @@ -1123,12 +1123,116 @@ a.stat-card:hover::after { | |||||
| } | } | ||||
| .attribute-row { | .attribute-row { | ||||
| display: grid; | |||||
| gap: 6px; | |||||
| } | |||||
| .attribute-fields { | |||||
| display: flex; | display: flex; | ||||
| align-items: flex-end; | align-items: flex-end; | ||||
| gap: 8px; | gap: 8px; | ||||
| flex-wrap: wrap; | flex-wrap: wrap; | ||||
| } | } | ||||
| .api-lookup-config { | |||||
| display: flex; | |||||
| gap: 8px; | |||||
| flex-wrap: wrap; | |||||
| padding-left: 26px; | |||||
| } | |||||
| .api-lookup-url-field { | |||||
| flex: 3; | |||||
| min-width: 240px; | |||||
| } | |||||
| .api-lookup-match-field { | |||||
| flex: 2; | |||||
| min-width: 180px; | |||||
| } | |||||
| .api-lookup-config .field { | |||||
| flex: 1; | |||||
| min-width: 130px; | |||||
| } | |||||
| .api-lookup-label { | |||||
| grid-column: 1 / -1; | |||||
| } | |||||
| .api-lookup-field { | |||||
| position: relative; | |||||
| } | |||||
| .api-lookup-dropdown { | |||||
| position: absolute; | |||||
| top: calc(100% + 3px); | |||||
| left: 0; | |||||
| right: 0; | |||||
| z-index: 200; | |||||
| background: var(--surface); | |||||
| border: 1px solid var(--border-strong); | |||||
| border-radius: var(--radius-sm); | |||||
| box-shadow: var(--shadow); | |||||
| overflow: hidden; | |||||
| } | |||||
| .api-lookup-loading { | |||||
| font-size: 13px; | |||||
| color: var(--text-muted); | |||||
| margin: 0; | |||||
| padding: 8px 10px; | |||||
| } | |||||
| .api-lookup-table { | |||||
| font-size: 13px; | |||||
| } | |||||
| .api-lookup-thead { | |||||
| display: grid; | |||||
| background: var(--surface-raised); | |||||
| border-bottom: 1px solid var(--border); | |||||
| font-weight: 600; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .api-lookup-thead span, | |||||
| .api-lookup-tr span { | |||||
| padding: 5px 10px; | |||||
| } | |||||
| .api-lookup-tbody { | |||||
| max-height: 220px; | |||||
| overflow-y: auto; | |||||
| } | |||||
| .api-lookup-tr { | |||||
| display: grid; | |||||
| cursor: pointer; | |||||
| border-bottom: 1px solid var(--border); | |||||
| transition: background 80ms ease; | |||||
| } | |||||
| .api-lookup-tr:last-child { | |||||
| border-bottom: none; | |||||
| } | |||||
| .api-lookup-tr:hover { | |||||
| background: var(--info-bg); | |||||
| } | |||||
| .api-lookup-tr.is-selected { | |||||
| background: var(--primary-light); | |||||
| color: var(--primary); | |||||
| font-weight: 500; | |||||
| } | |||||
| .api-lookup-empty { | |||||
| padding: 8px 10px; | |||||
| font-size: 13px; | |||||
| color: var(--text-muted); | |||||
| } | |||||
| .attr-drag-handle { | .attr-drag-handle { | ||||
| cursor: grab; | cursor: grab; | ||||
| user-select: none; | user-select: none; | ||||
| @@ -1188,6 +1292,11 @@ a.stat-card:hover::after { | |||||
| min-width: 160px; | min-width: 160px; | ||||
| } | } | ||||
| .attribute-alias-field { | |||||
| flex: 1; | |||||
| min-width: 100px; | |||||
| } | |||||
| .attribute-type-field { | .attribute-type-field { | ||||
| flex: 1; | flex: 1; | ||||
| min-width: 110px; | min-width: 110px; | ||||
| @@ -19,6 +19,26 @@ function _postDelete(action) { | |||||
| form.submit(); | form.submit(); | ||||
| } | } | ||||
| // Returns every {value, row} pair where `key` has a primitive value anywhere in the tree. | |||||
| // `row` is the nearest plain-object ancestor that directly owns `key`. | |||||
| function _deepFindRows(obj, key, out) { | |||||
| out = out || []; | |||||
| if (obj === null || typeof obj !== 'object') return out; | |||||
| if (Array.isArray(obj)) { | |||||
| for (var i = 0; i < obj.length; i++) { _deepFindRows(obj[i], key, out); } | |||||
| return out; | |||||
| } | |||||
| if (Object.prototype.hasOwnProperty.call(obj, key)) { | |||||
| var v = obj[key]; | |||||
| if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { | |||||
| out.push({ value: String(v), row: obj }); | |||||
| } | |||||
| } | |||||
| for (var k in obj) { | |||||
| if (Object.prototype.hasOwnProperty.call(obj, k)) { _deepFindRows(obj[k], key, out); } | |||||
| } | |||||
| return out;} | |||||
| function _escapeHtml(value) { | function _escapeHtml(value) { | ||||
| return String(value).replace(/[&<>"']/g, function (char) { | return String(value).replace(/[&<>"']/g, function (char) { | ||||
| return { | return { | ||||
| @@ -1158,7 +1178,7 @@ window.jobTypeForm = function (initialAttributes) { | |||||
| dragOverIndex: null, | dragOverIndex: null, | ||||
| addAttribute() { | addAttribute() { | ||||
| this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 }); | |||||
| this.attributes.push({ name: '', type: 'text', alias: '', order: this.attributes.length + 1, api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text' }); | |||||
| }, | }, | ||||
| removeAttribute(index) { | removeAttribute(index) { | ||||
| @@ -1207,64 +1227,165 @@ window.jobTypeForm = function (initialAttributes) { | |||||
| window.jobTable = function () { | window.jobTable = function () { | ||||
| return { | return { | ||||
| table: null, | table: null, | ||||
| isLoading: false, | |||||
| errorMessage: '', | |||||
| init() { | init() { | ||||
| this.initTable(); | |||||
| this.loadTable(); | |||||
| }, | }, | ||||
| initTable() { | |||||
| async loadTable() { | |||||
| const el = document.getElementById('job-table'); | const el = document.getElementById('job-table'); | ||||
| if (!el || typeof Tabulator === 'undefined') { | |||||
| if (!el || typeof Tabulator === 'undefined' || this.isLoading) { | |||||
| return; | return; | ||||
| } | } | ||||
| this.table = new Tabulator(el, { | |||||
| ajaxURL: '/jobs/data', | |||||
| layout: 'fitColumns', | |||||
| responsiveLayout: 'collapse', | |||||
| pagination: true, | |||||
| paginationMode: 'local', | |||||
| paginationSize: 10, | |||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | |||||
| placeholder: 'No jobs found.', | |||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | |||||
| columns: [ | |||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 160, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/jobs/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| this.isLoading = true; | |||||
| this.errorMessage = ''; | |||||
| try { | |||||
| const response = await fetch('/jobs/data', { | |||||
| headers: { Accept: 'application/json' }, | |||||
| }); | |||||
| if (!response.ok) { | |||||
| throw new Error('Unable to load jobs.'); | |||||
| } | |||||
| const rows = await response.json(); | |||||
| const jobRows = Array.isArray(rows) ? rows : []; | |||||
| const attributes = this.attributeColumnsForRows(jobRows); | |||||
| const tableRows = this.formatRows(jobRows, attributes); | |||||
| const columns = this.columnsForAttributes(attributes); | |||||
| if (this.table) { | |||||
| this.table.destroy(); | |||||
| this.table = null; | |||||
| } | |||||
| this.table = new Tabulator(el, { | |||||
| data: tableRows, | |||||
| layout: 'fitData', | |||||
| pagination: true, | |||||
| paginationMode: 'local', | |||||
| paginationSize: 10, | |||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | |||||
| placeholder: 'No jobs found.', | |||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | |||||
| columns: columns, | |||||
| }); | |||||
| } catch (error) { | |||||
| this.errorMessage = error.message || 'Unable to load jobs.'; | |||||
| } finally { | |||||
| this.isLoading = false; | |||||
| } | |||||
| }, | |||||
| attributeColumnsForRows(rows) { | |||||
| const attributes = []; | |||||
| rows.forEach((row) => { | |||||
| this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { | |||||
| if (!attributes.some((existing) => existing.name === attr.name)) { | |||||
| attributes.push(attr); | |||||
| } | |||||
| }); | |||||
| Object.keys(row.attribute_values || {}).forEach((name) => { | |||||
| if (!attributes.some((existing) => existing.name === name)) { | |||||
| attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| return attributes; | |||||
| }, | |||||
| normalizeAttributes(attributes) { | |||||
| return attributes | |||||
| .filter((attr) => attr && attr.name) | |||||
| .slice() | |||||
| .sort((a, b) => (a.order || 0) - (b.order || 0)); | |||||
| }, | |||||
| formatRows(rows, attributes) { | |||||
| return rows.map((row) => { | |||||
| const attributeValues = row.attribute_values || {}; | |||||
| const tableRow = { | |||||
| id: row.id, | |||||
| edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', | |||||
| campaign_id: row.campaign_id || '', | |||||
| campaign_type_name: row.campaign_type_name || '', | |||||
| job_type_id: row.job_type_id || '', | |||||
| job_type_name: row.job_type_name || '', | |||||
| created_at: row.created_at || '', | |||||
| updated_at: row.updated_at || '', | |||||
| }; | |||||
| attributes.forEach((attr, index) => { | |||||
| tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); | |||||
| }); | |||||
| return tableRow; | |||||
| }); | |||||
| }, | |||||
| formatAttributeValue(value) { | |||||
| if (value === null || value === undefined) { | |||||
| return ''; | |||||
| } | |||||
| if (Array.isArray(value) || typeof value === 'object') { | |||||
| return JSON.stringify(value); | |||||
| } | |||||
| return String(value); | |||||
| }, | |||||
| columnsForAttributes(attributes) { | |||||
| const columns = [ | |||||
| { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 160, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const url = cell.getValue(); | |||||
| const id = cell.getRow().getData().id; | |||||
| return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | }, | ||||
| { title: 'Campaign', field: 'campaign_type_name', minWidth: 160 }, | |||||
| { title: 'Job Type', field: 'job_type_name', minWidth: 160 }, | |||||
| { | |||||
| title: 'Attributes', | |||||
| field: 'attributes_summary', | |||||
| minWidth: 220, | |||||
| formatter: function (cell) { | |||||
| const v = cell.getValue(); | |||||
| return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>' | |||||
| : '<span class="attr-empty">—</span>'; | |||||
| }, | |||||
| }, | |||||
| { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, | |||||
| { title: 'Campaign', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' }, | |||||
| { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, | |||||
| ]; | |||||
| attributes.forEach((attr, index) => { | |||||
| columns.push({ | |||||
| title: attr.name, | |||||
| field: 'attr_' + index, | |||||
| minWidth: 150, | |||||
| headerFilter: 'input', | |||||
| formatter: function (cell) { | |||||
| const value = cell.getValue(); | |||||
| return value ? _escapeHtml(value) : '<span class="attr-empty">—</span>'; | |||||
| }, | }, | ||||
| { title: 'Created', field: 'created_at', minWidth: 160 }, | |||||
| ], | |||||
| }); | |||||
| }); | }); | ||||
| columns.push( | |||||
| { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, | |||||
| { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' } | |||||
| ); | |||||
| return columns; | |||||
| }, | }, | ||||
| reloadTable() { | reloadTable() { | ||||
| if (!this.table) { | |||||
| this.initTable(); | |||||
| return; | |||||
| } | |||||
| this.table.setData('/jobs/data'); | |||||
| this.loadTable(); | |||||
| }, | }, | ||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -1281,6 +1402,10 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| jobTypes: jobTypes, | jobTypes: jobTypes, | ||||
| selectedTypeId: String(initialTypeId || ''), | selectedTypeId: String(initialTypeId || ''), | ||||
| attributeValues: Object.assign({}, initialValues || {}), | attributeValues: Object.assign({}, initialValues || {}), | ||||
| apiLookupState: {}, | |||||
| apiLookupError: {}, | |||||
| apiLookupOptions: {}, | |||||
| apiLookupOpen: {}, | |||||
| get currentType() { | get currentType() { | ||||
| var id = this.selectedTypeId; | var id = this.selectedTypeId; | ||||
| @@ -1295,14 +1420,170 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| }); | }); | ||||
| }, | }, | ||||
| init() { | |||||
| var self = this; | |||||
| this.currentAttributes.forEach(function (attr) { | |||||
| if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } | |||||
| }); | |||||
| }, | |||||
| onTypeChange() { | onTypeChange() { | ||||
| this.attributeValues = {}; | |||||
| this.attributeValues = {}; | |||||
| this.apiLookupOptions = {}; | |||||
| this.apiLookupState = {}; | |||||
| this.apiLookupOpen = {}; | |||||
| var self = this; | |||||
| this.$nextTick(function () { | |||||
| self.currentAttributes.forEach(function (attr) { | |||||
| if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } | |||||
| }); | |||||
| }); | |||||
| }, | }, | ||||
| inputType(attrType) { | inputType(attrType) { | ||||
| return ['number', 'date'].includes(attrType) ? attrType : 'text'; | return ['number', 'date'].includes(attrType) ? attrType : 'text'; | ||||
| }, | }, | ||||
| getApiOptions(name) { | |||||
| return this.apiLookupOptions[name] || { fields: [], records: [] }; | |||||
| }, | |||||
| openApiLookup(name) { | |||||
| var o = {}; o[name] = true; | |||||
| this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o); | |||||
| }, | |||||
| closeApiLookup(name) { | |||||
| var o = {}; o[name] = false; | |||||
| this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o); | |||||
| }, | |||||
| getFilteredRecords(name, search) { | |||||
| var records = this.getApiOptions(name).records; | |||||
| if (!search) return records; | |||||
| var term = String(search).toLowerCase(); | |||||
| return records.filter(function (rec) { | |||||
| return rec._display.some(function (v) { | |||||
| return String(v).toLowerCase().indexOf(term) !== -1; | |||||
| }); | |||||
| }); | |||||
| }, | |||||
| selectApiOption(attr, rec) { | |||||
| var newValues = Object.assign({}, this.attributeValues); | |||||
| newValues[attr.name] = rec._primary; | |||||
| var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| var attrs = this.currentAttributes; | |||||
| autoFill.forEach(function (alias) { | |||||
| var target = null; | |||||
| for (var i = 0; i < attrs.length; i++) { | |||||
| if (attrs[i].alias === alias) { target = attrs[i]; break; } | |||||
| } | |||||
| if (!target) return; | |||||
| var rowVal = rec._row[alias]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[target.name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| this.attributeValues = newValues; | |||||
| this.closeApiLookup(attr.name); | |||||
| }, | |||||
| fetchApiValue(attr) { | |||||
| var self = this; | |||||
| if (!attr.api_url) return; | |||||
| var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || '')); | |||||
| // Reassign whole objects so Alpine sees new references and re-renders nested x-for | |||||
| var s = {}; s[attr.name] = 'loading'; | |||||
| var e = {}; e[attr.name] = ''; | |||||
| var o = {}; o[attr.name] = { fields: [], records: [] }; | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, s); | |||||
| self.apiLookupError = Object.assign({}, self.apiLookupError, e); | |||||
| self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o); | |||||
| var matchFields = (attr.api_match_field || '') | |||||
| .split(';') | |||||
| .map(function (s) { return s.trim(); }) | |||||
| .filter(Boolean); | |||||
| fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl)) | |||||
| .then(function (res) { return res.json(); }) | |||||
| .then(function (envelope) { | |||||
| if (envelope.error) { | |||||
| var se = {}; se[attr.name] = envelope.error; | |||||
| var ss = {}; ss[attr.name] = 'error'; | |||||
| self.apiLookupError = Object.assign({}, self.apiLookupError, se); | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, ss); | |||||
| return; | |||||
| } | |||||
| var body = envelope.body || ''; | |||||
| var result = { fields: matchFields.slice(), records: [] }; | |||||
| var seenRows = []; | |||||
| function addRow(rawRow) { | |||||
| if (seenRows.indexOf(rawRow) !== -1) return; | |||||
| seenRows.push(rawRow); | |||||
| var display = result.fields.map(function (f) { | |||||
| var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : ''; | |||||
| }); | |||||
| result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow }); | |||||
| } | |||||
| if (attr.api_format === 'xml') { | |||||
| try { | |||||
| var doc = new DOMParser().parseFromString(body, 'text/xml'); | |||||
| if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; } | |||||
| // Collect sibling-based rows keyed by the first match field element | |||||
| var firstField = result.fields[0]; | |||||
| var els = doc.getElementsByTagName(firstField); | |||||
| for (var xi = 0; xi < els.length; xi++) { | |||||
| var row = {}; | |||||
| var par = els[xi].parentNode; | |||||
| if (par) { | |||||
| for (var xc = 0; xc < par.childNodes.length; xc++) { | |||||
| var cn = par.childNodes[xc]; | |||||
| if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); } | |||||
| } | |||||
| } | |||||
| addRow(row); | |||||
| } | |||||
| } catch (e) { /* leave records empty */ } | |||||
| } else { | |||||
| try { | |||||
| var parsed = JSON.parse(body); | |||||
| if (result.fields.length > 0) { | |||||
| // Collect unique parent rows that own the first match field | |||||
| _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); }); | |||||
| } else if (Array.isArray(parsed)) { | |||||
| result.fields = ['Value']; | |||||
| parsed.forEach(function (item) { | |||||
| if (typeof item !== 'object') { addRow({ Value: String(item) }); } | |||||
| }); | |||||
| } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') { | |||||
| result.fields = ['Value']; | |||||
| addRow({ Value: String(parsed) }); | |||||
| } | |||||
| } catch (e) { /* leave records empty */ } | |||||
| } | |||||
| var oo = {}; oo[attr.name] = result; | |||||
| var os = {}; os[attr.name] = 'idle'; | |||||
| self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo); | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, os); | |||||
| }) | |||||
| .catch(function (err) { | |||||
| console.error('[api-lookup] fetch failed:', err); | |||||
| var ce = {}; ce[attr.name] = 'Network error — see browser console.'; | |||||
| var cs = {}; cs[attr.name] = 'error'; | |||||
| self.apiLookupError = Object.assign({}, self.apiLookupError, ce); | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, cs); | |||||
| }); | |||||
| }, | |||||
| confirmDelete(event) { | confirmDelete(event) { | ||||
| if (confirm('Delete this job? This cannot be undone.')) { | if (confirm('Delete this job? This cannot be undone.')) { | ||||
| event.target.submit(); | event.target.submit(); | ||||
| @@ -1310,3 +1591,36 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| }, | }, | ||||
| }; | }; | ||||
| }; | }; | ||||
| // Unsaved-changes guard — fires beforeunload warning when a .ct-form has been | |||||
| // touched but not yet submitted. Delete forms and the logout form are excluded | |||||
| // because they use different CSS classes and are intentional navigation. | |||||
| (function () { | |||||
| function initDirtyFormGuard() { | |||||
| var forms = document.querySelectorAll('form.ct-form'); | |||||
| if (!forms.length) return; | |||||
| var dirty = false; | |||||
| function markDirty() { dirty = true; } | |||||
| function markClean() { dirty = false; } | |||||
| forms.forEach(function (form) { | |||||
| form.addEventListener('input', markDirty); | |||||
| form.addEventListener('change', markDirty); | |||||
| form.addEventListener('submit', markClean); | |||||
| }); | |||||
| window.addEventListener('beforeunload', function (e) { | |||||
| if (!dirty) return; | |||||
| e.preventDefault(); | |||||
| e.returnValue = ''; | |||||
| }); | |||||
| } | |||||
| if (document.readyState === 'loading') { | |||||
| document.addEventListener('DOMContentLoaded', initDirtyFormGuard); | |||||
| } else { | |||||
| initDirtyFormGuard(); | |||||
| } | |||||
| }()); | |||||
| @@ -2,6 +2,7 @@ | |||||
| declare(strict_types=1); | declare(strict_types=1); | ||||
| use App\Controllers\ApiProxyController; | |||||
| use App\Controllers\AuthController; | use App\Controllers\AuthController; | ||||
| use App\Controllers\CampaignController; | use App\Controllers\CampaignController; | ||||
| use App\Controllers\CampaignTypeController; | use App\Controllers\CampaignTypeController; | ||||
| @@ -10,6 +11,9 @@ use App\Controllers\HomeController; | |||||
| use App\Controllers\JobController; | use App\Controllers\JobController; | ||||
| use App\Controllers\JobTypeController; | use App\Controllers\JobTypeController; | ||||
| // ── API Proxy ───────────────────────────────────────────────────────────────── | |||||
| $router->get('/api/proxy', [ApiProxyController::class, 'fetch'])->middleware('auth'); | |||||
| // ── Auth (public) ───────────────────────────────────────────────────────────── | // ── Auth (public) ───────────────────────────────────────────────────────────── | ||||
| $router->get('/login', [AuthController::class, 'login']); | $router->get('/login', [AuthController::class, 'login']); | ||||
| $router->get('/auth/callback', [AuthController::class, 'callback']); | $router->get('/auth/callback', [AuthController::class, 'callback']); | ||||
Powered by TurnKey Linux.