diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 4435f35..13b0ad5 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -232,6 +232,7 @@ }, "processes": { "title": "Prozessdefinitionen", + "filter-placeholder": "Name / Schlüssel suchen", "version": "Version", "change-definition": "Definition wechseln", "definition-id": "Definitions-ID", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index 71587ff..d6d69ae 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -232,6 +232,7 @@ }, "processes": { "title": "Process Definitions", + "filter-placeholder": "Search Name / Key", "version": "Version", "change-definition": "Change Definition", "definition-id": "Definition ID", diff --git a/public/locales/es-ES/translation.json b/public/locales/es-ES/translation.json index 0c0bfcc..eeb4021 100644 --- a/public/locales/es-ES/translation.json +++ b/public/locales/es-ES/translation.json @@ -232,6 +232,7 @@ }, "processes": { "title": "Definiciones de procesos", + "filter-placeholder": "Buscar nombre / clave", "version": "Versión", "change-definition": "Cambiar definición", "definition-id": "ID de definición", diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index c799a07..adf17d2 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -232,6 +232,7 @@ }, "processes": { "title": "Définitions de processus", + "filter-placeholder": "Chercher nom / clé", "version": "Version", "change-definition": "Changer de définition", "definition-id": "ID de définition", diff --git a/public/locales/nl-NL/translation.json b/public/locales/nl-NL/translation.json index de89c41..bfa8d43 100644 --- a/public/locales/nl-NL/translation.json +++ b/public/locales/nl-NL/translation.json @@ -232,6 +232,7 @@ }, "processes": { "title": "Procesdefinities", + "filter-placeholder": "Naam / sleutel zoeken", "version": "Versie", "change-definition": "Definitie wijzigen", "definition-id": "Definitie-ID", diff --git a/src/api/resources/process_definition.js b/src/api/resources/process_definition.js index 71214e2..1cc6804 100644 --- a/src/api/resources/process_definition.js +++ b/src/api/resources/process_definition.js @@ -1,7 +1,38 @@ -import { GET, GET_TEXT, POST } from '../helper.jsx' - -export const get_process_definitions = (state) => - GET('/process-definition/statistics', state, state.api.process.definition.list) +import { GET, GET_TEXT, POST, RESPONSE_STATE } from '../helper.jsx'; + +export const get_process_definitions = async (state, queryString = '') => { + if (!queryString) { + return GET('/process-definition/statistics', state, state.api.process.definition.list); + } + const defsSignal = { value: null }; + const statsSignal = { value: null }; + + await Promise.all([ + GET(`/process-definition?${queryString}`, state, defsSignal), + GET('/process-definition/statistics', state, statsSignal) + ]); + if (defsSignal.value.status === RESPONSE_STATE.ERROR || statsSignal.value.status === RESPONSE_STATE.ERROR) { + state.api.process.definition.list.value = { + status: RESPONSE_STATE.ERROR, + error: defsSignal.value.error || statsSignal.value.error + }; + return; + } + + const filteredDefs = defsSignal.value.data; + const allStats = statsSignal.value.data; + + const validDefinitionIds = new Set(filteredDefs.map(def => def.id)); + + const finalFilteredStats = allStats.filter(stat => + validDefinitionIds.has(stat.id || stat.definition?.id) + ); + + state.api.process.definition.list.value = { + status: RESPONSE_STATE.SUCCESS, + data: finalFilteredStats + }; +}; export const get_process_definition_statistics_with_incidents = (state, id) => GET(`/process-definition/${id}/statistics?incidents=true`, state, state.api.process.definition.statistics) diff --git a/src/assets/icons.jsx b/src/assets/icons.jsx index 7e3779b..e16421f 100644 --- a/src/assets/icons.jsx +++ b/src/assets/icons.jsx @@ -169,3 +169,18 @@ export const arrows_pointing_out = () => ( ) + +export const save = () => ( + + + +); + +export const x_mark = () => ( + + + +); \ No newline at end of file diff --git a/src/components/AdvancedFilter.jsx b/src/components/AdvancedFilter.jsx new file mode 100644 index 0000000..2b3ce2c --- /dev/null +++ b/src/components/AdvancedFilter.jsx @@ -0,0 +1,263 @@ +import { h } from 'preact'; +import { useState, useEffect, useRef, useContext } from 'preact/hooks'; +import { AppState } from '../state.js'; // 👈 Import your context +import '../css/components.css'; +import engine_rest from "../api/engine_rest.jsx"; +import * as Icons from '../assets/icons.jsx'; +import { chevron_down, link_out } from "../assets/icons.jsx"; + +const AVAILABLE_FIELDS = ['Name', 'Key', 'State', 'Tenant ID']; +const AVAILABLE_OPERATORS = ['like', '=', '!=']; + +export function AdvancedFilter() { + const state = useContext(AppState); + + const [activeFilters, setActiveFilters] = useState([]); + const [currentStep, setCurrentStep] = useState('FIELD'); + const [draftFilter, setDraftFilter] = useState({ field: null, operator: null }); + const [inputValue, setInputValue] = useState(''); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // New state for the Saved Queries dropdown + const [isSavedQueriesMenuOpen, setIsSavedQueriesMenuOpen] = useState(false); + const [savedQueriesList, setSavedQueriesList] = useState([]); + + const inputRef = useRef(null); + + // Trigger backend request whenever activeFilters change + useEffect(() => { + if (!state) return; + + const params = new URLSearchParams(); + + params.append('firstResult', '0'); + params.append('maxResults', '50'); + params.append('sortBy', 'name'); + params.append('sortOrder', 'asc'); + + activeFilters.forEach(filter => { + const { field, operator, value } = filter; + if (!value) return; + + let apiKey = ''; + let apiValue = value; + + if (field === 'Name') { + apiKey = operator === 'like' ? 'nameLike' : 'name'; + apiValue = operator === 'like' ? `%${value}%` : value; + } else if (field === 'Key') { + apiKey = operator === 'like' ? 'keyLike' : 'key'; + apiValue = operator === 'like' ? `%${value}%` : value; + } + + if (apiKey) params.append(apiKey, apiValue); + }); + + const queryString = params.toString(); + + console.log("Sending Request to Backend with URL query:", queryString); + + void engine_rest.process_definition.list(state, queryString); + + }, [activeFilters, state]); + + const saveToLocalStorage = () => { + if (activeFilters.length === 0) { + alert("Cannot save an empty query!"); + return; + } + + let existingSaved = JSON.parse(localStorage.getItem('savedQueries') || '[]'); + + // Migration: If the old format was just a single array of objects, wrap it + if (existingSaved.length > 0 && !Array.isArray(existingSaved[0])) { + existingSaved = [existingSaved]; + } + + // Prevent saving exact duplicates + const newQueryStr = JSON.stringify(activeFilters); + const isDuplicate = existingSaved.some(q => JSON.stringify(q) === newQueryStr); + + if (!isDuplicate) { + existingSaved.push(activeFilters); + localStorage.setItem('savedQueries', JSON.stringify(existingSaved)); + alert('Query saved to local storage!'); + } else { + alert('This query is already saved!'); + } + }; + + const toggleSavedQueriesMenu = () => { + if (!isSavedQueriesMenuOpen) { + let saved = JSON.parse(localStorage.getItem('savedQueries') || '[]'); + if (saved.length > 0 && !Array.isArray(saved[0])) { + saved = [saved]; // Migration catch for rendering + } + setSavedQueriesList(saved); + } + setIsSavedQueriesMenuOpen(!isSavedQueriesMenuOpen); + }; + + const applySavedQuery = (query) => { + setActiveFilters(query); + setIsSavedQueriesMenuOpen(false); + }; + + const resetInputState = () => { + setCurrentStep('FIELD'); + setDraftFilter({ field: null, operator: null }); + setInputValue(''); + setIsDropdownOpen(false); + }; + + const handleInputFocus = () => { + if (currentStep !== 'VALUE') { + setIsDropdownOpen(true); + } + }; + + const handleInputChange = (e) => { + const val = e.target.value; + setInputValue(val); + + if (val.trim() === '') { + resetInputState(); + setIsDropdownOpen(true); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && currentStep === 'VALUE') { + const prefix = `${draftFilter.field} ${draftFilter.operator} `; + if (inputValue.startsWith(prefix)) { + const extractedValue = inputValue.substring(prefix.length).trim(); + + if (extractedValue) { + setActiveFilters([...activeFilters, { ...draftFilter, value: extractedValue }]); + resetInputState(); + } + } + } + + if (e.key === 'Backspace' && inputValue === '') { + if (activeFilters.length > 0 && currentStep === 'FIELD') { + const newFilters = [...activeFilters]; + newFilters.pop(); + setActiveFilters(newFilters); + } + } + }; + + const handleOptionSelect = (option) => { + if (currentStep === 'FIELD') { + setDraftFilter({ field: option, operator: null }); + setCurrentStep('OPERATOR'); + setInputValue(`${option} `); + } else if (currentStep === 'OPERATOR') { + setDraftFilter(prev => ({ ...prev, operator: option })); + setCurrentStep('VALUE'); + setInputValue(`${draftFilter.field} ${option} `); + setIsDropdownOpen(false); + } + + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const removeFilter = (indexToRemove) => { + setActiveFilters(activeFilters.filter((_, index) => index !== indexToRemove)); + }; + + const dropdownOptions = currentStep === 'FIELD' ? AVAILABLE_FIELDS : AVAILABLE_OPERATORS; + + return ( +
+
+ {activeFilters.map((filter, index) => ( +
+ removeFilter(index)}>× + {filter.field} {filter.operator} {filter.value} +
+ ))} +
+ +
+ setTimeout(() => setIsDropdownOpen(false), 150)} + onInput={handleInputChange} + onKeyDown={handleKeyDown} + autocomplete="off" + /> + + {isDropdownOpen && ( + + )} +
+ +
+ {activeFilters.length} + + + +
+ + + + + {isSavedQueriesMenuOpen && ( +
    + {savedQueriesList.length === 0 ? ( +
  • No saved queries yet.
  • + ) : ( + savedQueriesList.map((query, index) => ( +
  • { + e.preventDefault(); + applySavedQuery(query); + }} + style={{ borderBottom: '1px solid #eee' }} + > + {/* Render the saved query beautifully as a text string */} + {query.map(f => `${f.field} ${f.operator} "${f.value}"`).join(' AND ')} +
  • + )) + )} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/css/components.css b/src/css/components.css index 79ca847..b6294c2 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -207,6 +207,15 @@ main#processes { z-index: 999; } } + + .space-between { + justify-content: space-between; + align-items: center; + } + + .search-input::placeholder { + font-size: var(--small-font-size); + } } /* tabs */ @@ -789,3 +798,107 @@ dialog#global-search { } /* migrations page */ + + +/* search filter */ +.filter-bar { + display: flex; + align-items: center; + border: 1px solid #102a5c; + background: #fff; + border-radius: 3px; + padding: 4px 8px; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); + position: relative; + min-height: 32px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.pills-container { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.filter-pill { + display: flex; + align-items: center; + border: 1px solid #dcdfe4; + background-color: #f8f9fa; + color: #0052cc; + border-radius: 4px; + padding: 2px 6px; + font-size: 14px; + white-space: nowrap; +} + +.filter-pill .remove-btn { + color: #0052cc; + cursor: pointer; + margin-right: 6px; + font-weight: bold; + font-size: 12px; +} + +.input-wrapper { + flex-grow: 1; + position: relative; + margin-left: 8px; +} + +.input-wrapper input { + border: none; + outline: none; + width: 100%; + font-size: 14px; + color: #333; + font-style: italic; + background: transparent; +} + +.input-wrapper input::placeholder { + color: #999; +} + +.input-wrapper input.active-typing { + font-style: normal; + color: #0052cc; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + background: white; + border: 1px solid #ccc; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + list-style: none; + padding: 0; + margin: 8px 0 0 0; + width: 200px; + z-index: 1000; +} + +.dropdown-menu li { + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + color: #333; +} + +.dropdown-menu li:hover { + background-color: #f4f5f7; +} + +.actions { + display: flex; + align-items: center; + gap: 12px; + margin-left: 10px; + color: #333; +} + +.action-btn { + cursor: pointer; + font-size: 16px; +} diff --git a/src/pages/Processes.jsx b/src/pages/Processes.jsx index c40d298..a2c5cf8 100644 --- a/src/pages/Processes.jsx +++ b/src/pages/Processes.jsx @@ -1,4 +1,4 @@ -import { signal, useSignalEffect } from '@preact/signals' +import { signal, useSignal, useSignalEffect } from '@preact/signals' import { useContext, useEffect } from 'preact/hooks' import { useLocation, useRoute } from 'preact-iso' import { useTranslation } from 'react-i18next' @@ -7,6 +7,7 @@ import * as Icons from '../assets/icons.jsx' import { AppState } from '../state.js' import { Accordion } from '../components/Accordion.jsx' import { BPMNViewer } from '../components/BPMNViewer.jsx' +import { AdvancedFilter } from '../components/AdvancedFilter' /** * Save custom split view width to localstorage @@ -146,46 +147,66 @@ const ProcessDiagram = () => { const ProcessDefinitionSelection = () => { - const - { api: { process: { definition } } } = useContext(AppState), - [t] = useTranslation() + // 1. Grab the FULL state object from context so we can pass it to our API function + const state = useContext(AppState); + const { api: { process: { definition } } } = state; - return
-

- {t("processes.title")} -

- - - - - - - - - - - - { - - const grouped_definitions = Object.groupBy(definition.list.value?.data, ({ definition }) => definition.key) - console.log(grouped_definitions) - const grouped_definitions_values = Object.entries(grouped_definitions) - console.log(grouped_definitions_values) - - return <> - {grouped_definitions_values.map(([key, definition_group]) => - - {definition_group.map(definition => )} - - )} - - } - } /> -
{t("common.name")}{t("processes.version")}{t("common.key")}{t("dashboard.instances")}{t("processes.tabs.incidents")}{t("common.state")}
-
-} + const [t] = useTranslation(); + + const filterFields = ['Name', 'Key', 'State', 'Tenant ID']; + const filterOperators = ['like', '=', '!=']; + + return ( +
+
+

{t("processes.title")}

+ +
+ +
+
+ + + + + + + + + + + + + + { + const data = definition.list.value?.data ?? []; + + const grouped_definitions = Object.groupBy(data, ({ definition }) => definition.key); + const grouped_definitions_values = Object.entries(grouped_definitions); + + return ( + <> + {grouped_definitions_values.map(([key, definition_group]) => ( + + {definition_group.map(def => ( + + ))} + + ))} + + ); + }} + /> +
{t("common.name")}{t("processes.version")}{t("common.key")}{t("dashboard.instances")}{t("processes.tabs.incidents")}{t("common.state")}
+
+ ); +}; const ProcessDefinitionDetails = () => { const