From 616d1cdbf39add09022c85ab0303969e53218812 Mon Sep 17 00:00:00 2001 From: Quentin AUBERT Date: Wed, 4 Dec 2024 11:21:11 +0100 Subject: [PATCH] Rework post UI --- daikoku/app/controllers/ApiController.scala | 23 +- daikoku/app/domain/SchemaDefinition.scala | 19 +- daikoku/app/storage/api.scala | 15 +- .../drivers/postgres/PostgresDataStore.scala | 18 +- .../src/components/frontend/api/ApiPost.tsx | 212 +++++++++++++----- .../components/frontend/api/ApiPricing.tsx | 32 +-- .../components/frontend/api/ApiSwagger.tsx | 1 - daikoku/javascript/src/services/index.ts | 6 +- daikoku/javascript/src/types/api.ts | 6 + 9 files changed, 237 insertions(+), 95 deletions(-) diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 67d6bfa21..34dd24984 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -5,24 +5,14 @@ import org.apache.pekko.http.scaladsl.util.FastFuture import org.apache.pekko.stream.Materializer import org.apache.pekko.stream.scaladsl.{Flow, JsonFraming, Sink, Source} import org.apache.pekko.util.ByteString -import cats.Id import cats.data.EitherT import cats.implicits.{catsSyntaxOptionId, toTraverseOps} import controllers.AppError import controllers.AppError._ -import fr.maif.otoroshi.daikoku.actions.{ - DaikokuAction, - DaikokuActionContext, - DaikokuActionMaybeWithGuest, - DaikokuActionMaybeWithoutUser -} +import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionContext, DaikokuActionMaybeWithGuest, DaikokuActionMaybeWithoutUser} import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent -import fr.maif.otoroshi.daikoku.audit.config.ElasticAnalyticsConfig import fr.maif.otoroshi.daikoku.ctrls.authorizations.async._ -import fr.maif.otoroshi.daikoku.domain.NotificationAction.{ - ApiAccess, - ApiSubscriptionDemand -} +import fr.maif.otoroshi.daikoku.domain.NotificationAction.{ApiAccess, ApiSubscriptionDemand} import fr.maif.otoroshi.daikoku.domain.UsagePlanVisibility.Private import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.domain.json._ @@ -41,6 +31,7 @@ import play.api.i18n.I18nSupport import play.api.libs.json._ import play.api.libs.streams.Accumulator import play.api.mvc._ +import storage.{Desc} import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} @@ -3580,13 +3571,17 @@ class ApiController( ) ), offset, - limit + limit, + Json.obj("lastModificationAt" -> 1).some, + Desc.some ) .map(data => Right( Json.obj( "posts" -> JsArray(data._1.map(_.asJson)), - "total" -> data._2 + "total" -> data._2, + "nextCursor" -> (if ((offset + limit) < data._2) offset + limit else JsNull), + "prevCursor" -> (if (offset < limit) JsNull else offset - limit ) ) ) ) diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 4012f1e00..c22ee3a71 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -2469,6 +2469,22 @@ object SchemaDefinition { ) ) ) + lazy val ApiSubscriptionTransferSuccessType = new PossibleObject( + ObjectType( + "ApiSubscriptionTransferSuccess", + "A notification triggered when a checkout session is available", + interfaces[ + (DataStore, DaikokuActionContext[JsValue]), + ApiSubscriptionTransferSuccess + ](NotificationActionType), + fields[ + (DataStore, DaikokuActionContext[JsValue]), + ApiSubscriptionTransferSuccess + ]( + Field("subscription", StringType, resolve = _.value.subscription.value) + ) + ) + ) lazy val NotificationInterfaceType: ObjectType[ (DataStore, DaikokuActionContext[JsValue]), @@ -2579,7 +2595,8 @@ object SchemaDefinition { TransferApiOwnershipType, ApiSubscriptionRejectType, ApiSubscriptionAcceptType, - CheckoutForSubscriptionType + CheckoutForSubscriptionType, + ApiSubscriptionTransferSuccessType ) ) ) diff --git a/daikoku/app/storage/api.scala b/daikoku/app/storage/api.scala index 0081e59af..05c15200a 100644 --- a/daikoku/app/storage/api.scala +++ b/daikoku/app/storage/api.scala @@ -12,6 +12,18 @@ import play.api.libs.json._ import scala.concurrent.{ExecutionContext, Future} +sealed trait SortingOrder { + def name: String +} + +case object Desc extends SortingOrder { + def name: String = "DESC" +} +case object Asc extends SortingOrder { + def name: String = "ASC" +} + + trait TenantCapableRepo[Of, Id <: ValueType] { def forTenant(tenant: Tenant): Repo[Of, Id] = forTenant(tenant.id) @@ -75,7 +87,8 @@ trait Repo[Of, Id <: ValueType] { query: JsObject, page: Int, pageSize: Int, - sort: Option[JsObject] = None + sort: Option[JsObject] = None, + order: Option[SortingOrder] = None )(implicit ec: ExecutionContext ): Future[(Seq[Of], Long)] diff --git a/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala b/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala index fc0f03dbb..60e57a474 100644 --- a/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala +++ b/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala @@ -1693,9 +1693,10 @@ abstract class PostgresRepo[Of, Id <: ValueType]( query: JsObject, page: Int, pageSize: Int, - sort: Option[JsObject] = None + sort: Option[JsObject] = None, + order: Option[SortingOrder] = None )(implicit ec: ExecutionContext): Future[(Seq[Of], Long)] = - super.findWithPagination(query, page, pageSize, sort) + super.findWithPagination(query, page, pageSize, sort, order) } abstract class PostgresTenantAwareRepo[Of, Id <: ValueType]( @@ -1930,13 +1931,15 @@ abstract class PostgresTenantAwareRepo[Of, Id <: ValueType]( query: JsObject, page: Int, pageSize: Int, - sort: Option[JsObject] = None + sort: Option[JsObject] = None, + order: Option[SortingOrder] = None )(implicit ec: ExecutionContext): Future[(Seq[Of], Long)] = super.findWithPagination( query ++ Json.obj("_tenant" -> tenant.value), page, pageSize, - sort + sort, + order ) } @@ -2264,7 +2267,8 @@ abstract class CommonRepo[Of, Id <: ValueType](env: Env, reactivePg: ReactivePg) query: JsObject, page: Int, pageSize: Int, - sort: Option[JsObject] = None + sort: Option[JsObject] = None, + order : Option[SortingOrder] = None )(implicit ec: ExecutionContext ): Future[(Seq[Of], Long)] = { @@ -2308,7 +2312,7 @@ abstract class CommonRepo[Of, Id <: ValueType](env: Env, reactivePg: ReactivePg) if (query.values.isEmpty) reactivePg.querySeq( - s"SELECT * FROM $tableName ORDER BY ${sortedKeys.mkString(",")} ASC LIMIT $$1 OFFSET $$2", + s"SELECT * FROM $tableName ORDER BY ${sortedKeys.mkString(",")} ${order.map(_.name).getOrElse(Asc.name)} LIMIT $$1 OFFSET $$2", Seq(Integer.valueOf(pageSize), Integer.valueOf(page * pageSize)) ) { row => rowToJson(row, format) @@ -2317,7 +2321,7 @@ abstract class CommonRepo[Of, Id <: ValueType](env: Env, reactivePg: ReactivePg) val (sql, params) = convertQuery(query) reactivePg.querySeq( s"SELECT * FROM $tableName WHERE $sql ORDER BY ${sortedKeys - .mkString(",")} ASC ${if (pageSize > 0) + .mkString(",")} ${order.map(_.name).getOrElse(Asc.name)} ${if (pageSize > 0) s"LIMIT ${Integer.valueOf(pageSize)}" else ""} OFFSET ${Integer.valueOf(page * pageSize)}", params.map { diff --git a/daikoku/javascript/src/components/frontend/api/ApiPost.tsx b/daikoku/javascript/src/components/frontend/api/ApiPost.tsx index 4d87858d3..073f6ffc7 100644 --- a/daikoku/javascript/src/components/frontend/api/ApiPost.tsx +++ b/daikoku/javascript/src/components/frontend/api/ApiPost.tsx @@ -1,67 +1,177 @@ -import React, { useState, useEffect, useContext } from 'react'; -import { I18nContext } from '../../../contexts'; +import { constraints, Form, format, Schema, type } from '@maif/react-forms'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import moment from 'moment'; +import { useContext, useState } from 'react'; +import ArrowLeft from 'react-feather/dist/icons/arrow-left'; +import ArrowRight from 'react-feather/dist/icons/arrow-right'; +import More from 'react-feather/dist/icons/more-vertical'; + +import { toast } from 'sonner'; +import { I18nContext, ModalContext } from '../../../contexts'; import * as Services from '../../../services/index'; import { converter } from '../../../services/showdown'; -import moment from 'moment'; +import { IApi, IApiPost, IApiPostCursor, isError, ITeamSimple } from '../../../types'; +import { api as API, Can, manage } from '../../utils/permissions'; +import { Spinner } from '../../utils/Spinner'; -type Pagination = { - limit: number, - offset: number, - total: number +type ApiPostProps = { + api: IApi + versionId: string + ownerTeam: ITeamSimple } -export function ApiPost({ - api, - versionId -}: any) { - const [posts, setPosts] = useState([]); +export function ApiPost(props: ApiPostProps) { const { translate, language } = useContext(I18nContext); + const { openRightPanel, closeRightPanel, confirm } = useContext(ModalContext); + const queryClient = useQueryClient(); + + const [currentPage, setCurrentPage] = useState(0); - const [pagination, setPagination] = useState({ - limit: 1, - offset: 0, - total: 0, - }); + const postQuery = useQuery({ + queryKey: ['posts', currentPage], + queryFn: () => Services.getAPIPosts(props.api._humanReadableId, props.versionId, currentPage, 1), + }) - useEffect(() => { - Services.getAPIPosts(api._humanReadableId, versionId, pagination.offset, pagination.limit).then( - (data) => { - setPosts( - [...posts, ...data.posts].reduce((acc, post) => { - if (!acc.find((p: any) => p._id === post._id)) acc.push(post); - return acc; - }, []) - ); - setPagination({ - ...pagination, - total: data.total, - }); - } - ); - }, [pagination.offset, pagination.limit]); + const schema: Schema = { + title: { + type: type.string, + label: translate('team_api_post.title'), + constraints: [ + constraints.required(translate('constraints.required.title')) + ] + }, + content: { + type: type.string, + format: format.markdown, + label: translate('team_api_post.content'), + constraints: [ + constraints.required(translate('constraints.required.content')) + ] + }, + }; - function formatDate(lastModificationAt: any) { + const formatDate = (lastModificationAt: string) => { moment.locale(language); return moment(lastModificationAt).format(translate('moment.date.format')) } - return (
- {posts.map((post, i) => (
-
-

{(post as any).title}

- {formatDate((post as any).lastModificationAt)} -
-
-
))} - {posts.length < pagination.total && ()} -
); + } + + function publishPost(post: IApiPost) { + Services.publishNewPost(props.api._id, props.ownerTeam._id, { + ...post, + _id: '', + }) + .then((res) => { + if (res.error) { + toast.error(translate('team_api_post.failed')); + } else { + toast.success(translate('team_api_post.saved')); + } + }) + .then(() => closeRightPanel()) + .then(() => queryClient.invalidateQueries({ queryKey: ['posts'] })) + } + + function removePost(post: IApiPost) { + return confirm({ message: translate('team_api_post.delete.confirm') }) + .then((ok) => { + if (ok) + Services.removePost(props.api._id, props.ownerTeam._id, post._id) + .then((res) => { + if (res.error) { + toast.error(translate('team_api_post.failed')); + } + else { + toast.success(translate('team_api_post.deleted')); + } + }) + .then(() => queryClient.invalidateQueries({ queryKey: ["posts"] })); + }); + } + + const createorUpdatePostForm = (value?: IApiPost, creation: boolean = true) => openRightPanel({ + title: creation ? "Create new post" : "Update post", + content: +
+ }) + + const isDataError = postQuery.data && !isError(postQuery.data); + return ( +
+ +