Provide email proposals from address book

This commit is contained in:
Eike Kettner 2020-01-12 01:04:42 +01:00
parent c84a69aa9c
commit d535130c9e
14 changed files with 426 additions and 43 deletions

View File

@ -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))
})
}

View File

@ -68,8 +68,12 @@ form:
<img src="../img/mail-item-1.jpg">
</div>
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View File

@ -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

View File

@ -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")
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}
}

View File

@ -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

View File

@ -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)

View File

@ -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 )

View File

@ -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" ]

View File

@ -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

View File

@ -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;