diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 80df397c..534eb1ca 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -38,6 +38,7 @@ trait BackendApp[F[_]] { def folder: OFolder[F] def customFields: OCustomFields[F] def simpleSearch: OSimpleSearch[F] + def clientSettings: OClientSettings[F] } object BackendApp { @@ -73,26 +74,28 @@ object BackendApp { folderImpl <- OFolder(store) customFieldsImpl <- OCustomFields(store) simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) + clientSettingsImpl <- OClientSettings(store) } yield new BackendApp[F] { - val login = loginImpl - val signup = signupImpl - val collective = collImpl - val source = sourceImpl - val tag = tagImpl - val equipment = equipImpl - val organization = orgImpl - val upload = uploadImpl - val node = nodeImpl - val job = jobImpl - val item = itemImpl - val itemSearch = itemSearchImpl - val fulltext = fulltextImpl - val mail = mailImpl - val joex = joexImpl - val userTask = userTaskImpl - val folder = folderImpl - val customFields = customFieldsImpl - val simpleSearch = simpleSearchImpl + val login = loginImpl + val signup = signupImpl + val collective = collImpl + val source = sourceImpl + val tag = tagImpl + val equipment = equipImpl + val organization = orgImpl + val upload = uploadImpl + val node = nodeImpl + val job = jobImpl + val item = itemImpl + val itemSearch = itemSearchImpl + val fulltext = fulltextImpl + val mail = mailImpl + val joex = joexImpl + val userTask = userTaskImpl + val folder = folderImpl + val customFields = customFieldsImpl + val simpleSearch = simpleSearchImpl + val clientSettings = clientSettingsImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala new file mode 100644 index 00000000..81f2aeec --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OClientSettings.scala @@ -0,0 +1,78 @@ +package docspell.backend.ops + +import cats.data.OptionT +import cats.effect.{Effect, Resource} +import cats.implicits._ + +import docspell.common.AccountId +import docspell.common._ +import docspell.common.syntax.all._ +import docspell.store.Store +import docspell.store.records.RClientSettings +import docspell.store.records.RUser + +import io.circe.Json +import org.log4s._ + +trait OClientSettings[F[_]] { + + def delete(clientId: Ident, account: AccountId): F[Boolean] + def save(clientId: Ident, account: AccountId, data: Json): F[Unit] + def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] + +} + +object OClientSettings { + private[this] val logger = getLogger + + def apply[F[_]: Effect](store: Store[F]): Resource[F, OClientSettings[F]] = + Resource.pure[F, OClientSettings[F]](new OClientSettings[F] { + + private def getUserId(account: AccountId): OptionT[F, Ident] = + OptionT(store.transact(RUser.findByAccount(account))).map(_.uid) + + def delete(clientId: Ident, account: AccountId): F[Boolean] = + (for { + _ <- OptionT.liftF( + logger.fdebug( + s"Deleting client settings for client ${clientId.id} and account $account" + ) + ) + userId <- getUserId(account) + n <- OptionT.liftF( + store.transact( + RClientSettings.delete(clientId, userId) + ) + ) + } yield n > 0).getOrElse(false) + + def save(clientId: Ident, account: AccountId, data: Json): F[Unit] = + (for { + _ <- OptionT.liftF( + logger.fdebug( + s"Storing client settings for client ${clientId.id} and account $account" + ) + ) + userId <- getUserId(account) + n <- OptionT.liftF( + store.transact(RClientSettings.upsert(clientId, userId, data)) + ) + _ <- OptionT.liftF( + if (n <= 0) Effect[F].raiseError(new Exception("No rows updated!")) + else ().pure[F] + ) + } yield ()).getOrElse(()) + + def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] = + (for { + _ <- OptionT.liftF( + logger.fdebug( + s"Loading client settings for client ${clientId.id} and account $account" + ) + ) + userId <- getUserId(account) + data <- OptionT(store.transact(RClientSettings.find(clientId, userId))) + } yield data).value + + }) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 01f4bade..92766627 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1185,6 +1185,68 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/clientSettings/{clientId}: + parameters: + - $ref: "#/components/parameters/clientId" + get: + tags: [ Client Settings ] + summary: Return the current user settings + description: | + Returns the settings for the current user. The `clientId` is + an identifier to a client application. It returns a JSON + structure. The server doesn't care about the actual data, + since it is meant to be interpreted by clients. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: {} + put: + tags: [ Client Settings ] + summary: Update current user settings + description: | + Updates (replaces or creates) the current user's settings with + the given data. The `clientId` is an identifier to a client + application. The request body is expected to be JSON, the + structure is not important to the server. + + The data is stored for the current user and given `clientId`. + + The data is only saved without being checked in any way + (besides being valid JSON). It is returned "as is" to the + client in the corresponding GET endpoint. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: {} + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + delete: + tags: [ Client Settings ] + summary: Clears the current user settings + description: | + Removes all stored user settings for the client identified by + `clientId`. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /admin/user/resetPassword: post: tags: [ Collective, Admin ] @@ -5571,3 +5633,11 @@ components: required: false schema: type: boolean + clientId: + name: clientId + in: path + required: true + description: | + some identifier for a client application + schema: + type: string diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 30e5733e..7891cb56 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -91,7 +91,8 @@ object RestServer { "calevent/check" -> CalEventCheckRoutes(), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), "folder" -> FolderRoutes(restApp.backend, token), - "customfield" -> CustomFieldRoutes(restApp.backend, token) + "customfield" -> CustomFieldRoutes(restApp.backend, token), + "clientSettings" -> ClientSettingsRoutes(restApp.backend, token) ) def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala new file mode 100644 index 00000000..3672a35b --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ClientSettingsRoutes.scala @@ -0,0 +1,52 @@ +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common._ +import docspell.restapi.model._ + +import io.circe.Json +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ClientSettingsRoutes { + + def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ PUT -> Root / Ident(clientId) => + for { + data <- req.as[Json] + _ <- backend.clientSettings.save(clientId, user.account, data) + res <- Ok(BasicResult(true, "Settings stored")) + } yield res + + case GET -> Root / Ident(clientId) => + for { + data <- backend.clientSettings.load(clientId, user.account) + res <- data match { + case Some(d) => Ok(d.settingsData) + case None => NotFound() + } + } yield res + + case DELETE -> Root / Ident(clientId) => + for { + flag <- backend.clientSettings.delete(clientId, user.account) + res <- Ok( + BasicResult( + flag, + if (flag) "Settings deleted" else "Deleting settings failed" + ) + ) + } yield res + } + } +} diff --git a/modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql b/modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql new file mode 100644 index 00000000..b06c44b4 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.23.0__clientsettings.sql @@ -0,0 +1,10 @@ +CREATE TABLE "client_settings" ( + "id" varchar(254) not null primary key, + "client_id" varchar(254) not null, + "user_id" varchar(254) not null, + "settings_data" text not null, + "created" timestamp not null, + "updated" timestamp not null, + foreign key ("user_id") references "user_"("uid") on delete cascade, + unique ("client_id", "user_id") +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql new file mode 100644 index 00000000..7014ccd9 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.23.0__clientsettings.sql @@ -0,0 +1,10 @@ +CREATE TABLE `client_settings` ( + `id` varchar(254) not null primary key, + `client_id` varchar(254) not null, + `user_id` varchar(254) not null, + `settings_data` longtext not null, + `created` timestamp not null, + `updated` timestamp not null, + foreign key (`user_id`) references `user_`(`uid`) on delete cascade, + unique (`client_id`, `user_id`) +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql new file mode 100644 index 00000000..b06c44b4 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.23.0__clientsettings.sql @@ -0,0 +1,10 @@ +CREATE TABLE "client_settings" ( + "id" varchar(254) not null primary key, + "client_id" varchar(254) not null, + "user_id" varchar(254) not null, + "settings_data" text not null, + "created" timestamp not null, + "updated" timestamp not null, + foreign key ("user_id") references "user_"("uid") on delete cascade, + unique ("client_id", "user_id") +); diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index e15da7ae..c38ff900 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -11,6 +11,7 @@ import doobie._ import doobie.implicits.legacy.instant._ import doobie.util.log.Success import emil.doobie.EmilDoobieMeta +import io.circe.Json import io.circe.{Decoder, Encoder} trait DoobieMeta extends EmilDoobieMeta { @@ -112,10 +113,18 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaOrgUse: Meta[OrgUse] = Meta[String].timap(OrgUse.unsafeFromString)(_.name) + + implicit val metaJsonString: Meta[Json] = + Meta[String].timap(DoobieMeta.parseJsonUnsafe)(_.noSpaces) } object DoobieMeta extends DoobieMeta { import org.log4s._ private val logger = getLogger + private def parseJsonUnsafe(str: String): Json = + io.circe.parser + .parse(str) + .fold(throw _, identity) + } diff --git a/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala b/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala new file mode 100644 index 00000000..ed7c3911 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RClientSettings.scala @@ -0,0 +1,79 @@ +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ +import io.circe.Json + +case class RClientSettings( + id: Ident, + clientId: Ident, + userId: Ident, + settingsData: Json, + updated: Timestamp, + created: Timestamp +) {} + +object RClientSettings { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "client_settings" + + val id = Column[Ident]("id", this) + val clientId = Column[Ident]("client_id", this) + val userId = Column[Ident]("user_id", this) + val settingsData = Column[Json]("settings_data", this) + val updated = Column[Timestamp]("updated", this) + val created = Column[Timestamp]("created", this) + val all = + NonEmptyList.of[Column[_]](id, clientId, userId, settingsData, updated, created) + } + + def as(alias: String): Table = Table(Some(alias)) + val T = Table(None) + + def insert(v: RClientSettings): ConnectionIO[Int] = { + val t = Table(None) + DML.insert( + t, + t.all, + fr"${v.id},${v.clientId},${v.userId},${v.settingsData},${v.updated},${v.created}" + ) + } + + def updateSettings( + clientId: Ident, + userId: Ident, + data: Json, + updateTs: Timestamp + ): ConnectionIO[Int] = + DML.update( + T, + T.clientId === clientId && T.userId === userId, + DML.set(T.settingsData.setTo(data), T.updated.setTo(updateTs)) + ) + + def upsert(clientId: Ident, userId: Ident, data: Json): ConnectionIO[Int] = + for { + id <- Ident.randomId[ConnectionIO] + now <- Timestamp.current[ConnectionIO] + nup <- updateSettings(clientId, userId, data, now) + nin <- + if (nup <= 0) insert(RClientSettings(id, clientId, userId, data, now, now)) + else 0.pure[ConnectionIO] + } yield nup + nin + + def delete(clientId: Ident, userId: Ident): ConnectionIO[Int] = + DML.delete(T, T.clientId === clientId && T.userId === userId) + + def find(clientId: Ident, userId: Ident): ConnectionIO[Option[RClientSettings]] = + run(select(T.all), from(T), T.clientId === clientId && T.userId === userId) + .query[RClientSettings] + .option +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 2ecf5171..c883d71c 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -37,6 +37,7 @@ module Api exposing , deleteUser , fileURL , getAttachmentMeta + , getClientSettings , getCollective , getCollectiveSettings , getContacts @@ -51,7 +52,6 @@ module Api exposing , getJobQueueState , getJobQueueStateIn , getMailSettings - , getNewUi , getNotifyDueItems , getOrgFull , getOrgLight @@ -92,6 +92,7 @@ module Api exposing , removeTagsMultiple , reprocessItem , reprocessMultiple + , saveClientSettings , sendMail , setAttachmentName , setCollectiveSettings @@ -124,7 +125,6 @@ module Api exposing , startOnceScanMailbox , startReIndex , submitNotifyDueItems - , toggleNewUi , toggleTags , unconfirmMultiple , updateNotifyDueItems @@ -206,8 +206,10 @@ import Api.Model.VersionInfo exposing (VersionInfo) import Data.ContactType exposing (ContactType) import Data.Flags exposing (Flags) import Data.Priority exposing (Priority) +import Data.UiSettings exposing (UiSettings) import File exposing (File) import Http +import Json.Decode as JsonDecode import Json.Encode as JsonEncode import Set exposing (Set) import Task @@ -1982,21 +1984,40 @@ getItemProposals flags item receive = } -toggleNewUi : Flags -> (Result Http.Error BasicResult -> msg) -> Cmd msg -toggleNewUi flags receive = - Http2.authPost - { url = flags.config.baseUrl ++ "/api/v1/sec/newui" + +--- Client Settings + + +getClientSettings : Flags -> (Result Http.Error UiSettings -> msg) -> Cmd msg +getClientSettings flags receive = + let + defaults = + Data.UiSettings.defaults + + decoder = + JsonDecode.map (\s -> Data.UiSettings.merge s defaults) + Data.UiSettings.storedUiSettingsDecoder + in + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/clientSettings/webClient" , account = getAccount flags - , body = Http.emptyBody - , expect = Http.expectJson receive Api.Model.BasicResult.decoder + , expect = Http.expectJson receive decoder } -getNewUi : Flags -> (Result Http.Error BasicResult -> msg) -> Cmd msg -getNewUi flags receive = - Http2.authGet - { url = flags.config.baseUrl ++ "/api/v1/sec/newui" +saveClientSettings : Flags -> UiSettings -> (Result Http.Error BasicResult -> msg) -> Cmd msg +saveClientSettings flags settings receive = + let + storedSettings = + Data.UiSettings.toStoredUiSettings settings + + encode = + Data.UiSettings.storedUiSettingsEncode storedSettings + in + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/clientSettings/webClient" , account = getAccount flags + , body = Http.jsonBody encode , expect = Http.expectJson receive Api.Model.BasicResult.decoder } diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index 845a9555..784d8493 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -7,11 +7,12 @@ module App.Data exposing ) import Api.Model.AuthResult exposing (AuthResult) +import Api.Model.BasicResult exposing (BasicResult) import Api.Model.VersionInfo exposing (VersionInfo) import Browser exposing (UrlRequest) import Browser.Navigation exposing (Key) import Data.Flags exposing (Flags) -import Data.UiSettings exposing (UiSettings) +import Data.UiSettings exposing (StoredUiSettings, UiSettings) import Data.UiTheme exposing (UiTheme) import Http import Messages.UiLanguage exposing (UiLanguage) @@ -155,11 +156,13 @@ type Msg | SessionCheckResp (Result Http.Error AuthResult) | ToggleNavMenu | ToggleUserMenu - | GetUiSettings UiSettings + | GetUiSettings (Result Http.Error UiSettings) | ToggleSidebar | ToggleDarkMode | ToggleLangMenu | SetLanguage UiLanguage + | ClientSettingsSaveResp UiSettings (Result Http.Error BasicResult) + | ReceiveBrowserSettings StoredUiSettings defaultPage : Flags -> Page diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm index 4752f4b0..fa92a709 100644 --- a/modules/webapp/src/main/elm/App/Update.elm +++ b/modules/webapp/src/main/elm/App/Update.elm @@ -8,6 +8,7 @@ import App.Data exposing (..) import Browser exposing (UrlRequest(..)) import Browser.Navigation as Nav import Data.Flags +import Data.UiSettings exposing (UiSettings) import Data.UiTheme import Page exposing (Page(..)) import Page.CollectiveSettings.Data @@ -65,11 +66,11 @@ updateWithSub msg model = { settings | uiTheme = next } in -- when authenticated, store it in settings only - -- once new settings arrive via a subscription, - -- the ui is updated. so it is also updated on - -- page refresh + -- once new settings are successfully saved (the + -- response is arrived), the ui is updated. so it + -- is also updated on page refresh ( { model | userMenuOpen = False } - , Ports.storeUiSettings model.flags newSettings + , Api.saveClientSettings model.flags newSettings (ClientSettingsSaveResp newSettings) , Sub.none ) @@ -84,6 +85,16 @@ updateWithSub msg model = , Sub.none ) + ClientSettingsSaveResp settings (Ok res) -> + if res.success then + applyClientSettings model settings + + else + ( model, Cmd.none, Sub.none ) + + ClientSettingsSaveResp _ (Err _) -> + ( model, Cmd.none, Sub.none ) + ToggleLangMenu -> ( { model | langMenuOpen = not model.langMenuOpen } , Cmd.none @@ -258,22 +269,37 @@ updateWithSub msg model = , Sub.none ) - GetUiSettings settings -> + GetUiSettings (Ok settings) -> + applyClientSettings model settings + + GetUiSettings (Err _) -> + ( model, Cmd.none, Sub.none ) + + ReceiveBrowserSettings sett -> let - setTheme = - Ports.setUiTheme settings.uiTheme + lm = + Page.UserSettings.Data.ReceiveBrowserSettings sett in - Util.Update.andThen2 - [ \m -> - ( { m | sidebarVisible = settings.sideMenuVisible } - , setTheme - , Sub.none - ) - , updateUserSettings Page.UserSettings.Data.UpdateSettings - , updateHome Page.Home.Data.UiSettingsUpdated - , updateItemDetail Page.ItemDetail.Data.UiSettingsUpdated - ] - { model | uiSettings = settings } + updateUserSettings lm model + + +applyClientSettings : Model -> UiSettings -> ( Model, Cmd Msg, Sub Msg ) +applyClientSettings model settings = + let + setTheme = + Ports.setUiTheme settings.uiTheme + in + Util.Update.andThen2 + [ \m -> + ( { m | sidebarVisible = settings.sideMenuVisible } + , setTheme + , Sub.none + ) + , updateUserSettings Page.UserSettings.Data.UpdateSettings + , updateHome Page.Home.Data.UiSettingsUpdated + , updateItemDetail Page.ItemDetail.Data.UiSettingsUpdated + ] + { model | uiSettings = settings } updateItemDetail : Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) @@ -360,14 +386,29 @@ updateQueue lmsg model = updateUserSettings : Page.UserSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) updateUserSettings lmsg model = let - ( lm, lc, ls ) = + result = Page.UserSettings.Update.update model.flags model.uiSettings lmsg model.userSettingsModel + + model_ = + { model | userSettingsModel = result.model } + + ( lm2, lc2, s2 ) = + case result.newSettings of + Just sett -> + applyClientSettings model_ sett + + Nothing -> + ( model_, Cmd.none, Sub.none ) in - ( { model - | userSettingsModel = lm - } - , Cmd.map UserSettingsMsg lc - , Sub.map UserSettingsMsg ls + ( lm2 + , Cmd.batch + [ Cmd.map UserSettingsMsg result.cmd + , lc2 + ] + , Sub.batch + [ Sub.map UserSettingsMsg result.sub + , s2 + ] ) @@ -415,14 +456,29 @@ updateHome lmsg model = _ -> Nothing - ( lm, lc, ls ) = + result = Page.Home.Update.update mid model.key model.flags model.uiSettings lmsg model.homeModel + + model_ = + { model | homeModel = result.model } + + ( lm, lc, ls ) = + case result.newSettings of + Just sett -> + applyClientSettings model_ sett + + Nothing -> + ( model_, Cmd.none, Sub.none ) in - ( { model - | homeModel = lm - } - , Cmd.map HomeMsg lc - , Sub.map HomeMsg ls + ( lm + , Cmd.batch + [ Cmd.map HomeMsg result.cmd + , lc + ] + , Sub.batch + [ Sub.map HomeMsg result.sub + , ls + ] ) diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm index eb3141a5..6a59ed87 100644 --- a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm @@ -463,9 +463,13 @@ update sett msg model = tagColorViewOpts2 : Texts -> Comp.ColorTagger.ViewOpts tagColorViewOpts2 texts = { renderItem = - \( _, v ) -> - span [ class (" label " ++ Data.Color.toString2 v) ] - [ text (texts.colorLabel v) ] + \( name, v ) -> + span [ class "flex inline-flex items-center" ] + [ span [ class "mr-2" ] [ text name ] + , span [ class (" label " ++ Data.Color.toString2 v) ] + [ text (texts.colorLabel v) + ] + ] , colorLabel = texts.colorLabel , label = texts.chooseTagColorLabel , description = Just texts.tagColorDescription diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm b/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm index c1e60230..178a287d 100644 --- a/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm +++ b/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm @@ -1,36 +1,49 @@ module Comp.UiSettingsManage exposing ( Model , Msg(..) + , UpdateResult , init , update , view2 ) +import Api import Api.Model.BasicResult exposing (BasicResult) import Comp.MenuBar as MB import Comp.UiSettingsForm +import Comp.UiSettingsMigrate import Data.Flags exposing (Flags) -import Data.UiSettings exposing (UiSettings) +import Data.UiSettings exposing (StoredUiSettings, UiSettings) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onClick) +import Http import Messages.Comp.UiSettingsManage exposing (Texts) -import Ports import Styles as S type alias Model = { formModel : Comp.UiSettingsForm.Model , settings : Maybe UiSettings - , message : Maybe BasicResult + , formResult : FormResult + , settingsMigrate : Comp.UiSettingsMigrate.Model } +type FormResult + = FormInit + | FormUnchanged + | FormSaved + | FormHttpError Http.Error + | FormUnknownError + + type Msg = UiSettingsFormMsg Comp.UiSettingsForm.Msg + | UiSettingsMigrateMsg Comp.UiSettingsMigrate.Msg | Submit - | SettingsSaved | UpdateSettings + | SaveSettingsResp UiSettings (Result Http.Error BasicResult) + | ReceiveBrowserSettings StoredUiSettings init : Flags -> UiSettings -> ( Model, Cmd Msg ) @@ -38,12 +51,19 @@ init flags settings = let ( fm, fc ) = Comp.UiSettingsForm.init flags settings + + ( mm, mc ) = + Comp.UiSettingsMigrate.init flags in ( { formModel = fm , settings = Nothing - , message = Nothing + , formResult = FormInit + , settingsMigrate = mm } - , Cmd.map UiSettingsFormMsg fc + , Cmd.batch + [ Cmd.map UiSettingsFormMsg fc + , Cmd.map UiSettingsMigrateMsg mc + ] ) @@ -51,7 +71,15 @@ init flags settings = --- update -update : Flags -> UiSettings -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , newSettings : Maybe UiSettings + } + + +update : Flags -> UiSettings -> Msg -> Model -> UpdateResult update flags settings msg model = case msg of UiSettingsFormMsg lm -> @@ -62,54 +90,89 @@ update flags settings msg model = ( m_, sett ) = Comp.UiSettingsForm.update inSettings lm model.formModel in - ( { model - | formModel = m_ - , settings = - if sett == Nothing then - model.settings + { model = + { model + | formModel = m_ + , settings = + if sett == Nothing then + model.settings - else - sett - , message = - if sett /= Nothing then - Nothing + else + sett + , formResult = + if sett /= Nothing then + FormInit - else - model.message - } - , Cmd.none - , Sub.none - ) + else + model.formResult + } + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Nothing + } + + UiSettingsMigrateMsg lm -> + let + result = + Comp.UiSettingsMigrate.update flags lm model.settingsMigrate + in + { model = { model | settingsMigrate = result.model } + , cmd = Cmd.map UiSettingsMigrateMsg result.cmd + , sub = Sub.map UiSettingsMigrateMsg result.sub + , newSettings = result.newSettings + } + + ReceiveBrowserSettings sett -> + let + lm = + UiSettingsMigrateMsg (Comp.UiSettingsMigrate.receiveBrowserSettings sett) + in + update flags settings lm model Submit -> case model.settings of Just s -> - ( { model | message = Nothing } - , Ports.storeUiSettings flags s - , Ports.onUiSettingsSaved SettingsSaved - ) + { model = { model | formResult = FormInit } + , cmd = Api.saveClientSettings flags s (SaveSettingsResp s) + , sub = Sub.none + , newSettings = Nothing + } Nothing -> - ( { model | message = Just (BasicResult False "Settings unchanged or invalid.") } - , Cmd.none - , Sub.none - ) + { model = { model | formResult = FormUnchanged } + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Nothing + } - SettingsSaved -> - ( { model | message = Just (BasicResult True "Settings saved.") } - , Cmd.none - , Sub.none - ) + SaveSettingsResp newSettings (Ok res) -> + if res.success then + { model = { model | formResult = FormSaved } + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Just newSettings + } + + else + { model = { model | formResult = FormUnknownError } + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Nothing + } + + SaveSettingsResp _ (Err err) -> + UpdateResult { model | formResult = FormHttpError err } Cmd.none Sub.none Nothing UpdateSettings -> let ( fm, fc ) = Comp.UiSettingsForm.init flags settings in - ( { model | formModel = fm } - , Cmd.map UiSettingsFormMsg fc - , Sub.none - ) + { model = { model | formModel = fm } + , cmd = Cmd.map UiSettingsFormMsg fc + , sub = Sub.none + , newSettings = Nothing + } @@ -118,12 +181,26 @@ update flags settings msg model = isError : Model -> Bool isError model = - Maybe.map .success model.message == Just False + case model.formResult of + FormSaved -> + False + + FormInit -> + False + + FormUnchanged -> + True + + FormHttpError _ -> + True + + FormUnknownError -> + True isSuccess : Model -> Bool isSuccess model = - Maybe.map .success model.message == Just True + not (isError model) view2 : Texts -> Flags -> UiSettings -> String -> Model -> Html Msg @@ -141,16 +218,32 @@ view2 texts flags settings classes model = , end = [] , rootClasses = "mb-4" } + , div [] + [ Html.map UiSettingsMigrateMsg + (Comp.UiSettingsMigrate.view model.settingsMigrate) + ] , div [ classList [ ( S.successMessage, isSuccess model ) , ( S.errorMessage, isError model ) - , ( "hidden", model.message == Nothing ) + , ( "hidden", model.formResult == FormInit ) ] ] - [ Maybe.map .message model.message - |> Maybe.withDefault "" - |> text + [ case model.formResult of + FormInit -> + text "" + + FormUnchanged -> + text texts.settingsUnchanged + + FormHttpError err -> + text (texts.httpError err) + + FormSaved -> + text texts.settingsSaved + + FormUnknownError -> + text texts.unknownSaveError ] , Html.map UiSettingsFormMsg (Comp.UiSettingsForm.view2 diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsMigrate.elm b/modules/webapp/src/main/elm/Comp/UiSettingsMigrate.elm new file mode 100644 index 00000000..58fdad67 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/UiSettingsMigrate.elm @@ -0,0 +1,186 @@ +module Comp.UiSettingsMigrate exposing + ( Model + , Msg + , UpdateResult + , init + , receiveBrowserSettings + , update + , view + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Data.Flags exposing (Flags) +import Data.UiSettings exposing (StoredUiSettings, UiSettings) +import Html exposing (..) +import Html.Attributes exposing (class, href, title) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.HttpError +import Ports +import Styles as S + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( Initialized + , Cmd.batch + [ Api.getClientSettings flags GetClientSettingsResp + , requestBrowserSettings flags + ] + ) + + +type Model + = Initialized + | WaitingForHttp StoredUiSettings + | WaitingForBrowser + | MigrateActive StoredUiSettings + | MigrateDone + | MigrateRequestRunning + | MigrateRequestFailed String + + +type Msg + = GetClientSettingsResp (Result Http.Error UiSettings) + | GetBrowserSettings StoredUiSettings + | MigrateSettings StoredUiSettings + | SaveSettingsResp UiSettings (Result Http.Error BasicResult) + + +receiveBrowserSettings : StoredUiSettings -> Msg +receiveBrowserSettings sett = + GetBrowserSettings sett + + + +--- Update + + +requestBrowserSettings : Flags -> Cmd Msg +requestBrowserSettings flags = + case flags.account of + Just acc -> + Ports.requestUiSettings acc + + Nothing -> + Cmd.none + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , newSettings : Maybe UiSettings + } + + +update : Flags -> Msg -> Model -> UpdateResult +update flags msg model = + let + empty = + { model = model + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Nothing + } + in + case msg of + GetClientSettingsResp (Err (Http.BadStatus 404)) -> + case model of + Initialized -> + { model = WaitingForBrowser + , cmd = requestBrowserSettings flags + , sub = Sub.none + , newSettings = Nothing + } + + WaitingForHttp sett -> + { empty | model = MigrateActive sett } + + _ -> + { empty + | sub = Sub.none + , cmd = requestBrowserSettings flags + , model = model + } + + GetBrowserSettings sett -> + case model of + Initialized -> + { empty | model = WaitingForHttp sett } + + WaitingForBrowser -> + { empty | model = MigrateActive sett } + + _ -> + empty + + GetClientSettingsResp _ -> + { empty | model = MigrateDone } + + MigrateSettings settings -> + let + uiSettings = + Data.UiSettings.merge settings Data.UiSettings.defaults + + cmd = + Api.saveClientSettings flags uiSettings (SaveSettingsResp uiSettings) + in + { empty | model = MigrateRequestRunning, cmd = cmd } + + SaveSettingsResp settings (Ok res) -> + if res.success then + { empty | model = MigrateDone, newSettings = Just settings } + + else + { empty | model = MigrateRequestFailed "Unknown error saving settings." } + + SaveSettingsResp _ (Err err) -> + { empty | model = MigrateRequestFailed <| Messages.Comp.HttpError.gb err } + + + +--- View +{- + Note: this module will be removed later, it only exists for the + transition from storing ui settings at the server. Therefore + strings here are not externalized; translation is not necessary. + +-} + + +view : Model -> Html Msg +view model = + case model of + MigrateActive sett -> + div + [ class (S.box ++ " px-2 py-2") + , class S.infoMessage + , class "flex flex-col" + ] + [ div [ class S.header2 ] [ text "Migrate your settings" ] + , p [ class " mb-3" ] + [ text "The UI settings are now stored at the server. You have " + , text "settings stored at the browser that you can now move to the " + , text "server by clicking below." + ] + , p [ class " mb-2" ] + [ text "Alternatively, change the default settings here and submit " + , text "this form. This message will disappear as soon as there are " + , text "settings at the server." + ] + , div [ class "flex flex-row items-center justify-center" ] + [ a + [ href "#" + , title "Move current settings to the server" + , onClick (MigrateSettings sett) + , class S.primaryButton + ] + [ text "Migrate current settings" + ] + ] + ] + + _ -> + span [ class "hidden" ] [] diff --git a/modules/webapp/src/main/elm/Data/UiSettings.elm b/modules/webapp/src/main/elm/Data/UiSettings.elm index 8e1e3cd0..71f1fc8b 100644 --- a/modules/webapp/src/main/elm/Data/UiSettings.elm +++ b/modules/webapp/src/main/elm/Data/UiSettings.elm @@ -15,6 +15,8 @@ module Data.UiSettings exposing , mergeDefaults , posFromString , posToString + , storedUiSettingsDecoder + , storedUiSettingsEncode , tagColor , tagColorFg2 , tagColorString2 @@ -30,6 +32,9 @@ import Data.UiTheme exposing (UiTheme) import Dict exposing (Dict) import Html exposing (Attribute) import Html.Attributes as HA +import Json.Decode as Decode +import Json.Decode.Pipeline as P +import Json.Encode as Encode import Messages import Messages.UiLanguage exposing (UiLanguage) @@ -67,6 +72,70 @@ type alias StoredUiSettings = } +storedUiSettingsDecoder : Decode.Decoder StoredUiSettings +storedUiSettingsDecoder = + let + maybeInt = + Decode.maybe Decode.int + + maybeString = + Decode.maybe Decode.string + in + Decode.succeed StoredUiSettings + |> P.optional "itemSearchPageSize" maybeInt Nothing + |> P.optional "tagCategoryColors" (Decode.keyValuePairs Decode.string) [] + |> P.optional "nativePdfPreview" Decode.bool False + |> P.optional "itemSearchNoteLength" maybeInt Nothing + |> P.optional "itemDetailNotesPosition" maybeString Nothing + |> P.optional "searchMenuFolderCount" maybeInt Nothing + |> P.optional "searchMenuTagCount" maybeInt Nothing + |> P.optional "searchMenuTagCatCount" maybeInt Nothing + |> P.optional "formFields" (Decode.maybe <| Decode.list Decode.string) Nothing + |> P.optional "itemDetailShortcuts" Decode.bool False + |> P.optional "searchMenuVisible" Decode.bool False + |> P.optional "editMenuVisible" Decode.bool False + |> P.optional "cardPreviewSize" maybeString Nothing + |> P.optional "cardTitleTemplate" maybeString Nothing + |> P.optional "cardSubtitleTemplate" maybeString Nothing + |> P.optional "searchStatsVisible" Decode.bool False + |> P.optional "cardPreviewFullWidth" Decode.bool False + |> P.optional "uiTheme" maybeString Nothing + |> P.optional "sideMenuVisible" Decode.bool False + |> P.optional "powerSearchEnabled" Decode.bool False + |> P.optional "uiLang" maybeString Nothing + + +storedUiSettingsEncode : StoredUiSettings -> Encode.Value +storedUiSettingsEncode value = + let + maybeEnc enca ma = + Maybe.map enca ma |> Maybe.withDefault Encode.null + in + Encode.object + [ ( "itemSearchPageSize", maybeEnc Encode.int value.itemSearchPageSize ) + , ( "tagCategoryColors", Encode.dict identity Encode.string (Dict.fromList value.tagCategoryColors) ) + , ( "nativePdfPreview", Encode.bool value.nativePdfPreview ) + , ( "itemSearchNoteLength", maybeEnc Encode.int value.itemSearchNoteLength ) + , ( "itemDetailNotesPosition", maybeEnc Encode.string value.itemDetailNotesPosition ) + , ( "searchMenuFolderCount", maybeEnc Encode.int value.searchMenuFolderCount ) + , ( "searchMenuTagCount", maybeEnc Encode.int value.searchMenuTagCount ) + , ( "searchMenuTagCatCount", maybeEnc Encode.int value.searchMenuTagCatCount ) + , ( "formFields", maybeEnc (Encode.list Encode.string) value.formFields ) + , ( "itemDetailShortcuts", Encode.bool value.itemDetailShortcuts ) + , ( "searchMenuVisible", Encode.bool value.searchMenuVisible ) + , ( "editMenuVisible", Encode.bool value.editMenuVisible ) + , ( "cardPreviewSize", maybeEnc Encode.string value.cardPreviewSize ) + , ( "cardTitleTemplate", maybeEnc Encode.string value.cardTitleTemplate ) + , ( "cardSubtitleTemplate", maybeEnc Encode.string value.cardSubtitleTemplate ) + , ( "searchStatsVisible", Encode.bool value.searchStatsVisible ) + , ( "cardPreviewFullWidth", Encode.bool value.cardPreviewFullWidth ) + , ( "uiTheme", maybeEnc Encode.string value.uiTheme ) + , ( "sideMenuVisible", Encode.bool value.sideMenuVisible ) + , ( "powerSearchEnabled", Encode.bool value.powerSearchEnabled ) + , ( "uiLang", maybeEnc Encode.string value.uiLang ) + ] + + {-| Settings for the web ui. These fields are all mandatory, since there is always a default value. diff --git a/modules/webapp/src/main/elm/Main.elm b/modules/webapp/src/main/elm/Main.elm index 9be17f90..3e4b3030 100644 --- a/modules/webapp/src/main/elm/Main.elm +++ b/modules/webapp/src/main/elm/Main.elm @@ -55,7 +55,7 @@ init flags url key = else Cmd.none - , Ports.getUiSettings flags + , Api.getClientSettings flags GetUiSettings ] ) @@ -85,5 +85,5 @@ subscriptions : Model -> Sub Msg subscriptions model = Sub.batch [ model.subs - , Ports.loadUiSettings GetUiSettings + , Ports.receiveUiSettings ReceiveBrowserSettings ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/UiSettingsManage.elm b/modules/webapp/src/main/elm/Messages/Comp/UiSettingsManage.elm index 9a79f50a..16028d75 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/UiSettingsManage.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/UiSettingsManage.elm @@ -1,6 +1,8 @@ module Messages.Comp.UiSettingsManage exposing (Texts, gb) +import Http import Messages.Basics +import Messages.Comp.HttpError import Messages.Comp.UiSettingsForm @@ -8,6 +10,10 @@ type alias Texts = { basics : Messages.Basics.Texts , uiSettingsForm : Messages.Comp.UiSettingsForm.Texts , saveSettings : String + , settingsUnchanged : String + , settingsSaved : String + , unknownSaveError : String + , httpError : Http.Error -> String } @@ -16,4 +22,8 @@ gb = { basics = Messages.Basics.gb , uiSettingsForm = Messages.Comp.UiSettingsForm.gb , saveSettings = "Save settings" + , settingsUnchanged = "Settings unchanged or invalid." + , settingsSaved = "Settings saved." + , unknownSaveError = "Unknown error while trying to save settings." + , httpError = Messages.Comp.HttpError.gb } diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 2190afb7..1ecfa5da 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -193,6 +193,7 @@ type Msg | KeyUpPowerSearchbarMsg (Maybe KeyCode) | RequestReprocessSelected | ReprocessSelectedConfirmed + | ClientSettingsSaveResp UiSettings (Result Http.Error BasicResult) type SearchType diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 97abd815..3c09e588 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -1,4 +1,7 @@ -module Page.Home.Update exposing (update) +module Page.Home.Update exposing + ( UpdateResult + , update + ) import Api import Api.Model.ItemLightList exposing (ItemLightList) @@ -16,7 +19,6 @@ import Data.Items import Data.UiSettings exposing (UiSettings) import Page exposing (Page(..)) import Page.Home.Data exposing (..) -import Ports import Process import Scroll import Set exposing (Set) @@ -28,7 +30,15 @@ import Util.ItemDragDrop as DD import Util.Update -update : Maybe String -> Nav.Key -> Flags -> UiSettings -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , newSettings : Maybe UiSettings + } + + +update : Maybe String -> Nav.Key -> Flags -> UiSettings -> Msg -> Model -> UpdateResult update mId key flags settings msg model = case msg of Init -> @@ -41,11 +51,12 @@ update mId key flags settings msg model = , scroll = True } in - Util.Update.andThen2 - [ update mId key flags settings (SearchMenuMsg Comp.SearchMenu.Init) - , doSearch searchParam - ] - model + makeResult <| + Util.Update.andThen3 + [ update mId key flags settings (SearchMenuMsg Comp.SearchMenu.Init) + , doSearch searchParam + ] + model ResetSearch -> let @@ -79,21 +90,21 @@ update mId key flags settings msg model = BasicSearch } - ( m2, c2, s2 ) = + result = if nextState.stateChange && not model.searchInProgress then doSearch (SearchParam flags BasicSearch settings.itemSearchPageSize 0 False) newModel else withSub ( newModel, Cmd.none ) in - ( m2 - , Cmd.batch - [ c2 - , Cmd.map SearchMenuMsg nextState.cmd - , dropCmd - ] - , s2 - ) + { result + | cmd = + Cmd.batch + [ result.cmd + , Cmd.map SearchMenuMsg nextState.cmd + , dropCmd + ] + } SetLinkTarget lt -> case linkTargetMsg lt of @@ -101,7 +112,7 @@ update mId key flags settings msg model = update mId key flags settings m model Nothing -> - ( model, Cmd.none, Sub.none ) + makeResult ( model, Cmd.none, Sub.none ) ItemCardListMsg m -> let @@ -144,15 +155,16 @@ update mId key flags settings msg model = , moreAvailable = list.groups /= [] } in - Util.Update.andThen2 - [ update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.SetResults list)) - , if scroll then - scrollToCard mId + makeResult <| + Util.Update.andThen3 + [ update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.SetResults list)) + , if scroll then + scrollToCard mId - else - \next -> ( next, Cmd.none, Sub.none ) - ] - m + else + \next -> makeResult ( next, Cmd.none, Sub.none ) + ] + m ItemSearchAddResp (Ok list) -> let @@ -167,10 +179,7 @@ update mId key flags settings msg model = , moreAvailable = list.groups /= [] } in - Util.Update.andThen2 - [ update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.AddResults list)) - ] - m + update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.AddResults list)) m ItemSearchAddResp (Err _) -> withSub @@ -514,10 +523,11 @@ update mId key flags settings msg model = res.change (MultiUpdateResp res.change) in - ( { model | viewMode = SelectView svm_ } - , Cmd.batch [ cmd_, upCmd ] - , sub_ - ) + makeResult + ( { model | viewMode = SelectView svm_ } + , Cmd.batch [ cmd_, upCmd ] + , sub_ + ) _ -> noSub ( model, Cmd.none ) @@ -540,10 +550,11 @@ update mId key flags settings msg model = noSub ( nm, Cmd.none ) MultiUpdateResp change (Err _) -> - ( updateSelectViewNameState False model change - , Cmd.none - , Sub.none - ) + makeResult + ( updateSelectViewNameState False model change + , Cmd.none + , Sub.none + ) ReplaceChangedItemsResp (Ok items) -> noSub ( replaceItems model items, Cmd.none ) @@ -592,10 +603,24 @@ update mId key flags settings msg model = { settings | cardPreviewFullWidth = not settings.cardPreviewFullWidth } cmd = - Ports.storeUiSettings flags newSettings + Api.saveClientSettings flags newSettings (ClientSettingsSaveResp newSettings) in noSub ( model, cmd ) + ClientSettingsSaveResp newSettings (Ok res) -> + if res.success then + { model = model + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Just newSettings + } + + else + noSub ( model, Cmd.none ) + + ClientSettingsSaveResp _ (Err _) -> + noSub ( model, Cmd.none ) + PowerSearchMsg lm -> let result = @@ -609,7 +634,7 @@ update mId key flags settings msg model = in case result.action of Comp.PowerSearchInput.NoAction -> - ( model_, cmd_, Sub.map PowerSearchMsg result.subs ) + makeResult ( model_, cmd_, Sub.map PowerSearchMsg result.subs ) Comp.PowerSearchInput.SubmitSearch -> update mId key flags settings (DoSearch model_.searchTypeDropdownValue) model_ @@ -703,21 +728,22 @@ loadChangedItems flags ids = Api.itemSearch flags search ReplaceChangedItemsResp -scrollToCard : Maybe String -> Model -> ( Model, Cmd Msg, Sub Msg ) +scrollToCard : Maybe String -> Model -> UpdateResult scrollToCard mId model = let scroll id = Scroll.scrollElementY "item-card-list" id 0.5 0.5 in - case mId of - Just id -> - ( { model | scrollToCard = mId } - , Task.attempt ScrollResult (scroll id) - , Sub.none - ) + makeResult <| + case mId of + Just id -> + ( { model | scrollToCard = mId } + , Task.attempt ScrollResult (scroll id) + , Sub.none + ) - Nothing -> - ( model, Cmd.none, Sub.none ) + Nothing -> + ( model, Cmd.none, Sub.none ) loadEditModel : Flags -> Cmd Msg @@ -725,7 +751,7 @@ loadEditModel flags = Cmd.map EditMenuMsg (Comp.ItemDetail.MultiEditMenu.loadModel flags) -doSearch : SearchParam -> Model -> ( Model, Cmd Msg, Sub Msg ) +doSearch : SearchParam -> Model -> UpdateResult doSearch param model = let param_ = @@ -798,16 +824,26 @@ doSearchMore flags settings model = ) -withSub : ( Model, Cmd Msg ) -> ( Model, Cmd Msg, Sub Msg ) +withSub : ( Model, Cmd Msg ) -> UpdateResult withSub ( m, c ) = - ( m - , c - , Throttle.ifNeeded - (Time.every 500 (\_ -> UpdateThrottle)) - m.throttle - ) + makeResult + ( m + , c + , Throttle.ifNeeded + (Time.every 500 (\_ -> UpdateThrottle)) + m.throttle + ) -noSub : ( Model, Cmd Msg ) -> ( Model, Cmd Msg, Sub Msg ) +noSub : ( Model, Cmd Msg ) -> UpdateResult noSub ( m, c ) = - ( m, c, Sub.none ) + makeResult ( m, c, Sub.none ) + + +makeResult : ( Model, Cmd Msg, Sub Msg ) -> UpdateResult +makeResult ( m, c, s ) = + { model = m + , cmd = c + , sub = s + , newSettings = Nothing + } diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm index 69c8f974..88545c60 100644 --- a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm +++ b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm @@ -12,7 +12,7 @@ import Comp.NotificationManage import Comp.ScanMailboxManage import Comp.UiSettingsManage import Data.Flags exposing (Flags) -import Data.UiSettings exposing (UiSettings) +import Data.UiSettings exposing (StoredUiSettings, UiSettings) type alias Model = @@ -62,3 +62,4 @@ type Msg | ScanMailboxMsg Comp.ScanMailboxManage.Msg | UiSettingsMsg Comp.UiSettingsManage.Msg | UpdateSettings + | ReceiveBrowserSettings StoredUiSettings diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm index d3f1fb6e..d9e07609 100644 --- a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm +++ b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm @@ -1,4 +1,4 @@ -module Page.UserSettings.Update exposing (update) +module Page.UserSettings.Update exposing (UpdateResult, update) import Comp.ChangePasswordForm import Comp.EmailSettingsManage @@ -11,7 +11,15 @@ import Data.UiSettings exposing (UiSettings) import Page.UserSettings.Data exposing (..) -update : Flags -> UiSettings -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , newSettings : Maybe UiSettings + } + + +update : Flags -> UiSettings -> Msg -> Model -> UpdateResult update flags settings msg model = case msg of SetTab t -> @@ -25,17 +33,25 @@ update flags settings msg model = ( em, c ) = Comp.EmailSettingsManage.init flags in - ( { m | emailSettingsModel = em }, Cmd.map EmailSettingsMsg c, Sub.none ) + { model = { m | emailSettingsModel = em } + , cmd = Cmd.map EmailSettingsMsg c + , sub = Sub.none + , newSettings = Nothing + } ImapSettingsTab -> let ( em, c ) = Comp.ImapSettingsManage.init flags in - ( { m | imapSettingsModel = em }, Cmd.map ImapSettingsMsg c, Sub.none ) + { model = { m | imapSettingsModel = em } + , cmd = Cmd.map ImapSettingsMsg c + , sub = Sub.none + , newSettings = Nothing + } ChangePassTab -> - ( m, Cmd.none, Sub.none ) + UpdateResult m Cmd.none Sub.none Nothing NotificationTab -> let @@ -43,7 +59,7 @@ update flags settings msg model = Cmd.map NotificationMsg (Tuple.second (Comp.NotificationManage.init flags)) in - ( m, initCmd, Sub.none ) + UpdateResult m initCmd Sub.none Nothing ScanMailboxTab -> let @@ -51,64 +67,86 @@ update flags settings msg model = Cmd.map ScanMailboxMsg (Tuple.second (Comp.ScanMailboxManage.init flags)) in - ( m, initCmd, Sub.none ) + UpdateResult m initCmd Sub.none Nothing UiSettingsTab -> - ( m, Cmd.none, Sub.none ) + UpdateResult m Cmd.none Sub.none Nothing ChangePassMsg m -> let ( m2, c2 ) = Comp.ChangePasswordForm.update flags m model.changePassModel in - ( { model | changePassModel = m2 }, Cmd.map ChangePassMsg c2, Sub.none ) + { model = { model | changePassModel = m2 } + , cmd = Cmd.map ChangePassMsg c2 + , sub = Sub.none + , newSettings = Nothing + } EmailSettingsMsg m -> let ( m2, c2 ) = Comp.EmailSettingsManage.update flags m model.emailSettingsModel in - ( { model | emailSettingsModel = m2 }, Cmd.map EmailSettingsMsg c2, Sub.none ) + { model = { model | emailSettingsModel = m2 } + , cmd = Cmd.map EmailSettingsMsg c2 + , sub = Sub.none + , newSettings = Nothing + } ImapSettingsMsg m -> let ( m2, c2 ) = Comp.ImapSettingsManage.update flags m model.imapSettingsModel in - ( { model | imapSettingsModel = m2 }, Cmd.map ImapSettingsMsg c2, Sub.none ) + { model = { model | imapSettingsModel = m2 } + , cmd = Cmd.map ImapSettingsMsg c2 + , sub = Sub.none + , newSettings = Nothing + } NotificationMsg lm -> let ( m2, c2 ) = Comp.NotificationManage.update flags lm model.notificationModel in - ( { model | notificationModel = m2 } - , Cmd.map NotificationMsg c2 - , Sub.none - ) + { model = { model | notificationModel = m2 } + , cmd = Cmd.map NotificationMsg c2 + , sub = Sub.none + , newSettings = Nothing + } ScanMailboxMsg lm -> let ( m2, c2 ) = Comp.ScanMailboxManage.update flags lm model.scanMailboxModel in - ( { model | scanMailboxModel = m2 } - , Cmd.map ScanMailboxMsg c2 - , Sub.none - ) + { model = { model | scanMailboxModel = m2 } + , cmd = Cmd.map ScanMailboxMsg c2 + , sub = Sub.none + , newSettings = Nothing + } UiSettingsMsg lm -> let - ( m2, c2, s2 ) = + res = Comp.UiSettingsManage.update flags settings lm model.uiSettingsModel in - ( { model | uiSettingsModel = m2 } - , Cmd.map UiSettingsMsg c2 - , Sub.map UiSettingsMsg s2 - ) + { model = { model | uiSettingsModel = res.model } + , cmd = Cmd.map UiSettingsMsg res.cmd + , sub = Sub.map UiSettingsMsg res.sub + , newSettings = res.newSettings + } UpdateSettings -> update flags settings (UiSettingsMsg Comp.UiSettingsManage.UpdateSettings) model + + ReceiveBrowserSettings sett -> + let + lm = + Comp.UiSettingsManage.ReceiveBrowserSettings sett + in + update flags settings (UiSettingsMsg lm) model diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm index 9f630a81..0e9ad372 100644 --- a/modules/webapp/src/main/elm/Ports.elm +++ b/modules/webapp/src/main/elm/Ports.elm @@ -1,21 +1,17 @@ port module Ports exposing ( checkSearchQueryString - , getUiSettings , initClipboard - , loadUiSettings - , onUiSettingsSaved , receiveCheckQueryResult + , receiveUiSettings , removeAccount + , requestUiSettings , setAccount , setUiTheme - , storeUiSettings ) import Api.Model.AuthResult exposing (AuthResult) -import Api.Model.BasicResult exposing (BasicResult) -import Data.Flags exposing (Flags) import Data.QueryParseResult exposing (QueryParseResult) -import Data.UiSettings exposing (StoredUiSettings, UiSettings) +import Data.UiSettings exposing (StoredUiSettings) import Data.UiTheme exposing (UiTheme) @@ -27,18 +23,6 @@ port setAccount : AuthResult -> Cmd msg port removeAccount : () -> Cmd msg -port saveUiSettings : ( AuthResult, StoredUiSettings ) -> Cmd msg - - -port receiveUiSettings : (StoredUiSettings -> msg) -> Sub msg - - -port requestUiSettings : ( AuthResult, StoredUiSettings ) -> Cmd msg - - -port uiSettingsSaved : (() -> msg) -> Sub msg - - port internalSetUiTheme : String -> Cmd msg @@ -48,45 +32,15 @@ port checkSearchQueryString : String -> Cmd msg port receiveCheckQueryResult : (QueryParseResult -> msg) -> Sub msg +port initClipboard : ( String, String ) -> Cmd msg + + +port receiveUiSettings : (StoredUiSettings -> msg) -> Sub msg + + +port requestUiSettings : AuthResult -> Cmd msg + + setUiTheme : UiTheme -> Cmd msg setUiTheme theme = internalSetUiTheme (Data.UiTheme.toString theme) - - -onUiSettingsSaved : msg -> Sub msg -onUiSettingsSaved m = - uiSettingsSaved (\_ -> m) - - -storeUiSettings : Flags -> UiSettings -> Cmd msg -storeUiSettings flags settings = - case flags.account of - Just ar -> - saveUiSettings - ( ar - , Data.UiSettings.toStoredUiSettings settings - ) - - Nothing -> - Cmd.none - - -loadUiSettings : (UiSettings -> msg) -> Sub msg -loadUiSettings tagger = - receiveUiSettings (Data.UiSettings.mergeDefaults >> tagger) - - -getUiSettings : Flags -> Cmd msg -getUiSettings flags = - case flags.account of - Just ar -> - requestUiSettings - ( ar - , Data.UiSettings.toStoredUiSettings Data.UiSettings.defaults - ) - - Nothing -> - Cmd.none - - -port initClipboard : ( String, String ) -> Cmd msg diff --git a/modules/webapp/src/main/elm/Util/Update.elm b/modules/webapp/src/main/elm/Util/Update.elm index 0a135e89..90127cca 100644 --- a/modules/webapp/src/main/elm/Util/Update.elm +++ b/modules/webapp/src/main/elm/Util/Update.elm @@ -1,6 +1,7 @@ module Util.Update exposing ( andThen1 , andThen2 + , andThen3 , cmdUnit ) @@ -44,6 +45,21 @@ andThen2 fs m = |> combine +andThen3 : + List (model -> { x | model : model, cmd : Cmd msg, sub : Sub msg }) + -> model + -> ( model, Cmd msg, Sub msg ) +andThen3 list m = + let + mkTuple r = + ( r.model, r.cmd, r.sub ) + + list2 = + List.map (\e -> e >> mkTuple) list + in + andThen2 list2 m + + cmdUnit : a -> Cmd a cmdUnit a = Task.perform (\_ -> a) (Task.succeed ()) diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js index d6fcc4c6..d13f6889 100644 --- a/modules/webapp/src/main/webjar/docspell.js +++ b/modules/webapp/src/main/webjar/docspell.js @@ -50,40 +50,22 @@ elmApp.ports.removeAccount.subscribe(function() { localStorage.removeItem("account"); }); - -elmApp.ports.saveUiSettings.subscribe(function(args) { - if (Array.isArray(args) && args.length == 2) { - var authResult = args[0]; - var settings = args[1]; - if (authResult && settings) { - var key = authResult.collective + "/" + authResult.user + "/uiSettings"; - console.log("Save ui settings to local storage"); - localStorage.setItem(key, JSON.stringify(settings)); - elmApp.ports.receiveUiSettings.send(settings); - elmApp.ports.uiSettingsSaved.send(null); - } - } -}); - elmApp.ports.requestUiSettings.subscribe(function(args) { console.log("Requesting ui settings"); - if (Array.isArray(args) && args.length == 2) { - var account = args[0]; - var defaults = args[1]; - var collective = account ? account.collective : null; - var user = account ? account.user : null; - if (collective && user) { - var key = collective + "/" + user + "/uiSettings"; - var settings = localStorage.getItem(key); + var account = args; + var collective = account ? account.collective : null; + var user = account ? account.user : null; + if (collective && user) { + var key = collective + "/" + user + "/uiSettings"; + var settings = localStorage.getItem(key); + try { var data = settings ? JSON.parse(settings) : null; - if (data && defaults) { - var defaults = extend(defaults, data); - elmApp.ports.receiveUiSettings.send(defaults); - } else if (defaults) { - elmApp.ports.receiveUiSettings.send(defaults); + if (data) { + console.log("Sending browser ui settings"); + elmApp.ports.receiveUiSettings.send(data); } - } else if (defaults) { - elmApp.ports.receiveUiSettings.send(defaults); + } catch (error) { + console.log(error); } } });