From 11d3d69bf0b5560a58243b202af8363cb049662d Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Mon, 24 Feb 2025 21:32:59 +0200 Subject: [PATCH 01/15] feat: Add multi-stage operation and MQTT tester components - Introduce reusable Alpine.js components for multi-stage operations and MQTT connection testing - Create `multiStageOperation.js` and `mqttTester.js` for handling complex, streaming API interactions - Refactor MQTT settings page to use new multi-stage operation component - Improve error handling, progress tracking, and user feedback for complex operations - Remove redundant inline JavaScript from integration settings page --- assets/js/components/multiStageOperation.js | 207 ++++++++++++ assets/tailwind.css | 7 - views/components/js/mqttTester.js | 188 +++++++++++ views/components/multiStageOperation.html | 131 ++++++++ views/pages/settings/integrationSettings.html | 295 +----------------- 5 files changed, 542 insertions(+), 286 deletions(-) create mode 100644 assets/js/components/multiStageOperation.js create mode 100644 views/components/js/mqttTester.js create mode 100644 views/components/multiStageOperation.html diff --git a/assets/js/components/multiStageOperation.js b/assets/js/components/multiStageOperation.js new file mode 100644 index 00000000..f6fd7d17 --- /dev/null +++ b/assets/js/components/multiStageOperation.js @@ -0,0 +1,207 @@ +// Multi-Stage Operation Component +// A reusable Alpine.js component for tracking multi-stage operations with progress reporting + +document.addEventListener('alpine:init', () => { + Alpine.data('multiStageOperation', (config = {}) => { + return { + // Configurable properties with defaults + apiEndpoint: config.apiEndpoint || '', + csrfToken: config.csrfToken || '', + timeoutDuration: config.timeoutDuration || 15000, + operationName: config.operationName || 'Operation', + stageOrder: config.stageOrder || ['Starting'], + + // Component state + isRunning: false, + results: [], + currentStage: null, + + // Helper methods + isProgressMessage(message) { + if (!message) return false; + const lowerMsg = message.toLowerCase(); + return lowerMsg.includes('running') || + lowerMsg.includes('testing') || + lowerMsg.includes('establishing') || + lowerMsg.includes('initializing') || + lowerMsg.includes('attempting') || + lowerMsg.includes('processing'); + }, + + // Start the operation + start(payload = {}, options = {}) { + const initialStage = options.initialStage || this.stageOrder[0]; + const initialMessage = options.initialMessage || `Initializing ${this.operationName}...`; + + this.isRunning = true; + this.currentStage = initialStage; + this.results = [{ + success: true, + stage: initialStage, + message: initialMessage, + state: 'running' + }]; + + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Operation timeout after ${this.timeoutDuration/1000} seconds`)), this.timeoutDuration); + }); + + if (!this.apiEndpoint) { + console.error('No API endpoint specified for the operation'); + this.results = [{ + success: false, + stage: 'Error', + message: 'No API endpoint specified for the operation', + state: 'failed' + }]; + this.isRunning = false; + return Promise.reject(new Error('No API endpoint specified')); + } + + // Create the fetch promise + const fetchPromise = fetch(this.apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify(payload) + }); + + // Race between fetch and timeout + return Promise.race([fetchPromise, timeoutPromise]) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + return new ReadableStream({ + start: (controller) => { + const push = () => { + reader.read().then(({done, value}) => { + if (done) { + controller.close(); + return; + } + + buffer += decoder.decode(value, {stream: true}); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep the incomplete line + + lines.forEach(line => { + if (line.trim()) { + try { + const result = JSON.parse(line); + this.currentStage = result.stage; + + // Find existing result for this stage + const existingIndex = this.results.findIndex(r => r.stage === result.stage); + + // Determine if this is a progress message + const isProgress = this.isProgressMessage(result.message); + + // Set the state based on the result + const state = result.state ? result.state : // Use existing state if provided + result.error ? 'failed' : + isProgress ? 'running' : + result.success ? 'completed' : + 'failed'; + + const updatedResult = { + ...result, + isProgress: isProgress && !result.error, // Progress state is false if there's an error + state, + success: result.error ? false : result.success + }; + + if (existingIndex >= 0) { + // Update existing result + this.results[existingIndex] = updatedResult; + } else { + // Add new result + this.results.push(updatedResult); + } + + // Also update previous stages to completed if this is a new stage + if (!isProgress && result.success && !result.error) { + const currentStageIndex = this.stageOrder.indexOf(result.stage); + this.results.forEach((r, idx) => { + const stageIndex = this.stageOrder.indexOf(r.stage); + if (stageIndex < currentStageIndex && r.state === 'running') { + this.results[idx] = { + ...r, + state: 'completed', + isProgress: false + }; + } + }); + } + + // Sort results according to stage order + this.results.sort((a, b) => + this.stageOrder.indexOf(a.stage) - this.stageOrder.indexOf(b.stage) + ); + } catch (e) { + console.error('Failed to parse result:', e); + } + } + }); + + controller.enqueue(value); + push(); + }).catch(error => { + controller.error(error); + }); + }; + + push(); + } + }); + }) + .catch(error => { + const errorMessage = error.message.includes('timeout') + ? `The operation took too long to complete. Please try again.` + : `Failed to perform ${this.operationName}`; + + this.results = [{ + success: false, + stage: 'Error', + message: errorMessage, + error: error.message, + state: 'failed' + }]; + this.currentStage = null; + return Promise.reject(error); + }) + .finally(() => { + this.isRunning = false; + this.currentStage = null; + }); + }, + + // Check if operation was completely successful + isCompleteSuccess() { + if (this.results.length === 0 || this.isRunning) return false; + + // Every result must be successful + if (!this.results.every(result => result.success)) return false; + + // Must have reached the final stage + const finalStage = this.stageOrder[this.stageOrder.length - 1]; + return this.results.some(result => result.stage === finalStage); + }, + + // Reset the operation state + reset() { + this.isRunning = false; + this.results = []; + this.currentStage = null; + } + }; + }); +}); \ No newline at end of file diff --git a/assets/tailwind.css b/assets/tailwind.css index 1371846d..8967c3f6 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -1124,13 +1124,6 @@ html { flex-grow: 1; } -.card-actions { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 0.5rem; -} - .card figure { display: flex; align-items: center; diff --git a/views/components/js/mqttTester.js b/views/components/js/mqttTester.js new file mode 100644 index 00000000..7b48015f --- /dev/null +++ b/views/components/js/mqttTester.js @@ -0,0 +1,188 @@ +// MQTT Tester Component +// A reusable Alpine.js component for testing MQTT connections + +document.addEventListener('alpine:init', () => { + Alpine.data('mqttTester', (config = {}) => { + return { + // Configurable properties with defaults + apiEndpoint: config.apiEndpoint || '/api/v1/mqtt/test', + csrfToken: config.csrfToken || '', + timeoutDuration: config.timeoutDuration || 15000, + + // Component state + isTesting: false, + testResults: [], + currentTestStage: null, + + // Test stage definitions + testStageOrder: [ + 'Starting Test', + 'Service Check', + 'Service Start', + 'DNS Resolution', + 'TCP Connection', + 'MQTT Connection', + 'Message Publishing' + ], + + // Helper methods + isProgressMessage(message) { + const lowerMsg = message.toLowerCase(); + return lowerMsg.includes('running') || + lowerMsg.includes('testing') || + lowerMsg.includes('establishing') || + lowerMsg.includes('initializing') || + lowerMsg.includes('attempting to start'); + }, + + // Test method + runTest(mqttConfig) { + this.isTesting = true; + this.currentTestStage = 'Starting Test'; + this.testResults = [{ + success: true, + stage: 'Starting Test', + message: 'Initializing MQTT connection test...', + state: 'running' + }]; + + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test timeout after ${this.timeoutDuration/1000} seconds`)), this.timeoutDuration); + }); + + // Create the fetch promise + const fetchPromise = fetch(this.apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify(mqttConfig) + }); + + // Race between fetch and timeout + Promise.race([fetchPromise, timeoutPromise]) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + return new ReadableStream({ + start: (controller) => { + const push = () => { + reader.read().then(({done, value}) => { + if (done) { + controller.close(); + return; + } + + buffer += decoder.decode(value, {stream: true}); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep the incomplete line + + lines.forEach(line => { + if (line.trim()) { + try { + const result = JSON.parse(line); + this.currentTestStage = result.stage; + + // Find existing result for this stage + const existingIndex = this.testResults.findIndex(r => r.stage === result.stage); + + // Determine if this is a progress message + const isProgress = this.isProgressMessage(result.message); + + // Set the state based on the result + const state = result.state ? result.state : // Use existing state if provided + result.error ? 'failed' : + isProgress ? 'running' : + result.success ? 'completed' : + 'failed'; + + const updatedResult = { + ...result, + isProgress: isProgress && !result.error, // Progress state is false if there's an error + state, + success: result.error ? false : result.success + }; + + if (existingIndex >= 0) { + // Update existing result + this.testResults[existingIndex] = updatedResult; + } else { + // Add new result + this.testResults.push(updatedResult); + } + + // Also update previous stages to completed if this is a new stage + if (!isProgress && result.success && !result.error) { + const currentStageIndex = this.testStageOrder.indexOf(result.stage); + this.testResults.forEach((r, idx) => { + const stageIndex = this.testStageOrder.indexOf(r.stage); + if (stageIndex < currentStageIndex && r.state === 'running') { + this.testResults[idx] = { + ...r, + state: 'completed', + isProgress: false + }; + } + }); + } + + // Sort results according to stage order + this.testResults.sort((a, b) => + this.testStageOrder.indexOf(a.stage) - this.testStageOrder.indexOf(b.stage) + ); + } catch (e) { + console.error('Failed to parse test result:', e); + } + } + }); + + controller.enqueue(value); + push(); + }).catch(error => { + controller.error(error); + }); + }; + + push(); + } + }); + }) + .catch(error => { + const errorMessage = error.message.includes('timeout') + ? `The test took too long to complete. Please check your broker connection and try again.` + : 'Failed to perform MQTT test'; + + this.testResults = [{ + success: false, + stage: 'Error', + message: errorMessage, + error: error.message, + state: 'failed' + }]; + this.currentTestStage = null; + }) + .finally(() => { + this.isTesting = false; + this.currentTestStage = null; + }); + }, + + // Check if test was successful + testWasSuccessful() { + return !this.isTesting && + this.testResults.length > 0 && + this.testResults.every(result => result.success) && + this.testResults.some(result => result.stage === 'Message Publishing') && + this.testResults[this.testResults.length - 1].stage === 'Message Publishing'; + } + }; + }); +}); \ No newline at end of file diff --git a/views/components/multiStageOperation.html b/views/components/multiStageOperation.html new file mode 100644 index 00000000..fc11e6e0 --- /dev/null +++ b/views/components/multiStageOperation.html @@ -0,0 +1,131 @@ +{{define "multiStageOperation"}} + +
+ + + +
+

