diff --git a/common/src/main/scala/explore/components/Tile.scala b/common/src/main/scala/explore/components/Tile.scala index 9d1fb310d..8f8fa7ab1 100644 --- a/common/src/main/scala/explore/components/Tile.scala +++ b/common/src/main/scala/explore/components/Tile.scala @@ -59,7 +59,7 @@ import lucuma.ui.syntax.all.given */ case class Tile[A]( id: Tile.TileId, - title: String, + title: VdomNode, initialState: A = (), back: Option[VdomNode] = None, canMinimize: Boolean = true, diff --git a/common/src/main/scala/explore/components/ui/ExploreStyles.scala b/common/src/main/scala/explore/components/ui/ExploreStyles.scala index 25dda8b11..93d64ecbb 100644 --- a/common/src/main/scala/explore/components/ui/ExploreStyles.scala +++ b/common/src/main/scala/explore/components/ui/ExploreStyles.scala @@ -224,6 +224,7 @@ object ExploreStyles: val ProposalTab: Css = Css("explore-proposal-tab") val ProposalDetailsGrid: Css = Css("explore-proposal-details-grid") val ProposalAbstract: Css = Css("explore-proposal-abstract") + val AbstractTitleTooLong: Css = Css("explore-abstract-too-long") val ProposalSubmissionBar: Css = Css("explore-proposal-submission-line") val ProposalDeadline: Css = Css("explore-proposal-deadline") val ProposalAttachmentsTile: Css = Css("explore-proposal-attachments-tile") diff --git a/common/src/main/webapp/sass/explore.scss b/common/src/main/webapp/sass/explore.scss index 4a1e2be45..c074655a0 100644 --- a/common/src/main/webapp/sass/explore.scss +++ b/common/src/main/webapp/sass/explore.scss @@ -2014,6 +2014,11 @@ svg.fa-triangle-exclamation.explore-error-icon { // ------ // Proposals and Partner Splits // ------ + +.explore-abstract-too-long { + color: var(--error-background-color); +} + .partner-splits-grid { --gap: 0.5em; diff --git a/explore/src/main/scala/explore/proposal/ProposalEditor.scala b/explore/src/main/scala/explore/proposal/ProposalEditor.scala index 52ec07511..5cc69bb36 100644 --- a/explore/src/main/scala/explore/proposal/ProposalEditor.scala +++ b/explore/src/main/scala/explore/proposal/ProposalEditor.scala @@ -5,7 +5,7 @@ package explore.proposal import cats.data.NonEmptySet import cats.effect.IO -import cats.syntax.option.* +import cats.syntax.all.* import clue.* import clue.data.Input import clue.data.syntax.* @@ -69,6 +69,16 @@ case class ProposalEditor( object ProposalEditor: private type Props = ProposalEditor + private val BaseWordLimit = 200 + private val HardWordLimit = 2 * BaseWordLimit + + extension (s: String) + inline def wordCount: Int = + val trim = s.trim + if (trim.isEmpty) 0 + else + trim.split("\\s+", HardWordLimit + 1).length // add a limit to restrict the performance hit + private val component = ScalaFnComponent .withHooks[Props] @@ -99,8 +109,14 @@ object ProposalEditor: // setClass >> setType >> setHours >> setPct2 // } // ) + .useStateBy((props, _) => props.proposal.get.abstrakt.map(_.value).foldMap(_.wordCount)) + .useEffectWithDepsBy((props, _, abstractCounter) => props.proposal.get.abstrakt.map(_.value))( + (_, _, abstractCounter) => + case Some(t) => abstractCounter.setState(t.wordCount) + case None => abstractCounter.setState(0) + ) .useResizeDetector() - .render: (props, ctx, resize) => + .render: (props, ctx, abstractCounter, resize) => import ctx.given val undoCtx: UndoContext[Proposal] = UndoContext(props.undoStacks, props.proposal) @@ -118,7 +134,8 @@ object ProposalEditor: val abstractAligner: Aligner[Option[NonEmptyString], Input[NonEmptyString]] = aligner.zoom(Proposal.abstrakt, ProposalPropertiesInput.`abstract`.modify) - val abstractView: View[Option[NonEmptyString]] = abstractAligner.view(_.orUnassign) + val abstractView: View[Option[NonEmptyString]] = abstractAligner + .view(_.orUnassign) val defaultLayouts = ExploreGridLayouts.sectionLayout(GridLayoutSection.ProposalLayout) @@ -159,15 +176,30 @@ object ProposalEditor: .orEmpty ) + val absTitle: VdomNode = + if (abstractCounter.value < 1) "Abstract" + else if (abstractCounter.value >= HardWordLimit) + React.Fragment( + "Abstract ", + <.span(ExploreStyles.AbstractTitleTooLong, s"($HardWordLimit or more words)") + ) + else if (abstractCounter.value >= BaseWordLimit) + React.Fragment( + "Abstract ", + <.span(ExploreStyles.AbstractTitleTooLong, s"(${abstractCounter.value} words)") + ) + else s"Abstract (${abstractCounter.value} words)" + val abstractTile = Tile( ProposalTabTileIds.AbstractId.id, - "Abstract", + absTitle, bodyClass = ExploreStyles.ProposalAbstract )(_ => FormInputTextAreaView( id = "abstract".refined, - value = abstractView.as(OptionNonEmptyStringIso) + value = abstractView.as(OptionNonEmptyStringIso), + onTextChange = t => abstractCounter.setState(t.wordCount).rateLimitMs(1000).void )(^.disabled := props.readonly, ^.cls := ExploreStyles.WarningInput.when_(abstractView.get.isEmpty).htmlClass ) diff --git a/project/Versions.scala b/project/Versions.scala index 52264e419..2e09b99bf 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -29,7 +29,7 @@ object Versions { val lucumaSchemas = "0.110.1" val lucumaOdbSchema = "0.17.2" val lucumaSSO = "0.7.1" - val lucumaUI = "0.124.7" + val lucumaUI = "0.125.0" val monocle = "3.3.0" val mouse = "1.3.2" val mUnit = "1.0.3"