From 87daa790f3dc292916ad191e1a122454bb6c0ffe Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Mon, 2 Dec 2024 15:45:35 +0100 Subject: [PATCH] feat: search filters & history --- .../izanami/datastores/SearchDatastore.scala | 338 +++++++++------ app/fr/maif/izanami/errors/Errors.scala | 4 + .../maif/izanami/web/SearchController.scala | 172 +++++--- conf/application.conf | 5 + conf/routes | 4 +- docs/docs/guides/configuration/index.html | 11 +- izanami-frontend/src/App.tsx | 4 +- .../SearchComponant/SearchAction.tsx | 96 +++++ .../SearchComponant/SearchInput.tsx | 66 +++ .../SearchComponant/SearchModal.tsx | 11 +- .../SearchComponant/SearchModalContent.tsx | 401 ++++-------------- .../SearchComponant/SearchResults.tsx | 247 +++++++++++ .../src/styles/components/_searchmodal.scss | 28 +- izanami-frontend/src/utils/queries.tsx | 30 +- izanami-frontend/src/utils/searchUtils.tsx | 138 ++++++ manual/docs/04-guides/12-configuration.mdx | 25 +- test/fr/maif/izanami/api/SearchAPISpec.scala | 23 +- 17 files changed, 1067 insertions(+), 536 deletions(-) create mode 100644 izanami-frontend/src/components/SearchComponant/SearchAction.tsx create mode 100644 izanami-frontend/src/components/SearchComponant/SearchInput.tsx create mode 100644 izanami-frontend/src/components/SearchComponant/SearchResults.tsx create mode 100644 izanami-frontend/src/utils/searchUtils.tsx diff --git a/app/fr/maif/izanami/datastores/SearchDatastore.scala b/app/fr/maif/izanami/datastores/SearchDatastore.scala index 841221362..44341d63a 100644 --- a/app/fr/maif/izanami/datastores/SearchDatastore.scala +++ b/app/fr/maif/izanami/datastores/SearchDatastore.scala @@ -3,149 +3,215 @@ package fr.maif.izanami.datastores import fr.maif.izanami.env.Env import fr.maif.izanami.env.pgimplicits.EnhancedRow import fr.maif.izanami.utils.Datastore -import play.api.libs.json.{JsObject} +import play.api.libs.json.{JsObject, Json} +import fr.maif.izanami.web.SearchController.{SearchEntityObject, SearchEntityType} import scala.concurrent.Future - class SearchDatastore(val env: Env) extends Datastore { - def tenantSearch(tenant: String, username: String, query: String): Future[List[(String, JsObject, Double)]] = { + private val similarityThresholdParam = env.configuration.get[Int]("app.search.similarity-threshold") + def tenantSearch( + tenant: String, + username: String, + query: String, + filter: List[Option[SearchEntityType]] + ): Future[List[(String, JsObject, Double)]] = { + val searchQuery = new StringBuilder() + searchQuery.append("WITH ") + + var scoredQueries = List[String]() + var unionQueries = List[String]() + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.Project))|| filter.contains(Some(SearchEntityObject.Feature))) { + scoredQueries :+= + s""" + scored_projects AS ( + SELECT DISTINCT + p.name, + p.description, + izanami.SIMILARITY(p.name, $$1) AS name_score, + izanami.SIMILARITY(p.description, $$1) AS description_score + FROM projects p + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + LEFT JOIN users_projects_rights upr ON (utr.username=$$2 AND p.name=upr.project) + WHERE utr.level='ADMIN' + OR upr.level IS NOT NULL + OR u.admin=true + ) + """ + } + if (filter.isEmpty || filter.contains(SearchEntityObject.Project)) { + unionQueries :+= s""" + SELECT row_to_json(p.*) as json, GREATEST(p.name_score, p.description_score) AS match_score, 'project' as _type, $$3 as tenant + FROM scored_projects p + WHERE p.name_score > $similarityThresholdParam OR p.description_score > $similarityThresholdParam""" + } + + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.Feature))) { + scoredQueries :+= + s""" + scored_features AS ( + SELECT DISTINCT + f.project, + f.name, + f.description, + izanami.SIMILARITY(f.name, $$1) AS name_score, + izanami.SIMILARITY(f.description, $$1) AS description_score + FROM scored_projects p, features f + WHERE f.project=p.name + ) + """ + unionQueries :+= s""" + SELECT row_to_json(f.*) as json, GREATEST(f.name_score, f.description_score) AS match_score, 'feature' as _type, $$3 as tenant + FROM scored_features f + WHERE f.name_score > $similarityThresholdParam OR f.description_score > $similarityThresholdParam""" + + } + + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.Key))) { + scoredQueries :+= + s""" + scored_keys AS ( + SELECT DISTINCT + k.name, + k.description, + izanami.SIMILARITY(k.name, $$1) AS name_score, + izanami.SIMILARITY(k.description, $$1) AS description_score + FROM apikeys k + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + LEFT JOIN users_keys_rights ukr ON (utr.username=$$2 AND k.name=ukr.apikey) + WHERE utr.level='ADMIN' + OR ukr.level IS NOT NULL + OR u.admin=true + ) + """ + unionQueries :+= s""" + SELECT row_to_json(k.*) as json, GREATEST(k.name_score, k.description_score) AS match_score, 'key' as _type, $$3 as tenant + FROM scored_keys k + WHERE k.name_score > $similarityThresholdParam OR k.description_score > $similarityThresholdParam""" + } + + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.Tag))) { + scoredQueries :+= + s""" + scored_tags AS ( + SELECT DISTINCT + t.name, + t.description, + izanami.SIMILARITY(t.name, $$1) AS name_score, + izanami.SIMILARITY(t.description, $$1) AS description_score + FROM tags t + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + WHERE utr.level IS NOT NULL + OR u.admin=true + ) + """ + unionQueries :+= s""" + SELECT row_to_json(t.*) as json, GREATEST(t.name_score, t.description_score) AS match_score, 'tag' as _type, $$3 as tenant + FROM scored_tags t + WHERE t.name_score > $similarityThresholdParam OR t.description_score > $similarityThresholdParam""" + } + + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.Script))) { + scoredQueries :+= + s""" + scored_scripts AS ( + SELECT DISTINCT + s.id as name, + izanami.SIMILARITY(s.id, $$1) as name_score + FROM wasm_script_configurations s + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + WHERE utr.level IS NOT NULL + OR u.admin=true + ) + """ + unionQueries :+= s""" + SELECT row_to_json(s.*) as json, s.name_score AS match_score, 'script' as _type, $$3 as tenant + FROM scored_scripts s + WHERE s.name_score > $similarityThresholdParam""" + } + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.GlobalContext))) { + scoredQueries :+= + s""" + scored_global_contexts AS ( + SELECT DISTINCT + c.parent, + c.name as name, + izanami.SIMILARITY(c.name, $$1) as name_score + FROM global_feature_contexts c + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + WHERE utr.level IS NOT NULL + OR u.admin=true + ) + """ + unionQueries :+= s""" + SELECT row_to_json(gc.*) as json, gc.name_score AS match_score, 'global_context' as _type, $$3 as tenant + FROM scored_global_contexts gc + WHERE gc.name_score > $similarityThresholdParam """ + } + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.LocalContext))) { + scoredQueries :+= + s""" + scored_local_contexts AS ( + SELECT DISTINCT + c.parent, + c.project, + c.name as name, + izanami.SIMILARITY(c.name, $$1) as name_score + FROM feature_contexts c + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + WHERE utr.level IS NOT NULL + OR u.admin=true + ) + """ + unionQueries :+= s""" + SELECT row_to_json(lc.*) as json, lc.name_score AS match_score, 'local_context' as _type, $$3 as tenant + FROM scored_local_contexts lc + WHERE lc.name_score > $similarityThresholdParam """ + } + if (filter.isEmpty || filter.contains(Some(SearchEntityObject.Webhook))) { + scoredQueries :+= + s""" + scored_webhooks AS ( + SELECT DISTINCT + w.name, + w.description, + izanami.SIMILARITY(w.name, $$1) as name_score, + izanami.SIMILARITY(w.description, $$1) as description_score + FROM webhooks w + LEFT JOIN izanami.users u ON u.username=$$2 + LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) + LEFT JOIN users_webhooks_rights uwr ON (utr.username=$$2 AND w.name=uwr.webhook) + WHERE utr.level='ADMIN' + OR uwr.level is not null + OR u.admin=true + ) + """ + unionQueries :+= s""" + SELECT row_to_json(w.*) as json, GREATEST(w.name_score, w.description_score) AS match_score, 'webhook' as _type, $$3 as tenant + FROM scored_webhooks w + WHERE w.name_score > $similarityThresholdParam OR w.description_score > $similarityThresholdParam""" + } + + searchQuery.append(scoredQueries.mkString(",")) + searchQuery.append(unionQueries.mkString(" UNION ALL ")) + searchQuery.append(" ORDER BY match_score DESC LIMIT 10") + env.postgresql.queryAll( - s""" - |WITH scored_projects AS ( - | SELECT DISTINCT - | p.name, - | p.description, - | izanami.SIMILARITY(p.name, $$1) as name_score, - | izanami.SIMILARITY(p.description, $$1) as description_score - | FROM projects p - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | LEFT JOIN users_projects_rights upr ON (utr.username=$$2 AND p.name=upr.project) - | WHERE utr.level='ADMIN' - | OR upr.level is not null - | OR u.admin=true - |), scored_features AS ( - | SELECT DISTINCT - | f.project, - | f.name, - | f.description, - | izanami.SIMILARITY(f.name, $$1) as name_score, - | izanami.SIMILARITY(f.description, $$1) as description_score - | FROM scored_projects p, features f - | WHERE f.project=p.name - |), scored_keys AS ( - | SELECT DISTINCT - | k.name, - | k.description, - | izanami.SIMILARITY(k.name, $$1) as name_score, - | izanami.SIMILARITY(k.description, $$1) as description_score - | FROM apikeys k - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | LEFT JOIN users_keys_rights ukr ON (utr.username=$$2 AND k.name=ukr.apikey) - | WHERE utr.level='ADMIN' - | OR ukr.level is not null - | OR u.admin=true - |), scored_tags AS ( - | SELECT DISTINCT - | t.name, - | t.description, - | izanami.SIMILARITY(t.name, $$1) as name_score, - | izanami.SIMILARITY(t.description, $$1) as description_score - | FROM tags t - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | WHERE utr.level IS NOT NULL - | OR u.admin=true - |), scored_scripts AS ( - | SELECT DISTINCT - | s.id as name, - | izanami.SIMILARITY(s.id, $$1) as name_score - | FROM wasm_script_configurations s - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | WHERE utr.level IS NOT NULL - | OR u.admin=true - | ), scored_global_contexts AS ( - | SELECT DISTINCT - | c.parent, - | c.name as name, - | izanami.SIMILARITY(c.name, $$1) as name_score - | FROM global_feature_contexts c - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | WHERE utr.level IS NOT NULL - | OR u.admin=true - | ), scored_local_contexts AS ( - | SELECT DISTINCT - | c.parent, - | c.global_parent, - | c.project, - | c.name as name, - | izanami.SIMILARITY(c.name, $$1) as name_score - | FROM feature_contexts c - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | WHERE utr.level IS NOT NULL - | OR u.admin=true - | ), scored_webhooks AS ( - | SELECT DISTINCT - | w.name, - | w.description, - | izanami.SIMILARITY(w.name, $$1) as name_score, - | izanami.SIMILARITY(w.description, $$1) as description_score - | FROM webhooks w - | LEFT JOIN izanami.users u ON u.username=$$2 - | LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3) - | LEFT JOIN users_webhooks_rights uwr ON (utr.username=$$2 AND w.name=uwr.webhook) - | WHERE utr.level='ADMIN' - | OR uwr.level is not null - | OR u.admin=true - | ) - |SELECT row_to_json(f.*) as json, GREATEST(f.name_score, f.description_score) AS match_score, 'feature' as _type, $$3 as tenant - |FROM scored_features f - |WHERE f.name_score > 0.2 OR f.description_score > 0.2 - |UNION ALL - |SELECT row_to_json(p.*) as json, GREATEST(p.name_score, p.description_score) AS match_score, 'project' as _type, $$3 as tenant - |FROM scored_projects p - |WHERE p.name_score > 0.2 OR p.description_score > 0.2 - |UNION ALL - |SELECT row_to_json(k.*) as json, GREATEST(k.name_score, k.description_score) AS match_score, 'key' as _type, $$3 as tenant - |FROM scored_keys k - |WHERE k.name_score > 0.2 OR k.description_score > 0.2 - |UNION ALL - |SELECT row_to_json(t.*) as json, GREATEST(t.name_score, t.description_score) AS match_score, 'tag' as _type, $$3 as tenant - |FROM scored_tags t - |WHERE t.name_score > 0.2 OR t.description_score > 0.2 - |UNION ALL - |SELECT row_to_json(s.*) as json, s.name_score AS match_score, 'script' as _type, $$3 as tenant - |FROM scored_scripts s - |WHERE s.name_score > 0.2 - |UNION ALL - |SELECT row_to_json(gc.*) as json, gc.name_score AS match_score, 'global_context' as _type, $$3 as tenant - |FROM scored_global_contexts gc - |WHERE gc.name_score > 0.2 - |UNION ALL - |SELECT row_to_json(lc.*) as json, lc.name_score AS match_score, 'local_context' as _type, $$3 as tenant - |FROM scored_local_contexts lc - |WHERE lc.name_score > 0.2 - |UNION ALL - |SELECT row_to_json(w.*) as json, GREATEST(w.name_score, w.description_score) AS match_score, 'webhook' as _type, $$3 as tenant - |FROM scored_webhooks w - |WHERE w.name_score > 0.2 OR w.description_score > 0.2 - |ORDER BY match_score DESC LIMIT 10 - |""".stripMargin, + searchQuery.toString(), List(query, username, tenant), schemas = Set(tenant) ) { r => - { - for ( - t <- r.optString("_type"); - json <- r.optJsObject("json"); - score <- r.optDouble("match_score") - ) yield { - (t, json, score) - } + for { + t <- r.optString("_type") + json <- r.optJsObject("json") + score <- r.optDouble("match_score") + } yield { + (t, json, score) } } } diff --git a/app/fr/maif/izanami/errors/Errors.scala b/app/fr/maif/izanami/errors/Errors.scala index 5d7ec50f4..9ab2e188a 100644 --- a/app/fr/maif/izanami/errors/Errors.scala +++ b/app/fr/maif/izanami/errors/Errors.scala @@ -164,6 +164,10 @@ case class ConflictingName(tenant: String, entityTpe: String, row: JsObject) .toString()}", status = 400 ) +case class SearchFilterError() + extends IzanamiError(message = s"Invalid filters provided. Please ensure your filters are correct.", status = BAD_REQUEST) +case class SearchQueryError() + extends IzanamiError(message = s"Query parameter is missing.", status = BAD_REQUEST) case class GenericBadRequest(override val message: String) extends IzanamiError(message = message, status = 400) case class PartialImportFailure(failedElements: Map[ExportedType, Seq[JsObject]]) extends IzanamiError(message = s"Some element couldn't be imported", status = 400) diff --git a/app/fr/maif/izanami/web/SearchController.scala b/app/fr/maif/izanami/web/SearchController.scala index cf70bfe9b..46795b7ab 100644 --- a/app/fr/maif/izanami/web/SearchController.scala +++ b/app/fr/maif/izanami/web/SearchController.scala @@ -1,8 +1,10 @@ package fr.maif.izanami.web import fr.maif.izanami.env.Env -import fr.maif.izanami.utils.syntax.implicits.{BetterJsValue, BetterSyntax} +import fr.maif.izanami.errors.{IzanamiError, SearchFilterError, SearchQueryError} +import fr.maif.izanami.utils.syntax.implicits.BetterSyntax import fr.maif.izanami.web.PathElement.pathElementWrite +import fr.maif.izanami.web.SearchController.SearchEntityObject import play.api.libs.json.{JsObject, Json, Writes} import play.api.mvc._ @@ -17,72 +19,88 @@ class SearchController( ) extends BaseController { implicit val ec: ExecutionContext = env.executionContext - def search(query: String): Action[AnyContent] = tenantRightAction.async { - implicit request: UserRequestWithTenantRights[AnyContent] => - { - val tenants = request.user.tenantRights.keySet - Future - .sequence( - tenants - .map(tenant => - env.datastores.search - .tenantSearch(tenant, request.user.username, query) - .map(l => - l.map(t => { - (t._1, t._2, t._3, tenant) - }) - ) - ) - ) - .map(l => l.flatten.toList.sortBy(_._3)(Ordering.Double.TotalOrdering.reverse).take(10)) - .flatMap(results => { - Future.sequence(results.map { case (rowType, rowJson, _, tenant) => - buildPath(rowType, rowJson, tenant) - .map(pathElements => { - val name = (rowJson \ "name").asOpt[String].getOrElse("") - val jsonPath = - Json.toJson(pathElements.prepended(TenantPathElement(tenant)))(Writes.seq(pathElementWrite)) - Json.obj( - "type" -> rowType, - "name" -> name, - "path" -> jsonPath, - "tenant" -> tenant - ) - }) + + private def checkSearchParams(query: String, filter: List[String]): Future[Either[IzanamiError, Unit]] = { + if (query.isEmpty) { + return Future.successful(Left(SearchQueryError())) + } + if (filter.nonEmpty && !filter.forall(SearchEntityObject.parseSearchEntityType(_).isDefined)) { + return Future.successful(Left(SearchFilterError())) + } + Future.successful(Right()) + } + + def search(query: String, filter: List[String]): Action[AnyContent] = tenantRightAction.async { + implicit request: UserRequestWithTenantRights[AnyContent] => { + checkSearchParams(query, filter).flatMap { + case Left(error) => error.toHttpResponse.future + case Right(_) => + val tenants = request.user.tenantRights.keySet + Future + .sequence( + tenants + .map(tenant => + env.datastores.search + .tenantSearch(tenant, request.user.username, query, filter.map( item => SearchEntityObject.parseSearchEntityType(item))) + .map(l => + l.map(t => { + (t._1, t._2, t._3, tenant) + }) + ) + ) + ) + .map(l => l.flatten.toList.sortBy(_._3)(Ordering.Double.TotalOrdering.reverse).take(10)) + .flatMap(results => { + Future.sequence(results.map { case (rowType, rowJson, _, tenant) => + buildPath(rowType, rowJson, tenant) + .map(pathElements => { + val name = (rowJson \ "name").asOpt[String].getOrElse("") + val jsonPath = + Json.toJson(pathElements.prepended(TenantPathElement(tenant)))(Writes.seq(pathElementWrite)) + Json.obj( + "type" -> rowType, + "name" -> name, + "path" -> jsonPath, + "tenant" -> tenant + ) + }) + }) }) - }) - .map(r => Ok(Json.toJson(r))) + .map(r => Ok(Json.toJson(r))) } + } } - def searchForTenant(tenant: String, query: String): Action[AnyContent] = simpleAuthAction.async { + def searchForTenant(tenant: String, query: String, filter: List[String]): Action[AnyContent] = simpleAuthAction.async { implicit request: UserNameRequest[AnyContent] => - { - env.datastores.search - .tenantSearch(tenant, request.user, query) - .flatMap(results => { - Future.sequence(results.map { case (rowType, rowJson, _) => - buildPath(rowType, rowJson, tenant) - .map(pathElements => { - val jsonPath = Json.toJson(pathElements)(Writes.seq(pathElementWrite)) - val name = (rowJson \ "name").asOpt[String].getOrElse("") - Json.obj( - "type" -> rowType, - "name" -> name, - "path" -> jsonPath, - "tenant" -> tenant - ) - }) + checkSearchParams(query, filter).flatMap { + case Left(error) => error.toHttpResponse.future + case Right(_) => + env.datastores.search + .tenantSearch(tenant, request.user, query, filter.map(item => SearchEntityObject.parseSearchEntityType(item))) + .flatMap(results => { + Future.sequence(results.map { case (rowType, rowJson, _) => + buildPath(rowType, rowJson, tenant) + .map(pathElements => { + val jsonPath = Json.toJson(pathElements)(Writes.seq(pathElementWrite)) + val name = (rowJson \ "name").asOpt[String].getOrElse("") + Json.obj( + "type" -> rowType, + "name" -> name, + "path" -> jsonPath, + "tenant" -> tenant + ) + }) + }) }) - }) - .map(res => Ok(Json.toJson(res))) + .map(res => Ok(Json.toJson(res))) } } - def buildPath(rowType: String, rowJson: JsObject, tenant: String): Future[Seq[PathElement]] = { + private def buildPath(rowType: String, rowJson: JsObject, tenant: String): Future[Seq[PathElement]] = { rowType match { case "feature" => Seq(ProjectPathElement((rowJson \ "project").as[String])).future - case "global_context" => { + case "global_context" => (rowJson \ "parent") .asOpt[String] .map(parent => { @@ -90,8 +108,8 @@ class SearchController( }) .getOrElse(Seq.empty) .future - } - case "local_context" => { + + case "local_context" => (rowJson \ "global_parent") .asOpt[String] .map(parent => { @@ -110,11 +128,11 @@ class SearchController( val parts = parent.split("_") val project = parts.head - // We look for shortes local context parent, since before him all contexts will be global + // We look for shortest local context parent, since before him all contexts will be global env.datastores.featureContext - .findLocalContexts(tenant, generateParentCandidates(parts.toSeq.drop(1)).map(s => s"${project}_${s}")) - .map(ctxs => { - val parentLocalContext = ctxs.sortBy(_.length).headOption + .findLocalContexts(tenant, generateParentCandidates(parts.toSeq.drop(1)).map(s => s"${project}_$s")) + .map(context => { + val parentLocalContext = context.sortBy(_.length).headOption val parts: Seq[PathElement] = parentLocalContext .map(lc => { val shortestLocalContextParts = lc.split("_") @@ -143,7 +161,7 @@ class SearchController( .getOrElse( // If there is no parent nor global parents, this is a root local context Future.successful(Seq(ProjectPathElement((rowJson \ "project").as[String]))) ) - } + case _ => Seq.empty.future } } @@ -188,3 +206,31 @@ object PathElement { ) } } +object SearchController { + sealed trait SearchEntityType + object SearchEntityObject { + case object Project extends SearchEntityType + case object Feature extends SearchEntityType + case object Key extends SearchEntityType + case object Tag extends SearchEntityType + case object Script extends SearchEntityType + case object GlobalContext extends SearchEntityType + case object LocalContext extends SearchEntityType + case object Webhook extends SearchEntityType + def parseSearchEntityType(str: String): Option[SearchEntityType] = { + Option(str).map(_.toUpperCase).flatMap { + case "PROJECT" => Some(Project) + case "FEATURE" => Some(Feature) + case "KEY" => Some(Key) + case "TAG" => Some(Tag) + case "SCRIPT" => Some(Script) + case "GLOBAL_CONTEXT" => Some(GlobalContext) + case "LOCAL_CONTEXT" => Some(LocalContext) + case "WEBHOOK" => Some(Webhook) + case _ => None + } + } + + } + +} diff --git a/conf/application.conf b/conf/application.conf index 71e1b257d..9a676c8bc 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -115,6 +115,11 @@ app { ttl = ${IZANAMI_PASSWORD_RESET_REQUEST_TTL} ttl = 900 } + # Modify the score related to the similarity of the search query + search { + similarity-threshold = ${?IZANAMI_SIMILARITY_THRESHOLD_PARAMETER} + similarity-threshold= 0.2 + } } # Copyright (C) Lightbend Inc. diff --git a/conf/routes b/conf/routes index b7f9b80ff..c0dacd34a 100644 --- a/conf/routes +++ b/conf/routes @@ -111,8 +111,8 @@ GET /api/admin/tenants/:tenant/webhooks/:id/users PUT /api/admin/tenants/:tenant/webhooks/:webhook/users/:user/rights fr.maif.izanami.web.UserController.updateUserRightsForWebhook(tenant: String, webhook: String, user: String) # Search application endpoints -GET /api/admin/search fr.maif.izanami.web.SearchController.search(query: String) -GET /api/admin/tenants/:tenant/search fr.maif.izanami.web.SearchController.searchForTenant(tenant: String, query: String) +GET /api/admin/search fr.maif.izanami.web.SearchController.search(query: String, filter: List[String]) +GET /api/admin/tenants/:tenant/search fr.maif.izanami.web.SearchController.searchForTenant(tenant: String, query: String, filter: List[String]) POST /api/admin/tenants/:tenant/_export fr.maif.izanami.web.ExportController.exportTenantData(tenant: String) diff --git a/docs/docs/guides/configuration/index.html b/docs/docs/guides/configuration/index.html index 9cd0b86f5..c93cea885 100644 --- a/docs/docs/guides/configuration/index.html +++ b/docs/docs/guides/configuration/index.html @@ -4,11 +4,11 @@ Configuring Izanami | Izanami - - + + -
Skip to main content