{{if .resultsTitle}}{{.resultsTitle}}{{else}}Results{{end}}:

+ + + + +
+
+{{end}} \ No newline at end of file diff --git a/views/pages/settings/integrationSettings.html b/views/pages/settings/integrationSettings.html index c8bb1cbb..f6e70c5a 100644 --- a/views/pages/settings/integrationSettings.html +++ b/views/pages/settings/integrationSettings.html @@ -129,24 +129,7 @@ password: '{{.Settings.Realtime.MQTT.Password}}', testResults: [], isTesting: false, - currentTestStage: null, - testStageOrder: [ - 'Starting Test', - 'Service Check', - 'Service Start', - 'DNS Resolution', - 'TCP Connection', - 'MQTT Connection', - 'Message Publishing' - ], - isProgressMessage(message) { - const lowerMsg = message.toLowerCase(); - return lowerMsg.includes('running') || - lowerMsg.includes('testing') || - lowerMsg.includes('establishing') || - lowerMsg.includes('initializing') || - lowerMsg.includes('attempting to start'); - } + currentTestStage: null }, mqttSettingsOpen: false, showTooltip: null, @@ -314,267 +297,18 @@ "tooltip" "The MQTT password." }} - -
- - - -
-

Test Results:

- - - -
-
+ + {{template "multiStageOperation" dict + "operationName" "MQTT Connection Test" + "apiEndpoint" "/api/v1/mqtt/test" + "stageOrder" "['Starting Test', 'Service Check', 'Service Start', 'DNS Resolution', 'TCP Connection', 'MQTT Connection', 'Message Publishing']" + "buttonText" "Test MQTT Connection" + "buttonLoadingText" "Testing..." + "buttonDisabledCondition" "!mqtt.enabled || !mqtt.broker || isRunning" + "buttonTooltipMap" "!mqtt.enabled ? 'MQTT must be enabled to test' : !mqtt.broker ? 'MQTT broker must be specified' : isRunning ? 'Test in progress...' : 'Test MQTT connection'" + "payload" "{enabled: mqtt.enabled, broker: mqtt.broker, topic: mqtt.topic, username: mqtt.username, password: mqtt.password}" + "completionMessage" "Please remember to save settings to apply the changes permanently." + }} @@ -789,4 +523,7 @@

