From a54393bf99497503c69f28485d9b7eec78834c03 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Fri, 27 Oct 2023 10:01:27 +0200 Subject: [PATCH 01/18] apply live search layout --- pool/web/view/component/component_search.ml | 28 ++++++++------------- pool/web/view/page/page_admin_contact.ml | 2 +- resources/admin/filter.js | 4 ++- resources/admin/utils.js | 13 ++++++++-- resources/index.scss | 20 +++++++++------ 5 files changed, 37 insertions(+), 30 deletions(-) diff --git a/pool/web/view/component/component_search.ml b/pool/web/view/component/component_search.ml index e709dd34a..91a7e1534 100644 --- a/pool/web/view/component/component_search.ml +++ b/pool/web/view/component/component_search.ml @@ -23,19 +23,7 @@ let input_element = let result_list = let wrap = - div - ~a: - [ a_class - [ "flexcolumn" - ; "gap-sm" - ; "striped" - ; "bg-white" - ; "inset-sm" - ; "border" - ; "border-radius" - ; "hide-empty" - ] - ] + div ~a:[ a_class [ "data-list"; "active"; "hide-empty"; "relative" ] ] %> CCList.return in match results with @@ -44,7 +32,7 @@ let input_element | Some results -> results |> CCList.map item_to_html |> wrap in let attrs = - [ a_input_type `Text + [ a_input_type `Search ; a_value value ; a_name Field.(show query_field) ; a_class [ "query-input" ] @@ -62,7 +50,7 @@ let input_element ()) () :: result_list - |> div ~a:[ a_class [ "flexcolumn" ]; a_user_data "query" "input" ] + |> div ~a:[ a_class [ "relative" ]; a_user_data "query" "input" ] ;; let create @@ -88,7 +76,11 @@ let create ([ label [ txt Pool_common.(Utils.nav_link_to_string language field_label) ] ; input_element item_to_html placeholder ?disabled ?value ?results path ; div - ~a:[ a_user_data "query" "results"; a_class [ "hide-empty" ] ] + ~a: + [ a_user_data "query" "results" + ; a_user_data "search-selection" "" + ; a_class [ "hide-empty" ] + ] (CCList.map item_to_html current) ] @ CCOption.map_or @@ -105,8 +97,8 @@ let create let search_item ~id ~title = div - ~a:[ a_user_data "id" id; a_class [ "has-icon"; "inset-xs" ] ] - [ Component_icon.(to_html ~classnames:[ "toggle-item" ] CloseCircle) + ~a:[ a_user_data "id" id; a_user_data "selection-item" "" ] + [ Component_icon.(to_html ~classnames:[ "toggle-item" ] Close) ; span [ txt title ] ; input ~a: diff --git a/pool/web/view/page/page_admin_contact.ml b/pool/web/view/page/page_admin_contact.ml index 9a256eac4..7d8f09630 100644 --- a/pool/web/view/page/page_admin_contact.ml +++ b/pool/web/view/page/page_admin_contact.ml @@ -161,7 +161,7 @@ let assign_contact_experiment_list [ a_class ([ "bg-red-lighter" ] @ base_class) ] @ htmx_attribs experiment_id in div - ~a:[ a_class [ "data-list"; "relative"; "flexcolumn" ] ] + ~a:[ a_class [ "data-list"; "relative"; "flexcolumn"; "active" ] ] (match experiments with | [] -> [ div diff --git a/resources/admin/filter.js b/resources/admin/filter.js index 17e8251f1..45f8cb8a0 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -200,12 +200,14 @@ function addOperatorChangeListeners(wrapper) { } function configRequest(e, form) { + if (!e.srcElement.value) { + e.preventDefault() + } const isPredicateType = e.detail.parameters.predicate; const allowEmpty = e.detail.parameters.allow_empty_values; const isSubmit = e.target.type === "submit" const isSearchForm = Boolean(e.detail.elt.classList.contains("query-input")); e.detail.parameters._csrf = csrfToken(form); - const filterId = form.dataset.filter; if (filterId) { e.detail.parameters.filter = filterId; diff --git a/resources/admin/utils.js b/resources/admin/utils.js index d30a6427b..c52353fbb 100644 --- a/resources/admin/utils.js +++ b/resources/admin/utils.js @@ -42,8 +42,9 @@ export function destroySelected(item) { } export function addInputListeners(queryInput) { - var wrapper = queryInput.closest("[data-query='wrapper']"); - var results = wrapper.querySelector("[data-query='results']"); + const wrapper = queryInput.closest("[data-query='wrapper']"); + const results = wrapper.querySelector("[data-query='results']"); + const dataList = wrapper.querySelector(".data-list"); [...queryInput.querySelectorAll("[data-id]")].forEach(item => { item.addEventListener("click", () => { @@ -51,4 +52,12 @@ export function addInputListeners(queryInput) { item.querySelector(".toggle-item").addEventListener("click", () => destroySelected(item)); }, { once: true }) }) + + if (dataList) { + queryInput.addEventListener("change", (e) => { + if (!e.currentTarget.value) { + dataList.classList.remove("active") + } + }) + } } diff --git a/resources/index.scss b/resources/index.scss index dbf1e00b9..420f2bc0f 100644 --- a/resources/index.scss +++ b/resources/index.scss @@ -102,17 +102,18 @@ ul.no-style { [data-id] { cursor: pointer; } + } + .data-list > [data-selection-item] { + @extend .has-icon; + padding: $space-sm; + &:hover { + background-color: $grey-light; + } .toggle-item { - color: $green; - transform: rotate(45deg) + display: none; } } - - [data-query="results"] .toggle-item { - color: $red; - cursor: pointer; - } } // Flex @@ -280,8 +281,11 @@ table.simple { // Live Search .data-list.relative { - display: flex; + display: none; position: relative; + &.active { + display: flex; + } } .data-list>.data-item.bg-red-lighter { From 50e005cf2f1e06b91f9b182a6a6663a5f4be09ff Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Fri, 27 Oct 2023 11:05:47 +0200 Subject: [PATCH 02/18] fix add button --- resources/admin/filter.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/resources/admin/filter.js b/resources/admin/filter.js index 45f8cb8a0..3309f7d18 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -5,6 +5,16 @@ const notificationId = "filter-notification"; const form = document.getElementById("filter-form"); +function isTextInput(ele) { + let tagName = ele.tagName; + if (tagName === "INPUT") { + let validType = ['text', 'number', 'search']; + let eleType = ele.type; + return validType.includes(eleType); + } + return false; +} + const isListOperator = (operator) => { return ["contains_all", "contains_some", "contains_none"].includes(operator) } @@ -200,7 +210,7 @@ function addOperatorChangeListeners(wrapper) { } function configRequest(e, form) { - if (!e.srcElement.value) { + if (isTextInput(e.srcElement) && !e.srcElement.value) { e.preventDefault() } const isPredicateType = e.detail.parameters.predicate; From 8afff090e6a67d41f45018f6d89328d78ebe6652 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Fri, 27 Oct 2023 14:18:04 +0200 Subject: [PATCH 03/18] allow to select multiple values for select fields --- pool/app/filter/entity.ml | 11 ++- pool/web/view/component/component_filter.ml | 79 +++++++++++---------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/pool/app/filter/entity.ml b/pool/app/filter/entity.ml index 6082835d4..0dfd7d944 100644 --- a/pool/app/filter/entity.ml +++ b/pool/app/filter/entity.ml @@ -371,6 +371,7 @@ module Operator = struct [@@deriving show { with_path = false }, eq, enum, yojson] let all = generate_all min max of_enum + let single_select_operators = [ ContainsSome; ContainsNone ] let json_key = "List" let read yojson = @@ -379,6 +380,8 @@ module Operator = struct ;; let to_sql = function + (* TODO: Differ between select ( = & != ) and multi select ( LIKE / NOT + LIKE ), if it is performance relevant *) (* List operators are used to query custom field answers by their value which store json arrays *) | ContainsSome | ContainsAll -> "LIKE" @@ -482,6 +485,7 @@ module Operator = struct let all_string_operators = StringM.all >|= string let all_size_operators = Size.all >|= size let all_list_operators = ListM.all >|= list + let all_select_operators = ListM.single_select_operators >|= list let all_existence_operators = Existence.all >|= existence let all = @@ -536,9 +540,10 @@ module Operator = struct let input_type_to_operator (key : Key.input_type) = let open Key in match key with - | Bool | Languages _ | Select _ -> all_equality_operators + | Bool | Languages _ -> all_equality_operators | Date | Nr -> all_equality_operators @ all_size_operators | MultiSelect _ | QueryExperiments | QueryTags -> all_list_operators + | Select _ -> all_select_operators | Str -> all_equality_operators @ all_string_operators ;; @@ -604,9 +609,9 @@ module Predicate = struct match yojson with | `Assoc assoc -> let open CCResult in - let go key of_yojson = + let go json_key of_yojson = assoc - |> CCList.assoc_opt ~eq:CCString.equal key + |> CCList.assoc_opt ~eq:CCString.equal json_key |> CCOption.map of_yojson in let* key = go key_string Key.of_yojson |> to_result Message.Field.Key in diff --git a/pool/web/view/component/component_filter.ml b/pool/web/view/component/component_filter.ml index 7668da306..7a26f4bed 100644 --- a/pool/web/view/component/component_filter.ml +++ b/pool/web/view/component/component_filter.ml @@ -38,7 +38,7 @@ let operators_select language ?operators ?selected () = match operators with | None -> txt "" | Some operators -> - Component_input.selector + Input.selector ~option_formatter:Operator.to_human language Pool_common.Message.Field.Operator @@ -72,6 +72,20 @@ let value_input fun option -> SelectOption.Id.equal option.SelectOption.id option_id) options in + let selected_options options = + CCOption.map_or + ~default:[] + (fun value -> + match value with + | NoValue | Single _ -> [] + | Lst lst -> + CCList.filter_map + (function[@warning "-4"] + | Option id -> find_in_options options id + | _ -> None) + lst) + value + in match input_type with | None -> div [] | Some input_type -> @@ -86,7 +100,7 @@ let value_input | Str s -> Some s | _ -> None in - Component_input.input_element + Input.input_element ~additional_attributes ~disabled ?value @@ -101,7 +115,7 @@ let value_input | _ -> None) |> CCOption.map (fun f -> f |> CCFloat.to_int |> CCInt.to_string) in - Component_input.input_element + Input.input_element ~additional_attributes ~disabled language @@ -119,7 +133,7 @@ let value_input single_value in (* TODO: Add option to disable *) - Component_input.checkbox_element + Input.checkbox_element ~additional_attributes ~as_switch:true ~disabled @@ -133,27 +147,27 @@ let value_input | Date d -> Some d | _ -> None in - Component_input.date_picker_element + Input.date_picker_element ~additional_attributes ?value language field_name | Key.Select options -> - let selected = - single_value - >>= function[@warning "-4"] - | Option o -> find_in_options options o - | _ -> None + let selected = selected_options options in + let multi_select = + Input. + { selected + ; options + ; to_label = Custom_field.SelectOption.name language + ; to_value = Custom_field.SelectOption.show_id + } in - Component_input.selector - ~attributes:additional_attributes - ~option_formatter:(Custom_field.SelectOption.name language) + Input.multi_select + ~additional_attributes ~disabled language + multi_select field_name - Custom_field.SelectOption.show_id - options - selected () | Key.Languages languages -> let selected = @@ -163,7 +177,7 @@ let value_input CCList.find_opt (Pool_common.Language.equal lang) languages | _ -> None in - Component_input.selector + Input.selector ~attributes:additional_attributes ~disabled language @@ -173,29 +187,16 @@ let value_input selected () | Key.MultiSelect options -> - let selected = - CCOption.map_or - ~default:[] - (fun value -> - match value with - | NoValue | Single _ -> [] - | Lst lst -> - CCList.filter_map - (function[@warning "-4"] - | Option id -> find_in_options options id - | _ -> None) - lst) - value - in + let selected = selected_options options in let multi_select = - Component_input. + Input. { options ; selected ; to_label = Custom_field.SelectOption.name language ; to_value = Custom_field.SelectOption.show_id } in - Component_input.multi_select + Input.multi_select ~additional_attributes ~orientation:`Vertical ~disabled @@ -315,7 +316,7 @@ let single_predicate_form ~templates_disabled () in - Component_input.selector + Input.selector ~attributes ~add_empty:true ~option_formatter:(Key.human_to_label language) @@ -360,7 +361,7 @@ let predicate_type_select then CCList.remove ~eq:equal_filter_label ~key:Template all_filter_labels else all_filter_labels in - Component_input.selector + Input.selector ~option_formatter:to_label ~attributes ~hide_label:true @@ -467,7 +468,7 @@ let rec predicate_form template_list |> CCList.find_opt (fun filter -> Pool_common.Id.equal filter.id id)) in - Component_input.selector + Input.selector ~add_empty:true ~option_formatter:(fun f -> f.title @@ -609,7 +610,7 @@ let filter_form | Template _ -> let open CCOption.Infix in let open Pool_common in - ( Component_input.input_element + ( Input.input_element ?value:(filter >>= fun filter -> filter.title >|= Title.value) ~required:true language @@ -637,13 +638,13 @@ let filter_form ; a_class [ "stack" ] ] @ filter_id) - [ Component_input.csrf_element csrf () + [ Input.csrf_element csrf () ; title_input ; predicates ; div ~a:[ a_class [ "flexrow"; "align-center"; "gap" ] ] [ delete_form - ; Component_input.submit_element + ; Input.submit_element language ~classnames:[ "push" ] ~attributes: From 79adf0a4ce24a53e542b1f324ed2c38483bcf5df Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Fri, 27 Oct 2023 17:10:49 +0200 Subject: [PATCH 04/18] add static search --- pool/cqrs_command/experiment_command.ml | 2 +- pool/web/view/component/component_filter.ml | 5 +- pool/web/view/component/component_input.ml | 55 +++++++++++++++++ resources/admin/filter.js | 4 ++ resources/admin/staticSearch.js | 68 +++++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 resources/admin/staticSearch.js diff --git a/pool/cqrs_command/experiment_command.ml b/pool/cqrs_command/experiment_command.ml index f02139144..1a0f1a050 100644 --- a/pool/cqrs_command/experiment_command.ml +++ b/pool/cqrs_command/experiment_command.ml @@ -456,7 +456,7 @@ end = struct type t = Filter.query let handle ?(tags = Logs.Tag.empty) experiment key_list template_list query = - Logs.info ~src (fun m -> m "Handle command UpdateFilter" ~tags); + Logs.info ~src (fun m -> m "Handle command CreateFilter" ~tags); let open CCResult in let* query = Filter.validate_query key_list template_list query in let id = Pool_common.Id.create () in diff --git a/pool/web/view/component/component_filter.ml b/pool/web/view/component/component_filter.ml index 7a26f4bed..9b1ef1a66 100644 --- a/pool/web/view/component/component_filter.ml +++ b/pool/web/view/component/component_filter.ml @@ -162,12 +162,11 @@ let value_input ; to_value = Custom_field.SelectOption.show_id } in - Input.multi_select + Input.multi_live_search ~additional_attributes - ~disabled language - multi_select field_name + multi_select () | Key.Languages languages -> let selected = diff --git a/pool/web/view/component/component_input.ml b/pool/web/view/component/component_input.ml index ea1c0197a..5f5e90dc4 100644 --- a/pool/web/view/component/component_input.ml +++ b/pool/web/view/component/component_input.ml @@ -728,6 +728,61 @@ let multi_select ] ;; +let multi_live_search + langauge + field + { selected; options; to_label; to_value } + ?(additional_attributes = []) + () + = + let open Pool_common in + let selected_item item = + span + ~a:[ a_user_data "selection-item" "" ] + [ txt (to_label item) + ; Icon.(to_html Close) + ; input + ~a: + [ a_input_type `Checkbox + ; a_value (to_value item) + ; a_user_data + "input-type" + "option" (* input-type and array_key are filter specific*) + ; a_name (Message.Field.array_key field) + ; a_checked () + ; a_hidden () + ] + () + ] + in + let available_item item = + span + ~a:[ a_class [ "data-item" ]; a_user_data "value" (to_value item) ] + [ txt (to_label item) ] + in + div + ~a:[ a_class [ "form-group" ] ] + [ label + [ txt (Utils.field_to_string langauge field |> CCString.capitalize_ascii) + ] + ; input + ~a: + ([ a_input_type `Search + ; a_user_data "name" (Message.Field.show field) + ; a_user_data "search" "static" + ] + @ additional_attributes) + () + ; div + ~a:[ a_class [ "data-list"; "relative" ] ] + (CCList.map available_item options) + ; div + ~a:[ a_user_data "search-selection" ""; a_user_data "query" "results" ] + (* 'a_user_data "query" "results"' is filter specific*) + (CCList.map selected_item selected) + ] +;; + let reset_form_button language = span ~a: diff --git a/resources/admin/filter.js b/resources/admin/filter.js index 3309f7d18..8f95d2d78 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -1,4 +1,5 @@ import { addCloseListener, addInputListeners, csrfToken, destroySelected, icon, notifyUser, globalErrorMsg } from "./utils.js"; +import { initStaticSearch } from "./staticSearch.js" const errorClass = "error-message"; const notificationId = "filter-notification"; @@ -156,6 +157,7 @@ const predicateToJson = (outerPredicate, allowEmpty = false) => { if (isQueryKey(key)) { values = [...outerPredicate.querySelectorAll(`[data-query="results"] [name="value[]"]:checked`)]; } else { + console.log("IS NOT QUERY KEY") values = [...outerPredicate.querySelectorAll(`[name="value[]"]:checked`)]; } value = values.map(toValue) @@ -268,6 +270,7 @@ export function initFilterForm() { e.querySelector(".toggle-item").addEventListener("click", () => destroySelected(e)) ); addOperatorChangeListeners(form); + initStaticSearch(form); form.addEventListener('htmx:afterSwap', (e) => { addRemovePredicateListener(e.detail.elt); @@ -279,6 +282,7 @@ export function initFilterForm() { updateContactCount(); } addCloseListener(notificationId); + initStaticSearch(e.detail.elt) }) updateContactCount() form.addEventListener('htmx:configRequest', (e) => configRequest(e, form)) diff --git a/resources/admin/staticSearch.js b/resources/admin/staticSearch.js new file mode 100644 index 000000000..5354d19e5 --- /dev/null +++ b/resources/admin/staticSearch.js @@ -0,0 +1,68 @@ +const createTag = (option) => { + const item = document.createElement("span"); + item.setAttribute("data-selection-item", ""); + item.innerHTML = option.innerHTML; + const icon = document.createElement("i"); + icon.classList.add("icon-close"); + const input = document.createElement("input"); + input.setAttribute("type", "checkbox"); + input.setAttribute("data-input-type", "option") + input.checked = true; + input.setAttribute("hidden", "") + input.value = option.dataset.value; + input.name = "value[]"; + item.appendChild(icon); + item.appendChild(input); + return item; +} + +const addRemoveItemListener = (item) => { + item.querySelector(".icon-close").addEventListener("click", () => { + item.remove() + }, { once: true }) +} + +export const initStaticSearch = (target) => { + const container = target || document; + container.querySelectorAll('[data-search="static"]').forEach((el) => { + const wrapper = el.closest(".form-group"); + const results = wrapper.querySelector("[data-search-selection]"); + const optionsWrapper = wrapper.querySelector(".data-list"); + const options = optionsWrapper.querySelectorAll(".data-item") + const filterOptions = () => { + const value = String(el.value).toLocaleLowerCase(); + const selected = [...results.querySelectorAll("[data-selection-item] input")].map((item) => item.value) + options.forEach((option) => { + if (option.innerText.includes(value) && !selected.includes(option.dataset.value)) { + option.style.display = "flex"; + } else { + option.style.display = "none"; + } + }) + } + el.addEventListener("focusin", () => { + optionsWrapper.classList.add("active"); + }) + document.addEventListener("click", (e) => { + if (!container.contains(e.target)) { + optionsWrapper.classList.remove("active"); + } + }); + el.addEventListener("keyup", () => { + filterOptions(); + }); + + options.forEach((option) => { + option.addEventListener("click", () => { + const item = createTag(option); + option.style.display = "none"; + results.appendChild(item); + addRemoveItemListener(item); + filterOptions(); + }) + }); + + [...results.querySelectorAll("[data-selection-item]")].forEach((item) => addRemoveItemListener(item)); + filterOptions(); + }) +} From 35cd57821d24419d4bde2c26ce7a142831bb0c6f Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Mon, 30 Oct 2023 10:59:55 +0100 Subject: [PATCH 05/18] refactor search components --- pool/web/handler/admin_admins.ml | 30 +- pool/web/handler/helpers_search.ml | 35 +- pool/web/view/component/component_filter.ml | 34 +- pool/web/view/component/component_input.ml | 55 ---- pool/web/view/component/component_role.ml | 45 ++- pool/web/view/component/component_search.ml | 341 +++++++++++++------- resources/admin.js | 2 - resources/admin/filter.js | 4 - resources/admin/utils.js | 3 +- resources/htmx.js | 13 + resources/index.js | 4 + resources/index.scss | 5 + resources/search.js | 136 ++++++++ 13 files changed, 459 insertions(+), 248 deletions(-) create mode 100644 resources/htmx.js create mode 100644 resources/search.js diff --git a/pool/web/handler/admin_admins.ml b/pool/web/handler/admin_admins.ml index 406edef02..6d6c9c989 100644 --- a/pool/web/handler/admin_admins.ml +++ b/pool/web/handler/admin_admins.ml @@ -119,15 +119,20 @@ let handle_toggle_role req = result |> HttpUtils.Htmx.handle_error_message ~src req ;; -let grant_role ({ Rock.Request.target; _ } as req) = +let grant_role req = let open Utils.Lwt_result.Infix in let lift = Lwt_result.lift in + let admin_id = HttpUtils.find_id Admin.Id.of_string Field.Admin req in + (* let redirect_path = CCString.replace ~which:`Right ~sub:"/grant-role" + ~by:"/edit" target in *) + let redirect_path = + Format.asprintf "/admin/admins/%s/edit" (Admin.Id.value admin_id) + in let result { Pool_context.database_label; _ } = + Utils.Lwt_result.map_error (fun err -> err, redirect_path) + @@ let tags = Pool_context.Logger.Tags.req req in - let* admin = - HttpUtils.find_id Admin.Id.of_string Field.Admin req - |> Admin.find database_label - in + let* admin = Admin.find database_label admin_id in let%lwt urlencoded = Sihl.Web.Request.to_urlencoded req in let* role = HttpUtils.find_in_urlencoded Field.Role urlencoded @@ -176,16 +181,13 @@ let grant_role ({ Rock.Request.target; _ } as req) = let handle events = Lwt_list.iter_s (Pool_event.handle_event ~tags database_label) events in - expand_targets - >>= events - |>> handle - |>> HttpUtils.Htmx.htmx_redirect - ~skip_externalize:true - (CCString.replace ~which:`Right ~sub:"/grant-role" ~by:"/edit" target) - ~actions: - [ Message.set ~success:[ Pool_common.Message.Created Field.Role ] ] + let* () = expand_targets >>= events |>> handle in + Lwt_result.ok + (Http_utils.redirect_to_with_actions + redirect_path + [ Message.set ~success:[ Pool_common.Message.Created Field.Role ] ]) in - result |> HttpUtils.Htmx.handle_error_message ~src req + result |> extract_happy_path req ;; let revoke_role ({ Rock.Request.target; _ } as req) = diff --git a/pool/web/handler/helpers_search.ml b/pool/web/handler/helpers_search.ml index 6ac985216..695873e64 100644 --- a/pool/web/handler/helpers_search.ml +++ b/pool/web/handler/helpers_search.ml @@ -5,15 +5,8 @@ module HttpUtils = Http_utils let src = Logs.Src.create "handler.helper.search" -let create search_type ?path req = +let create search_type req = let query_field = Field.Search in - let path = - let value default = CCOption.value ~default path in - match search_type with - | `Experiment -> value "/admin/experiments/search" - | `Location -> value "/admin/locations/search" - | `ContactTag -> value "/admin/settings/tags/search" - in let result { Pool_context.database_label; user; _ } = let open CCList in let%lwt actor = @@ -47,6 +40,11 @@ let create search_type ?path req = let exclude = HttpUtils.find_in_urlencoded_list_opt Field.Exclude urlencoded in + let to_response html = + html + |> HttpUtils.Htmx.multi_html_to_plain_text_response + |> CCResult.return + in let open Guard.Persistence in match search_type with | `Experiment -> @@ -66,10 +64,8 @@ let create search_type ?path req = | None, _ | Some _, None -> Lwt.return [] | Some value, Some actor -> search_experiment (exclude @ exclude_roles_of) value actor) - ||> fun results -> - input_element ?value:query ~results path - |> HttpUtils.Htmx.html_to_plain_text_response - |> CCResult.return + ||> query_results + ||> to_response | `Location -> let open Component.Search.Location in let open Pool_location.Guard.Access in @@ -89,12 +85,9 @@ let create search_type ?path req = |> HttpUtils.Htmx.html_to_plain_text_response |> Lwt_result.return | Some value, Some actor -> - let%lwt results = - search_location (exclude @ exclude_roles_of) value actor - in - input_element ?value:query ~results path - |> HttpUtils.Htmx.html_to_plain_text_response - |> Lwt_result.return) + search_location (exclude @ exclude_roles_of) value actor + ||> query_results + ||> to_response) | `ContactTag -> let open Component.Search.Tag in let open Tags.Guard.Access in @@ -115,10 +108,8 @@ let create search_type ?path req = | None, _ | Some _, None -> Lwt.return [] | Some value, Some actor -> search_tags (exclude @ exclude_roles_of) value actor) - ||> fun results -> - input_element ?value:query ~results path - |> HttpUtils.Htmx.html_to_plain_text_response - |> CCResult.return + ||> query_results + ||> to_response in result |> HttpUtils.Htmx.handle_error_message ~src req ;; diff --git a/pool/web/view/component/component_filter.ml b/pool/web/view/component/component_filter.ml index 9b1ef1a66..a67e9c755 100644 --- a/pool/web/view/component/component_filter.ml +++ b/pool/web/view/component/component_filter.ml @@ -154,16 +154,20 @@ let value_input field_name | Key.Select options -> let selected = selected_options options in + let open Component_search in let multi_select = - Input. - { selected - ; options - ; to_label = Custom_field.SelectOption.name language - ; to_value = Custom_field.SelectOption.show_id - } + Static + Input. + { selected + ; options + ; to_label = Custom_field.SelectOption.name language + ; to_value = Custom_field.SelectOption.show_id + } in - Input.multi_live_search + multi_search ~additional_attributes + ~is_filter:true + ~input_type language field_name multi_select @@ -204,7 +208,7 @@ let value_input field_name () | Key.QueryExperiments -> - let current = + let selected = value |> CCOption.map_or ~default:[] (function | NoValue | Single _ -> [] @@ -219,13 +223,13 @@ let value_input | _ -> None) lst) in - Component_search.Experiment.create - ~current + Component_search.Experiment.filter_multi_search + ~selected ~disabled language - "/admin/experiments/search" + () | Key.QueryTags -> - let current = + let selected = value |> CCOption.map_or ~default:[] (function | NoValue | Single _ -> [] @@ -240,11 +244,7 @@ let value_input | _ -> None) lst) in - Component_search.Tag.create - ~current - ~disabled - language - "/admin/settings/tags/search") + Component_search.Tag.filter_multi_search ~selected ~disabled language ()) ;; let predicate_value_form diff --git a/pool/web/view/component/component_input.ml b/pool/web/view/component/component_input.ml index 5f5e90dc4..ea1c0197a 100644 --- a/pool/web/view/component/component_input.ml +++ b/pool/web/view/component/component_input.ml @@ -728,61 +728,6 @@ let multi_select ] ;; -let multi_live_search - langauge - field - { selected; options; to_label; to_value } - ?(additional_attributes = []) - () - = - let open Pool_common in - let selected_item item = - span - ~a:[ a_user_data "selection-item" "" ] - [ txt (to_label item) - ; Icon.(to_html Close) - ; input - ~a: - [ a_input_type `Checkbox - ; a_value (to_value item) - ; a_user_data - "input-type" - "option" (* input-type and array_key are filter specific*) - ; a_name (Message.Field.array_key field) - ; a_checked () - ; a_hidden () - ] - () - ] - in - let available_item item = - span - ~a:[ a_class [ "data-item" ]; a_user_data "value" (to_value item) ] - [ txt (to_label item) ] - in - div - ~a:[ a_class [ "form-group" ] ] - [ label - [ txt (Utils.field_to_string langauge field |> CCString.capitalize_ascii) - ] - ; input - ~a: - ([ a_input_type `Search - ; a_user_data "name" (Message.Field.show field) - ; a_user_data "search" "static" - ] - @ additional_attributes) - () - ; div - ~a:[ a_class [ "data-list"; "relative" ] ] - (CCList.map available_item options) - ; div - ~a:[ a_user_data "search-selection" ""; a_user_data "query" "results" ] - (* 'a_user_data "query" "results"' is filter specific*) - (CCList.map selected_item selected) - ] -;; - let reset_form_button language = span ~a: diff --git a/pool/web/view/component/component_role.ml b/pool/web/view/component/component_role.ml index cfad2ac37..5630594f2 100644 --- a/pool/web/view/component/component_role.ml +++ b/pool/web/view/component/component_role.ml @@ -157,28 +157,26 @@ module Search = struct Format.asprintf "/admin/admins/%s/%s" Admin.(admin |> id |> Id.value) ;; - let value_input ?exclude_roles_of ?role ?value language = + let[@warning "-27"] value_input ?exclude_roles_of ?role ?value language = let open Role.Role in function | Some QueryLocations -> + (* What is exclude_roles_of? / value *) let hint = Pool_common.I18n.RoleIntro (Field.Location, Field.Locations) in - Component_search.Location.create - ?exclude_roles_of + Component_search.Location.multi_search ~hint - ?role - ?value + ~tag_name:Field.Target language - "/admin/locations/search" + () | Some QueryExperiments -> let hint = Pool_common.I18n.RoleIntro (Field.Experiment, Field.Experiments) in - Component_search.Experiment.create - ?exclude_roles_of + Component_search.Experiment.multi_search ~hint - ?role + ~tag_name:Field.Target language - "/admin/experiments/search" + () | None -> div [] ;; @@ -193,7 +191,7 @@ module Search = struct div ~a:[ a_class [ "switcher-sm"; "flex-gap" ] ] [ input_field ] ;; - let role_form ?key ?value language admin identifier role_list = + let role_form ?key ?value language csrf admin identifier role_list = let toggle_id = Format.asprintf "role-search-%i" identifier in let toggled_content = value_form language ?key ?value () in let key_selector = @@ -220,34 +218,35 @@ module Search = struct () in let submit_btn = - let action = action_path admin "grant-role" in Component_input.submit_element language ~classnames:[ "push"; "align-self-end" ] - ~attributes: - (a_id "submit-role-search-form" - :: Utils.htmx_attribs ~action ~swap:"none" ~trigger:"click" ()) Pool_common.Message.(Add None) () in - div - ~a:[ a_class [ "flexrow"; "flex-gap" ] ] - [ key_selector - ; div ~a:[ a_id toggle_id; a_class [ "grow-2" ] ] [ toggled_content ] - ; submit_btn + form + ~a:[ a_action (action_path admin "grant-role"); a_method `Post ] + [ Component_input.csrf_element csrf () + ; div + ~a:[ a_class [ "flexrow"; "flex-gap" ] ] + [ key_selector + ; div ~a:[ a_id toggle_id; a_class [ "grow-2" ] ] [ toggled_content ] + ; submit_btn + ] ] ;; let input_form ?(identifier = 0) ?key ?value csrf language admin role_list () = - let role_form = role_form ?key ?value language admin identifier role_list in + let role_form = + role_form ?key ?value language csrf admin identifier role_list + in let stack = "stack-sm" in div ~a:[ a_class [ stack; "inset-sm"; "border"; "role-search" ] ] [ div ~a: [ a_id "role-search-form"; a_user_data "detect-unsaved-changes" "" ] - [ Component_input.csrf_element csrf () - ; div + [ div ~a:[ a_class [ stack; "grow"; "role-search-wrapper" ] ] [ role_form ] ] diff --git a/pool/web/view/component/component_search.ml b/pool/web/view/component/component_search.ml index 91a7e1534..731ae428d 100644 --- a/pool/web/view/component/component_search.ml +++ b/pool/web/view/component/component_search.ml @@ -5,142 +5,263 @@ module I18n = Pool_common.I18n let query_field = Field.Search -let hidden_input name decode = - CCOption.map_or ~default:[] (fun value -> - [ input - ~a:[ a_hidden (); a_name (Field.show name); a_value (decode value) ] - () - ]) +type 'a dynamic_search = + { hx_post : string + ; to_label : 'a -> string + ; to_value : 'a -> string + ; selected : 'a list + } + +type 'a multi_search = + | Dynamic of 'a dynamic_search + | Static of 'a Component_input.multi_select + +let default_query_results_item ~to_label ~to_value item = + span + ~a:[ a_class [ "data-item" ]; a_user_data "value" (to_value item) ] + [ txt (to_label item) ] +;; + +let query_results to_item items = + div ~a:[ a_class [ "data-list"; "relative" ] ] (CCList.map to_item items) ;; -let input_element - item_to_html - placeholder +let multi_search + langauge + field + multi_search + ?(additional_attributes = []) ?(disabled = false) - ?(value = "") - ?results - action + ?hint + ?(is_filter = false) + ?input_type + ?placeholder + ?tag_name + () = - let result_list = - let wrap = - div ~a:[ a_class [ "data-list"; "active"; "hide-empty"; "relative" ] ] - %> CCList.return - in - match results with + let open Pool_common in + let filter_selected_attributes = + match input_type with | None -> [] - | Some [] -> [ txt "No results found" ] |> wrap - | Some results -> results |> CCList.map item_to_html |> wrap + | Some input_type -> + [ a_user_data "input-type" (Filter.Key.show_input_type input_type) ] + in + let tag_name = CCOption.value ~default:field tag_name in + let selected_item to_label to_value item = + span + ~a:[ a_user_data "selection-item" "" ] + [ txt (to_label item) + ; Component_icon.(to_html Close) + ; input + ~a: + ([ a_input_type `Checkbox + ; a_value (to_value item) + ; a_name (Message.Field.array_key tag_name) + ; a_checked () + ; a_hidden () + ] + @ filter_selected_attributes) + () + ] in - let attrs = + let hint = Component_input.Elements.help langauge hint in + let base_attributes = + let placeholder = + CCOption.map_or + ~default:[] + CCFun.(a_placeholder %> CCList.return) + placeholder + in + let disabled = if disabled then [ a_disabled () ] else [] in [ a_input_type `Search - ; a_value value - ; a_name Field.(show query_field) - ; a_class [ "query-input" ] - ; a_placeholder placeholder + ; a_name (Message.Field.show query_field) + ; a_user_data "name" (Message.Field.array_key tag_name) ] + @ placeholder + @ disabled + @ filter_selected_attributes in - let attrs = if disabled then a_disabled () :: attrs else attrs in - input - ~a: - (attrs - @ Component_utils.htmx_attribs - ~action - ~trigger:"keyup changed delay:1s" - ~target:"closest [data-query='input']" - ()) - () - :: result_list - |> div ~a:[ a_class [ "relative" ]; a_user_data "query" "input" ] -;; - -let create - item_to_html - placeholder - field_label - ?(current = []) - ?disabled - ?hint - ?role - ?exclude_roles_of - ?value - ?results - language - path - = - let search_role = hidden_input Field.Role Role.Role.show role in - let exclude_roles_of = - hidden_input Field.ExcludeRolesOf Admin.(Id.value) exclude_roles_of + let wrap html = + (* TODO: Place hint *) + div + ~a:[ a_class [ "form-group" ] ] + ((label + [ txt + (Utils.field_to_string langauge field |> CCString.capitalize_ascii) + ] + :: html) + @ hint) in - div - ~a:[ a_class [ "form-group" ]; a_user_data "query" "wrapper" ] - ([ label [ txt Pool_common.(Utils.nav_link_to_string language field_label) ] - ; input_element item_to_html placeholder ?disabled ?value ?results path - ; div - ~a: - [ a_user_data "query" "results" - ; a_user_data "search-selection" "" - ; a_class [ "hide-empty" ] - ] - (CCList.map item_to_html current) - ] - @ CCOption.map_or - ~default:[] - (fun hint -> - [ span - ~a:[ a_class [ "help" ] ] - [ Pool_common.(Utils.hint_to_string language hint) |> txt ] - ]) - hint - @ search_role - @ exclude_roles_of) -;; - -let search_item ~id ~title = - div - ~a:[ a_user_data "id" id; a_user_data "selection-item" "" ] - [ Component_icon.(to_html ~classnames:[ "toggle-item" ] Close) - ; span [ txt title ] - ; input + let selected_items_wrapper items = + div + ~a: + (a_user_data "search-selection" "" + :: (if is_filter then [ a_user_data "query" "results" ] else [])) + items + in + match multi_search with + | Static { Component_input.options; selected; to_label; to_value } -> + [ input ~a: - [ a_input_type `Checkbox - ; a_class [ "hidden" ] - ; a_name Field.(array_key Value) - ; a_value id - ; a_checked () - ] + ((a_user_data "search" "static" :: base_attributes) + @ additional_attributes) () + ; div + ~a:[ a_class [ "data-list"; "relative" ] ] + (CCList.map (default_query_results_item ~to_label ~to_value) options) + ; selected_items_wrapper + (CCList.map (selected_item to_label to_value) selected) + ] + |> wrap + | Dynamic { hx_post; to_label; to_value; selected } -> + [ div + ~a:[ a_class [ "relative"; "stack-xs" ] ] + [ input + ~a: + ((base_attributes + @ [ a_user_data "search" "" + ; a_user_data "hx-post" (Sihl.Web.externalize_path hx_post) + ; a_user_data "hx-trigger" "keyup changed delay:1s" + ; a_user_data "hx-target" "next .data-list" + ]) + @ additional_attributes) + () + ; div ~a:[ a_class [ "data-list"; "relative" ] ] [] + ; selected_items_wrapper + (CCList.map (selected_item to_label to_value) selected) + ] ] + |> wrap +;; + +let hidden_input name decode = + CCOption.map_or ~default:[] (fun value -> + [ input + ~a:[ a_hidden (); a_name (Field.show name); a_value (decode value) ] + () + ]) ;; module Experiment = struct - let item (id, title) = - let open Experiment in - search_item ~id:(Id.value id) ~title:(Title.value title) - ;; + open Experiment let placeholder = "Search by experiment title" - let input_element = input_element item placeholder - let create = create item placeholder I18n.Experiments + let to_label = snd %> Title.value + let to_value = fst %> Id.value + + let multi_search + ?disabled + ?hint + ?is_filter + ?tag_name + ?(selected = []) + language + = + let dynamic_search = + { hx_post = "/admin/experiments/search"; to_label; to_value; selected } + in + multi_search + ?disabled + ?hint + ?is_filter + ~placeholder + ?tag_name + language + Field.Experiments + (Dynamic dynamic_search) + ;; + + let filter_multi_search ~selected ~disabled language = + multi_search + ~selected + ~disabled + ~is_filter:true + ~tag_name:Pool_common.Message.Field.Value + language + ;; + + let query_results = + CCList.map (default_query_results_item ~to_label ~to_value) + ;; end module Location = struct - let item (id, name) = - let open Pool_location in - search_item ~id:(Id.value id) ~title:(Name.value name) - ;; + open Pool_location let placeholder = "Search by location name" - let input_element = input_element item placeholder - let create = create item placeholder I18n.Locations + let to_label ({ name; _ } : t) = Name.value name + let to_value { id; _ } = Id.value id + + let multi_search + ?disabled + ?hint + ?is_filter + ?tag_name + ?(selected = []) + language + = + let dynamic_search = + ({ hx_post = "/admin/locations/search"; to_label; to_value; selected } + : t dynamic_search) + in + multi_search + ?disabled + ?hint + ?is_filter + ~placeholder + ?tag_name + language + Field.Locations + (Dynamic dynamic_search) + ;; + + let query_results = + let open CCFun in + let to_label = snd %> Name.value in + let to_value = fst %> Id.value in + CCList.map (default_query_results_item ~to_label ~to_value) + ;; end module Tag = struct - let item (id, title) = - let open Tags in - search_item ~id:(Id.value id) ~title:(Title.value title) - ;; + open Tags let placeholder = "Search by tag title" - let input_element = input_element item placeholder - let create = create item placeholder I18n.Tags + let to_label = snd %> Title.value + let to_value = fst %> Id.value + + let multi_search + ?disabled + ?hint + ?is_filter + ?tag_name + ?(selected = []) + language + = + let dynamic_search = + { hx_post = "/admin/settings/tags/search"; to_label; to_value; selected } + in + multi_search + ?disabled + ?hint + ?is_filter + ~placeholder + ?tag_name + language + Field.Tag + (Dynamic dynamic_search) + ;; + + let filter_multi_search ~selected ~disabled language = + multi_search + ~selected + ~disabled + ~is_filter:true + ~tag_name:Pool_common.Message.Field.Value + language + ;; + + let query_results = + CCList.map (default_query_results_item ~to_label ~to_value) + ;; end diff --git a/resources/admin.js b/resources/admin.js index f07b281fe..50115a1e2 100644 --- a/resources/admin.js +++ b/resources/admin.js @@ -4,7 +4,6 @@ import { initCopyClipboard } from "./admin/copyClipboard.js"; import { initFilterForm } from "./admin/filter.js" import { initPrint } from "./admin/print" import { initRichTextEditor } from "./admin/richTextEditor.js" -import { initRoleSearchForm } from "./admin/role-search.js" initButtonList() initCalendar(); @@ -12,7 +11,6 @@ initCopyClipboard(); initFilterForm(); initPrint(); initRichTextEditor(); -initRoleSearchForm(); window['pool-tool'] = { initRichTextEditor diff --git a/resources/admin/filter.js b/resources/admin/filter.js index 8f95d2d78..3309f7d18 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -1,5 +1,4 @@ import { addCloseListener, addInputListeners, csrfToken, destroySelected, icon, notifyUser, globalErrorMsg } from "./utils.js"; -import { initStaticSearch } from "./staticSearch.js" const errorClass = "error-message"; const notificationId = "filter-notification"; @@ -157,7 +156,6 @@ const predicateToJson = (outerPredicate, allowEmpty = false) => { if (isQueryKey(key)) { values = [...outerPredicate.querySelectorAll(`[data-query="results"] [name="value[]"]:checked`)]; } else { - console.log("IS NOT QUERY KEY") values = [...outerPredicate.querySelectorAll(`[name="value[]"]:checked`)]; } value = values.map(toValue) @@ -270,7 +268,6 @@ export function initFilterForm() { e.querySelector(".toggle-item").addEventListener("click", () => destroySelected(e)) ); addOperatorChangeListeners(form); - initStaticSearch(form); form.addEventListener('htmx:afterSwap', (e) => { addRemovePredicateListener(e.detail.elt); @@ -282,7 +279,6 @@ export function initFilterForm() { updateContactCount(); } addCloseListener(notificationId); - initStaticSearch(e.detail.elt) }) updateContactCount() form.addEventListener('htmx:configRequest', (e) => configRequest(e, form)) diff --git a/resources/admin/utils.js b/resources/admin/utils.js index c52353fbb..3732f12c2 100644 --- a/resources/admin/utils.js +++ b/resources/admin/utils.js @@ -1,7 +1,8 @@ export const globalErrorMsg = "An Error occurred"; export const csrfToken = (form) => { - return form.querySelector('[name="_csrf"]').value; + const container = form || document; + return container.querySelector('[name="_csrf"]').value; } export const icon = (classnames) => { diff --git a/resources/htmx.js b/resources/htmx.js new file mode 100644 index 000000000..5ef2f20ab --- /dev/null +++ b/resources/htmx.js @@ -0,0 +1,13 @@ +import { csrfToken, notifyUser } from "./admin/utils.js"; +import { initSearch } from "./search.js"; + +const configRequest = (e, form) => { + if (e.detail.verb.toLowerCase() != "get") { + e.detail.parameters._csrf = csrfToken(form); + } +} + +export const initHTMX = () => { + document.addEventListener('htmx:configRequest', (e) => configRequest(e)) + document.addEventListener('htmx:afterSwap', (e) => initSearch(e.detail.elt)) +} diff --git a/resources/index.js b/resources/index.js index 314b0ceb8..ddff08538 100644 --- a/resources/index.js +++ b/resources/index.js @@ -1,6 +1,8 @@ import 'htmx.org' import './index.scss' import { initDatepicker } from "./flatpickr.js" +import { initHTMX } from './htmx' +import { initSearch } from "./search" import { initConfirmable, initFileInput, @@ -19,3 +21,5 @@ initNotification(); initSortable(); initDetectFormChanges(); initFormSubmitInterferer(); +initHTMX(); +initSearch(); diff --git a/resources/index.scss b/resources/index.scss index 420f2bc0f..37024d48b 100644 --- a/resources/index.scss +++ b/resources/index.scss @@ -116,6 +116,11 @@ ul.no-style { } } +[data-search-selection] { + row-gap: $space-xs; + column-gap: $space-sm; +} + // Flex .flexcolumn-reversed-tablet { &>* { diff --git a/resources/search.js b/resources/search.js new file mode 100644 index 000000000..6f6b20364 --- /dev/null +++ b/resources/search.js @@ -0,0 +1,136 @@ +const createTag = (option, name, inputType) => { + const item = document.createElement("span"); + item.setAttribute("data-selection-item", ""); + item.innerHTML = option.innerHTML; + const icon = document.createElement("i"); + icon.classList.add("icon-close"); + const input = document.createElement("input"); + input.setAttribute("type", "checkbox"); + if (inputType) { + input.setAttribute("data-input-type", "option") + } + input.checked = true; + input.setAttribute("hidden", "") + input.value = option.dataset.value; + input.name = name; + item.appendChild(icon); + item.appendChild(input); + return item; +} + +const addRemoveItemListener = (item, callback) => { + item.querySelector(".icon-close").addEventListener("click", () => { + item.remove() + if (callback) { + callback(); + } + }, { once: true }) +} + +class Search { + constructor(input) { + const { name, inputType } = input.dataset; + this.input = input; + this.name = name; + this.inputType = inputType; + this.wrapper = this.input.closest(".form-group"); + this.selection = this.wrapper.querySelector("[data-search-selection]"); + this.optionsWrapper = this.wrapper.querySelector(".data-list"); + + this.addOptionsCloseListener(); + } + + addSelectedItemsRemoveListeners(callback) { + [...this.selection.querySelectorAll("[data-selection-item]")].forEach(item => addRemoveItemListener(item, callback)) + } + + addOptionsCloseListener() { + document.addEventListener("click", (e) => { + if (!this.wrapper.contains(e.target)) { + this.optionsWrapper.classList.remove("active"); + } + }); + } + + createSelectedItem(option) { + const item = createTag(option, this.name, this.inputType); + this.selection.appendChild(item); + addRemoveItemListener(item); + } +} + +class StaticSearch extends Search { + constructor(input) { + super(input); + this.options = this.optionsWrapper.querySelectorAll(".data-item"); + this.filterOptions(); + this.addShowOptionsEventListeners(); + this.addInputEventListener(); + this.addClickOptionListener(); + this.addSelectedItemsRemoveListeners(() => this.filterOptions()); + } + + filterOptions() { + const value = this.input.value.toLocaleLowerCase(); + const selected = [...this.selection.querySelectorAll("[data-selection-item] input")].map((item) => item.value) + this.options.forEach((option) => { + if (option.innerText.toLocaleLowerCase().includes(value) && !selected.includes(option.dataset.value)) { + option.style.display = "flex"; + } else { + option.style.display = "none"; + } + }) + } + + addShowOptionsEventListeners() { + this.input.addEventListener("focusin", () => { + this.optionsWrapper.classList.add("active"); + }) + } + + addInputEventListener() { + this.input.addEventListener("keyup", () => { + this.filterOptions(); + }); + } + + addClickOptionListener() { + [...this.options].forEach(option => { + option.addEventListener("click", () => { + this.createSelectedItem(option); + option.style.display = "none"; + }) + }) + } +} + +class DynamicSearch extends Search { + constructor(input) { + super(input); + this.addClickOptionListener(); + this.addSelectedItemsRemoveListeners(); + } + + addClickOptionListener() { + this.wrapper.addEventListener("htmx:afterSwap", (e) => { + [...e.detail.elt.querySelectorAll(".data-item")].forEach(option => { + option.addEventListener("click", () => { + this.createSelectedItem(option); + option.style.display = "none"; + }) + }) + this.optionsWrapper.classList.add("active") + }); + } +} + +export const initSearch = (target) => { + const container = target || document; + [...container.querySelectorAll("[data-search]")].forEach(element => { + if (element.dataset.search === "static") { + new StaticSearch(element) + } else { + new DynamicSearch(element) + } + }); +} From 9c1241be8e75d2ddd2537dbe423934c9ffda1064 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Mon, 30 Oct 2023 13:04:29 +0100 Subject: [PATCH 06/18] use search component for contact assign form --- pool/web/handler/admin_contacts.ml | 2 +- pool/web/view/component/component_modal.ml | 10 ++- pool/web/view/component/component_role.ml | 8 +- pool/web/view/component/component_search.ml | 94 +++++++++++++-------- pool/web/view/page/page_admin_contact.ml | 52 +----------- resources/htmx.js | 2 +- resources/search.js | 43 ++++++++-- 7 files changed, 113 insertions(+), 98 deletions(-) diff --git a/pool/web/handler/admin_contacts.ml b/pool/web/handler/admin_contacts.ml index d7966d3d1..15fe1f40b 100644 --- a/pool/web/handler/admin_contacts.ml +++ b/pool/web/handler/admin_contacts.ml @@ -247,7 +247,7 @@ let htmx_experiments_get req = let open Utils.Lwt_result.Infix in let contact_id = contact_id req in let result ({ Pool_context.database_label; user; _ } as context) = - let query = Sihl.Web.Request.query Field.(show Experiment) req in + let query = Sihl.Web.Request.query Field.(show Search) req in let* contact = Contact.find database_label contact_id in let* actor = Pool_context.Utils.find_authorizable ~admin_only:true database_label user diff --git a/pool/web/view/component/component_modal.ml b/pool/web/view/component/component_modal.ml index 00c3ec315..164f9810b 100644 --- a/pool/web/view/component/component_modal.ml +++ b/pool/web/view/component/component_modal.ml @@ -14,10 +14,14 @@ let create ?(active = false) ?subtitle language title id html = sub language |> txt |> CCList.pure |> p ~a:[ a_class [ "gap-xs" ] ]) in let attrs = - let base = [ "fullscreen-overlay"; "modal" ] in + let base_classnames = [ "fullscreen-overlay"; "modal" ] in + let dataset = a_user_data "modal" "" in match active with - | true -> [ a_class ("active" :: base); a_aria "hidden" [ "false" ] ] - | false -> [ a_class base; a_aria "hidden" [ "true" ] ] + | true -> + dataset + :: [ a_class ("active" :: base_classnames); a_aria "hidden" [ "false" ] ] + | false -> + dataset :: [ a_class base_classnames; a_aria "hidden" [ "true" ] ] in div ~a:(a_id id :: attrs) diff --git a/pool/web/view/component/component_role.ml b/pool/web/view/component/component_role.ml index 5630594f2..3260de7ee 100644 --- a/pool/web/view/component/component_role.ml +++ b/pool/web/view/component/component_role.ml @@ -163,16 +163,12 @@ module Search = struct | Some QueryLocations -> (* What is exclude_roles_of? / value *) let hint = Pool_common.I18n.RoleIntro (Field.Location, Field.Locations) in - Component_search.Location.multi_search - ~hint - ~tag_name:Field.Target - language - () + Component_search.Location.create ~hint ~tag_name:Field.Target language () | Some QueryExperiments -> let hint = Pool_common.I18n.RoleIntro (Field.Experiment, Field.Experiments) in - Component_search.Experiment.multi_search + Component_search.Experiment.create ~hint ~tag_name:Field.Target language diff --git a/pool/web/view/component/component_search.ml b/pool/web/view/component/component_search.ml index 731ae428d..97924b4ee 100644 --- a/pool/web/view/component/component_search.ml +++ b/pool/web/view/component/component_search.ml @@ -6,7 +6,8 @@ module I18n = Pool_common.I18n let query_field = Field.Search type 'a dynamic_search = - { hx_post : string + { hx_url : string + ; hx_method : [ `Get | `Post ] ; to_label : 'a -> string ; to_value : 'a -> string ; selected : 'a list @@ -34,6 +35,7 @@ let multi_search ?(disabled = false) ?hint ?(is_filter = false) + ?js_callback ?input_type ?placeholder ?tag_name @@ -73,6 +75,11 @@ let multi_search placeholder in let disabled = if disabled then [ a_disabled () ] else [] in + let js_callback = + match js_callback with + | Some cb -> [ a_user_data "callback" cb ] + | None -> [] + in [ a_input_type `Search ; a_name (Message.Field.show query_field) ; a_user_data "name" (Message.Field.array_key tag_name) @@ -80,6 +87,7 @@ let multi_search @ placeholder @ disabled @ filter_selected_attributes + @ js_callback in let wrap html = (* TODO: Place hint *) @@ -113,15 +121,21 @@ let multi_search (CCList.map (selected_item to_label to_value) selected) ] |> wrap - | Dynamic { hx_post; to_label; to_value; selected } -> + | Dynamic { hx_url; hx_method; to_label; to_value; selected } -> + let url = Sihl.Web.externalize_path hx_url in + let hx_method = + match hx_method with + | `Get -> a_user_data "hx-get" url + | `Post -> a_user_data "hx-post" url + in [ div ~a:[ a_class [ "relative"; "stack-xs" ] ] [ input ~a: ((base_attributes @ [ a_user_data "search" "" - ; a_user_data "hx-post" (Sihl.Web.externalize_path hx_post) - ; a_user_data "hx-trigger" "keyup changed delay:1s" + ; hx_method + ; a_user_data "hx-trigger" "keyup changed delay:400ms" ; a_user_data "hx-target" "next .data-list" ]) @ additional_attributes) @@ -149,16 +163,14 @@ module Experiment = struct let to_label = snd %> Title.value let to_value = fst %> Id.value - let multi_search - ?disabled - ?hint - ?is_filter - ?tag_name - ?(selected = []) - language - = + let create ?disabled ?hint ?is_filter ?tag_name ?(selected = []) language = let dynamic_search = - { hx_post = "/admin/experiments/search"; to_label; to_value; selected } + { hx_url = "/admin/experiments/search" + ; hx_method = `Post + ; to_label + ; to_value + ; selected + } in multi_search ?disabled @@ -172,7 +184,7 @@ module Experiment = struct ;; let filter_multi_search ~selected ~disabled language = - multi_search + create ~selected ~disabled ~is_filter:true @@ -180,6 +192,26 @@ module Experiment = struct language ;; + let assign_contact_search language contact = + let dynamic_search = + { hx_url = + Format.asprintf + "/admin/contacts/%s/experiments" + (Contact.id contact |> Pool_common.Id.value) + ; hx_method = `Get + ; to_label + ; to_value + ; selected = [] + } + in + multi_search + ~placeholder + ~js_callback:"assign-contact" + language + Field.Experiments + (Dynamic dynamic_search) + ;; + let query_results = CCList.map (default_query_results_item ~to_label ~to_value) ;; @@ -192,16 +224,14 @@ module Location = struct let to_label ({ name; _ } : t) = Name.value name let to_value { id; _ } = Id.value id - let multi_search - ?disabled - ?hint - ?is_filter - ?tag_name - ?(selected = []) - language - = + let create ?disabled ?hint ?is_filter ?tag_name ?(selected = []) language = let dynamic_search = - ({ hx_post = "/admin/locations/search"; to_label; to_value; selected } + ({ hx_url = "/admin/locations/search" + ; hx_method = `Get + ; to_label + ; to_value + ; selected + } : t dynamic_search) in multi_search @@ -230,16 +260,14 @@ module Tag = struct let to_label = snd %> Title.value let to_value = fst %> Id.value - let multi_search - ?disabled - ?hint - ?is_filter - ?tag_name - ?(selected = []) - language - = + let create ?disabled ?hint ?is_filter ?tag_name ?(selected = []) language = let dynamic_search = - { hx_post = "/admin/settings/tags/search"; to_label; to_value; selected } + { hx_url = "/admin/settings/tags/search" + ; hx_method = `Post + ; to_label + ; to_value + ; selected + } in multi_search ?disabled @@ -253,7 +281,7 @@ module Tag = struct ;; let filter_multi_search ~selected ~disabled language = - multi_search + create ~selected ~disabled ~is_filter:true diff --git a/pool/web/view/page/page_admin_contact.ml b/pool/web/view/page/page_admin_contact.ml index 7d8f09630..d23da5576 100644 --- a/pool/web/view/page/page_admin_contact.ml +++ b/pool/web/view/page/page_admin_contact.ml @@ -194,50 +194,7 @@ let assign_contact_experiment_list let assign_contact_form { Pool_context.csrf; language; _ } contact = let open Pool_common in - let result_target = "query-results" in let form_identifier = Pool_common.Id.(create () |> value) in - let field_name = Pool_common.Message.Field.Experiment in - let htmx_attribs = - [ a_user_data "hx-target" (Format.asprintf "#%s" result_target) - ; a_user_data "hx-get" (enroll_contact_path (Contact.id contact)) - ; a_user_data "hx-trigger" "keyup changed delay:200ms" - ; a_id form_identifier - ] - in - let functions = - Format.asprintf - {js| - const formId = '%s'; - const inputName = '%s'; - const resultTargetId = '%s'; - - const form = document.getElementById(formId); - const input = form.querySelector(`[name="${inputName}"]`); - const target = document.getElementById(resultTargetId); - - document.addEventListener('htmx:beforeRequest', (e) => { - if(e.detail.target && e.detail.target.id === resultTargetId) { - const icon = document.createElement("i"); - icon.classList.add("icon-spinner-outline", "rotate"); - target.innerHTML = ''; - target.appendChild(icon); - } - }); - - %s - - %s - - input.addEventListener('keyup', () => { - target.innerHTML = ''; - }) - |js} - form_identifier - (Message.Field.show field_name) - result_target - (Modal.js_modal_add_spinner enroll_contact_modal_id) - Modal.js_add_modal_close_listener - in let legend = Component.Table.( table_legend @@ -257,11 +214,10 @@ let assign_contact_form { Pool_context.csrf; language; _ } contact = ; form ~a:[ a_id form_identifier ] [ Input.csrf_element csrf () - ; Input.input_element - ~additional_attributes:htmx_attribs + ; Component.Search.Experiment.assign_contact_search language - `Search - field_name + contact + () ] ] ; div @@ -270,8 +226,6 @@ let assign_contact_form { Pool_context.csrf; language; _ } contact = ; a_class [ "modal"; "fullscreen-overlay" ] ] [] - ; div ~a:[ a_id result_target ] [] - ; script (Unsafe.data functions) ] ;; diff --git a/resources/htmx.js b/resources/htmx.js index 5ef2f20ab..ec326e8a8 100644 --- a/resources/htmx.js +++ b/resources/htmx.js @@ -1,4 +1,4 @@ -import { csrfToken, notifyUser } from "./admin/utils.js"; +import { csrfToken } from "./admin/utils.js"; import { initSearch } from "./search.js"; const configRequest = (e, form) => { diff --git a/resources/search.js b/resources/search.js index 6f6b20364..1ea522ec3 100644 --- a/resources/search.js +++ b/resources/search.js @@ -1,3 +1,27 @@ +let assignContactCallback = (option) => { + const { hxTarget } = option.dataset; + const body = document.querySelector("body"); + document.addEventListener("htmx:afterSwap", (e) => { + const { elt } = e.detail; + if (elt.id === hxTarget.substring(1)) { + body.toggleAttribute("data-noscroll") + elt.querySelector(".modal-close").addEventListener("click", () => { + body.toggleAttribute("data-noscroll"); + elt.classList.remove("active") + }, { once: true }) + } + }, { once: true }) +} + +let callbackOfAttribute = (callback) => { + switch (callback) { + case "assign-contact": + return assignContactCallback + default: + return null; + } +} + const createTag = (option, name, inputType) => { const item = document.createElement("span"); item.setAttribute("data-selection-item", ""); @@ -29,10 +53,11 @@ const addRemoveItemListener = (item, callback) => { class Search { constructor(input) { - const { name, inputType } = input.dataset; + const { name, inputType, callback } = input.dataset; this.input = input; this.name = name; this.inputType = inputType; + this.clickCallack = callbackOfAttribute(callback) this.wrapper = this.input.closest(".form-group"); this.selection = this.wrapper.querySelector("[data-search-selection]"); this.optionsWrapper = this.wrapper.querySelector(".data-list"); @@ -97,8 +122,12 @@ class StaticSearch extends Search { addClickOptionListener() { [...this.options].forEach(option => { option.addEventListener("click", () => { - this.createSelectedItem(option); - option.style.display = "none"; + if (this.clickCallack) { + this.clickCallack(option) + } else { + this.createSelectedItem(option); + option.style.display = "none"; + } }) }) } @@ -115,8 +144,12 @@ class DynamicSearch extends Search { this.wrapper.addEventListener("htmx:afterSwap", (e) => { [...e.detail.elt.querySelectorAll(".data-item")].forEach(option => { option.addEventListener("click", () => { - this.createSelectedItem(option); - option.style.display = "none"; + if (this.clickCallack) { + this.clickCallack(option) + } else { + this.createSelectedItem(option); + option.style.display = "none"; + } }) }) this.optionsWrapper.classList.add("active") From 2201a282b3f8f504ae8b37dc5cdd701cef63dd38 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Mon, 30 Oct 2023 13:05:27 +0100 Subject: [PATCH 07/18] remove unused files --- resources/admin/role-search.js | 58 ---------------------------- resources/admin/staticSearch.js | 68 --------------------------------- 2 files changed, 126 deletions(-) delete mode 100644 resources/admin/role-search.js delete mode 100644 resources/admin/staticSearch.js diff --git a/resources/admin/role-search.js b/resources/admin/role-search.js deleted file mode 100644 index a4135f7b5..000000000 --- a/resources/admin/role-search.js +++ /dev/null @@ -1,58 +0,0 @@ -import { addCloseListener, addInputListeners, csrfToken, destroySelected, notifyUser } from "./utils.js"; - -const form = document.getElementById("role-search-form"); -const notificationId = "role-search-notification"; - -function configRequest(e, form) { - const isSubmit = e.target.type === "submit" - const isSearchForm = Boolean(e.detail.elt.classList.contains("query-input")); - e.detail.parameters._csrf = csrfToken(form); - - if (isSubmit || isSearchForm) { - try { - var checked_results = [...form.querySelectorAll(`[data-query="results"] [name="value[]"]:checked`)].map(elt => elt.value); - if (isSearchForm) { - // Exclude currently selected experiments form query results - e.detail.parameters["exclude[]"] = checked_results; - e.detail.parameters.role = form.querySelector("[name='role']").value; - e.detail.parameters.exclude_roles_of = form.querySelector("[name='exclude_roles_of']").value; - } - else if (isSubmit) { - e.detail.parameters["target[]"] = checked_results; - e.detail.parameters.role = form.querySelector("[name='role']").value; - } - } catch (error) { - console.error(error); - e.preventDefault(); - notifyUser(notificationId, "error", error) - } - } - - var event = new Event('submit'); - form.dispatchEvent(event); -} - -export function initRoleSearchForm() { - if (form) { - const submitButton = document.getElementById("submit-role-search-form"); - submitButton.addEventListener('htmx:beforeSwap', (e) => { - if (e.detail.xhr.status > 200 && e.detail.xhr.status < 300) { - e.detail.shouldSwap = true; - } - }); - // Query event listeners - [...form.querySelectorAll("[data-query='input']")].forEach(e => addInputListeners(e)); - [...form.querySelectorAll("[data-query='results'] [data-id]")].forEach(e => - e.querySelector(".toggle-item").addEventListener("click", () => destroySelected(e)) - ); - - form.addEventListener('htmx:afterSwap', (e) => { - if (e.detail.elt.dataset.query) { - addInputListeners(e.detail.elt) - } - addCloseListener(notificationId); - }) - - form.addEventListener('htmx:configRequest', (e) => configRequest(e, form)) - } -} diff --git a/resources/admin/staticSearch.js b/resources/admin/staticSearch.js deleted file mode 100644 index 5354d19e5..000000000 --- a/resources/admin/staticSearch.js +++ /dev/null @@ -1,68 +0,0 @@ -const createTag = (option) => { - const item = document.createElement("span"); - item.setAttribute("data-selection-item", ""); - item.innerHTML = option.innerHTML; - const icon = document.createElement("i"); - icon.classList.add("icon-close"); - const input = document.createElement("input"); - input.setAttribute("type", "checkbox"); - input.setAttribute("data-input-type", "option") - input.checked = true; - input.setAttribute("hidden", "") - input.value = option.dataset.value; - input.name = "value[]"; - item.appendChild(icon); - item.appendChild(input); - return item; -} - -const addRemoveItemListener = (item) => { - item.querySelector(".icon-close").addEventListener("click", () => { - item.remove() - }, { once: true }) -} - -export const initStaticSearch = (target) => { - const container = target || document; - container.querySelectorAll('[data-search="static"]').forEach((el) => { - const wrapper = el.closest(".form-group"); - const results = wrapper.querySelector("[data-search-selection]"); - const optionsWrapper = wrapper.querySelector(".data-list"); - const options = optionsWrapper.querySelectorAll(".data-item") - const filterOptions = () => { - const value = String(el.value).toLocaleLowerCase(); - const selected = [...results.querySelectorAll("[data-selection-item] input")].map((item) => item.value) - options.forEach((option) => { - if (option.innerText.includes(value) && !selected.includes(option.dataset.value)) { - option.style.display = "flex"; - } else { - option.style.display = "none"; - } - }) - } - el.addEventListener("focusin", () => { - optionsWrapper.classList.add("active"); - }) - document.addEventListener("click", (e) => { - if (!container.contains(e.target)) { - optionsWrapper.classList.remove("active"); - } - }); - el.addEventListener("keyup", () => { - filterOptions(); - }); - - options.forEach((option) => { - option.addEventListener("click", () => { - const item = createTag(option); - option.style.display = "none"; - results.appendChild(item); - addRemoveItemListener(item); - filterOptions(); - }) - }); - - [...results.querySelectorAll("[data-selection-item]")].forEach((item) => addRemoveItemListener(item)); - filterOptions(); - }) -} From 94f36d8990f48e34dd9da73a5486df2167064c41 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Mon, 30 Oct 2023 13:07:47 +0100 Subject: [PATCH 08/18] remove unused csrf token function --- resources/admin/filter.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/admin/filter.js b/resources/admin/filter.js index 3309f7d18..cf1ad8bfc 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -1,4 +1,4 @@ -import { addCloseListener, addInputListeners, csrfToken, destroySelected, icon, notifyUser, globalErrorMsg } from "./utils.js"; +import { addCloseListener, addInputListeners, destroySelected, icon, notifyUser, globalErrorMsg } from "./utils.js"; const errorClass = "error-message"; const notificationId = "filter-notification"; @@ -217,7 +217,6 @@ function configRequest(e, form) { const allowEmpty = e.detail.parameters.allow_empty_values; const isSubmit = e.target.type === "submit" const isSearchForm = Boolean(e.detail.elt.classList.contains("query-input")); - e.detail.parameters._csrf = csrfToken(form); const filterId = form.dataset.filter; if (filterId) { e.detail.parameters.filter = filterId; From b1eed34063a4c3905495c7a735fe43e2e7b9b466 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Tue, 31 Oct 2023 07:56:54 +0100 Subject: [PATCH 09/18] add role search handler --- pool/routes/routes.ml | 4 + pool/web/handler/admin_admins.ml | 89 +++++++++++-- pool/web/handler/helpers_search.ml | 8 +- pool/web/view/component/component_filter.ml | 1 - pool/web/view/component/component_role.ml | 28 ++-- pool/web/view/component/component_search.ml | 137 ++++++++++++-------- 6 files changed, 174 insertions(+), 93 deletions(-) diff --git a/pool/routes/routes.ml b/pool/routes/routes.ml index fc6526fe5..463d33156 100644 --- a/pool/routes/routes.ml +++ b/pool/routes/routes.ml @@ -555,6 +555,10 @@ module Admin = struct [ get "" ~middlewares:[ Access.read ] detail ; get "/edit" ~middlewares:[ Access.update ] edit ; post "/toggle-role" ~middlewares:[ Access.read ] handle_toggle_role + ; post + "/search-role" + ~middlewares:[ Access.grant_role ] + search_role_entities ; post "/grant-role" ~middlewares:[ Access.grant_role ] grant_role ; post "/revoke-role" ~middlewares:[ Access.revoke_role ] revoke_role ] diff --git a/pool/web/handler/admin_admins.ml b/pool/web/handler/admin_admins.ml index 6d6c9c989..8209ff019 100644 --- a/pool/web/handler/admin_admins.ml +++ b/pool/web/handler/admin_admins.ml @@ -98,33 +98,96 @@ let create_admin req = let handle_toggle_role req = let result (_ : Pool_context.t) = + let admin_id = HttpUtils.find_id Admin.Id.of_string Field.Admin req in Sihl.Web.Request.to_urlencoded req ||> HttpUtils.find_in_urlencoded Field.Role >== Role.Role.of_string_res %> CCResult.map_err Pool_common.Message.authorization >|+ fun key -> - let exclude_roles_of = - try - HttpUtils.find_id Admin.Id.of_string Field.Admin req |> CCOption.return - with - | _ -> None - in - Component.Role.Search.value_form - Pool_common.Language.En - ?exclude_roles_of - ~key - () + Component.Role.Search.value_form Pool_common.Language.En ~key admin_id () |> HttpUtils.Htmx.html_to_plain_text_response in result |> HttpUtils.Htmx.handle_error_message ~src req ;; +let search_role_entities req = + let admin_id = HttpUtils.find_id Admin.Id.of_string Field.Admin req in + let result { Pool_context.database_label; user; language; _ } = + let* admin = Admin.find database_label admin_id in + let* actor = + Pool_context.Utils.find_authorizable ~admin_only:true database_label user + in + let%lwt urlencoded = Sihl.Web.Request.to_urlencoded req in + let query = HttpUtils.find_in_urlencoded_opt Field.Search urlencoded in + let* search_role = + let open CCOption in + HttpUtils.find_in_urlencoded_opt Field.Role urlencoded + |> flip bind (fun role -> + try Role.Role.of_string role |> return with + | _ -> None) + |> to_result Pool_common.Message.(NotFound Field.Role) + |> Lwt_result.lift + in + let%lwt existing_targets = + (* TODO: Could be solved on database lvl *) + Helpers_guard.find_roles database_label admin + ||> CCList.filter_map + (fun ({ Guard.ActorRole.target_uuid; role; _ }, _, _) -> + if Role.Role.equal role search_role then None else target_uuid) + in + let entities_to_exclude encode_id = + let open CCList in + let%lwt selected = + HttpUtils.htmx_urlencoded_list Field.(array_key Target) req + in + (* TODO: NOT SURE THIS WORKS CORRECTLY *) + Lwt.return + (map (Guard.Uuid.Target.to_string %> encode_id) existing_targets + @ map encode_id selected) + in + let execute_search search_fnc to_html encode_id = + (match query with + | None -> Lwt.return [] + | Some query -> + let%lwt exclude = entities_to_exclude encode_id in + search_fnc exclude query actor) + ||> to_html language + ||> HttpUtils.Htmx.multi_html_to_plain_text_response %> CCResult.return + in + let open Guard.Persistence in + match search_role with + | `Assistant | `Experimenter -> + let open Experiment.Guard.Access in + let search_experiment exclude value actor = + Experiment.search database_label exclude value + >|> Lwt_list.filter_s (fun (id, _) -> + (* TODO: Could be solved on database lvl *) + validate database_label (read id) actor ||> CCResult.is_ok) + in + execute_search + search_experiment + Component.Search.Experiment.query_results + Experiment.Id.of_string + | `LocationManager -> + let open Pool_location.Guard.Access in + let search_location exclude value actor = + Pool_location.search database_label exclude value + >|> Lwt_list.filter_s (fun (id, _) -> + validate database_label (read id) actor ||> CCResult.is_ok) + in + execute_search + search_location + Component.Search.Location.query_results + Pool_location.Id.of_string + | _ -> Lwt_result.fail Pool_common.Message.(Invalid Field.Role) + in + result |> HttpUtils.Htmx.handle_error_message ~src req +;; + let grant_role req = let open Utils.Lwt_result.Infix in let lift = Lwt_result.lift in let admin_id = HttpUtils.find_id Admin.Id.of_string Field.Admin req in - (* let redirect_path = CCString.replace ~which:`Right ~sub:"/grant-role" - ~by:"/edit" target in *) let redirect_path = Format.asprintf "/admin/admins/%s/edit" (Admin.Id.value admin_id) in diff --git a/pool/web/handler/helpers_search.ml b/pool/web/handler/helpers_search.ml index 695873e64..79115f609 100644 --- a/pool/web/handler/helpers_search.ml +++ b/pool/web/handler/helpers_search.ml @@ -7,7 +7,7 @@ let src = Logs.Src.create "handler.helper.search" let create search_type req = let query_field = Field.Search in - let result { Pool_context.database_label; user; _ } = + let result { Pool_context.database_label; user; language; _ } = let open CCList in let%lwt actor = Pool_context.Utils.find_authorizable_opt @@ -64,7 +64,7 @@ let create search_type req = | None, _ | Some _, None -> Lwt.return [] | Some value, Some actor -> search_experiment (exclude @ exclude_roles_of) value actor) - ||> query_results + ||> query_results language ||> to_response | `Location -> let open Component.Search.Location in @@ -86,7 +86,7 @@ let create search_type req = |> Lwt_result.return | Some value, Some actor -> search_location (exclude @ exclude_roles_of) value actor - ||> query_results + ||> query_results language ||> to_response) | `ContactTag -> let open Component.Search.Tag in @@ -108,7 +108,7 @@ let create search_type req = | None, _ | Some _, None -> Lwt.return [] | Some value, Some actor -> search_tags (exclude @ exclude_roles_of) value actor) - ||> query_results + ||> query_results language ||> to_response in result |> HttpUtils.Htmx.handle_error_message ~src req diff --git a/pool/web/view/component/component_filter.ml b/pool/web/view/component/component_filter.ml index a67e9c755..666d71f51 100644 --- a/pool/web/view/component/component_filter.ml +++ b/pool/web/view/component/component_filter.ml @@ -227,7 +227,6 @@ let value_input ~selected ~disabled language - () | Key.QueryTags -> let selected = value diff --git a/pool/web/view/component/component_role.ml b/pool/web/view/component/component_role.ml index 3260de7ee..7b8017edb 100644 --- a/pool/web/view/component/component_role.ml +++ b/pool/web/view/component/component_role.ml @@ -157,39 +157,32 @@ module Search = struct Format.asprintf "/admin/admins/%s/%s" Admin.(admin |> id |> Id.value) ;; - let[@warning "-27"] value_input ?exclude_roles_of ?role ?value language = + let value_input language admin_id = let open Role.Role in function | Some QueryLocations -> - (* What is exclude_roles_of? / value *) let hint = Pool_common.I18n.RoleIntro (Field.Location, Field.Locations) in - Component_search.Location.create ~hint ~tag_name:Field.Target language () + Component_search.RoleTarget.locations ~hint language admin_id | Some QueryExperiments -> let hint = Pool_common.I18n.RoleIntro (Field.Experiment, Field.Experiments) in - Component_search.Experiment.create - ~hint - ~tag_name:Field.Target - language - () + Component_search.RoleTarget.experiments ~hint language admin_id | None -> div [] ;; - let value_form language ?exclude_roles_of ?key ?value () = + let value_form language admin_id ?key () = CCOption.map_or ~default:(Ok None) Role.Role.type_of_key key |> function | Error err -> p [ Pool_common.Utils.error_to_string language err |> txt ] | Ok input_type -> - let input_field = - value_input ?exclude_roles_of ?role:key ?value language input_type - in + let input_field = value_input language admin_id input_type in div ~a:[ a_class [ "switcher-sm"; "flex-gap" ] ] [ input_field ] ;; - let role_form ?key ?value language csrf admin identifier role_list = + let role_form ?key language csrf admin identifier role_list = let toggle_id = Format.asprintf "role-search-%i" identifier in - let toggled_content = value_form language ?key ?value () in + let toggled_content = value_form language (Admin.id admin) ?key () in let key_selector = let attributes = Utils.htmx_attribs @@ -232,10 +225,9 @@ module Search = struct ] ;; - let input_form ?(identifier = 0) ?key ?value csrf language admin role_list () = - let role_form = - role_form ?key ?value language csrf admin identifier role_list - in + (* TODO: Identifier? Can I remove? *) + let input_form ?(identifier = 0) ?key csrf language admin role_list () = + let role_form = role_form ?key language csrf admin identifier role_list in let stack = "stack-sm" in div ~a:[ a_class [ stack; "inset-sm"; "border"; "role-search" ] ] diff --git a/pool/web/view/component/component_search.ml b/pool/web/view/component/component_search.ml index 97924b4ee..a3ada0f4b 100644 --- a/pool/web/view/component/component_search.ml +++ b/pool/web/view/component/component_search.ml @@ -23,12 +23,22 @@ let default_query_results_item ~to_label ~to_value item = [ txt (to_label item) ] ;; +let with_empty_message language = function + | [] -> + [ span + ~a:[ a_class [ "data-item" ] ] + [ txt Pool_common.(Utils.text_to_string language I18n.EmptyListGeneric) + ] + ] + | html -> html +;; + let query_results to_item items = div ~a:[ a_class [ "data-list"; "relative" ] ] (CCList.map to_item items) ;; let multi_search - langauge + language field multi_search ?(additional_attributes = []) @@ -66,7 +76,7 @@ let multi_search () ] in - let hint = Component_input.Elements.help langauge hint in + let hint = Component_input.Elements.help language hint in let base_attributes = let placeholder = CCOption.map_or @@ -95,7 +105,7 @@ let multi_search ~a:[ a_class [ "form-group" ] ] ((label [ txt - (Utils.field_to_string langauge field |> CCString.capitalize_ascii) + (Utils.field_to_string language field |> CCString.capitalize_ascii) ] :: html) @ hint) @@ -159,50 +169,36 @@ let hidden_input name decode = module Experiment = struct open Experiment + let field = Field.Experiments let placeholder = "Search by experiment title" let to_label = snd %> Title.value let to_value = fst %> Id.value - let create ?disabled ?hint ?is_filter ?tag_name ?(selected = []) language = + let dynamic_search ?(selected = []) hx_url hx_method = + { hx_url; hx_method; to_label; to_value; selected } + ;; + + let filter_multi_search ?selected ~disabled language = let dynamic_search = - { hx_url = "/admin/experiments/search" - ; hx_method = `Post - ; to_label - ; to_value - ; selected - } + dynamic_search ?selected "/admin/experiments/search" `Post in multi_search - ?disabled - ?hint - ?is_filter - ~placeholder - ?tag_name - language - Field.Experiments - (Dynamic dynamic_search) - ;; - - let filter_multi_search ~selected ~disabled language = - create - ~selected ~disabled ~is_filter:true ~tag_name:Pool_common.Message.Field.Value language + field + (Dynamic dynamic_search) + () ;; let assign_contact_search language contact = let dynamic_search = - { hx_url = - Format.asprintf - "/admin/contacts/%s/experiments" - (Contact.id contact |> Pool_common.Id.value) - ; hx_method = `Get - ; to_label - ; to_value - ; selected = [] - } + dynamic_search + (Format.asprintf + "/admin/contacts/%s/experiments" + (Contact.id contact |> Pool_common.Id.value)) + `Get in multi_search ~placeholder @@ -212,44 +208,30 @@ module Experiment = struct (Dynamic dynamic_search) ;; - let query_results = - CCList.map (default_query_results_item ~to_label ~to_value) + let query_results language items = + CCList.map (default_query_results_item ~to_label ~to_value) items + |> with_empty_message language ;; end module Location = struct open Pool_location + let field = Field.Locations let placeholder = "Search by location name" let to_label ({ name; _ } : t) = Name.value name let to_value { id; _ } = Id.value id - let create ?disabled ?hint ?is_filter ?tag_name ?(selected = []) language = - let dynamic_search = - ({ hx_url = "/admin/locations/search" - ; hx_method = `Get - ; to_label - ; to_value - ; selected - } - : t dynamic_search) - in - multi_search - ?disabled - ?hint - ?is_filter - ~placeholder - ?tag_name - language - Field.Locations - (Dynamic dynamic_search) + let dynamic_search ?(selected = []) hx_url hx_method : t dynamic_search = + { hx_url; hx_method; to_label; to_value; selected } ;; - let query_results = + let query_results language items = let open CCFun in let to_label = snd %> Name.value in let to_value = fst %> Id.value in - CCList.map (default_query_results_item ~to_label ~to_value) + CCList.map (default_query_results_item ~to_label ~to_value) items + |> with_empty_message language ;; end @@ -289,7 +271,48 @@ module Tag = struct language ;; - let query_results = - CCList.map (default_query_results_item ~to_label ~to_value) + let query_results language items = + CCList.map (default_query_results_item ~to_label ~to_value) items + |> with_empty_message language + ;; +end + +module RoleTarget = struct + let hx_url admin_id = + Format.asprintf "/admin/admins/%s/search-role" Admin.(Id.value admin_id) + ;; + + let additional_attributes = + [ a_user_data + "hx-params" + Pool_common.Message.Field.( + [ array_key Target; show Role; show Search ] |> CCString.concat ", ") + ] + ;; + + let experiments ?hint language admin_id = + let open Experiment in + multi_search + ~additional_attributes + ?hint + ~tag_name:Field.Target + ~placeholder + language + field + (Dynamic (dynamic_search (hx_url admin_id) `Post)) + () + ;; + + let locations ?hint language admin_id = + let open Location in + multi_search + ~additional_attributes + ?hint + ~tag_name:Field.Target + ~placeholder + language + field + (Dynamic (dynamic_search (hx_url admin_id) `Post)) + () ;; end From cd8522fc3305c4f1551814ffefb5e794696d7de0 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Tue, 31 Oct 2023 08:53:39 +0100 Subject: [PATCH 10/18] add filter search handler --- pool/web/handler/admin_admins.ml | 22 ++--- pool/web/handler/admin_experiments.ml | 2 +- pool/web/handler/admin_location.ml | 2 +- pool/web/handler/admin_settings_tags.ml | 2 +- pool/web/handler/helpers_search.ml | 103 ++++++-------------- pool/web/view/component/component_search.ml | 18 ++-- resources/admin/filter.js | 13 +-- resources/admin/utils.js | 25 ----- 8 files changed, 53 insertions(+), 134 deletions(-) diff --git a/pool/web/handler/admin_admins.ml b/pool/web/handler/admin_admins.ml index 8209ff019..979082d61 100644 --- a/pool/web/handler/admin_admins.ml +++ b/pool/web/handler/admin_admins.ml @@ -145,12 +145,10 @@ let search_role_entities req = (map (Guard.Uuid.Target.to_string %> encode_id) existing_targets @ map encode_id selected) in - let execute_search search_fnc to_html encode_id = + let execute_search search_fnc to_html = (match query with | None -> Lwt.return [] - | Some query -> - let%lwt exclude = entities_to_exclude encode_id in - search_fnc exclude query actor) + | Some query -> search_fnc query actor) ||> to_html language ||> HttpUtils.Htmx.multi_html_to_plain_text_response %> CCResult.return in @@ -158,27 +156,23 @@ let search_role_entities req = match search_role with | `Assistant | `Experimenter -> let open Experiment.Guard.Access in - let search_experiment exclude value actor = + let%lwt exclude = entities_to_exclude Experiment.Id.of_string in + let search_experiment value actor = Experiment.search database_label exclude value >|> Lwt_list.filter_s (fun (id, _) -> (* TODO: Could be solved on database lvl *) validate database_label (read id) actor ||> CCResult.is_ok) in - execute_search - search_experiment - Component.Search.Experiment.query_results - Experiment.Id.of_string + execute_search search_experiment Component.Search.Experiment.query_results | `LocationManager -> let open Pool_location.Guard.Access in - let search_location exclude value actor = + let%lwt exclude = entities_to_exclude Pool_location.Id.of_string in + let search_location value actor = Pool_location.search database_label exclude value >|> Lwt_list.filter_s (fun (id, _) -> validate database_label (read id) actor ||> CCResult.is_ok) in - execute_search - search_location - Component.Search.Location.query_results - Pool_location.Id.of_string + execute_search search_location Component.Search.Location.query_results | _ -> Lwt_result.fail Pool_common.Message.(Invalid Field.Role) in result |> HttpUtils.Htmx.handle_error_message ~src req diff --git a/pool/web/handler/admin_experiments.ml b/pool/web/handler/admin_experiments.ml index 232751e68..df0592024 100644 --- a/pool/web/handler/admin_experiments.ml +++ b/pool/web/handler/admin_experiments.ml @@ -427,7 +427,7 @@ let delete req = result |> HttpUtils.extract_happy_path ~src req ;; -let search = Helpers.Search.create `Experiment +let search = Helpers.Search.htmx_search_helper `Experiment module Filter = struct open HttpUtils.Filter diff --git a/pool/web/handler/admin_location.ml b/pool/web/handler/admin_location.ml index 76fd226f8..c8b50e158 100644 --- a/pool/web/handler/admin_location.ml +++ b/pool/web/handler/admin_location.ml @@ -275,7 +275,7 @@ let delete req = result |> HttpUtils.extract_happy_path ~src req ;; -let search = Helpers.Search.create `Location +let search = Helpers.Search.htmx_search_helper `Location module Access : sig include module type of Helpers.Access diff --git a/pool/web/handler/admin_settings_tags.ml b/pool/web/handler/admin_settings_tags.ml index 6f0f90afc..17e525d0b 100644 --- a/pool/web/handler/admin_settings_tags.ml +++ b/pool/web/handler/admin_settings_tags.ml @@ -102,7 +102,7 @@ let write action req = let create = write `Create let update req = write (`Update (id req)) req -let search = Helpers.Search.create `ContactTag +let search = Helpers.Search.htmx_search_helper `ContactTag module Access : sig include module type of Helpers.Access diff --git a/pool/web/handler/helpers_search.ml b/pool/web/handler/helpers_search.ml index 79115f609..c392c1c03 100644 --- a/pool/web/handler/helpers_search.ml +++ b/pool/web/handler/helpers_search.ml @@ -5,97 +5,59 @@ module HttpUtils = Http_utils let src = Logs.Src.create "handler.helper.search" -let create search_type req = - let query_field = Field.Search in +let htmx_search_helper + ?(query_field = Field.Search) + ?(exclude_field = Field.Exclude) + entity + req + = let result { Pool_context.database_label; user; language; _ } = - let open CCList in - let%lwt actor = - Pool_context.Utils.find_authorizable_opt - ~admin_only:true - database_label - user + let* actor = + Pool_context.Utils.find_authorizable ~admin_only:true database_label user in let%lwt urlencoded = Sihl.Web.Request.to_urlencoded req in let query = HttpUtils.find_in_urlencoded_opt query_field urlencoded in - let search_role = - let open CCOption in - HttpUtils.find_in_urlencoded_opt Field.Role urlencoded - |> flip bind (fun role -> - try Role.Role.of_string role |> return with - | _ -> None) - in - let%lwt exclude_roles_of = - HttpUtils.find_in_urlencoded_opt Field.ExcludeRolesOf urlencoded - |> CCOption.map_or - ~default:Lwt.return_none - (Admin.Id.of_string - %> fun id -> Admin.find database_label id ||> CCResult.to_opt) - >|> CCOption.map_or - ~default:(Lwt.return []) - (Helpers_guard.find_roles database_label) - ||> filter_map (fun ({ Guard.ActorRole.target_uuid; role; _ }, _, _) -> - CCOption.bind search_role (fun search_role -> - if Role.Role.equal role search_role then None else target_uuid)) - in - let exclude = - HttpUtils.find_in_urlencoded_list_opt Field.Exclude urlencoded + let entities_to_exclude encode_id = + let%lwt selected = + HttpUtils.htmx_urlencoded_list Field.(array_key exclude_field) req + in + CCList.map encode_id selected |> Lwt.return in - let to_response html = - html - |> HttpUtils.Htmx.multi_html_to_plain_text_response - |> CCResult.return + let execute_search search_fnc to_html = + (match query with + | None -> Lwt.return [] + | Some query -> search_fnc query actor) + ||> to_html language + ||> HttpUtils.Htmx.multi_html_to_plain_text_response %> CCResult.return in let open Guard.Persistence in - match search_type with + match entity with | `Experiment -> let open Component.Search.Experiment in let open Experiment.Guard.Access in - let exclude = exclude >|= Experiment.Id.of_string in - let exclude_roles_of = - exclude_roles_of - >|= Guard.Uuid.Target.to_string %> Experiment.Id.of_string - in - let search_experiment exclude value actor = + let%lwt exclude = entities_to_exclude Experiment.Id.of_string in + let search_experiment value actor = Experiment.search database_label exclude value >|> Lwt_list.filter_s (fun (id, _) -> + (* TODO: Could be solved on database lvl *) validate database_label (read id) actor ||> CCResult.is_ok) in - (match query, actor with - | None, _ | Some _, None -> Lwt.return [] - | Some value, Some actor -> - search_experiment (exclude @ exclude_roles_of) value actor) - ||> query_results language - ||> to_response + execute_search search_experiment query_results | `Location -> let open Component.Search.Location in let open Pool_location.Guard.Access in - let exclude = exclude >|= Pool_location.Id.of_string in - let exclude_roles_of = - exclude_roles_of - >|= Guard.Uuid.Target.to_string %> Pool_location.Id.of_string - in - let search_location exclude value actor = + let%lwt exclude = entities_to_exclude Pool_location.Id.of_string in + let search_location value actor = Pool_location.search database_label exclude value >|> Lwt_list.filter_s (fun (id, _) -> validate database_label (read id) actor ||> CCResult.is_ok) in - (match query, actor with - | None, _ | Some _, None -> - Tyxml.Html.txt "" - |> HttpUtils.Htmx.html_to_plain_text_response - |> Lwt_result.return - | Some value, Some actor -> - search_location (exclude @ exclude_roles_of) value actor - ||> query_results language - ||> to_response) + execute_search search_location query_results | `ContactTag -> let open Component.Search.Tag in let open Tags.Guard.Access in - let exclude = exclude >|= Tags.Id.of_string in - let exclude_roles_of = - exclude_roles_of >|= Guard.Uuid.Target.to_string %> Tags.Id.of_string - in - let search_tags exclude value actor = + let%lwt exclude = entities_to_exclude Tags.Id.of_string in + let search_tags value actor = Tags.search_by_title database_label ~model:Tags.Model.Contact @@ -104,12 +66,7 @@ let create search_type req = >|> Lwt_list.filter_s (fun (id, _) -> validate database_label (read id) actor ||> CCResult.is_ok) in - (match query, actor with - | None, _ | Some _, None -> Lwt.return [] - | Some value, Some actor -> - search_tags (exclude @ exclude_roles_of) value actor) - ||> query_results language - ||> to_response + execute_search search_tags query_results in result |> HttpUtils.Htmx.handle_error_message ~src req ;; diff --git a/pool/web/view/component/component_search.ml b/pool/web/view/component/component_search.ml index a3ada0f4b..8c3ccea75 100644 --- a/pool/web/view/component/component_search.ml +++ b/pool/web/view/component/component_search.ml @@ -102,7 +102,9 @@ let multi_search let wrap html = (* TODO: Place hint *) div - ~a:[ a_class [ "form-group" ] ] + ~a: + (a_class [ "form-group" ] + :: (if is_filter then [ a_user_data "query" "wrapper" ] else [])) ((label [ txt (Utils.field_to_string language field |> CCString.capitalize_ascii) @@ -158,12 +160,12 @@ let multi_search |> wrap ;; -let hidden_input name decode = - CCOption.map_or ~default:[] (fun value -> - [ input - ~a:[ a_hidden (); a_name (Field.show name); a_value (decode value) ] - () - ]) +let additional_filter_attributes = + [ a_user_data + "hx-params" + Pool_common.Message.Field.( + [ array_key Value; show Search ] |> CCString.concat ", ") + ] ;; module Experiment = struct @@ -264,9 +266,9 @@ module Tag = struct let filter_multi_search ~selected ~disabled language = create - ~selected ~disabled ~is_filter:true + ~selected ~tag_name:Pool_common.Message.Field.Value language ;; diff --git a/resources/admin/filter.js b/resources/admin/filter.js index cf1ad8bfc..60986271c 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -1,4 +1,4 @@ -import { addCloseListener, addInputListeners, destroySelected, icon, notifyUser, globalErrorMsg } from "./utils.js"; +import { addCloseListener, icon, notifyUser, globalErrorMsg } from "./utils.js"; const errorClass = "error-message"; const notificationId = "filter-notification"; @@ -216,7 +216,7 @@ function configRequest(e, form) { const isPredicateType = e.detail.parameters.predicate; const allowEmpty = e.detail.parameters.allow_empty_values; const isSubmit = e.target.type === "submit" - const isSearchForm = Boolean(e.detail.elt.classList.contains("query-input")); + const isSearchForm = e.detail.elt.name === "search"; const filterId = form.dataset.filter; if (filterId) { e.detail.parameters.filter = filterId; @@ -261,19 +261,10 @@ export function initFilterForm() { } }) addRemovePredicateListener(form); - // Query event listeners - [...form.querySelectorAll("[data-query='input']")].forEach(e => addInputListeners(e)); - [...form.querySelectorAll("[data-query='results'] [data-id]")].forEach(e => - e.querySelector(".toggle-item").addEventListener("click", () => destroySelected(e)) - ); addOperatorChangeListeners(form); - form.addEventListener('htmx:afterSwap', (e) => { addRemovePredicateListener(e.detail.elt); addOperatorChangeListeners(e.detail.elt); - if (e.detail.elt.dataset.query) { - addInputListeners(e.detail.elt) - } if (e.detail.target.type === "submit") { updateContactCount(); } diff --git a/resources/admin/utils.js b/resources/admin/utils.js index 3732f12c2..de33f3868 100644 --- a/resources/admin/utils.js +++ b/resources/admin/utils.js @@ -37,28 +37,3 @@ export const notifyUser = (notificationId, classname, msg) => { notification.parentElement.replaceChild(wrapper, notification) addCloseListener(notificationId); } - -export function destroySelected(item) { - item.remove(); -} - -export function addInputListeners(queryInput) { - const wrapper = queryInput.closest("[data-query='wrapper']"); - const results = wrapper.querySelector("[data-query='results']"); - const dataList = wrapper.querySelector(".data-list"); - - [...queryInput.querySelectorAll("[data-id]")].forEach(item => { - item.addEventListener("click", () => { - results.appendChild(item); - item.querySelector(".toggle-item").addEventListener("click", () => destroySelected(item)); - }, { once: true }) - }) - - if (dataList) { - queryInput.addEventListener("change", (e) => { - if (!e.currentTarget.value) { - dataList.classList.remove("active") - } - }) - } -} From b001507d41b92279a76eea91c375e7ceac5bdfa3 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Tue, 31 Oct 2023 09:19:22 +0100 Subject: [PATCH 11/18] fix filter error notifications --- pool/web/handler/admin_filter.ml | 3 ++- pool/web/handler/helpers_search.ml | 3 ++- resources/htmx.js | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pool/web/handler/admin_filter.ml b/pool/web/handler/admin_filter.ml index 822f263c9..928fa2569 100644 --- a/pool/web/handler/admin_filter.ml +++ b/pool/web/handler/admin_filter.ml @@ -162,7 +162,8 @@ let write action req = in events |>> handle |>> success in - result |> HttpUtils.Htmx.handle_error_message ~src req + result + |> HttpUtils.Htmx.handle_error_message ~error_as_notification:true ~src req ;; let handle_toggle_predicate_type action req = diff --git a/pool/web/handler/helpers_search.ml b/pool/web/handler/helpers_search.ml index c392c1c03..395199a4c 100644 --- a/pool/web/handler/helpers_search.ml +++ b/pool/web/handler/helpers_search.ml @@ -68,5 +68,6 @@ let htmx_search_helper in execute_search search_tags query_results in - result |> HttpUtils.Htmx.handle_error_message ~src req + result + |> HttpUtils.Htmx.handle_error_message ~error_as_notification:true ~src req ;; diff --git a/resources/htmx.js b/resources/htmx.js index ec326e8a8..a0228d264 100644 --- a/resources/htmx.js +++ b/resources/htmx.js @@ -1,5 +1,6 @@ import { csrfToken } from "./admin/utils.js"; import { initSearch } from "./search.js"; +import { initNotification } from '../node_modules/@econ/frontend-framework/dist/main' const configRequest = (e, form) => { if (e.detail.verb.toLowerCase() != "get") { @@ -9,5 +10,8 @@ const configRequest = (e, form) => { export const initHTMX = () => { document.addEventListener('htmx:configRequest', (e) => configRequest(e)) - document.addEventListener('htmx:afterSwap', (e) => initSearch(e.detail.elt)) + document.addEventListener('htmx:afterSwap', (e) => { + initSearch(e.detail.elt) + initNotification() + }) } From 8a1baeee841d25a476d5429f8ac5c8a6531b32c9 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Tue, 31 Oct 2023 09:22:24 +0100 Subject: [PATCH 12/18] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d764d9d..cfa3debc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [unreleased](https://github.com/uzh/pool/tree/HEAD) +### Changed +- use multi select in filter form for select custom fields +- standardize the creation of search components + ## [0.4.8](https://github.com/uzh/pool/tree/0.4.8) - 2023-10-24 ### Added From a4dc909e5e43a2595a3bed9e9034cd41598bbeb5 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Tue, 31 Oct 2023 09:29:05 +0100 Subject: [PATCH 13/18] handle mailing htmx error --- pool/web/handler/admin_experiments_mailing.ml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pool/web/handler/admin_experiments_mailing.ml b/pool/web/handler/admin_experiments_mailing.ml index 4f5625011..3d9c96f79 100644 --- a/pool/web/handler/admin_experiments_mailing.ml +++ b/pool/web/handler/admin_experiments_mailing.ml @@ -235,7 +235,8 @@ let add_condition req = >|= HttpUtils.Htmx.html_to_plain_text_response |> Lwt.return in - result |> HttpUtils.Htmx.handle_error_message ~src req + result + |> HttpUtils.Htmx.handle_error_message ~error_as_notification:true ~src req ;; let disabler command success_handler req = From d5976aa92201ab43406710a40a0f218af6876cb4 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Tue, 31 Oct 2023 09:32:10 +0100 Subject: [PATCH 14/18] remove comment --- pool/web/view/component/component_search.ml | 1 - 1 file changed, 1 deletion(-) diff --git a/pool/web/view/component/component_search.ml b/pool/web/view/component/component_search.ml index 8c3ccea75..400226079 100644 --- a/pool/web/view/component/component_search.ml +++ b/pool/web/view/component/component_search.ml @@ -100,7 +100,6 @@ let multi_search @ js_callback in let wrap html = - (* TODO: Place hint *) div ~a: (a_class [ "form-group" ] From 7ce6bc0894b7b1695b8e8e6bcfc70a7c213e092d Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Wed, 1 Nov 2023 07:32:21 +0100 Subject: [PATCH 15/18] refactor search function to exclude currently assigned entities --- pool/app/experiment/experiment.ml | 1 + pool/app/experiment/experiment.mli | 16 ++- pool/app/experiment/repo/repo.ml | 124 +++++++++++--------- pool/app/pool_location/pool_location.ml | 1 + pool/app/pool_location/pool_location.mli | 15 ++- pool/app/pool_location/repo/repo.ml | 131 +++++++++++++--------- pool/app/utils/database.ml | 20 ++++ pool/web/handler/admin_admins.ml | 30 ++--- pool/web/handler/helpers_search.ml | 5 +- pool/web/view/component/component_role.ml | 9 +- 10 files changed, 219 insertions(+), 133 deletions(-) diff --git a/pool/app/experiment/experiment.ml b/pool/app/experiment/experiment.ml index 9d48c41f3..771acfa91 100644 --- a/pool/app/experiment/experiment.ml +++ b/pool/app/experiment/experiment.ml @@ -44,6 +44,7 @@ let search = Repo.search let search_multiple_by_id = Repo.search_multiple_by_id let find_to_enroll_directly = Repo.find_to_enroll_directly let contact_is_enrolled = Repo.contact_is_enrolled +let find_targets_grantable_by_admin = Repo.find_targets_grantable_by_admin let possible_participant_count _ = Lwt.return 0 let possible_participants _ = Lwt.return [] diff --git a/pool/app/experiment/experiment.mli b/pool/app/experiment/experiment.mli index 33fe32473..b133c5be0 100644 --- a/pool/app/experiment/experiment.mli +++ b/pool/app/experiment/experiment.mli @@ -241,8 +241,12 @@ val find_past_experiments_by_contact val session_count : Pool_database.Label.t -> Id.t -> int Lwt.t val search - : Pool_database.Label.t - -> Id.t list + : ?conditions:string + -> ?dyn:Utils.Database.Dynparam.t + -> ?exclude:Id.t list + -> ?joins:string + -> ?limit:int + -> Pool_database.Label.t -> string -> (Id.t * Title.t) list Lwt.t @@ -264,6 +268,14 @@ val contact_is_enrolled -> Contact.Id.t -> bool Lwt.t +val find_targets_grantable_by_admin + : ?exclude:Id.t list + -> Pool_database.Label.t + -> Admin.t + -> Role.Role.t + -> string + -> (Id.t * Title.t) list Lwt.t + val possible_participant_count : t -> int Lwt.t val possible_participants : t -> Contact.t list Lwt.t val title_value : t -> string diff --git a/pool/app/experiment/repo/repo.ml b/pool/app/experiment/repo/repo.ml index 2e417d443..73c309d48 100644 --- a/pool/app/experiment/repo/repo.ml +++ b/pool/app/experiment/repo/repo.ml @@ -158,6 +158,21 @@ module Sql = struct Format.asprintf "%s %s" select_from where_fragment ;; + let search_select = + {sql| + SELECT + LOWER(CONCAT( + SUBSTR(HEX(pool_experiments.uuid), 1, 8), '-', + SUBSTR(HEX(pool_experiments.uuid), 9, 4), '-', + SUBSTR(HEX(pool_experiments.uuid), 13, 4), '-', + SUBSTR(HEX(pool_experiments.uuid), 17, 4), '-', + SUBSTR(HEX(pool_experiments.uuid), 21) + )), + pool_experiments.title + FROM pool_experiments + |sql} + ;; + let validate_experiment_sql m = Format.asprintf " AND %s " m, Dynparam.empty let select_count where_fragment = @@ -305,72 +320,62 @@ module Sql = struct (id |> Entity.Id.value) ;; - let search_request ?(limit = 20) ids = - let base = - {sql| - SELECT - LOWER(CONCAT( - SUBSTR(HEX(pool_experiments.uuid), 1, 8), '-', - SUBSTR(HEX(pool_experiments.uuid), 9, 4), '-', - SUBSTR(HEX(pool_experiments.uuid), 13, 4), '-', - SUBSTR(HEX(pool_experiments.uuid), 17, 4), '-', - SUBSTR(HEX(pool_experiments.uuid), 21) - )), - pool_experiments.title - FROM pool_experiments - WHERE pool_experiments.title LIKE $1 - |sql} + let search_request ?conditions ?joins ~limit () = + let default_contidion = "pool_experiments.title LIKE ?" in + let joined_select = + CCOption.map_or + ~default:search_select + (Format.asprintf "%s %s" search_select) + joins in - let query = - match ids with - | [] -> base - | ids -> - CCList.mapi - (fun i _ -> Format.asprintf "UNHEX(REPLACE($%i, '-', ''))" (i + 2)) - ids - |> CCString.concat "," - |> Format.asprintf - {sql| - %s - AND pool_experiments.uuid NOT IN (%s) - |sql} - base + let where = + CCOption.map_or + ~default:default_contidion + (Format.asprintf "%s AND %s" default_contidion) + conditions in - Format.asprintf "%s LIMIT %i" query limit + Format.asprintf "%s WHERE %s LIMIT %i" joined_select where limit ;; - let search pool exclude query = + let search + ?conditions + ?(dyn = Dynparam.empty) + ?exclude + ?joins + ?(limit = 20) + pool + query + = let open Caqti_request.Infix in - let dyn = - CCList.fold_left - (fun dyn id -> - dyn |> Dynparam.add Caqti_type.string (id |> Entity.Id.value)) - Dynparam.(empty |> add Caqti_type.string ("%" ^ query ^ "%")) - exclude + let exclude_ids = + Utils.Database.exclude_ids "pool_experiments.uuid" Entity.Id.value + in + let dyn = Dynparam.(dyn |> add Caqti_type.string ("%" ^ query ^ "%")) in + let dyn, exclude = + exclude |> CCOption.map_or ~default:(dyn, None) (exclude_ids dyn) + in + let conditions = + [ conditions; exclude ] + |> CCList.filter_map CCFun.id + |> function + | [] -> None + | conditions -> conditions |> CCString.concat " AND " |> CCOption.return in let (Dynparam.Pack (pt, pv)) = dyn in let request = - search_request exclude - |> pt ->* Caqti_type.(Repo_entity.(tup2 Repo_entity.Id.t Title.t)) + search_request ?conditions ?joins ~limit () + |> pt ->* Repo_entity.(Caqti_type.tup2 Id.t Title.t) in - Utils.Database.collect (pool |> Database.Label.value) request pv + Utils.Database.collect (pool |> Pool_database.Label.value) request pv ;; let search_multiple_by_id_request ids = Format.asprintf {sql| - SELECT - LOWER(CONCAT( - SUBSTR(HEX(pool_experiments.uuid), 1, 8), '-', - SUBSTR(HEX(pool_experiments.uuid), 9, 4), '-', - SUBSTR(HEX(pool_experiments.uuid), 13, 4), '-', - SUBSTR(HEX(pool_experiments.uuid), 17, 4), '-', - SUBSTR(HEX(pool_experiments.uuid), 21) - )), - pool_experiments.title - FROM pool_experiments + %s WHERE pool_experiments.uuid in ( %s ) |sql} + search_select (CCList.map (fun _ -> Format.asprintf "UNHEX(REPLACE(?, '-', ''))") ids |> CCString.concat ",") ;; @@ -524,6 +529,24 @@ module Sql = struct contact_is_enrolled_request (experiment_id |> Entity.Id.value, contact_id |> Contact.Id.value) ;; + + let find_targets_grantable_by_admin ?exclude database_label admin role query = + let joins = + {sql| + LEFT JOIN guardian_actor_role_targets t ON t.target_uuid = pool_experiments.uuid + AND t.actor_uuid = UNHEX(REPLACE(?, '-', '')) + AND t.role = ? + |sql} + in + let conditions = "t.role IS NULL" in + let dyn = + Dynparam.( + empty + |> add Caqti_type.string Admin.(id admin |> Id.value) + |> add Caqti_type.string Role.Role.(show role)) + in + search ~conditions ~joins ~dyn ?exclude database_label query + ;; end let find = Sql.find @@ -539,3 +562,4 @@ let search = Sql.search let search_multiple_by_id = Sql.search_multiple_by_id let find_to_enroll_directly = Sql.find_to_enroll_directly let contact_is_enrolled = Sql.contact_is_enrolled +let find_targets_grantable_by_admin = Sql.find_targets_grantable_by_admin diff --git a/pool/app/pool_location/pool_location.ml b/pool/app/pool_location/pool_location.ml index 7be4c43cc..4bf885e27 100644 --- a/pool/app/pool_location/pool_location.ml +++ b/pool/app/pool_location/pool_location.ml @@ -9,3 +9,4 @@ let find_all = Repo.find_all let find_location_file = Repo_file_mapping.find let search = Repo.search let search_multiple_by_id = Repo.search_multiple_by_id +let find_targets_grantable_by_admin = Repo.find_targets_grantable_by_admin diff --git a/pool/app/pool_location/pool_location.mli b/pool/app/pool_location/pool_location.mli index 8767808f9..cf5d4f03b 100644 --- a/pool/app/pool_location/pool_location.mli +++ b/pool/app/pool_location/pool_location.mli @@ -289,8 +289,12 @@ val find_location_file -> (Mapping.file, Entity.Message.error) result Lwt.t val search - : Pool_database.Label.t - -> Id.t list + : ?conditions:string + -> ?dyn:Utils.Database.Dynparam.t + -> ?exclude:Id.t list + -> ?joins:string + -> ?limit:int + -> Pool_database.Label.t -> string -> (Id.t * Name.t) list Lwt.t @@ -299,6 +303,13 @@ val search_multiple_by_id -> Id.t list -> (Id.t * Name.t) list Lwt.t +val find_targets_grantable_by_admin + : ?exclude:Id.t list + -> Pool_database.Label.t + -> Admin.t + -> string + -> (Id.t * Name.t) list Lwt.t + val default_values : t list module Human : sig diff --git a/pool/app/pool_location/repo/repo.ml b/pool/app/pool_location/repo/repo.ml index d23c49f9c..00e419ea5 100644 --- a/pool/app/pool_location/repo/repo.ml +++ b/pool/app/pool_location/repo/repo.ml @@ -5,6 +5,11 @@ module Dynparam = Utils.Database.Dynparam let to_entity = to_entity let of_entity = of_entity +let files_to_location pool ({ id; _ } as location) = + let open Utils.Lwt_result.Infix in + RepoFileMapping.find_by_location pool id ||> to_entity location +;; + module Sql = struct let select_sql = {sql| @@ -34,6 +39,21 @@ module Sql = struct |sql} ;; + let search_select = + {sql| + SELECT + LOWER(CONCAT( + SUBSTR(HEX(pool_locations.uuid), 1, 8), '-', + SUBSTR(HEX(pool_locations.uuid), 9, 4), '-', + SUBSTR(HEX(pool_locations.uuid), 13, 4), '-', + SUBSTR(HEX(pool_locations.uuid), 17, 4), '-', + SUBSTR(HEX(pool_locations.uuid), 21) + )), + pool_locations.name + FROM pool_locations + |sql} + ;; + let find_request = let open Caqti_request.Infix in {sql| @@ -140,50 +160,51 @@ module Sql = struct (id, (name, (description, (address, (link, status))))) ;; - let search_request = - let base = - {sql| - SELECT - LOWER(CONCAT( - SUBSTR(HEX(pool_locations.uuid), 1, 8), '-', - SUBSTR(HEX(pool_locations.uuid), 9, 4), '-', - SUBSTR(HEX(pool_locations.uuid), 13, 4), '-', - SUBSTR(HEX(pool_locations.uuid), 17, 4), '-', - SUBSTR(HEX(pool_locations.uuid), 21) - )), - pool_locations.name - FROM pool_locations - WHERE pool_locations.name LIKE $1 - |sql} + let search_request ?joins ?conditions ~limit () = + let default_contidion = "pool_locations.name LIKE ?" in + let joined_select = + CCOption.map_or + ~default:search_select + (Format.asprintf "%s %s" search_select) + joins in - function - | [] -> base - | ids -> - ids - |> CCList.mapi (fun i _ -> - Format.asprintf "UNHEX(REPLACE($%i, '-', ''))" (i + 2)) - |> CCString.concat "," - |> Format.asprintf - {sql| - %s - AND pool_locations.uuid NOT IN (%s) - |sql} - base + let where = + CCOption.map_or + ~default:default_contidion + (Format.asprintf "%s AND %s" default_contidion) + conditions + in + Format.asprintf "%s WHERE %s LIMIT %i" joined_select where limit ;; - let search pool exclude query = + let search + ?conditions + ?(dyn = Dynparam.empty) + ?exclude + ?joins + ?(limit = 20) + pool + query + = let open Caqti_request.Infix in - let dyn = - CCList.fold_left - (fun dyn id -> - dyn |> Dynparam.add Caqti_type.string (id |> Entity.Id.value)) - Dynparam.(empty |> add Caqti_type.string ("%" ^ query ^ "%")) - exclude + let exclude_ids = + Utils.Database.exclude_ids "pool_locations.uuid" Entity.Id.value + in + let dyn = Dynparam.(dyn |> add Caqti_type.string ("%" ^ query ^ "%")) in + let dyn, exclude = + exclude |> CCOption.map_or ~default:(dyn, None) (exclude_ids dyn) + in + let conditions = + [ conditions; exclude ] + |> CCList.filter_map CCFun.id + |> function + | [] -> None + | conditions -> conditions |> CCString.concat " AND " |> CCOption.return in let (Dynparam.Pack (pt, pv)) = dyn in let request = - search_request exclude - |> pt ->* Caqti_type.(Repo_entity.(tup2 Id.t Name.t)) + search_request ?joins ?conditions ~limit () + |> pt ->* Caqti_type.tup2 Id.t Name.t in Utils.Database.collect (pool |> Pool_database.Label.value) request pv ;; @@ -191,18 +212,10 @@ module Sql = struct let search_multiple_by_id_request ids = Format.asprintf {sql| - SELECT - LOWER(CONCAT( - SUBSTR(HEX(pool_locations.uuid), 1, 8), '-', - SUBSTR(HEX(pool_locations.uuid), 9, 4), '-', - SUBSTR(HEX(pool_locations.uuid), 13, 4), '-', - SUBSTR(HEX(pool_locations.uuid), 17, 4), '-', - SUBSTR(HEX(pool_locations.uuid), 21) - )), - pool_locations.name - FROM pool_locations + %s WHERE pool_locations.uuid in ( %s ) |sql} + search_select (CCList.map (fun _ -> Format.asprintf "UNHEX(REPLACE(?, '-', ''))") ids |> CCString.concat ",") ;; @@ -226,12 +239,25 @@ module Sql = struct in Utils.Database.collect (pool |> Pool_database.Label.value) request pv ;; -end -let files_to_location pool ({ id; _ } as location) = - let open Utils.Lwt_result.Infix in - RepoFileMapping.find_by_location pool id ||> to_entity location -;; + let find_targets_grantable_by_admin ?exclude database_label admin query = + let joins = + {sql| + LEFT JOIN guardian_actor_role_targets t ON t.target_uuid = pool_locations.uuid + AND t.actor_uuid = UNHEX(REPLACE(?, '-', '')) + AND t.role = ? + |sql} + in + let conditions = "t.role IS NULL" in + let dyn = + Dynparam.( + empty + |> add Caqti_type.string Admin.(id admin |> Id.value) + |> add Caqti_type.string Role.Role.(show `LocationManager)) + in + search ~conditions ~joins ~dyn ?exclude database_label query + ;; +end let find pool id = let open Utils.Lwt_result.Infix in @@ -251,3 +277,4 @@ let insert pool location files = let update = Sql.update let search = Sql.search let search_multiple_by_id = Sql.search_multiple_by_id +let find_targets_grantable_by_admin = Sql.find_targets_grantable_by_admin diff --git a/pool/app/utils/database.ml b/pool/app/utils/database.ml index 9a29297a3..ad45527a8 100644 --- a/pool/app/utils/database.ml +++ b/pool/app/utils/database.ml @@ -148,6 +148,26 @@ let exec_as_transaction database_label commands = transaction database_label fnc ;; +let exclude_ids column_name decode_id dyn exclude = + let sql = "UNHEX(REPLACE(?, '-', ''))" in + match exclude with + | [] -> dyn, None + | exclude -> + let dyn, sql_strings = + CCList.fold_left + (fun (dyn, sql_strings) id -> + ( dyn |> Dynparam.add Caqti_type.string (id |> decode_id) + , sql :: sql_strings )) + (dyn, []) + exclude + in + sql_strings + |> CCString.concat ", " + |> Format.asprintf "%s NOT IN (%s)" column_name + |> CCOption.return + |> CCPair.make dyn +;; + let set_fk_check_request = let open Caqti_request.Infix in "SET FOREIGN_KEY_CHECKS = ?" |> Caqti_type.(bool ->. unit) diff --git a/pool/web/handler/admin_admins.ml b/pool/web/handler/admin_admins.ml index 979082d61..3dd548baa 100644 --- a/pool/web/handler/admin_admins.ml +++ b/pool/web/handler/admin_admins.ml @@ -128,22 +128,9 @@ let search_role_entities req = |> to_result Pool_common.Message.(NotFound Field.Role) |> Lwt_result.lift in - let%lwt existing_targets = - (* TODO: Could be solved on database lvl *) - Helpers_guard.find_roles database_label admin - ||> CCList.filter_map - (fun ({ Guard.ActorRole.target_uuid; role; _ }, _, _) -> - if Role.Role.equal role search_role then None else target_uuid) - in let entities_to_exclude encode_id = - let open CCList in - let%lwt selected = - HttpUtils.htmx_urlencoded_list Field.(array_key Target) req - in - (* TODO: NOT SURE THIS WORKS CORRECTLY *) - Lwt.return - (map (Guard.Uuid.Target.to_string %> encode_id) existing_targets - @ map encode_id selected) + HttpUtils.htmx_urlencoded_list Field.(array_key Target) req + ||> CCList.map encode_id in let execute_search search_fnc to_html = (match query with @@ -158,17 +145,22 @@ let search_role_entities req = let open Experiment.Guard.Access in let%lwt exclude = entities_to_exclude Experiment.Id.of_string in let search_experiment value actor = - Experiment.search database_label exclude value + Experiment.find_targets_grantable_by_admin + ~exclude + database_label + admin + search_role + value >|> Lwt_list.filter_s (fun (id, _) -> - (* TODO: Could be solved on database lvl *) validate database_label (read id) actor ||> CCResult.is_ok) in execute_search search_experiment Component.Search.Experiment.query_results | `LocationManager -> let open Pool_location.Guard.Access in - let%lwt exclude = entities_to_exclude Pool_location.Id.of_string in + let open Pool_location in + let%lwt exclude = entities_to_exclude Id.of_string in let search_location value actor = - Pool_location.search database_label exclude value + find_targets_grantable_by_admin ~exclude database_label admin value >|> Lwt_list.filter_s (fun (id, _) -> validate database_label (read id) actor ||> CCResult.is_ok) in diff --git a/pool/web/handler/helpers_search.ml b/pool/web/handler/helpers_search.ml index 395199a4c..b0271762f 100644 --- a/pool/web/handler/helpers_search.ml +++ b/pool/web/handler/helpers_search.ml @@ -37,9 +37,8 @@ let htmx_search_helper let open Experiment.Guard.Access in let%lwt exclude = entities_to_exclude Experiment.Id.of_string in let search_experiment value actor = - Experiment.search database_label exclude value + Experiment.search ~exclude database_label value >|> Lwt_list.filter_s (fun (id, _) -> - (* TODO: Could be solved on database lvl *) validate database_label (read id) actor ||> CCResult.is_ok) in execute_search search_experiment query_results @@ -48,7 +47,7 @@ let htmx_search_helper let open Pool_location.Guard.Access in let%lwt exclude = entities_to_exclude Pool_location.Id.of_string in let search_location value actor = - Pool_location.search database_label exclude value + Pool_location.search database_label ~exclude value >|> Lwt_list.filter_s (fun (id, _) -> validate database_label (read id) actor ||> CCResult.is_ok) in diff --git a/pool/web/view/component/component_role.ml b/pool/web/view/component/component_role.ml index 7b8017edb..d21200ee7 100644 --- a/pool/web/view/component/component_role.ml +++ b/pool/web/view/component/component_role.ml @@ -180,8 +180,8 @@ module Search = struct div ~a:[ a_class [ "switcher-sm"; "flex-gap" ] ] [ input_field ] ;; - let role_form ?key language csrf admin identifier role_list = - let toggle_id = Format.asprintf "role-search-%i" identifier in + let role_form ?key language csrf admin role_list = + let toggle_id = "role-search" in let toggled_content = value_form language (Admin.id admin) ?key () in let key_selector = let attributes = @@ -225,9 +225,8 @@ module Search = struct ] ;; - (* TODO: Identifier? Can I remove? *) - let input_form ?(identifier = 0) ?key csrf language admin role_list () = - let role_form = role_form ?key language csrf admin identifier role_list in + let input_form ?key csrf language admin role_list () = + let role_form = role_form ?key language csrf admin role_list in let stack = "stack-sm" in div ~a:[ a_class [ stack; "inset-sm"; "border"; "role-search" ] ] From dc56a8900d66f69c0d9b09519808f69f448122a6 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Wed, 1 Nov 2023 07:44:16 +0100 Subject: [PATCH 16/18] remove comment --- pool/app/filter/entity.ml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pool/app/filter/entity.ml b/pool/app/filter/entity.ml index 0dfd7d944..7121b482a 100644 --- a/pool/app/filter/entity.ml +++ b/pool/app/filter/entity.ml @@ -380,8 +380,6 @@ module Operator = struct ;; let to_sql = function - (* TODO: Differ between select ( = & != ) and multi select ( LIKE / NOT - LIKE ), if it is performance relevant *) (* List operators are used to query custom field answers by their value which store json arrays *) | ContainsSome | ContainsAll -> "LIKE" From eeef67afb9708b98f778e8b3d1356fa816d4da64 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Wed, 1 Nov 2023 07:45:33 +0100 Subject: [PATCH 17/18] move function out of sql module --- pool/app/pool_location/repo/repo.ml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pool/app/pool_location/repo/repo.ml b/pool/app/pool_location/repo/repo.ml index 00e419ea5..0cfe64c60 100644 --- a/pool/app/pool_location/repo/repo.ml +++ b/pool/app/pool_location/repo/repo.ml @@ -5,11 +5,6 @@ module Dynparam = Utils.Database.Dynparam let to_entity = to_entity let of_entity = of_entity -let files_to_location pool ({ id; _ } as location) = - let open Utils.Lwt_result.Infix in - RepoFileMapping.find_by_location pool id ||> to_entity location -;; - module Sql = struct let select_sql = {sql| @@ -259,6 +254,11 @@ module Sql = struct ;; end +let files_to_location pool ({ id; _ } as location) = + let open Utils.Lwt_result.Infix in + RepoFileMapping.find_by_location pool id ||> to_entity location +;; + let find pool id = let open Utils.Lwt_result.Infix in Sql.find pool id |>> files_to_location pool From bc489a2e04491e17831b5a72a402dfb8fa17b5e9 Mon Sep 17 00:00:00 2001 From: Timo Huber Date: Wed, 1 Nov 2023 14:45:20 +0100 Subject: [PATCH 18/18] fix disabling of search input in filter form --- pool/web/view/component/component_filter.ml | 2 +- resources/admin/filter.js | 2 +- resources/index.scss | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pool/web/view/component/component_filter.ml b/pool/web/view/component/component_filter.ml index 666d71f51..38d896c92 100644 --- a/pool/web/view/component/component_filter.ml +++ b/pool/web/view/component/component_filter.ml @@ -132,7 +132,6 @@ let value_input | _ -> false) single_value in - (* TODO: Add option to disable *) Input.checkbox_element ~additional_attributes ~as_switch:true @@ -168,6 +167,7 @@ let value_input ~additional_attributes ~is_filter:true ~input_type + ~disabled language field_name multi_select diff --git a/resources/admin/filter.js b/resources/admin/filter.js index 60986271c..5bf0b50f4 100644 --- a/resources/admin/filter.js +++ b/resources/admin/filter.js @@ -203,7 +203,7 @@ function addOperatorChangeListeners(wrapper) { [...wrapper.querySelectorAll("[name='operator']")].forEach((elm) => { elm.addEventListener("change", (e) => { const predicate = e.target.closest('.predicate'); - const inputs = [...predicate.querySelectorAll("[name='value'],[name='value[]']")]; + const inputs = [...predicate.querySelectorAll("[data-name='value[]'],[name='value'],[name='value[]']")]; inputs.forEach(input => input.disabled = disableValueInput(e.currentTarget.value)) }) }) diff --git a/resources/index.scss b/resources/index.scss index 37024d48b..9c4815415 100644 --- a/resources/index.scss +++ b/resources/index.scss @@ -114,6 +114,9 @@ ul.no-style { display: none; } } + input[disabled] { + cursor: not-allowed; + } } [data-search-selection] {