Configuring Izanami

Mandatory parameters

+

Configuring Izanami

Mandatory parameters

Secret

This parameter is mandatory for production purpose. This secret is used to encrypt various stuff such as token, cookies, or passwords.

@@ -55,9 +55,6 @@

WebhooksLogging

-

To change logger, refer to play documentation page.

-

Izanami embed a json logger by default, therefore to have log at logstash json format, you can use the -Dlogger.resource=json-logger.xml argument.

+
Play configuration keyEnvironnement variable
max retry countapp.webhooks.retry.countIZANAMI_WEBHOOK_RETRY_COUNT
initial delay (in seconds)app.webhooks.retry.intial-delayIZANAMI_WEBHOOK_RETRY_INITIAL_DELAY
max delay (in seconds)app.webhooks.retry.max-delayIZANAMI_WEBHOOK_RETRY_MAX_DELAY
multiplierapp.webhooks.retry.multiplierIZANAMI_WEBHOOK_RETRY_MULTIPLIER
\ No newline at end of file diff --git a/izanami-frontend/src/App.tsx b/izanami-frontend/src/App.tsx index 4d325d47a..b519e44f9 100644 --- a/izanami-frontend/src/App.tsx +++ b/izanami-frontend/src/App.tsx @@ -607,7 +607,8 @@ function Layout() { user?.admin || Object.keys(user?.rights.tenants || {}).length > 0) && ( setIsOpenModal(false)} /> @@ -635,7 +636,6 @@ export class App extends Component { }; constructor(props: any) { super(props); - this.state = { version: undefined, user: undefined, diff --git a/izanami-frontend/src/components/SearchComponant/SearchAction.tsx b/izanami-frontend/src/components/SearchComponant/SearchAction.tsx new file mode 100644 index 000000000..79d4b46f8 --- /dev/null +++ b/izanami-frontend/src/components/SearchComponant/SearchAction.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import Select, { components } from "react-select"; +import { customStyles } from "../../styles/reactSelect"; +import { SearchModalStatus, Option } from "../../utils/searchUtils"; +interface SearchActionProps { + tenant?: string; + allTenants?: string[]; + modalStatus: { all: boolean; tenant?: string }; + setModalStatus: (status: SearchModalStatus) => void; + filterOptions: { value: string; label: string }[]; + setFilters: (filters: { value: string; label: string }[]) => void; +} + +const CustomOption = (props: any) => { + return ( + + {props.data.icon} + {props.data.label} + + ); +}; + +export function SearchAction({ + tenant, + allTenants, + modalStatus, + setModalStatus, + filterOptions, + setFilters, +}: SearchActionProps) { + return ( +
+
+ + {tenant && ( + <> + + + )} +
+
+ {!tenant && ( + setFilters(e as Option[])} + components={{ Option: CustomOption }} + styles={customStyles} + isMulti + isClearable + placeholder="Apply filter" + /> +
+
+ ); +} diff --git a/izanami-frontend/src/components/SearchComponant/SearchInput.tsx b/izanami-frontend/src/components/SearchComponant/SearchInput.tsx new file mode 100644 index 000000000..a10cbb448 --- /dev/null +++ b/izanami-frontend/src/components/SearchComponant/SearchInput.tsx @@ -0,0 +1,66 @@ +import React, { useRef, useState } from "react"; +import { SearchResultStatus } from "../../utils/searchUtils"; + +interface SearchInputProps { + modalStatus: { all: boolean; tenant?: string }; + setSearchTerm: (term: string) => void; + setResultStatus: (result: SearchResultStatus) => void; +} + +const SearchInput: React.FC = ({ + modalStatus, + setSearchTerm, + setResultStatus, +}) => { + const inputRef = useRef(null); + const [searchTerm, setLocalSearchTerm] = useState(""); + + const handleSearchChange = (event: React.ChangeEvent) => { + const term = event.target.value; + setSearchTerm(term); + setLocalSearchTerm(term); + if (!term) { + clearInput(); + } + }; + const clearInput = () => { + if (inputRef.current) { + inputRef.current.value = ""; + setResultStatus({ state: "INITIAL" }); + } + }; + return ( +
+