diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 90099c0a..22141327 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -1,10 +1,11 @@ package docspell.backend.ops +import fs2.Stream import cats.implicits._ import cats.effect.{Effect, Resource} import docspell.common._ import docspell.store.{AddResult, Store} -import docspell.store.records.{RCollective, RUser} +import docspell.store.records.{RCollective, RContact, RUser} import OCollective._ import docspell.backend.PasswordCrypt import docspell.store.queries.QCollective @@ -30,6 +31,13 @@ trait OCollective[F[_]] { current: Password, newPass: Password ): F[PassChangeResult] + + def getContacts( + collective: Ident, + query: Option[String], + kind: Option[ContactKind] + ): Stream[F, RContact] + } object OCollective { @@ -119,5 +127,13 @@ object OCollective { store.transact(q) } + + def getContacts( + collective: Ident, + query: Option[String], + kind: Option[ContactKind] + ): Stream[F, RContact] = + store.transact(QCollective.getContacts(collective, query, kind)) + }) } diff --git a/modules/microsite/docs/doc/mailitem.md b/modules/microsite/docs/doc/mailitem.md index d9e44b3a..da46a206 100644 --- a/modules/microsite/docs/doc/mailitem.md +++ b/modules/microsite/docs/doc/mailitem.md @@ -68,8 +68,12 @@ form: -Then write the mail. Multiple recipients may be specified by -separating their addresses by comma. +Then write the mail. Multiple recipients may be specified. The input +field shows completion proposals from all contacts in your address +book (from organizations and persons). Choose an address by pressing +*Enter* or by clicking a proposal from the list. The proposal list can +be iterated by the *Up* and *Down* arrows. You can type in any +address, of course, it doesn't need to match a proposal. If you have multiple mail settings defined, you can choose in the top dropdown which account to use for sending. @@ -77,7 +81,7 @@ dropdown which account to use for sending. The last checkbox allows to choose whether docspell should add all attachments of the item to the mail. If it is unchecked, no attachments will be added. It is currently not possible to pick -attachments, it's all or nothing. +specific attachments, it's all or nothing. Clicking *Cancel* will delete the inputs and close the mail form, but clicking the envelope icon again, will only close the form without diff --git a/modules/microsite/src/main/resources/microsite/img/mail-item-1.jpg b/modules/microsite/src/main/resources/microsite/img/mail-item-1.jpg index b8001cf9..b50a746d 100644 Binary files a/modules/microsite/src/main/resources/microsite/img/mail-item-1.jpg and b/modules/microsite/src/main/resources/microsite/img/mail-item-1.jpg differ diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 2e9a69e3..319fa2a9 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -652,6 +652,25 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemInsights" + /sec/collective/contacts: + get: + tags: [ Collective ] + summary: Return a list of contacts. + description: | + Return a list of all contacts available from the collectives + address book. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/q" + - $ref: "#/components/parameters/contactKind" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/ContactList" /sec/user: get: tags: [ Collective ] @@ -2182,6 +2201,16 @@ components: type: string country: type: string + ContactList: + description: | + A list of contacts. + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/Contact" Contact: description: | Contact information. @@ -2487,3 +2516,11 @@ components: required: true schema: type: string + contactKind: + name: kind + in: query + required: false + description: | + One of the available contact kinds. + schema: + type: string diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala new file mode 100644 index 00000000..5b7c180d --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -0,0 +1,24 @@ +package docspell.restserver.http4s + +import org.http4s.QueryParamDecoder +import org.http4s.ParseFailure +import docspell.common.ContactKind +import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher + +object QueryParam { + case class QueryString(q: String) + + implicit val contactKindDecoder: QueryParamDecoder[ContactKind] = + QueryParamDecoder[String].emap(str => + ContactKind.fromString(str).left.map(s => ParseFailure(str, s)) + ) + + implicit val queryStringDecoder: QueryParamDecoder[QueryString] = + QueryParamDecoder[String].map(s => QueryString(s.trim.toLowerCase)) + + + + object ContactKindOpt extends OptionalQueryParamDecoderMatcher[ContactKind]("kind") + + object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q") +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index 44a25356..bf5b9021 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -6,7 +6,7 @@ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.restapi.model._ import docspell.restserver.conv.Conversions -import docspell.restserver.http4s.ResponseGenerator +import docspell.restserver.http4s._ import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ @@ -39,6 +39,16 @@ object CollectiveRoutes { resp <- sett.toResponse() } yield resp + case GET -> Root / "contacts" :? QueryParam.QueryOpt(q) +& QueryParam.ContactKindOpt(kind) => + for { + res <- backend.collective + .getContacts(user.account.collective, q.map(_.q), kind) + .take(50) + .compile + .toList + resp <- Ok(ContactList(res.map(Conversions.mkContact))) + } yield resp + case GET -> Root => for { collDb <- backend.collective.find(user.account.collective) diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala index 3d58d88a..9d697bf4 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -39,6 +39,9 @@ case class Column(name: String, ns: String = "", alias: String = "") { def isIn(values: Seq[Fragment]): Fragment = f ++ fr"IN (" ++ commas(values) ++ fr")" + def isIn(frag: Fragment): Fragment = + f ++ fr"IN (" ++ frag ++ fr")" + def isOrDiscard[A: Put](value: Option[A]): Fragment = value match { case Some(v) => is(v) diff --git a/modules/store/src/main/scala/docspell/store/queries/QCollective.scala b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala index 1de40050..28440b4b 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QCollective.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala @@ -1,10 +1,12 @@ package docspell.store.queries +import fs2.Stream import doobie._ import doobie.implicits._ import docspell.common.{Direction, Ident} import docspell.store.impl.Implicits._ -import docspell.store.records.{RAttachment, RItem, RTag, RTagItem} +import docspell.store.records._ +import docspell.common.ContactKind object QCollective { @@ -50,4 +52,38 @@ object QCollective { } yield InsightData(n0, n1, n2.getOrElse(0), Map.from(n3)) } + def getContacts( + coll: Ident, + query: Option[String], + kind: Option[ContactKind] + ): Stream[ConnectionIO, RContact] = { + val RO = ROrganization + val RP = RPerson + val RC = RContact + + val orgCond = selectSimple(Seq(RO.Columns.oid), RO.table, RO.Columns.cid.is(coll)) + val persCond = selectSimple(Seq(RP.Columns.pid), RP.table, RP.Columns.cid.is(coll)) + val queryCond = query match { + case Some(q) => + Seq(RC.Columns.value.lowerLike(s"%${q.toLowerCase}%")) + case None => + Seq.empty + } + val kindCond = kind match { + case Some(k) => + Seq(RC.Columns.kind.is(k)) + case None => + Seq.empty + } + + val q = selectSimple( + RC.Columns.all, + RC.table, + and( + Seq(or(RC.Columns.orgId.isIn(orgCond), RC.Columns.personId.isIn(persCond))) ++ queryCond ++ kindCond + ) + ) ++ orderBy(RC.Columns.value.f) + + q.query[RContact].stream + } } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index fe364cff..12098ea2 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -12,6 +12,7 @@ module Api exposing , deleteUser , getCollective , getCollectiveSettings + , getContacts , getEquipments , getInsights , getItemProposals @@ -64,6 +65,7 @@ import Api.Model.AuthResult exposing (AuthResult) import Api.Model.BasicResult exposing (BasicResult) import Api.Model.Collective exposing (Collective) import Api.Model.CollectiveSettings exposing (CollectiveSettings) +import Api.Model.ContactList exposing (ContactList) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.EmailSettings exposing (EmailSettings) import Api.Model.EmailSettingsList exposing (EmailSettingsList) @@ -98,6 +100,7 @@ import Api.Model.User exposing (User) import Api.Model.UserList exposing (UserList) import Api.Model.UserPass exposing (UserPass) import Api.Model.VersionInfo exposing (VersionInfo) +import Data.ContactType exposing (ContactType) import Data.Flags exposing (Flags) import File exposing (File) import Http @@ -370,6 +373,48 @@ setCollectiveSettings flags settings receive = } +getContacts : + Flags + -> Maybe ContactType + -> Maybe String + -> (Result Http.Error ContactList -> msg) + -> Cmd msg +getContacts flags kind q receive = + let + pk = + case kind of + Just k -> + [ "kind=" ++ Data.ContactType.toString k ] + + Nothing -> + [] + + pq = + case q of + Just str -> + [ "q=" ++ str ] + + Nothing -> + [] + + params = + pk ++ pq + + query = + case String.join "&" params of + "" -> + "" + + str -> + "?" ++ str + in + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/collective/contacts" ++ query + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.ContactList.decoder + } + + -- Tags diff --git a/modules/webapp/src/main/elm/Comp/EmailInput.elm b/modules/webapp/src/main/elm/Comp/EmailInput.elm new file mode 100644 index 00000000..bf17f651 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/EmailInput.elm @@ -0,0 +1,190 @@ +module Comp.EmailInput exposing + ( Model + , Msg + , init + , update + , view + ) + +import Api +import Api.Model.ContactList exposing (ContactList) +import Data.ContactType +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Http +import Util.Html exposing (onKeyUp) +import Util.List +import Util.Maybe + + +type alias Model = + { input : String + , menuOpen : Bool + , candidates : List String + , active : Maybe String + } + + +init : Model +init = + { input = "" + , menuOpen = False + , candidates = [] + , active = Nothing + } + + +type Msg + = SetInput String + | ContactResp (Result Http.Error ContactList) + | KeyPress Int + | AddEmail String + | RemoveEmail String + + +getCandidates : Flags -> Model -> Cmd Msg +getCandidates flags model = + case Util.Maybe.fromString model.input of + Just q -> + Api.getContacts flags (Just Data.ContactType.Email) (Just q) ContactResp + + Nothing -> + Cmd.none + + +update : Flags -> List String -> Msg -> Model -> ( Model, Cmd Msg, List String ) +update flags current msg model = + case msg of + SetInput str -> + let + nm = + { model | input = str, menuOpen = str /= "" } + in + ( nm, getCandidates flags nm, current ) + + ContactResp (Ok list) -> + ( { model + | candidates = List.map .value (List.take 10 list.items) + , active = Nothing + , menuOpen = list.items /= [] + } + , Cmd.none + , current + ) + + ContactResp (Err _) -> + ( model, Cmd.none, current ) + + KeyPress code -> + let + addCurrent = + let + email = + Maybe.withDefault model.input model.active + in + update flags current (AddEmail email) model + in + case Util.Html.intToKeyCode code of + Just Util.Html.Up -> + let + prev = + case model.active of + Nothing -> + List.reverse model.candidates + |> List.head + + Just act -> + Util.List.findPrev (\e -> e == act) model.candidates + in + ( { model | active = prev }, Cmd.none, current ) + + Just Util.Html.Down -> + let + next = + case model.active of + Nothing -> + List.head model.candidates + + Just act -> + Util.List.findNext (\e -> e == act) model.candidates + in + ( { model | active = next }, Cmd.none, current ) + + Just Util.Html.Enter -> + addCurrent + + Just Util.Html.Space -> + addCurrent + + _ -> + ( model, Cmd.none, current ) + + AddEmail str -> + ( { model | input = "", menuOpen = False } + , Cmd.none + , Util.List.distinct (current ++ [ String.trim str ]) + ) + + RemoveEmail str -> + ( model, Cmd.none, List.filter (\e -> e /= str) current ) + + +view : List String -> Model -> Html Msg +view values model = + div + [ classList + [ ( "ui search dropdown multiple selection", True ) + , ( "open", model.menuOpen ) + ] + ] + (List.map renderValue values + ++ [ input + [ type_ "text" + , class "search" + , placeholder "Recipients…" + , onKeyUp KeyPress + , onInput SetInput + ] + [ text model.input + ] + ] + ++ [ renderMenu model ] + ) + + +renderValue : String -> Html Msg +renderValue str = + a + [ class "ui label" + , href "#" + , onClick (RemoveEmail str) + ] + [ text str + , i [ class "delete icon" ] [] + ] + + +renderMenu : Model -> Html Msg +renderMenu model = + let + mkItem v = + a + [ classList + [ ( "item", True ) + , ( "active", model.active == Just v ) + ] + , href "#" + , onClick (AddEmail v) + ] + [ text v + ] + in + div + [ classList + [ ( "menu", True ) + , ( "transition visible", model.menuOpen ) + ] + ] + (List.map mkItem model.candidates) diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 2559821e..b36f71fe 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -715,12 +715,12 @@ update key flags next msg model = ItemMailMsg m -> let - ( im, fa ) = - Comp.ItemMail.update m model.itemMail + ( im, ic, fa ) = + Comp.ItemMail.update flags m model.itemMail in case fa of Comp.ItemMail.FormNone -> - ( { model | itemMail = im }, Cmd.none ) + ( { model | itemMail = im }, Cmd.map ItemMailMsg ic ) Comp.ItemMail.FormCancel -> ( { model @@ -728,7 +728,7 @@ update key flags next msg model = , mailOpen = False , mailSendResult = Nothing } - , Cmd.none + , Cmd.map ItemMailMsg ic ) Comp.ItemMail.FormSend sm -> @@ -739,7 +739,12 @@ update key flags next msg model = , conn = sm.conn } in - ( model, Api.sendMail flags mail SendMailResp ) + ( model + , Cmd.batch + [ Cmd.map ItemMailMsg ic + , Api.sendMail flags mail SendMailResp + ] + ) ToggleMail -> ( { model | mailOpen = not model.mailOpen }, Cmd.none ) diff --git a/modules/webapp/src/main/elm/Comp/ItemMail.elm b/modules/webapp/src/main/elm/Comp/ItemMail.elm index 960d60ee..0aeea8b3 100644 --- a/modules/webapp/src/main/elm/Comp/ItemMail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemMail.elm @@ -13,6 +13,7 @@ import Api import Api.Model.EmailSettingsList exposing (EmailSettingsList) import Api.Model.SimpleMail exposing (SimpleMail) import Comp.Dropdown +import Comp.EmailInput import Data.Flags exposing (Flags) import Html exposing (..) import Html.Attributes exposing (..) @@ -24,7 +25,8 @@ import Util.Http type alias Model = { connectionModel : Comp.Dropdown.Model String , subject : String - , receiver : String + , recipients : List String + , recipientsModel : Comp.EmailInput.Model , body : String , attachAll : Bool , formError : Maybe String @@ -33,7 +35,7 @@ type alias Model = type Msg = SetSubject String - | SetReceiver String + | RecipientMsg Comp.EmailInput.Msg | SetBody String | ConnMsg (Comp.Dropdown.Msg String) | ConnResp (Result Http.Error EmailSettingsList) @@ -62,7 +64,8 @@ emptyModel = , placeholder = "Select connection..." } , subject = "" - , receiver = "" + , recipients = [] + , recipientsModel = Comp.EmailInput.init , body = "" , attachAll = True , formError = Nothing @@ -78,22 +81,29 @@ clear : Model -> Model clear model = { model | subject = "" - , receiver = "" + , recipients = [] , body = "" } -update : Msg -> Model -> ( Model, FormAction ) -update msg model = +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, FormAction ) +update flags msg model = case msg of SetSubject str -> - ( { model | subject = str }, FormNone ) + ( { model | subject = str }, Cmd.none, FormNone ) - SetReceiver str -> - ( { model | receiver = str }, FormNone ) + RecipientMsg m -> + let + ( em, ec, rec ) = + Comp.EmailInput.update flags model.recipients m model.recipientsModel + in + ( { model | recipients = rec, recipientsModel = em } + , Cmd.map RecipientMsg ec + , FormNone + ) SetBody str -> - ( { model | body = str }, FormNone ) + ( { model | body = str }, Cmd.none, FormNone ) ConnMsg m -> let @@ -101,10 +111,10 @@ update msg model = --TODO dropdown doesn't use cmd!! Comp.Dropdown.update m model.connectionModel in - ( { model | connectionModel = cm }, FormNone ) + ( { model | connectionModel = cm }, Cmd.none, FormNone ) ToggleAttachAll -> - ( { model | attachAll = not model.attachAll }, FormNone ) + ( { model | attachAll = not model.attachAll }, Cmd.none, FormNone ) ConnResp (Ok list) -> let @@ -128,35 +138,33 @@ update msg model = else Nothing } + , Cmd.none , FormNone ) ConnResp (Err err) -> - ( { model | formError = Just (Util.Http.errorToString err) }, FormNone ) + ( { model | formError = Just (Util.Http.errorToString err) }, Cmd.none, FormNone ) Cancel -> - ( model, FormCancel ) + ( model, Cmd.none, FormCancel ) Send -> case ( model.formError, Comp.Dropdown.getSelected model.connectionModel ) of ( Nothing, conn :: [] ) -> let - rec = - String.split "," model.receiver - sm = - SimpleMail rec model.subject model.body model.attachAll [] + SimpleMail model.recipients model.subject model.body model.attachAll [] in - ( model, FormSend { conn = conn, mail = sm } ) + ( model, Cmd.none, FormSend { conn = conn, mail = sm } ) _ -> - ( model, FormNone ) + ( model, Cmd.none, FormNone ) isValid : Model -> Bool isValid model = - model.receiver - /= "" + model.recipients + /= [] && model.subject /= "" && model.body @@ -182,16 +190,9 @@ view model = ] , div [ class "field" ] [ label [] - [ text "Receiver(s)" - , span [ class "muted" ] - [ text "Separate multiple recipients by comma" ] + [ text "Recipient(s)" ] - , input - [ type_ "text" - , onInput SetReceiver - , value model.receiver - ] - [] + , Html.map RecipientMsg (Comp.EmailInput.view model.recipients model.recipientsModel) ] , div [ class "field" ] [ label [] [ text "Subject" ] diff --git a/modules/webapp/src/main/elm/Util/Html.elm b/modules/webapp/src/main/elm/Util/Html.elm index 9131386b..50036a2c 100644 --- a/modules/webapp/src/main/elm/Util/Html.elm +++ b/modules/webapp/src/main/elm/Util/Html.elm @@ -18,6 +18,7 @@ type KeyCode | Left | Right | Enter + | Space intToKeyCode : Int -> Maybe KeyCode @@ -38,6 +39,9 @@ intToKeyCode code = 13 -> Just Enter + 32 -> + Just Space + _ -> Nothing diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 39decf23..25dfff32 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -29,8 +29,12 @@ } .default-layout .ui.multiple.search.dropdown>input.search { - width: 3.5em; + width: auto; } +/* .default-layout .ui.multiple.search.dropdown>input.search.long-search { */ +/* width: auto; */ +/* } */ + .default-layout .job-log { background: #181819; @@ -111,6 +115,10 @@ span.small-info { white-space: pre; } +.ui.form textarea.search { + border: 0; +} + .login-layout, .register-layout, .newinvite-layout { background: #708090; height: 101vh;