diff --git a/src/api-gateway/api-gateway.graphql b/src/api-gateway/api-gateway.graphql index 291dc6a..050c9e4 100644 --- a/src/api-gateway/api-gateway.graphql +++ b/src/api-gateway/api-gateway.graphql @@ -4,8 +4,8 @@ # ----------------------------------------------- type ApiTokens { - _id: String! - launchRoomToken: String! + _id: String + launchRoomToken: String } type Clause { @@ -27,10 +27,21 @@ type ClientSideAvailability { usingMobileKey: Boolean! } +# The javascript `Date` as string. Type represents date and time as the ISO Date string. +scalar DateTime + type Empty { ok: Boolean! } +type Environment { + _id: ID! + apiToken: ID! + deletedAt: DateTime + name: String! + project: ID! +} + type Fallthrough { rollout: VariationsObject variation: Int @@ -47,6 +58,7 @@ type FlagDetails { clientSideAvailability: ClientSideAvailability! deleted: Boolean! description: String! + environment: ID! fallthrough: Fallthrough! key: String! name: String! @@ -77,7 +89,9 @@ scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/fi type Mutation { subscribeToNewsletter(email: String!, firstName: String, lastName: String): Empty! upsertApiTokens(_id: String!, launchRoomToken: String!): ApiTokens - upsertFlag(archived: Boolean, description: String, fallthrough: FallthroughInput, key: ID!, name: String, offVariation: Int, on: Boolean, rules: [RuleInput!], variationsBoolean: [Boolean!], variationsJson: [String!], variationsNumber: [Int!], variationsString: [String!], workspaceId: ID!): Boolean! + upsertEnvironment(_id: ID, deletedAt: DateTime, launchRoomToken: String, name: String!, project: ID): Environment + upsertFlag(archived: Boolean, description: String, environment: ID!, fallthrough: FallthroughInput, key: ID!, name: String, offVariation: Int, on: Boolean, rules: [RuleInput!], variationsBoolean: [Boolean!], variationsJson: [String!], variationsNumber: [Int!], variationsString: [String!], workspaceId: ID!): Boolean! + upsertProject(_id: ID, deletedAt: DateTime, name: String!, workspace: ID!): Project } type Prerequisite { @@ -85,8 +99,17 @@ type Prerequisite { variation: Int! } +type Project { + _id: ID! + deletedAt: DateTime + name: String! + workspace: ID! +} + type Query { - apiTokens: ApiTokens! + fetchApiTokens(_id: ID!): ApiTokens! + fetchEnvironments(workspace: ID!): [Environment!]! + fetchProjects(workspace: ID!): [Project!]! flagDetails(key: ID!, workspaceId: ID!): FlagDetails flagsStatus(archived: Boolean!, limit: Int!, skip: Int!, workspaceId: ID!): FlagsStatus! diff --git a/src/api-gateway/api-gateway.ts b/src/api-gateway/api-gateway.ts index f382a4a..1fdf59e 100644 --- a/src/api-gateway/api-gateway.ts +++ b/src/api-gateway/api-gateway.ts @@ -8,6 +8,8 @@ import { OnefxAuth } from "onefx-auth"; import { NonEmptyArray } from "type-graphql/dist/interfaces/NonEmptyArray"; import { NewsletterResolver } from "@/shared/newsletter/newsletter-resolver"; import { FlagResolver } from "@/api-gateway/resolvers/flag-resolver"; +import { ProjectsResolver } from "@/api-gateway/resolvers/project-resolver"; +import { EnvironmentsResolver } from "@/api-gateway/resolvers/environment-resolver"; import { MyServer } from "@/server/start-server"; import { customAuthChecker } from "@/api-gateway/auth-checker"; import { ApiTokensResolver } from "@/shared/api-tokens/api-tokens-resolver"; @@ -29,6 +31,8 @@ export async function setApiGateway(server: MyServer): Promise { NewsletterResolver, FlagResolver, ApiTokensResolver, + ProjectsResolver, + EnvironmentsResolver, ]; server.resolvers = resolvers; diff --git a/src/api-gateway/resolvers/environment-resolver.ts b/src/api-gateway/resolvers/environment-resolver.ts new file mode 100644 index 0000000..ce82259 --- /dev/null +++ b/src/api-gateway/resolvers/environment-resolver.ts @@ -0,0 +1,125 @@ +import { + Args, + ID, + ArgsType, + Authorized, + Ctx, + Field, + Mutation, + ObjectType, + Query, + Resolver, +} from "type-graphql"; +import { IContext } from "@/api-gateway/api-gateway"; +import { Environment as EnvironmentDoc } from "@/model/environment-model"; + +@ObjectType() +class Environment { + @Field(() => ID) + _id: string; + + @Field(() => String) + name: string; + + @Field(() => ID) + project: string; + + @Field(() => ID) + apiToken: string; + + @Field(() => Date, { nullable: true }) + deletedAt: Date; +} + +@ArgsType() +class UpsertEnvironmentRequest { + @Field(() => ID, { nullable: true }) + _id: string; + + @Field(() => String) + name: string; + + @Field(() => ID, { nullable: true }) + project?: string; + + @Field(() => String, { nullable: true }) + launchRoomToken?: string; + + @Field(() => Date, { nullable: true }) + deletedAt?: Date; +} + +@ArgsType() +class RequestByWorkspaceId { + @Field(() => ID) + workspace: string; +} + +@Resolver() +export class EnvironmentsResolver { + @Authorized() + @Query(() => [Environment]) + public async fetchEnvironments( + @Args() { workspace }: RequestByWorkspaceId, + @Ctx() { model: { environmentModel, projectModel } }: IContext + ): Promise { + const projects = await projectModel.find({ workspace, deletedAt: null }); + const environments = await Promise.all( + projects.map( + async (project): Promise => + environmentModel.find({ + project: project._id, + deletedAt: null, + }) + ) + ); + return environments.flat(); + } + + @Authorized() + @Mutation(() => Environment, { nullable: true }) + async upsertEnvironment( + @Args() { _id, name, project, deletedAt }: UpsertEnvironmentRequest, + @Ctx() + { + model: { environmentModel, projectModel, apiTokens, flagModel }, + }: IContext + ): Promise { + if (_id) { + return environmentModel.findOneAndUpdate( + { _id }, + { name, project, deletedAt } + ); + } + const currentProject = await projectModel.findOne({ _id: project }); + const apiToken = await apiTokens.create({ + workspace: currentProject?.workspace, + }); + const environment = await environmentModel.create({ + name, + project, + apiToken, + }); + + const prevEnvironment = await environmentModel.findOne({ + project, + deletedAt: null, + }); + if (prevEnvironment) { + const flags = await flagModel + .find({ + workspace: currentProject?.workspace, + environment: prevEnvironment._id, + }) + .lean(); + + await Promise.all( + flags.map(async (flag) => { + delete flag._id; + await flagModel.create({ ...flag, environment: environment._id }); + }) + ); + } + return environment; + } +} diff --git a/src/api-gateway/resolvers/flag-resolver.ts b/src/api-gateway/resolvers/flag-resolver.ts index da1e647..84252e5 100644 --- a/src/api-gateway/resolvers/flag-resolver.ts +++ b/src/api-gateway/resolvers/flag-resolver.ts @@ -188,6 +188,9 @@ class FlagDetails { @Field(() => ClientSideAvailability) clientSideAvailability: ClientSideAvailability; + @Field(() => ID) + environment: boolean; + @Field(() => Boolean) archived: boolean; } @@ -281,6 +284,9 @@ class UpFlagDetailsArgs { @Field(() => FallthroughInput, { nullable: true }) fallthrough: FallthroughInput; + @Field(() => ID) + environment: string; + @Field(() => Boolean, { nullable: true }) archived: boolean; } @@ -341,7 +347,8 @@ export class FlagResolver { @Mutation(() => Boolean) async upsertFlag( @Args() detail: UpFlagDetailsArgs, - @Ctx() { model: { flagModel, userWorkspace }, userId }: IContext + @Ctx() + { model: { flagModel, userWorkspace, environmentModel }, userId }: IContext ): Promise { const { key, @@ -351,11 +358,15 @@ export class FlagResolver { offVariation, fallthrough, archived, + environment, } = detail; - await assertWorkspace(userWorkspace, userId, workspaceId); - const flag = await flagModel.findOne({ key, workspace: workspaceId }); + const flag = await flagModel.findOne({ + key, + workspace: workspaceId, + environment, + }); if (flag) { const updated = {} as Record; @@ -381,11 +392,13 @@ export class FlagResolver { if (archived !== undefined) { updated.archived = archived; } + updated.environment = environment; await flagModel.findOneAndUpdate( { key, workspace: workspaceId, + environment, }, updated ); @@ -401,20 +414,29 @@ export class FlagResolver { variationsJson?.map((value) => JSON.parse(value)) || variationsNumber || variationsString; - - await flagModel.create({ - workspace: workspaceId, - clientSideAvailability: { - usingMobileKey: true, - usingEnvironmentId: true, - }, - isOn: true, - salt: "", - sel: "", - targets: [], - variations, - ...detail, + const currentEnvironment = await environmentModel.findById(environment); + const environments = await environmentModel.find({ + project: currentEnvironment?.project, + deletedAt: null, }); + await Promise.all( + environments.map(async (value) => + flagModel.create({ + workspace: workspaceId, + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + isOn: true, + salt: "", + sel: "", + targets: [], + variations, + ...detail, + environment: value._id, + }) + ) + ); } return true; diff --git a/src/api-gateway/resolvers/project-resolver.ts b/src/api-gateway/resolvers/project-resolver.ts new file mode 100644 index 0000000..c7fc9d3 --- /dev/null +++ b/src/api-gateway/resolvers/project-resolver.ts @@ -0,0 +1,77 @@ +import { + Args, + ID, + ArgsType, + Authorized, + Ctx, + Field, + Mutation, + ObjectType, + Query, + Resolver, +} from "type-graphql"; +import { IContext } from "@/api-gateway/api-gateway"; +import { Project as ProjectDoc } from "@/model/project-model"; + +@ObjectType() +class Project { + @Field(() => ID) + _id: string; + + @Field(() => String) + name: string; + + @Field(() => ID) + workspace: string; + + @Field(() => Date, { nullable: true }) + deletedAt: Date; +} + +@ArgsType() +class UpsertProjectRequest { + @Field(() => ID, { nullable: true }) + _id: string; + + @Field(() => String) + name: string; + + @Field(() => ID) + workspace: string; + + @Field(() => Date, { nullable: true }) + deletedAt?: Date; +} + +@ArgsType() +class RequestByWorkspaceId { + @Field(() => ID) + workspace: string; +} + +@Resolver() +export class ProjectsResolver { + @Authorized() + @Query(() => [Project]) + public async fetchProjects( + @Args() { workspace }: RequestByWorkspaceId, + @Ctx() { model: { projectModel } }: IContext + ): Promise { + return projectModel.find({ workspace, deletedAt: null }); + } + + @Authorized() + @Mutation(() => Project, { nullable: true }) + async upsertProject( + @Args() { _id, name, workspace, deletedAt }: UpsertProjectRequest, + @Ctx() { model: { projectModel } }: IContext + ): Promise { + if (_id) { + return projectModel.findOneAndUpdate( + { _id }, + { name, workspace, deletedAt } + ); + } + return projectModel.create({ name, workspace }); + } +} diff --git a/src/model/environment-model.ts b/src/model/environment-model.ts new file mode 100644 index 0000000..14ca3e7 --- /dev/null +++ b/src/model/environment-model.ts @@ -0,0 +1,23 @@ +import { getModelForClass, mongoose, prop, Ref } from "@typegoose/typegoose"; +import { TimeStamps } from "@typegoose/typegoose/lib/defaultClasses"; +import { Project } from "@/model/project-model"; +import { ApiTokensDoc } from "@/shared/api-tokens/api-tokens-model"; + +export class Environment extends TimeStamps { + @prop({ default: () => new mongoose.Types.ObjectId() }) + _id?: mongoose.Types.ObjectId; + + @prop({ ref: Project }) + project?: Ref; + + @prop({ ref: ApiTokensDoc }) + apiToken?: Ref; + + @prop() + name: string; + + @prop() + deletedAt?: Date; +} + +export const EnvironmentModel = getModelForClass(Environment); diff --git a/src/model/flag-model.ts b/src/model/flag-model.ts index 5421bde..630cdcf 100644 --- a/src/model/flag-model.ts +++ b/src/model/flag-model.ts @@ -8,8 +8,9 @@ import { } from "@typegoose/typegoose"; import { TimeStamps } from "@typegoose/typegoose/lib/defaultClasses"; import { Workspace } from "@/model/workspace-model"; +import { Environment } from "@/model/environment-model"; -@index({ key: 1, workspace: 1 }, { unique: true }) +@index({ key: 1, workspace: 1, environment: 1 }, { unique: true }) @modelOptions({ options: { allowMixed: Severity.ALLOW } }) export class Flag extends TimeStamps { @prop() @@ -94,6 +95,9 @@ export class Flag extends TimeStamps { @prop() description?: string; + @prop({ ref: Environment }) + environment: Ref; + @prop({ default: false }) archived?: boolean; } diff --git a/src/model/model.ts b/src/model/model.ts index b9d8483..31fc655 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -3,6 +3,8 @@ import { FlagModel } from "@/model/flag-model"; import { SegmentModel } from "@/model/segment-model"; import { WorkspaceModel } from "@/model/workspace-model"; import { UserWorkspaceModel } from "@/model/user-workspace-model"; +import { ProjectModel } from "@/model/project-model"; +import { EnvironmentModel } from "@/model/environment-model"; import { ApiTokensModel } from "@/shared/api-tokens/api-tokens-model"; export type Model = { @@ -11,6 +13,8 @@ export type Model = { workspaceModel: typeof WorkspaceModel; userWorkspace: typeof UserWorkspaceModel; apiTokens: typeof ApiTokensModel; + projectModel: typeof ProjectModel; + environmentModel: typeof EnvironmentModel; }; export async function setModel(server: MyServer): Promise { @@ -20,4 +24,6 @@ export async function setModel(server: MyServer): Promise { server.model.workspaceModel = WorkspaceModel; server.model.userWorkspace = UserWorkspaceModel; server.model.apiTokens = ApiTokensModel; + server.model.projectModel = ProjectModel; + server.model.environmentModel = EnvironmentModel; } diff --git a/src/model/project-model.ts b/src/model/project-model.ts new file mode 100644 index 0000000..90efb22 --- /dev/null +++ b/src/model/project-model.ts @@ -0,0 +1,19 @@ +import { getModelForClass, mongoose, prop, Ref } from "@typegoose/typegoose"; +import { TimeStamps } from "@typegoose/typegoose/lib/defaultClasses"; +import { Workspace } from "@/model/workspace-model"; + +export class Project extends TimeStamps { + @prop({ default: () => new mongoose.Types.ObjectId() }) + _id?: mongoose.Types.ObjectId; + + @prop({ ref: Workspace }) + workspace: Ref; + + @prop() + name: string; + + @prop() + deletedAt?: Date; +} + +export const ProjectModel = getModelForClass(Project); diff --git a/src/server/server-routes.tsx b/src/server/server-routes.tsx index 7934539..14b020c 100644 --- a/src/server/server-routes.tsx +++ b/src/server/server-routes.tsx @@ -6,6 +6,7 @@ import { apolloSSR } from "@/shared/common/apollo-ssr"; import { setEmailPasswordIdentityProviderRoutes } from "@/shared/onefx-auth-provider/email-password-identity-provider/email-password-identity-provider-handler"; import { setApiGateway } from "@/api-gateway/api-gateway"; import { setSdkApiRoutes } from "@/server/sdk-api/sdk-api-routes"; +import { Environment as EnvironmentDoc } from "@/model/environment-model"; import { MyServer } from "./start-server"; export function setServerRoutes(server: MyServer): void { @@ -33,6 +34,59 @@ export function setServerRoutes(server: MyServer): void { }); ctx.setState("base.workspaceId", userWorkspace?.workspace); + const projects = await server.model.projectModel.find({ + workspace: userWorkspace?.workspace, + deletedAt: null, + }); + + if (projects.length === 0) { + const project = await server.model.projectModel.create({ + name: "default", + workspace: userWorkspace?.workspace, + }); + const apiToken = await server.model.apiTokens.create({ + workspace: userWorkspace?.workspace, + }); + const environment = await server.model.environmentModel.create({ + name: "prod", + project: project._id, + apiToken, + }); + + ctx.setState("base.currentEnvironment", environment); + } else { + const urlProjectName = ctx.request.url.split("/")[2]; + const urlEnvironmentName = ctx.request.url.split("/")[3]; + + const environments = await Promise.all( + projects.map( + async (project): Promise => + server.model.environmentModel.find({ + project: project._id, + deletedAt: null, + }) + ) + ); + let urlEnvironment; + if (urlProjectName && urlEnvironmentName) { + const urlProject = projects?.find( + (item) => item.name === urlProjectName + ); + urlEnvironment = environments + ?.flat() + .find( + (item) => + item.name === urlEnvironmentName && + item.project?.toString() === urlProject?._id.toString() + ); + } + if (urlEnvironment) { + ctx.setState("base.currentEnvironment", urlEnvironment); + } else { + ctx.setState("base.currentEnvironment", environments?.flat()[0]); + } + } + ctx.body = await apolloSSR(ctx, { VDom: , reducer: noopReducer, diff --git a/src/server/start-server.ts b/src/server/start-server.ts index a95186f..48b5226 100644 --- a/src/server/start-server.ts +++ b/src/server/start-server.ts @@ -41,7 +41,7 @@ export async function startServer(): Promise { server.auth = new OnefxAuth(server.gateways.mongoose, { ...authConfig, loginUrl: `${routePrefix}/`, - allowedLoginNext: [`${routePrefix}/default/`], + allowedLoginNext: [`${routePrefix}/default/prod`], allowedLogoutNext: [`${routePrefix}/`], }); diff --git a/src/shared/api-tokens/api-tokens-resolver.ts b/src/shared/api-tokens/api-tokens-resolver.ts index fda6d3a..1c7ff5d 100644 --- a/src/shared/api-tokens/api-tokens-resolver.ts +++ b/src/shared/api-tokens/api-tokens-resolver.ts @@ -4,19 +4,21 @@ import { Authorized, Ctx, Field, + ID, Mutation, ObjectType, Query, Resolver, } from "type-graphql"; import { IContext } from "@/api-gateway/api-gateway"; +import { ApiTokensDoc } from "@/shared/api-tokens/api-tokens-model"; @ObjectType() class ApiTokens { - @Field(() => String) + @Field(() => String, { nullable: true }) _id: string; - @Field(() => String) + @Field(() => String, { nullable: true }) launchRoomToken?: string; } @@ -29,23 +31,21 @@ class UpsertTokensRequest { launchRoomToken: string; } +@ArgsType() +class RequestByApiTokenId { + @Field(() => ID) + _id: string; +} + @Resolver() export class ApiTokensResolver { @Authorized() @Query(() => ApiTokens) - public async apiTokens(@Ctx() ctx: IContext): Promise { - const userWorkspace = await ctx.model.userWorkspace.findOne({ - user: ctx.userId, - }); - - const tokens = await ctx.model.apiTokens.findOne({ - workspace: userWorkspace?.workspace, - }); - - return ( - tokens || - ctx.model.apiTokens.create({ workspace: userWorkspace?.workspace }) - ); + public async fetchApiTokens( + @Args() { _id }: RequestByApiTokenId, + @Ctx() { model: { apiTokens } }: IContext + ): Promise { + return apiTokens.findById(_id); } @Authorized() diff --git a/src/shared/api-tokens/view/api-tokens-controller.tsx b/src/shared/api-tokens/view/api-tokens-controller.tsx deleted file mode 100644 index 40ace7c..0000000 --- a/src/shared/api-tokens/view/api-tokens-controller.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect } from "react"; -import Button from "antd/lib/button"; -import Input from "antd/lib/input"; -import notification from "antd/lib/notification"; -import Form from "antd/lib/form"; -import { useUpsertApiTokens } from "@/shared/api-tokens/view/hooks/use-upsert-api-tokens"; -import { useApiTokens } from "@/shared/api-tokens/view/hooks/use-api-tokens"; -import { t } from "onefx/lib/iso-i18n"; - -export const ApiTokensController = () => { - const { data, refetch } = useApiTokens(); - const { mutate } = useUpsertApiTokens(); - - const [form] = Form.useForm(); - - useEffect(() => { - form.setFieldsValue(data?.apiTokens); - }, [data?.apiTokens]); - - const onFinish = async (values: Record) => { - await mutate({ variables: values }); - await refetch(); - notification.success({ message: t("notification.update") }); - }; - - return ( -
- - - - - - - - prevValues.launchRoomToken !== curValues.launchRoomToken - } - > - {({ getFieldValue }) => ( - - )} - -
- ); -}; diff --git a/src/shared/api-tokens/view/data/queries.ts b/src/shared/api-tokens/view/data/queries.ts index ff5072e..55ad91d 100644 --- a/src/shared/api-tokens/view/data/queries.ts +++ b/src/shared/api-tokens/view/data/queries.ts @@ -1,8 +1,8 @@ import { gql } from "@apollo/client/core"; -export const apiTokens = gql` - query ApiTokens { - apiTokens { +export const fetchApiTokens = gql` + query FetchApiTokens($_id: ID!) { + fetchApiTokens(_id: $_id) { _id launchRoomToken } diff --git a/src/shared/api-tokens/view/hooks/use-api-tokens.ts b/src/shared/api-tokens/view/hooks/use-api-tokens.ts index 23837b9..058983e 100644 --- a/src/shared/api-tokens/view/hooks/use-api-tokens.ts +++ b/src/shared/api-tokens/view/hooks/use-api-tokens.ts @@ -1,13 +1,16 @@ import { useQuery } from "@apollo/client"; -import { apiTokens } from "@/shared/api-tokens/view/data/queries"; -import { ApiTokens } from "../data/__generated__/ApiTokens"; +import { fetchApiTokens } from "@/shared/api-tokens/view/data/queries"; +import { + FetchApiTokens, + FetchApiTokensVariables, +} from "../data/__generated__/FetchApiTokens"; -export const useApiTokens = () => { - const { data, loading, refetch } = useQuery(apiTokens, { - ssr: false, +export const useFetchApiTokens = (variables: FetchApiTokensVariables) => { + const { data, loading, refetch } = useQuery(fetchApiTokens, { + variables, }); return { - data, + data: data?.fetchApiTokens, loading, refetch, }; diff --git a/src/shared/api-tokens/view/hooks/use-upsert-api-tokens.ts b/src/shared/api-tokens/view/hooks/use-upsert-api-tokens.ts index 4020f4d..b0e6f7d 100644 --- a/src/shared/api-tokens/view/hooks/use-upsert-api-tokens.ts +++ b/src/shared/api-tokens/view/hooks/use-upsert-api-tokens.ts @@ -1,6 +1,9 @@ import { useMutation } from "@apollo/client"; import { upsertApiTokens } from "@/shared/api-tokens/view/data/mutations"; -import { UpsertApiTokens } from "../data/__generated__/UpsertApiTokens"; +import { + UpsertApiTokens, + UpsertApiTokensVariables, +} from "../data/__generated__/UpsertApiTokens"; export const useUpsertApiTokens = () => { const [mutate, { data, loading }] = useMutation( @@ -8,6 +11,9 @@ export const useUpsertApiTokens = () => { ); return { mutate, + upsertApiTokens: async (variables: UpsertApiTokensVariables) => { + await mutate({ variables }); + }, data, loading, }; diff --git a/src/shared/app.tsx b/src/shared/app.tsx index 2b055a4..c0ba40e 100644 --- a/src/shared/app.tsx +++ b/src/shared/app.tsx @@ -41,11 +41,18 @@ export const App: React.FC = (props: Props) => { - + diff --git a/src/shared/common/base-reducer.ts b/src/shared/common/base-reducer.ts index e055f5d..529156d 100644 --- a/src/shared/common/base-reducer.ts +++ b/src/shared/common/base-reducer.ts @@ -17,7 +17,7 @@ export function baseReducer( themeCode: defaultThemeCode, }, action: { type: string; payload: ThemeCode } -): { themeCode?: ThemeCode; userId?: string } { +) { if (action.type === "SET_THEME") { const themeCode = action.payload === "light" ? "light" : "dark"; window.document && @@ -28,6 +28,12 @@ export function baseReducer( themeCode, }; } + if (action.type === "SET_CURRENT_ENVIRONMENT") { + return { + ...initialState, + currentEnvironment: action.payload, + }; + } if (!initialState.themeCode) { initialState.themeCode = defaultThemeCode; } diff --git a/src/shared/common/top-bar.tsx b/src/shared/common/top-bar.tsx index d00300f..8407744 100644 --- a/src/shared/common/top-bar.tsx +++ b/src/shared/common/top-bar.tsx @@ -40,7 +40,7 @@ export const TopBar = (): JSX.Element => { const renderMenu = (): JSX.Element => userId ? ( <> - + {t("topbar.flags")} diff --git a/src/shared/feature-flags/data/queries.ts b/src/shared/feature-flags/data/queries.ts index ae23e3c..fd0b696 100644 --- a/src/shared/feature-flags/data/queries.ts +++ b/src/shared/feature-flags/data/queries.ts @@ -30,6 +30,7 @@ export const flagsStatus = gql` variation } variations + environment on } } diff --git a/src/shared/feature-flags/flags-status-table-controller.tsx b/src/shared/feature-flags/flags-status-table-controller.tsx index 3095c5f..a8233e3 100644 --- a/src/shared/feature-flags/flags-status-table-controller.tsx +++ b/src/shared/feature-flags/flags-status-table-controller.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useFlagsStatus } from "@/shared/feature-flags/hooks/use-flags-status"; import { useSelector } from "react-redux"; import { useUpsertFlag } from "@/shared/flag-details/hooks/use-upsert-flag"; +import { RootState } from "@/client/javascripts/main"; import { FlagsStatusTable } from "./flags-status-table"; import { WorkspaceIdContext, RefetchContext } from "./context"; @@ -10,6 +11,10 @@ export const FlagsStatusTableController: React.FC = () => { const workspaceId = useSelector( (state: { base: { workspaceId: string } }) => state.base.workspaceId ); + const currentEnvironment = useSelector( + (state: RootState) => state.base.currentEnvironment + ); + const { flagsStatus, refetch, loading } = useFlagsStatus({ workspaceId, skip: 0, @@ -22,7 +27,11 @@ export const FlagsStatusTableController: React.FC = () => { value.environment === currentEnvironment._id + ) || [] + } archived={flagsStatus?.archived || false} loading={loading} upsertFlag={upsertFlag} diff --git a/src/shared/feature-flags/flags-status-table.tsx b/src/shared/feature-flags/flags-status-table.tsx index 40a6dab..d5b1c39 100644 --- a/src/shared/feature-flags/flags-status-table.tsx +++ b/src/shared/feature-flags/flags-status-table.tsx @@ -1,5 +1,6 @@ /* eslint-disable camelcase */ import React from "react"; +import { useSelector } from "react-redux"; import { Link } from "onefx/lib/react-router-dom"; import { t } from "onefx/lib/iso-i18n"; import Table from "antd/lib/table/Table"; @@ -25,6 +26,7 @@ import { } from "@/shared/feature-flags/context"; import { CommonMargin } from "@/shared/common/common-margin"; import { VarIcon } from "@/shared/common/icons/var-icon"; +import { RootState } from "@/client/javascripts/main"; import { NewFlagController } from "./new-flag-controller"; type Props = { @@ -42,6 +44,9 @@ export const FlagsStatusTable: React.FC = ({ }) => { const refetch = React.useContext(RefetchContext); const workspaceId = React.useContext(WorkspaceIdContext); + const environment = useSelector( + (state: RootState) => state.base.currentEnvironment?._id + ); const columns: ColumnsType = [ { @@ -98,11 +103,17 @@ export const FlagsStatusTable: React.FC = ({ render(value, record) { return ( { try { - await upsertFlag({ workspaceId, key: record.key, on: !value }); - + await upsertFlag({ + environment, + workspaceId, + key: record.key, + on: !value, + }); + refetch(); notification.success({ message: t("notification.update") }); } catch (e) { notification.error({ message: e.message }); @@ -151,6 +162,7 @@ export const FlagsStatusTable: React.FC = ({ onConfirm={async () => { try { await upsertFlag({ + environment, workspaceId, key, archived: !archived, diff --git a/src/shared/feature-flags/flags-tabs.tsx b/src/shared/feature-flags/flags-tabs.tsx index 8919fff..84f5d0a 100644 --- a/src/shared/feature-flags/flags-tabs.tsx +++ b/src/shared/feature-flags/flags-tabs.tsx @@ -1,27 +1,127 @@ -import React from "react"; -import Tabs from "antd/lib/tabs"; +import React, { useMemo, useCallback, useEffect } from "react"; import { useHistory, useRouteMatch } from "react-router"; +import { useDispatch, useSelector } from "react-redux"; +import Tabs from "antd/lib/tabs"; +import Space from "antd/lib/space"; +import Typography from "antd/lib/typography"; +import Select from "antd/lib/select"; +import { t } from "onefx/lib/iso-i18n"; import { FlagsStatusTableController } from "@/shared/feature-flags/flags-status-table-controller"; -import { ApiTokensController } from "@/shared//api-tokens/view/api-tokens-controller"; +import { ProjectsSettingsController } from "@/shared/settings/projects-settings-controller"; import { ContentPadding } from "@/shared/common/styles/style-padding"; -import { t } from "onefx/lib/iso-i18n"; +import { useFetchProjects } from "@/shared/settings/hooks/use-fetch-projects"; +import { useFetchEnvironments } from "@/shared/settings/hooks/use-fetch-environments"; +import { RootState } from "@/client/javascripts/main"; const { TabPane } = Tabs; export const FlagsTabs: React.FC = () => { - const match = useRouteMatch<{ tab: string }>("/default/:tab"); + const match = useRouteMatch<{ tab: string }>( + "/:projectName/:environmentName/:tab" + ); const history = useHistory(); - const onChange = (activeKey: string) => { - history.push(`/default/${activeKey}`); + + const dispatch = useDispatch(); + + const workspaceId = useSelector((state: RootState) => state.base.workspaceId); + const currentEnvironment = useSelector( + (state: RootState) => state.base.currentEnvironment + ); + + const { data: projects } = useFetchProjects({ + workspace: workspaceId, + }); + const { data: environments } = useFetchEnvironments({ + workspace: workspaceId, + }); + + const currentProject = useMemo( + () => + projects?.find((project) => project?._id === currentEnvironment?.project), + [projects, currentEnvironment] + ); + + useEffect(() => { + if (currentEnvironment && currentProject) { + history.push( + `/${currentProject?.name}/${currentEnvironment?.name}/${ + match?.params.tab || "flags" + }` + ); + } + }, [currentProject, currentEnvironment]); + + const environmentOptions = useMemo( + () => + environments?.reduce( + (result: any, item) => ({ + ...result, + [item.project]: [...(result[item.project] || []), item], + }), + [] + ), + [environments] + ); + + const onChange = useCallback( + (activeKey: string) => { + history.push( + `/${currentProject?.name}/${currentEnvironment?.name}/${activeKey}` + ); + }, + [currentProject, currentEnvironment] + ); + + const onEnvironmentChange = (id: string) => { + const targetEnvironment = environments?.find((value) => value._id === id); + + dispatch({ type: "SET_CURRENT_ENVIRONMENT", payload: targetEnvironment }); }; + return ( - - + + + {currentProject && currentEnvironment._id && ( + <> + {currentProject.name} + + + )} + + ), + }} + defaultActiveKey={match?.params.tab || "flags"} + onChange={onChange} + > - + diff --git a/src/shared/feature-flags/new-flag-form.tsx b/src/shared/feature-flags/new-flag-form.tsx index dd91022..cb0a0ca 100644 --- a/src/shared/feature-flags/new-flag-form.tsx +++ b/src/shared/feature-flags/new-flag-form.tsx @@ -1,4 +1,5 @@ import React, { useContext } from "react"; +import { useSelector } from "react-redux"; import Form from "antd/lib/form"; import Input from "antd/lib/input"; import InputNumber from "antd/lib/input-number"; @@ -15,6 +16,7 @@ import { CommonMargin } from "@/shared/common/common-margin"; import { styled } from "onefx/lib/styletron-react"; import { t } from "onefx/lib/iso-i18n"; import { margin } from "polished"; +import { RootState } from "@/client/javascripts/main"; import { RefetchContext, WorkspaceIdContext } from "./context"; import { VarIcon } from "../common/icons/var-icon"; import { UpsertFlagVariables } from "../flag-details/data/__generated__/UpsertFlag"; @@ -49,6 +51,9 @@ export const NewFlagForm: React.FC = ({ }) => { const workspaceId = useContext(WorkspaceIdContext); const refetch = useContext(RefetchContext); + const environment = useSelector( + (state: RootState) => state.base.currentEnvironment?._id + ); const [form] = Form.useForm(); @@ -99,6 +104,7 @@ export const NewFlagForm: React.FC = ({ workspaceId, key: values.key as string, on: true, + environment, variationsBoolean: values.variationsBoolean as boolean[], variationsJson: values.variationsJson as string[], variationsNumber: values.variationsNumber as number[], diff --git a/src/shared/flag-details/data/mutations.ts b/src/shared/flag-details/data/mutations.ts index a2ce53f..42bf1f0 100644 --- a/src/shared/flag-details/data/mutations.ts +++ b/src/shared/flag-details/data/mutations.ts @@ -14,6 +14,7 @@ export const upsertFlag = gql` $variationsNumber: [Int!] $fallthrough: FallthroughInput $offVariation: Int + $environment: ID! $archived: Boolean ) { upsertFlag( @@ -29,6 +30,7 @@ export const upsertFlag = gql` variationsNumber: $variationsNumber fallthrough: $fallthrough offVariation: $offVariation + environment: $environment archived: $archived ) } diff --git a/src/shared/flag-details/flag-details.tsx b/src/shared/flag-details/flag-details.tsx index 17ebbad..ee75cf2 100644 --- a/src/shared/flag-details/flag-details.tsx +++ b/src/shared/flag-details/flag-details.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from "react"; +import { useSelector } from "react-redux"; import Switch from "antd/lib/switch"; import Form from "antd/lib/form"; import Button from "antd/lib/button"; @@ -22,6 +23,7 @@ import { CommonMargin } from "@/shared/common/common-margin"; import { Rules } from "@/shared/flag-details/rules"; import deepOmit from "@/shared/utils/deep-omit"; import operators from "@/shared/flag-details/data/operators"; +import { RootState } from "@/client/javascripts/main"; import { ClauseInput } from "../../../__generated__/globalTypes"; const StyledRow = styled(Row, ({ $theme }) => ({ @@ -51,6 +53,9 @@ export function FlagDetails({ isPosting, }: Props): JSX.Element { const [form] = Form.useForm(); + const environment = useSelector( + (state: RootState) => state.base.currentEnvironment?._id + ); const rules = useMemo( () => @@ -89,6 +94,7 @@ export function FlagDetails({ })); await upsertFlag({ + environment, workspaceId, key: flagKey, on, @@ -186,6 +192,7 @@ export function FlagDetails({ onConfirm={async () => { try { await upsertFlag({ + environment, workspaceId, key: flagKey, archived: !flagDetails?.archived, diff --git a/src/shared/settings/data/mutations.ts b/src/shared/settings/data/mutations.ts new file mode 100644 index 0000000..f30d0ca --- /dev/null +++ b/src/shared/settings/data/mutations.ts @@ -0,0 +1,37 @@ +import { gql } from "@apollo/client/core"; + +export const upsertProject = gql` + mutation UpsertProject( + $_id: ID + $name: String! + $workspace: ID! + $deletedAt: DateTime + ) { + upsertProject( + _id: $_id + name: $name + workspace: $workspace + deletedAt: $deletedAt + ) { + _id + } + } +`; + +export const upsertEnvironment = gql` + mutation UpsertEnvironment( + $_id: ID + $name: String! + $project: ID + $deletedAt: DateTime + ) { + upsertEnvironment( + _id: $_id + name: $name + project: $project + deletedAt: $deletedAt + ) { + _id + } + } +`; diff --git a/src/shared/settings/data/queries.ts b/src/shared/settings/data/queries.ts new file mode 100644 index 0000000..c4abe7b --- /dev/null +++ b/src/shared/settings/data/queries.ts @@ -0,0 +1,24 @@ +import { gql } from "@apollo/client/core"; + +export const fetchProjects = gql` + query FetchProjects($workspace: ID!) { + fetchProjects(workspace: $workspace) { + _id + name + workspace + deletedAt + } + } +`; + +export const fetchEnvironments = gql` + query FetchEnvironments($workspace: ID!) { + fetchEnvironments(workspace: $workspace) { + _id + name + project + apiToken + deletedAt + } + } +`; diff --git a/src/shared/settings/environment-details.tsx b/src/shared/settings/environment-details.tsx new file mode 100644 index 0000000..9f0d2ba --- /dev/null +++ b/src/shared/settings/environment-details.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import Row from "antd/lib/row"; +import { EnvironmentModal } from "@/shared/settings/environment-modal"; +import { useUpsertEnvironment } from "@/shared/settings/hooks/use-upsert-environment"; +import { useFetchEnvironments } from "@/shared/settings/hooks/use-fetch-environments"; +import { useFetchApiTokens } from "@/shared/api-tokens/view/hooks/use-api-tokens"; +import { useUpsertApiTokens } from "@/shared/api-tokens/view/hooks/use-upsert-api-tokens"; + +type Props = { + environment: { + _id: string; + name: string; + project: string; + apiToken: string; + deletedAt: any | null; + }; + project: string; +}; + +export const EnvironmentDetails: React.FC = ({ + environment, + project, +}) => { + const workspaceId = useSelector( + (state: { base: { workspaceId: string } }) => state.base.workspaceId + ); + + const { upsertEnvironment } = useUpsertEnvironment(); + const { upsertApiTokens } = useUpsertApiTokens(); + + const { refetch: refetchEnvironments } = useFetchEnvironments({ + workspace: workspaceId, + }); + + const { data: apiToken, refetch: refetchApiTokens } = useFetchApiTokens({ + _id: environment.apiToken, + }); + + const onUpsertEnvironmentClick = async (values: any) => { + await upsertEnvironment({ ...values, project }); + if (values._id && apiToken?._id) { + await upsertApiTokens({ + _id: apiToken._id, + launchRoomToken: values.launchRoomToken, + }); + await refetchApiTokens(); + } + await refetchEnvironments(); + }; + + return ( + +
{environment.name}
+
{apiToken?.launchRoomToken}
+ +
+ ); +}; diff --git a/src/shared/settings/environment-modal.tsx b/src/shared/settings/environment-modal.tsx new file mode 100644 index 0000000..a7529a3 --- /dev/null +++ b/src/shared/settings/environment-modal.tsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import Button from "antd/lib/button"; +import Modal from "antd/lib/modal/Modal"; +import Form from "antd/lib/form"; +import Input from "antd/lib/input/Input"; +import notification from "antd/lib/notification"; +import Popconfirm from "antd/lib/popconfirm"; +import Space from "antd/lib/space"; +import { styled } from "onefx/lib/styletron-react"; +import { margin } from "polished"; +import { UpsertEnvironmentVariables } from "@/shared/settings/data/__generated__/UpsertEnvironment"; +import { t } from "onefx/lib/iso-i18n"; + +type Props = { + environment?: UpsertEnvironmentVariables; + action: (variables: UpsertEnvironmentVariables) => Promise; + loading?: boolean; + launchRoomToken?: string | null; +}; + +const StyledAddFlag = styled("div", ({ $theme }) => ({ + ...margin($theme.sizing[2], 0), +})); + +export const EnvironmentModal: React.FC = ({ + environment, + launchRoomToken, + action, + loading, +}) => { + const [visible, setVisible] = useState(false); + + const [form] = Form.useForm(); + + const close = () => { + form.resetFields(); + setVisible(false); + }; + + const open = () => setVisible(true); + + return ( + + + +
{ + await action(values); + close(); + notification.info({ + message: !environment + ? "Create the environment!" + : "Updated the environment!", + placement: "topLeft", + }); + }} + > + + + + + {environment && ( + + + + )} + + {({ getFieldValue }) => { + return ( + + + {!!environment && ( + { + await action({ + ...environment, + deletedAt: new Date(), + }); + close(); + notification.info({ + message: "Delete the environment!", + placement: "topLeft", + }); + }} + okText="Yes" + cancelText="No" + > + + + )} + + ); + }} + +
+
+
+ ); +}; diff --git a/src/shared/settings/hooks/use-fetch-environments.tsx b/src/shared/settings/hooks/use-fetch-environments.tsx new file mode 100644 index 0000000..7caaa7c --- /dev/null +++ b/src/shared/settings/hooks/use-fetch-environments.tsx @@ -0,0 +1,20 @@ +import { useQuery } from "@apollo/client"; +import { fetchEnvironments } from "@/shared/settings/data/queries"; +import { + FetchEnvironments, + FetchEnvironmentsVariables, +} from "@/shared/settings/data/__generated__/FetchEnvironments"; + +export const useFetchEnvironments = (variables: FetchEnvironmentsVariables) => { + const { data, loading, refetch } = useQuery( + fetchEnvironments, + { + variables, + } + ); + return { + data: data?.fetchEnvironments, + loading, + refetch, + }; +}; diff --git a/src/shared/settings/hooks/use-fetch-projects.tsx b/src/shared/settings/hooks/use-fetch-projects.tsx new file mode 100644 index 0000000..ae181ca --- /dev/null +++ b/src/shared/settings/hooks/use-fetch-projects.tsx @@ -0,0 +1,17 @@ +import { useQuery } from "@apollo/client"; +import { fetchProjects } from "@/shared/settings/data/queries"; +import { + FetchProjects, + FetchProjectsVariables, +} from "@/shared/settings/data/__generated__/FetchProjects"; + +export const useFetchProjects = (variables: FetchProjectsVariables) => { + const { data, loading, refetch } = useQuery(fetchProjects, { + variables, + }); + return { + data: data?.fetchProjects, + loading, + refetch, + }; +}; diff --git a/src/shared/settings/hooks/use-upsert-environment.tsx b/src/shared/settings/hooks/use-upsert-environment.tsx new file mode 100644 index 0000000..9129b81 --- /dev/null +++ b/src/shared/settings/hooks/use-upsert-environment.tsx @@ -0,0 +1,19 @@ +import { useMutation } from "@apollo/client"; +import { + UpsertEnvironment, + UpsertEnvironmentVariables, +} from "@/shared/settings/data/__generated__/UpsertEnvironment"; +import { upsertEnvironment } from "@/shared/settings/data/mutations"; + +export const useUpsertEnvironment = () => { + const [mutate, { data, loading }] = useMutation( + upsertEnvironment + ); + return { + upsertEnvironment: async (variables: UpsertEnvironmentVariables) => { + await mutate({ variables }); + }, + data, + loading, + }; +}; diff --git a/src/shared/settings/hooks/use-upsert-project.tsx b/src/shared/settings/hooks/use-upsert-project.tsx new file mode 100644 index 0000000..728d5fe --- /dev/null +++ b/src/shared/settings/hooks/use-upsert-project.tsx @@ -0,0 +1,17 @@ +import { useMutation } from "@apollo/client"; +import { + UpsertProject, + UpsertProjectVariables, +} from "@/shared/settings/data/__generated__/UpsertProject"; +import { upsertProject } from "@/shared/settings/data/mutations"; + +export const useUpsertProject = () => { + const [mutate, { data, loading }] = useMutation(upsertProject); + return { + upsertProject: async (variables: UpsertProjectVariables) => { + await mutate({ variables }); + }, + data, + loading, + }; +}; diff --git a/src/shared/settings/project-modal.tsx b/src/shared/settings/project-modal.tsx new file mode 100644 index 0000000..82da5e3 --- /dev/null +++ b/src/shared/settings/project-modal.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import Button from "antd/lib/button"; +import Modal from "antd/lib/modal/Modal"; +import Form from "antd/lib/form"; +import Input from "antd/lib/input/Input"; +import notification from "antd/lib/notification"; +import Popconfirm from "antd/lib/popconfirm"; +import { styled } from "onefx/lib/styletron-react"; +import { margin } from "polished"; +import { UpsertProjectVariables } from "@/shared/settings/data/__generated__/UpsertProject"; +import Space from "antd/lib/space"; + +type Props = { + project?: { + _id: string; + name: string; + workspace: string; + deletedAt: Date; + }; + action: (variables: UpsertProjectVariables) => Promise; +}; + +const StyledAddFlag = styled("div", ({ $theme }) => ({ + ...margin($theme.sizing[2], 0), +})); + +export const ProjectModal: React.FC = ({ project, action }) => { + const [visible, setVisible] = useState(false); + + const [form] = Form.useForm(); + + const close = () => { + form.resetFields(); + setVisible(false); + }; + + const open = () => setVisible(true); + + return ( + + + +
{ + await action(values); + close(); + notification.info({ + message: !project + ? "Create the project!" + : "Updated the project!", + placement: "topLeft", + }); + }} + > + + + + + + {({ getFieldValue }) => { + return ( + + + {!!project && ( + { + await action({ ...project, deletedAt: new Date() }); + close(); + notification.info({ + message: "Delete the project!", + placement: "topLeft", + }); + }} + okText="Yes" + cancelText="No" + > + + + )} + + ); + }} + +
+
+
+ ); +}; diff --git a/src/shared/settings/projects-settings-controller.tsx b/src/shared/settings/projects-settings-controller.tsx new file mode 100644 index 0000000..ce8a634 --- /dev/null +++ b/src/shared/settings/projects-settings-controller.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import Card from "antd/lib/card"; +import Skeleton from "antd/lib/skeleton"; +import Space from "antd/lib/space"; +import { ProjectModal } from "@/shared/settings/project-modal"; +import { EnvironmentModal } from "@/shared/settings/environment-modal"; +import { EnvironmentDetails } from "@/shared/settings/environment-details"; +import { useUpsertProject } from "@/shared/settings/hooks/use-upsert-project"; +import { useFetchProjects } from "@/shared/settings/hooks/use-fetch-projects"; +import { useUpsertEnvironment } from "@/shared/settings/hooks/use-upsert-environment"; +import { useFetchEnvironments } from "@/shared/settings/hooks/use-fetch-environments"; +import { UpsertProjectVariables } from "@/shared/settings/data/__generated__/UpsertProject"; +import { UpsertEnvironmentVariables } from "@/shared/settings/data/__generated__/UpsertEnvironment"; + +export const ProjectsSettingsController: React.FC = () => { + const workspaceId = useSelector( + (state: { base: { workspaceId: string } }) => state.base.workspaceId + ); + + const { upsertProject } = useUpsertProject(); + const { upsertEnvironment } = useUpsertEnvironment(); + const { data: projects, refetch, loading: fetchLoading } = useFetchProjects({ + workspace: workspaceId, + }); + const { + data: environments, + refetch: refetchEnvironments, + } = useFetchEnvironments({ + workspace: workspaceId, + }); + const onUpsertProjectClick = async (values: UpsertProjectVariables) => { + await upsertProject({ ...values, workspace: workspaceId }); + await refetch(); + }; + const onUpsertEnvironmentClick = (project: string) => async ( + values: UpsertEnvironmentVariables + ) => { + await upsertEnvironment({ ...values, project }); + await refetchEnvironments(); + }; + + return ( +
+ + + {fetchLoading ? ( + + + + ) : ( + projects?.map((item, i) => ( + + + + + } + style={{ width: "100%", marginBottom: "16px" }} + > + {environments + ?.filter((environment) => environment.project === item._id) + ?.map((value) => ( + + ))} + + )) + )} +
+ ); +};