diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..b937d45 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.ui.components.laminar.TableComponentsModule +import works.iterative.core.UserMessage +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.ComponentContext + +trait HtmlTableBuilderModule: + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] + + trait HtmlTableBuilder[A]: + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + dataRowMod((a, _) => mod(a)) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] + def headerCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + headerCellMod(_ => mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] + def dataCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + dataCellMod((_, _) => mod) + def dataCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + dataCellMod((s, _) => mod(s)) + + def build: HtmlElement + +trait HtmlTableBuilderModuleImpl(using resolver: TableHeaderResolver) + extends HtmlTableBuilderModule: + self: TableComponentsModule => + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + new HtmlTableBuilderImpl[A](data) + + case class HtmlTableBuilderImpl[A: HtmlTabular]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + ) extends HtmlTableBuilder[A]: + + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def build: HtmlElement = + val tab = summon[HtmlTabular[A]] + tables.simpleTable( + tables.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tables + .headerCell( + Seq[HtmlMod](headerCellMod(n), resolver(n)) + ) + }* + ) + )( + data.zipWithIndex.map((d, idx) => + tables.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tables.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + ) diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..b937d45 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.ui.components.laminar.TableComponentsModule +import works.iterative.core.UserMessage +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.ComponentContext + +trait HtmlTableBuilderModule: + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] + + trait HtmlTableBuilder[A]: + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + dataRowMod((a, _) => mod(a)) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] + def headerCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + headerCellMod(_ => mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] + def dataCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + dataCellMod((_, _) => mod) + def dataCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + dataCellMod((s, _) => mod(s)) + + def build: HtmlElement + +trait HtmlTableBuilderModuleImpl(using resolver: TableHeaderResolver) + extends HtmlTableBuilderModule: + self: TableComponentsModule => + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + new HtmlTableBuilderImpl[A](data) + + case class HtmlTableBuilderImpl[A: HtmlTabular]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + ) extends HtmlTableBuilder[A]: + + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def build: HtmlElement = + val tab = summon[HtmlTabular[A]] + tables.simpleTable( + tables.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tables + .headerCell( + Seq[HtmlMod](headerCellMod(n), resolver(n)) + ) + }* + ) + )( + data.zipWithIndex.map((d, idx) => + tables.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tables.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..b93e6bd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..b937d45 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.ui.components.laminar.TableComponentsModule +import works.iterative.core.UserMessage +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.ComponentContext + +trait HtmlTableBuilderModule: + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] + + trait HtmlTableBuilder[A]: + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + dataRowMod((a, _) => mod(a)) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] + def headerCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + headerCellMod(_ => mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] + def dataCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + dataCellMod((_, _) => mod) + def dataCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + dataCellMod((s, _) => mod(s)) + + def build: HtmlElement + +trait HtmlTableBuilderModuleImpl(using resolver: TableHeaderResolver) + extends HtmlTableBuilderModule: + self: TableComponentsModule => + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + new HtmlTableBuilderImpl[A](data) + + case class HtmlTableBuilderImpl[A: HtmlTabular]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + ) extends HtmlTableBuilder[A]: + + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def build: HtmlElement = + val tab = summon[HtmlTabular[A]] + tables.simpleTable( + tables.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tables + .headerCell( + Seq[HtmlMod](headerCellMod(n), resolver(n)) + ) + }* + ) + )( + data.zipWithIndex.map((d, idx) => + tables.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tables.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..b93e6bd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala index d0c324e..71dbd92 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -6,3 +6,14 @@ trait ComponentContext: def messages: MessageCatalogue def style: StyleGuide + + def nested(prefixes: String*): ComponentContext = + ComponentContext.Nested(this, prefixes) + +object ComponentContext: + case class Nested(parent: ComponentContext, prefixes: Seq[String]) + extends ComponentContext: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala index be9aa4c..836acaf 100644 --- a/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala +++ b/core/shared/src/main/scala/works/iterative/core/MessageCatalogue.scala @@ -7,53 +7,63 @@ // we need to be able to render HTML messages // like a list of items for example trait MessageCatalogue: - def apply(id: MessageId, fallback: MessageId*): String = - @tailrec - def getFirstOf(ids: List[MessageId], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - getFirstOf(id :: fallback.to(List), id.toString()) - - def apply(msg: UserMessage, fallback: UserMessage*): String = - @tailrec - def getFirstOf(ids: List[UserMessage], default: String): String = - ids match - case Nil => default - case i :: is => - get(i) match - case Some(m) => m - case _ => getFirstOf(is, default) - - getFirstOf(msg :: fallback.to(List), msg.id.toString()) - - def opt(id: MessageId, fallback: MessageId*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[MessageId]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(id :: fallback.to(List)) - - def opt(msg: UserMessage, fallback: UserMessage*): Option[String] = - @tailrec - def getFirstOf(ids: Seq[UserMessage]): Option[String] = - ids match - case Nil => None - case i :: is => - get(i) match - case m @ Some(_) => m - case _ => getFirstOf(is) - - getFirstOf(msg :: fallback.to(List)) - + // These need to be implemented def get(id: MessageId): Option[String] def get(msg: UserMessage): Option[String] + + def apply(id: MessageId, fallback: => MessageId*): String = + resolve(id, fallback*)(get(_: MessageId)) + + def apply(msg: UserMessage, fallback: => UserMessage*): String = + resolve(msg, fallback*)(get(_: UserMessage)) + + def opt(id: MessageId, fallback: => MessageId*): Option[String] = + maybeResolve(id, fallback*)(get(_: MessageId)) + + def opt(msg: UserMessage, fallback: => UserMessage*): Option[String] = + maybeResolve(msg, fallback*)(get(_: UserMessage)) + + @tailrec + private def maybeResolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): Option[String] = + (tryResolve(id), fallback) match + case (m @ Some(_), _) => m + case (None, next :: rest) => maybeResolve(next, rest*)(tryResolve) + case (None, _) => None + + private inline def resolve[T, U](id: T, fallback: T*)( + tryResolve: T => Option[String] + ): String = + maybeResolve(id, fallback*)(tryResolve).getOrElse(id.toString()) + + def nested(prefixes: String*): MessageCatalogue = + NestedMessageCatalogue(this, prefixes*) + +private class NestedMessageCatalogue( + underlying: MessageCatalogue, + prefixes: String* +) extends MessageCatalogue: + // All members of MessageCatalogue, calling underlying with prefixed ids falling back to unprefixed + def get(id: MessageId): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => underlying.get(s"${prefix}.${id}")) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(id)) + + def get(msg: UserMessage): Option[String] = + // Iterate over the prefixes, trying to find the message, returning the first one found, trying bare id last, or None + prefixes.view + .map(prefix => + underlying.get(UserMessage(s"${prefix}.${msg.id}", msg.args*)) + ) + .collect { case Some(msg) => + msg + } + .headOption + .orElse(underlying.get(msg)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala new file mode 100644 index 0000000..b3902e4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/HtmlTabular.scala @@ -0,0 +1,7 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.HtmlMod +import works.iterative.ui.model.tables.Tabular + +/** A tabular typclass that can be rendered into HTML */ +trait HtmlTabular[A] extends Tabular[A, HtmlMod] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala new file mode 100644 index 0000000..b937d45 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableBuilderModule.scala @@ -0,0 +1,80 @@ +package works.iterative.ui.components.laminar.tables + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.model.tables.Tabular +import works.iterative.ui.components.laminar.TableComponentsModule +import works.iterative.core.UserMessage +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.ComponentContext + +trait HtmlTableBuilderModule: + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] + + trait HtmlTableBuilder[A]: + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] + def dataRowMod(mod: A => HtmlMod): HtmlTableBuilder[A] = + dataRowMod((a, _) => mod(a)) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] + def headerCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + headerCellMod(_ => mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] + def dataCellMod(mod: HtmlMod): HtmlTableBuilder[A] = + dataCellMod((_, _) => mod) + def dataCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + dataCellMod((s, _) => mod(s)) + + def build: HtmlElement + +trait HtmlTableBuilderModuleImpl(using resolver: TableHeaderResolver) + extends HtmlTableBuilderModule: + self: TableComponentsModule => + + def buildTable[A: HtmlTabular](data: List[A]): HtmlTableBuilder[A] = + new HtmlTableBuilderImpl[A](data) + + case class HtmlTableBuilderImpl[A: HtmlTabular]( + data: List[A], + headerRowMod: HtmlMod = emptyMod, + dataRowMod: (A, Int) => HtmlMod = (_: A, _) => emptyMod, + headerCellMod: String => HtmlMod = _ => emptyMod, + dataCellMod: (String, A) => HtmlMod = (_, _: A) => emptyMod + ) extends HtmlTableBuilder[A]: + + def headerRowMod(mod: HtmlMod): HtmlTableBuilder[A] = + copy(headerRowMod = mod) + + def dataRowMod(mod: (A, Int) => HtmlMod): HtmlTableBuilder[A] = + copy(dataRowMod = mod) + + def headerCellMod(mod: String => HtmlMod): HtmlTableBuilder[A] = + copy(headerCellMod = mod) + + def dataCellMod(mod: (String, A) => HtmlMod): HtmlTableBuilder[A] = + copy(dataCellMod = mod) + + def build: HtmlElement = + val tab = summon[HtmlTabular[A]] + tables.simpleTable( + tables.headerRow(headerRowMod)( + tab.columns.map(_.name).map { n => + tables + .headerCell( + Seq[HtmlMod](headerCellMod(n), resolver(n)) + ) + }* + ) + )( + data.zipWithIndex.map((d, idx) => + tables.dataRow(dataRowMod(d, idx))( + tab.columns + .map(c => c.name -> c.get(d)) + .map { (n, v) => + tables.dataCell(Seq(v, dataCellMod(n, d))) + }* + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala new file mode 100644 index 0000000..b93e6bd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tables/TableHeaderResolver.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.tables + +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.core.MessageCatalogue + +opaque type TableHeaderResolver = String => String + +object TableHeaderResolver extends LowPriorityTableHeaderResolverImplicits: + def apply(resolver: String => String): TableHeaderResolver = resolver + + given (using ctx: ComponentContext): TableHeaderResolver = + name => ctx.messages(name) + + given (using cat: MessageCatalogue): TableHeaderResolver = + name => cat(name) + + extension (v: TableHeaderResolver) def apply(name: String): String = v(name) + +trait LowPriorityTableHeaderResolverImplicits: + given default: TableHeaderResolver = TableHeaderResolver(identity[String]) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala index d0c324e..71dbd92 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/ComponentContext.scala @@ -6,3 +6,14 @@ trait ComponentContext: def messages: MessageCatalogue def style: StyleGuide + + def nested(prefixes: String*): ComponentContext = + ComponentContext.Nested(this, prefixes) + +object ComponentContext: + case class Nested(parent: ComponentContext, prefixes: Seq[String]) + extends ComponentContext: + export parent.{messages => _, *} + + override lazy val messages: MessageCatalogue = + parent.messages.nested(prefixes*) diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala b/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala new file mode 100644 index 0000000..70bebaf --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/model/tables/Tabular.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.model.tables + +/** A column in a table + * + * @param name + * the name of the column, must be unique in a row + * @param get + * a function to get the value of the column from a type + */ +case class Column[A, Cell](name: String, get: A => Cell) + +/** A typeclass to represet a type that can be tabulated into Cells */ +trait Tabular[A, Cell]: + def columns: List[Column[A, Cell]]