Test Results:

+ + + {{end}} \ No newline at end of file From 8ee56b1333f6499bb7d5fac14d31570aa22e904d Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Mon, 24 Feb 2025 22:18:51 +0200 Subject: [PATCH 02/15] refactor: Enhance settings page with modular and accessible components - Introduce new reusable HTML template components for settings sections - Add `noteField`, `numberField`, `selectField`, and `sectionHeader` templates - Refactor integration, MQTT, telemetry, and weather settings to use new components - Improve accessibility with ARIA attributes and semantic HTML structure - Standardize Alpine.js data management and change tracking across settings sections --- views/components/noteField.html | 6 + views/components/numberField.html | 31 ++ views/components/sectionHeader.html | 13 + views/components/selectField.html | 32 ++ views/components/settingsSection.html | 33 ++ views/pages/settings/integrationSettings.html | 509 +++++++----------- views/pages/settings/settingsBase.html | 63 ++- 7 files changed, 363 insertions(+), 324 deletions(-) create mode 100644 views/components/noteField.html create mode 100644 views/components/numberField.html create mode 100644 views/components/sectionHeader.html create mode 100644 views/components/selectField.html create mode 100644 views/components/settingsSection.html diff --git a/views/components/noteField.html b/views/components/noteField.html new file mode 100644 index 00000000..d825c9e3 --- /dev/null +++ b/views/components/noteField.html @@ -0,0 +1,6 @@ +{{define "noteField"}} +
+
+
+
+{{end}} \ No newline at end of file diff --git a/views/components/numberField.html b/views/components/numberField.html new file mode 100644 index 00000000..f73917ce --- /dev/null +++ b/views/components/numberField.html @@ -0,0 +1,31 @@ +{{define "numberField"}} +
+ + + +
+{{end}} \ No newline at end of file diff --git a/views/components/sectionHeader.html b/views/components/sectionHeader.html new file mode 100644 index 00000000..5dbfdf1a --- /dev/null +++ b/views/components/sectionHeader.html @@ -0,0 +1,13 @@ +{{define "sectionHeader"}} +
+
+ +
+ + changed + +
+
+

{{.description}}

+
+{{end}} \ No newline at end of file diff --git a/views/components/selectField.html b/views/components/selectField.html new file mode 100644 index 00000000..d8074523 --- /dev/null +++ b/views/components/selectField.html @@ -0,0 +1,32 @@ +{{define "selectField"}} +
+ + + +
+{{end}} \ No newline at end of file diff --git a/views/components/settingsSection.html b/views/components/settingsSection.html new file mode 100644 index 00000000..de221eeb --- /dev/null +++ b/views/components/settingsSection.html @@ -0,0 +1,33 @@ +{{define "settingsSection"}} +
+ + + + + {{template "sectionHeader" dict + "id" .id + "title" .title + "description" .description}} + +
+ {{.content}} +
+
+{{end}} \ No newline at end of file diff --git a/views/pages/settings/integrationSettings.html b/views/pages/settings/integrationSettings.html index f6e70c5a..aa595fc9 100644 --- a/views/pages/settings/integrationSettings.html +++ b/views/pages/settings/integrationSettings.html @@ -30,65 +30,40 @@ - -
-
- -
- - changed - -
-
-

Upload detections to BirdWeather

-
+ x-on:change="birdweatherSettingsOpen = !birdweatherSettingsOpen" + aria-controls="birdweatherSettingsContent" + aria-expanded="true" /> - - -
-
- - -
+ {{template "sectionHeader" dict + "id" "birdweather" + "title" "BirdWeather" + "description" "Upload detections to BirdWeather"}} -
- -
- -
- Enable debug mode for additional logging information. -
-
- -
- {{template "passwordField" dict +
+ {{template "checkbox" dict + "id" "birdweatherEnabled" + "model" "birdweather.enabled" + "name" "realtime.birdweather.enabled" + "label" "Enable BirdWeather Uploads" + "tooltip" "Enable or disable uploads to BirdWeather service."}} + +
+ + {{template "checkbox" dict + "id" "birdweatherDebug" + "model" "birdweather.debug" + "name" "realtime.birdweather.debug" + "label" "Debug Mode" + "tooltip" "Enable debug mode for additional logging information."}} + + + {{template "passwordField" dict "id" "birdweatherId" "model" "birdweather.id" "name" "realtime.birdweather.id" @@ -96,21 +71,17 @@ "tooltip" "Your unique BirdWeather token." }} -
- - -
- Minimum confidence threshold for uploading predictions to BirdWeather. -
-
+ {{template "numberField" dict + "id" "birdweatherThreshold" + "model" "birdweather.threshold" + "name" "realtime.birdweather.threshold" + "label" "Upload Threshold" + "step" "0.01" + "min" "0" + "max" "1" + "tooltip" "Minimum confidence threshold for uploading predictions to BirdWeather." + }} -
@@ -173,120 +144,49 @@ aria-controls="mqttSettingsContent" aria-expanded="true" /> -
-
- -
- - changed - -
-
-

Configure MQTT broker connection

-
+ {{template "sectionHeader" dict + "id" "mqtt" + "title" "MQTT" + "description" "Configure MQTT broker connection"}}
-
- - -
+ {{template "checkbox" dict + "id" "mqttEnabled" + "model" "mqtt.enabled" + "name" "realtime.mqtt.enabled" + "label" "Enable MQTT Integration" + "tooltip" "Enable or disable integration with MQTT service."}}
-
- - - -
- -
- - -
- MQTT topic to publish detections to -
-
- -
- - -
- The MQTT username. -
-
- - + {{template "textField" dict + "id" "mqttBroker" + "model" "mqtt.broker" + "name" "realtime.mqtt.broker" + "label" "MQTT Broker" + "placeholder" "mqtt://localhost:1883" + "tooltip" "MQTT broker URL (e.g., mqtt://localhost:1883)"}} + + {{template "textField" dict + "id" "mqttTopic" + "model" "mqtt.topic" + "name" "realtime.mqtt.topic" + "label" "MQTT Topic" + "placeholder" "birdnet/detections" + "tooltip" "MQTT topic to publish detections to"}} + + {{template "textField" dict + "id" "mqttUsername" + "model" "mqtt.username" + "name" "realtime.mqtt.username" + "label" "Username" + "tooltip" "The MQTT username."}} {{template "passwordField" dict "id" "mqttPassword" @@ -294,8 +194,7 @@ "name" "realtime.mqtt.password" "label" "Password" "placeholder" "" - "tooltip" "The MQTT password." - }} + "tooltip" "The MQTT password."}} {{template "multiStageOperation" dict @@ -315,7 +214,10 @@ -
- - -
-
- -
- - changed - -
-
-

Monitor BirdNET-Go's performance and bird detection metrics through - Prometheus-compatible endpoint

-
- - - -
-
- -
- Enable or disable integration with Telemetry service. -
-
- -
- -
+ -
- - -
- The IP address and port to listen on (e.g., 0.0.0.0:8090). -
-
+ {{template "sectionHeader" dict + "id" "telemetry" + "title" "Telemetry" + "description" "Monitor BirdNET-Go's performance and bird detection metrics through Prometheus-compatible endpoint"}} -
+
+ {{template "checkbox" dict + "id" "telemetryEnabled" + "model" "telemetry.enabled" + "name" "realtime.telemetry.enabled" + "label" "Enable Telemetry Integration" + "tooltip" "Enable or disable integration with Telemetry service."}} + +
+ + {{template "textField" dict + "id" "telemetryListen" + "model" "telemetry.listen" + "name" "realtime.telemetry.listen" + "label" "Listen Address" + "tooltip" "The IP address and port to listen on (e.g., 0.0.0.0:8090)."}}
@@ -393,7 +273,10 @@ -
- - -
-
- -
- - changed - -
-
-

Configure weather data collection

-
- -
- -
- - -
- Select the weather data provider or choose 'None' to disable weather data collection. -
-
- - -
-
-

No weather data will be retrieved.

-
-
- - -
-
-

Weather forecast data is provided by Yr.no, a joint service by the Norwegian Meteorological Institute (met.no) and the Norwegian Broadcasting Corporation (NRK).

-

Yr is a free weather data service. For more information, visit Yr.no.

-
-
+ - -
-
-

Use of OpenWeather requires an API key, sign up for a free API key at OpenWeather.

-
-
+ {{template "sectionHeader" dict + "id" "weather" + "title" "Weather" + "description" "Configure weather data collection"}} -
+
+ + {{template "selectField" dict + "id" "weatherProvider" + "model" "weather.provider" + "name" "realtime.weather.provider" + "label" "Weather Provider" + "tooltip" "Select the weather data provider or choose 'None' to disable weather data collection." + "options" (dict + "none" "None" + "yrno" "Yr.no" + "openweather" "OpenWeather" + )}} + + + {{template "noteField" dict + "condition" "weather.provider === 'none'" + "content" "

No weather data will be retrieved.

"}} + + {{template "noteField" dict + "condition" "weather.provider === 'yrno'" + "content" "

Weather forecast data is provided by Yr.no, a joint service by the Norwegian Meteorological Institute (met.no) and the Norwegian Broadcasting Corporation (NRK).

Yr is a free weather data service. For more information, visit Yr.no.

"}} + + {{template "noteField" dict + "condition" "weather.provider === 'openweather'" + "content" "

Use of OpenWeather requires an API key, sign up for a free API key at OpenWeather.

"}} + +
-
+
{{template "passwordField" dict "id" "openWeatherApiKey" "model" "weather.openWeather.apiKey" @@ -480,42 +358,33 @@ "tooltip" "Your OpenWeather API key. Keep this secret!" }} -
- - -
- The OpenWeather API endpoint URL. -
-
- -
- - -
- Choose the units system for weather data. -
-
- -
- - -
- Language code for the API response (e.g., 'en' for English). -
-
+ {{template "textField" dict + "id" "openWeatherEndpoint" + "model" "weather.openWeather.endpoint" + "name" "realtime.weather.openweather.endpoint" + "label" "API Endpoint" + "tooltip" "The OpenWeather API endpoint URL." + }} + + {{template "selectField" dict + "id" "openWeatherUnits" + "model" "weather.openWeather.units" + "name" "realtime.weather.openweather.units" + "label" "Units of Measurement" + "tooltip" "Choose the units system for weather data." + "options" (dict + "standard" "Standard" + "metric" "Metric" + "imperial" "Imperial" + )}} + + {{template "textField" dict + "id" "openWeatherLanguage" + "model" "weather.openWeather.language" + "name" "realtime.weather.openweather.language" + "label" "Language" + "tooltip" "Language code for the API response (e.g., 'en' for English)." + }}
diff --git a/views/pages/settings/settingsBase.html b/views/pages/settings/settingsBase.html index d17c1181..79df0433 100644 --- a/views/pages/settings/settingsBase.html +++ b/views/pages/settings/settingsBase.html @@ -139,6 +139,25 @@ } return visibleFieldsValid; }, + addNotification(message, type = 'info') { + const id = Date.now() + Math.random(); + this.notifications.push({ + id: id, + message: message, + type: type, + removing: false + }); + + setTimeout(() => { + const index = this.notifications.findIndex(n => n.id === id); + if (index !== -1) { + this.notifications[index].removing = true; + setTimeout(() => { + this.notifications = this.notifications.filter(n => n.id !== id); + }, 300); + } + }, 5000); + }, saveSettings() { const form = document.getElementById('settingsForm'); const formData = new FormData(form); @@ -160,13 +179,30 @@ if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } + // Check the content type before trying to parse as JSON + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } else { + // For non-JSON responses, just return a success object + return { success: true }; + } + }) + .then(data => { + // Success notification comes from SSE, no need to manually add it here + + // Reset all hasChanges flags + this.resetComponentChanges(); + + // Check if security settings changed, which requires a page reload const security = Alpine.store('security'); if (security?.hasChanges) { + security.hasChanges = false; setTimeout(() => { + this.addNotification('Security settings changed, reloading page...', 'info'); setTimeout(() => window.location.reload(), 1500); - }, 50); + }, 500); } else { - this.resetComponentChanges(); this.saving = false; } }) @@ -177,9 +213,28 @@ }); }, resetComponentChanges() { + // Reset the main hasChanges flag + this.hasChanges = false; + + // Reset Alpine store flags + if (Alpine.store('security')) { + Alpine.store('security').hasChanges = false; + } + + // Reset individual component hasChanges flags this.$root.querySelectorAll('[x-data]').forEach(el => { - if (el._x_resetChanges && typeof el._x_resetChanges === 'function') { - el._x_resetChanges(); + if (el._x_dataStack && el._x_dataStack.length > 0) { + const data = el._x_dataStack[0]; + + // Reset hasChanges flag if exists + if ('hasChanges' in data) { + data.hasChanges = false; + } + + // Call resetChanges method if exists + if (el._x_resetChanges && typeof el._x_resetChanges === 'function') { + el._x_resetChanges(); + } } }); } From b51629e418feded06dd28efbfde3fccab864afc6 Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Tue, 25 Feb 2025 18:29:12 +0200 Subject: [PATCH 03/15] refactor: Improve accessibility and modularity of filter settings page - Add ARIA attributes and semantic HTML structure to filter settings sections - Refactor privacy and dog bark filter sections using new reusable components - Improve section headers, tooltips, and form input styling - Enhance Alpine.js data management and change tracking - Standardize layout and accessibility across filter settings --- views/components/checkbox.html | 2 +- views/components/hostField.html | 2 +- views/components/numberField.html | 2 +- views/components/passwordField.html | 2 +- views/components/selectField.html | 2 +- views/components/textField.html | 2 +- views/pages/settings/filtersSettings.html | 227 ++++++++++------------ 7 files changed, 113 insertions(+), 126 deletions(-) diff --git a/views/components/checkbox.html b/views/components/checkbox.html index e83ec399..2f65c3fe 100644 --- a/views/components/checkbox.html +++ b/views/components/checkbox.html @@ -1,7 +1,7 @@ {{define "checkbox"}} -
+