├── .eslintrc.json ├── .example.env ├── .gitignore ├── Dockerfile ├── README.md ├── components ├── account │ ├── account-switcher-button.tsx │ └── index.tsx ├── audit-log │ ├── index.tsx │ └── log-renderer │ │ └── index.tsx ├── color-mode-switcher │ └── index.tsx ├── fire-feature │ └── index.tsx ├── footer │ ├── copyright.tsx │ ├── index.tsx │ └── sm-links.tsx ├── index.ts ├── login │ ├── divider-with-text.tsx │ ├── index.tsx │ ├── login-form.tsx │ └── underline-link.tsx ├── logo │ └── index.tsx ├── navbar │ ├── index.tsx │ ├── mobile-nav.tsx │ └── nav-link.tsx ├── project-card │ └── index.tsx ├── projects │ ├── details-section │ │ ├── heat-renderer │ │ │ └── index.tsx │ │ ├── heats-selection │ │ │ └── index.tsx │ │ └── index.tsx │ ├── index.tsx │ └── project-section │ │ └── index.tsx ├── toggle-button-group │ └── index.tsx └── toggle-button │ └── index.tsx ├── context └── flags-context.tsx ├── cypress.env.json ├── docker-compose.yml ├── hooks ├── index.ts ├── useAppUrl │ └── index.ts ├── useFlagMutation │ └── index.ts ├── useHeatMutation │ └── index.ts ├── useLogs │ └── index.ts ├── useProject │ └── index.ts ├── useProjectMutation │ └── index.ts └── useProjects │ └── index.ts ├── img └── app-demo.png ├── lib ├── pino.ts └── prisma.ts ├── mocks ├── browser.ts ├── handlers.ts ├── index.ts └── server.ts ├── next-env.d.ts ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ ├── auth │ │ └── [...nextauth].ts │ ├── flag │ │ ├── create │ │ │ └── index.ts │ │ └── update │ │ │ └── index.ts │ ├── flags │ │ └── [projectId].ts │ ├── heat │ │ ├── create │ │ │ └── index.ts │ │ ├── delete │ │ │ └── index.ts │ │ └── update │ │ │ └── index.ts │ ├── logs │ │ └── [id].ts │ ├── project │ │ ├── [projectId].ts │ │ ├── create │ │ │ └── index.ts │ │ └── update │ │ │ └── index.ts │ └── projects │ │ └── index.ts ├── index.tsx ├── projects │ └── [id].tsx └── signin.tsx ├── pino.json ├── prisma ├── migrations │ ├── 20210724070207_inital │ │ └── migration.sql │ ├── 20210806140102_add_projects_to_schema │ │ └── migration.sql │ ├── 20210820191030_add_is_archived_field_to_the_project │ │ └── migration.sql │ ├── 20210823191108_add_heats_to_schema │ │ └── migration.sql │ ├── 20210824170027_add_created_and_updated_columns_to_heats │ │ └── migration.sql │ ├── 20210904201333_add_new_heats_structure │ │ └── migration.sql │ ├── 20210904203126_removed_default_fields │ │ └── migration.sql │ ├── 20210904205403_audit_logs │ │ └── migration.sql │ ├── 20210905072832_add_user_to_audit_log │ │ └── migration.sql │ ├── 20210906125329_correct_mandatory_fields │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── logo.svg └── mockServiceWorker.js ├── tsconfig.json ├── types └── next-auth.d.ts └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint"], 3 | "extends": [ 4 | "next", 5 | "next/core-web-vitals", 6 | "plugin:@typescript-eslint/recommended" 7 | ], 8 | "rules": { 9 | "@typescript-eslint/no-unused-vars": "error", 10 | "@typescript-eslint/explicit-module-boundary-types":"off" 11 | } 12 | } -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:secret@localhost:4343/flags?schema=public 2 | NEXT_AUTH_SECRET=RANDOM_STRING 3 | NEXTAUTH_URL=http://localhost:3000/api/auth 4 | SMTP_HOST=smtp.sendgrid.net 5 | SMTP_PORT=587 6 | SMTP_USER=apikey 7 | SMTP_PASSWORD=XXXX 8 | SMTP_FROM=hey@flags.com 9 | GITHUB_CLIENT_ID=XXXX 10 | GITHUB_CLIENT_SECRET=XXXX 11 | TWITTER_CONSUMER_KEY=XXXX 12 | TWITTER_CONSUMER_SECRET=XXXX 13 | NODE_ENV=development -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # database 37 | db 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /home/node/app 4 | COPY package*.json ./ 5 | COPY . . 6 | RUN yarn add prisma -g 7 | 8 | EXPOSE 3000 9 | CMD ["sh", "-c", "yarn vercel-build ; yarn start"] 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![fireflags-banner](https://user-images.githubusercontent.com/29632358/132315785-1daa0f46-92d9-45da-963d-d991dc705d5f.png) 2 | 3 | Suggest new features here 4 | 5 | 6 | Join the discussion on Github 7 | 8 | 9 | Dead simple and blazing fast feature-flags platform. Get started in minutes. Be confident when releasing new features for your application - you are one kill switch away from disabling the feature if it breaks. Just turn it off now and fix it later 👌 10 | 11 | ![App example](img/app-demo.png?raw=true "App example") 12 | 13 | https://user-images.githubusercontent.com/29632358/133240482-2bdb08be-b41a-406c-9e1c-df6064e0d2cd.mp4 14 | 15 | 16 | ## Get started with managed version 17 | 18 | - Visit [flags.stackonfire.dev](https://flags.stackonfire.dev) and sign in with any convenient method. 19 | - Create a new project 20 | - Create a new flag, rename it and add description if needed 21 | - Copy the link from the project page and make a request to that url to retrieve your feature flags 22 | 23 | Here is a simple implementation of how you might user the feature. 24 | 25 | ### Use with React 26 | 27 | [Official fire-flags library for react](https://github.com/stack-on-fire/react-fire-flags) - engineered by [Alfredo Salzillo](https://github.com/alfredosalzillo) 28 | 29 | ## Get started with self-hosted version 30 | 31 | Fire flags is dead simple to self host! You need to have an instance of Postgres database running, as long as you have the connection string you can safely deploy to Vercel. Environments variables necessary to run the app are listed in `.example.env` 32 | 33 | Fire-flags currently offers three methods of authentication - magic link, github and twitter. The auth setup is powered by Next-auth and to make it work you need to provide correct environment variables to the project. 34 | 35 | > Contributions for dockerised version are highly welcome 36 | 37 | ## Contribute 38 | 39 | - Clone the repo 40 | - run `docker-compose up -d` in the terminal to boot up the database 41 | - development environment uses MSW to mock session, so you don't need to set up next-auth related env variables to be able to log in. If you need to mimic login workflow - disable service workers in `_app.tsx` 42 | - run `yarn run dev` 43 | -------------------------------------------------------------------------------- /components/account/account-switcher-button.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | FlexProps, 5 | HStack, 6 | useMediaQuery, 7 | useMenuButton, 8 | } from "@chakra-ui/react"; 9 | import * as React from "react"; 10 | import Avatar from "boring-avatars"; 11 | import { HiSelector } from "react-icons/hi"; 12 | import { User } from "@prisma/client"; 13 | 14 | export const AccountSwitcherButton = (props: FlexProps & { user: User }) => { 15 | const buttonProps = useMenuButton(props); 16 | const [isSmallerThan600px] = useMediaQuery("(max-width: 600px)"); 17 | 18 | return ( 19 | 35 | 36 | 42 | {!isSmallerThan600px && ( 43 | 44 | 45 | {props.user.email || props.user.name} 46 | 47 | 48 | )} 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /components/account/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Menu, 3 | MenuItem, 4 | MenuList, 5 | useColorModeValue, 6 | Switch, 7 | Stack, 8 | Flex, 9 | Box, 10 | useColorMode, 11 | MenuDivider, 12 | } from "@chakra-ui/react"; 13 | import * as React from "react"; 14 | import { AccountSwitcherButton } from "./account-switcher-button"; 15 | import { signOut } from "next-auth/client"; 16 | import { useRouter } from "next/dist/client/router"; 17 | import { MoonIcon, SunIcon } from "@chakra-ui/icons"; 18 | 19 | export const AccountSwitcher = ({ session }) => { 20 | const { colorMode, toggleColorMode } = useColorMode(); 21 | 22 | const router = useRouter(); 23 | return ( 24 | 25 | 26 | 32 | 33 | 34 | 35 | 36 | 40 | 41 | 42 | 43 | 44 | { 46 | router.push("/"); 47 | setTimeout(() => { 48 | signOut(); 49 | }, 600); 50 | }} 51 | rounded="md" 52 | > 53 | Logout 54 | 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /components/audit-log/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@chakra-ui/react"; 2 | import { AuditLog, FeatureFlag, User } from "@prisma/client"; 3 | import { useLogs } from "hooks/useLogs"; 4 | import { useRouter } from "next/dist/client/router"; 5 | import React from "react"; 6 | import LogRenderer from "./log-renderer"; 7 | 8 | const AuditLogComponent = () => { 9 | const router = useRouter(); 10 | const { 11 | data: logs, 12 | }: { 13 | data: ReadonlyArray< 14 | AuditLog & { before: FeatureFlag; after: FeatureFlag; User: User } 15 | >; 16 | } = useLogs({ id: router.query.flag }); 17 | 18 | return ( 19 | <> 20 | 21 | Audit log 22 | 23 | {logs?.map((log) => { 24 | return ; 25 | })} 26 | 27 | ); 28 | }; 29 | 30 | export default AuditLogComponent; 31 | -------------------------------------------------------------------------------- /components/audit-log/log-renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Stack, 3 | Flex, 4 | Circle, 5 | Text, 6 | useColorModeValue, 7 | Heading, 8 | } from "@chakra-ui/react"; 9 | import { AuditLog, FeatureFlag, User } from "@prisma/client"; 10 | import React from "react"; 11 | import { format } from "date-fns"; 12 | 13 | const typeToTitle = { 14 | FLAG_CREATE: "Created feature flag", 15 | FLAG_UPDATE: "Updated feature flag", 16 | HEAT_CREATE: "Created heat for flag", 17 | HEAT_UPDATE: "Updated heat for flag", 18 | HEAT_DELETE: "Deleted heat for flag", 19 | }; 20 | 21 | const LogRenderer = ({ 22 | log, 23 | }: { 24 | log: AuditLog & { after: FeatureFlag; before: FeatureFlag; User: User }; 25 | }) => { 26 | return ( 27 | 28 | 44 | 45 | 46 | 47 | {typeToTitle[log.type]} 48 | 49 | 50 | {log.User.name} 51 | 52 | 53 | {format(new Date(log.createdAt), "PP p")} 54 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default LogRenderer; 62 | -------------------------------------------------------------------------------- /components/color-mode-switcher/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MoonIcon, SunIcon } from "@chakra-ui/icons"; 3 | import { Stack } from "@chakra-ui/layout"; 4 | import { Switch } from "@chakra-ui/switch"; 5 | import { useColorMode } from "@chakra-ui/color-mode"; 6 | 7 | export const ColorModeSwitcher = () => { 8 | const { colorMode, toggleColorMode } = useColorMode(); 9 | 10 | return ( 11 | 12 | 13 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /components/fire-feature/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useFlags } from "context/flags-context"; 3 | 4 | const FireFeature = ({ flagName, children }) => { 5 | const flags = useFlags(); 6 | const foundFlag = flags.find((flag) => flag.name === flagName); 7 | if (!foundFlag) return null; 8 | return
{foundFlag.isActive ? children : null}
; 9 | }; 10 | 11 | export default FireFeature; 12 | -------------------------------------------------------------------------------- /components/footer/copyright.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from "@chakra-ui/layout"; 2 | import * as React from "react"; 3 | 4 | export const Copyright = (props: TextProps) => ( 5 | 6 | © {new Date().getFullYear()} Stack on Fire, Inc. All rights reserved. 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /components/footer/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import { Copyright } from "./copyright"; 4 | import { Logo } from "components/logo"; 5 | import { SocialMediaLinks } from "./sm-links"; 6 | 7 | export const Footer = () => ( 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /components/footer/sm-links.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import { FaGithub, FaLinkedin, FaTwitter } from "react-icons/fa"; 4 | 5 | export const SocialMediaLinks = (props: ButtonGroupProps) => ( 6 | 7 | } 12 | /> 13 | } 18 | /> 19 | } 24 | /> 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /components/index.ts: -------------------------------------------------------------------------------- 1 | import { Footer } from "./footer"; 2 | import { Navbar } from "./navbar"; 3 | import { Login } from "./login"; 4 | 5 | export { Footer, Navbar, Login }; 6 | -------------------------------------------------------------------------------- /components/login/divider-with-text.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Divider, 4 | Flex, 5 | FlexProps, 6 | Text, 7 | useColorModeValue, 8 | } from "@chakra-ui/react"; 9 | import * as React from "react"; 10 | 11 | export const DividerWithText = (props: FlexProps) => { 12 | const { children, ...flexProps } = props; 13 | return ( 14 | 15 | 16 | 17 | 18 | 24 | {children} 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /components/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | Button, 5 | Center, 6 | Heading, 7 | SimpleGrid, 8 | Stack, 9 | useColorModeValue, 10 | VisuallyHidden, 11 | } from "@chakra-ui/react"; 12 | 13 | import { LoginForm } from "./login-form"; 14 | import { Logo } from "components/logo"; 15 | import { DividerWithText } from "./divider-with-text"; 16 | import { FaGithub, FaTwitter } from "react-icons/fa"; 17 | import { signIn } from "next-auth/client"; 18 | 19 | type Props = { 20 | variant: "signin"; 21 | }; 22 | 23 | export const Login = ({ variant }: Props) => { 24 | return ( 25 | <> 26 | {" "} 27 | 35 | 36 |
37 | 38 |
39 | 40 | 41 | 47 | {variant === "signin" && "Sign in to your account"} 48 | 49 | 53 | We will send you a magic link 54 | 55 | 56 | 57 | 58 |
59 | 60 | or continue with 61 | 62 | 63 | 73 | 85 | 86 |
87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /components/login/login-form.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | FormControl, 6 | FormLabel, 7 | Input, 8 | LightMode, 9 | Stack, 10 | useColorModeValue as mode, 11 | } from "@chakra-ui/react"; 12 | import { useAppUrl } from "hooks/useAppUrl"; 13 | import { signIn } from "next-auth/client"; 14 | import Link from "next/link"; 15 | import * as React from "react"; 16 | import { UnderlineLink } from "./underline-link"; 17 | 18 | type Props = { 19 | variant: "signin" | "signup" | "forgot-password"; 20 | }; 21 | 22 | export const LoginForm = ({ variant }: Props) => { 23 | const [email, setEmail] = React.useState(""); 24 | const [isLoading, setIsLoading] = React.useState(false); 25 | const appUrl = useAppUrl(); 26 | 27 | return ( 28 |
{ 30 | setIsLoading(true); 31 | e.preventDefault(); 32 | signIn("email", { email, callbackUrl: appUrl }); 33 | }} 34 | > 35 | 36 | 37 | Email address 38 | setEmail(e.target.value)} 49 | /> 50 | 51 | 52 | {/* {variant !== "forgot-password" && ( 53 | 54 | Password 55 | setPassword(e.target.value)} 66 | placeholder="Password" 67 | /> 68 | 69 | )} */} 70 | 71 | {/* {variant === "signin" && ( 72 | 73 | 74 | 75 | Forgot Password 76 | 77 | 78 | )} */} 79 | {["forgot-password", "signup"].includes(variant) && ( 80 | 81 | 82 | 83 | Back to sign in 84 | 85 | 86 | )} 87 | 88 | 102 | 103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /components/login/underline-link.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | 4 | export const UnderlineLink = (props: BoxProps) => { 5 | return ( 6 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /components/logo/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Stack } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import Image from "next/image"; 4 | 5 | export const Logo = () => { 6 | return ( 7 | 8 | <> 9 | Logo 10 | Fire Flags 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /components/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | HStack, 5 | useColorModeValue as mode, 6 | VisuallyHidden, 7 | Skeleton, 8 | } from "@chakra-ui/react"; 9 | 10 | import Link from "next/link"; 11 | import * as React from "react"; 12 | 13 | import { Logo } from "components/logo"; 14 | import { useSession } from "next-auth/client"; 15 | import { AccountSwitcher } from "components/account"; 16 | import { useRouter } from "next/dist/client/router"; 17 | export const Navbar = () => { 18 | const [session, loading] = useSession(); 19 | const router = useRouter(); 20 | 21 | const signInComponent = session ? ( 22 | 23 | 24 | 25 | ) : ( 26 | 27 | 28 | 29 | ); 30 | return ( 31 | 32 | 33 | 34 | router.push("/", null, { shallow: true })} 36 | cursor="pointer" 37 | > 38 | Stack on fire 39 | 40 | 41 | 42 | 43 | {loading ? : signInComponent} 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /components/navbar/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Center, 5 | Flex, 6 | Portal, 7 | SimpleGrid, 8 | useBoolean, 9 | useFocusOnShow, 10 | VStack, 11 | useColorModeValue as mode, 12 | } from "@chakra-ui/react"; 13 | import { Logo } from "components/logo"; 14 | import { HTMLMotionProps, motion, Variants } from "framer-motion"; 15 | import * as React from "react"; 16 | import FocusLock from "react-focus-lock"; 17 | import { 18 | HiBookOpen, 19 | HiCurrencyDollar, 20 | HiOutlineMenu, 21 | HiOutlineX, 22 | } from "react-icons/hi"; 23 | import { RemoveScroll } from "react-remove-scroll"; 24 | import { NavLink } from "./nav-link"; 25 | import { signIn } from "next-auth/client"; 26 | 27 | const variants: Variants = { 28 | show: { 29 | display: "revert", 30 | opacity: 1, 31 | scale: 1, 32 | transition: { duration: 0.2, ease: "easeOut" }, 33 | }, 34 | hide: { 35 | opacity: 0, 36 | scale: 0.98, 37 | transition: { duration: 0.1, ease: "easeIn" }, 38 | transitionEnd: { display: "none" }, 39 | }, 40 | }; 41 | 42 | const Backdrop = ({ show }: { show?: boolean }) => ( 43 | 44 | 60 | 61 | ); 62 | 63 | const Transition = (props: HTMLMotionProps<"div"> & { in?: boolean }) => { 64 | const { in: inProp, ...rest } = props; 65 | return ( 66 | 81 | ); 82 | }; 83 | 84 | export const MobileNav = () => { 85 | const [show, { toggle, off }] = useBoolean(); 86 | const ref = React.useRef(null); 87 | useFocusOnShow(ref, { visible: show, shouldFocus: true }); 88 | 89 | return ( 90 | <> 91 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 116 | 117 | 118 | 119 | 120 |
129 | Close menu 130 | 131 |
132 |
133 |
134 | 135 | Features 136 | Pricing 137 | 138 | 139 | 142 | 143 | Have an account?{" "} 144 | signIn()} 146 | as="a" 147 | color={mode("blue.600", "blue.400")} 148 | > 149 | Log in 150 | 151 | 152 | 153 |
154 |
155 |
156 |
157 | 158 | ); 159 | }; 160 | -------------------------------------------------------------------------------- /components/navbar/nav-link.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | chakra, 4 | Flex, 5 | HTMLChakraProps, 6 | Icon, 7 | useColorModeValue as mode, 8 | } from "@chakra-ui/react"; 9 | import * as React from "react"; 10 | 11 | interface DesktopNavLinkProps extends HTMLChakraProps<"a"> { 12 | active?: boolean; 13 | } 14 | 15 | const DesktopNavLink = (props: DesktopNavLinkProps) => { 16 | const { active, ...rest } = props; 17 | return ( 18 | 29 | ); 30 | }; 31 | 32 | interface MobileNavLinkProps { 33 | icon: React.ElementType; 34 | children: React.ReactNode; 35 | href?: string; 36 | } 37 | 38 | const MobileNavLink = (props: MobileNavLinkProps) => { 39 | const { icon, children, href } = props; 40 | return ( 41 | 51 | 52 | 53 | {children} 54 | 55 | 56 | ); 57 | }; 58 | 59 | export const NavLink = { 60 | Desktop: DesktopNavLink, 61 | Mobile: MobileNavLink, 62 | }; 63 | -------------------------------------------------------------------------------- /components/project-card/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box } from "@chakra-ui/layout"; 3 | import { 4 | Flex, 5 | HStack, 6 | Icon, 7 | Text, 8 | useColorModeValue as mode, 9 | } from "@chakra-ui/react"; 10 | import { FaFire } from "react-icons/fa"; 11 | import Link from "next/link"; 12 | import { FeatureFlag, Project } from "@prisma/client"; 13 | import BoringAvatar from "boring-avatars"; 14 | import { HiArchive } from "react-icons/hi"; 15 | 16 | const ProjectCard = ({ 17 | project, 18 | }: { 19 | project: Project & { featureFlags: ReadonlyArray }; 20 | }) => { 21 | const numberOfActiveFlags = project.featureFlags.filter( 22 | (flag) => !flag.isArchived && flag.isActive 23 | ).length; 24 | 25 | return ( 26 | 27 | 40 | 41 | 42 | 49 | 50 | 56 | {project.name} 57 | 58 | 59 | 60 | {project.description} 61 | 62 | 63 | 64 | {project.isArchived && ( 65 | 66 | )} 67 | 68 | 73 | {numberOfActiveFlags} active flag 74 | {numberOfActiveFlags > 1 ? "s" : ""} 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default ProjectCard; 83 | -------------------------------------------------------------------------------- /components/projects/details-section/heat-renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Text, 4 | Checkbox, 5 | CheckboxGroup, 6 | HStack, 7 | Input, 8 | Button, 9 | Tag, 10 | Flex, 11 | TagLabel, 12 | TagCloseButton, 13 | } from "@chakra-ui/react"; 14 | import { Heat } from "@prisma/client"; 15 | import { useHeatMutation } from "hooks"; 16 | import { useAppUrl } from "hooks/useAppUrl"; 17 | 18 | import React, { useState } from "react"; 19 | import { useEffect } from "react"; 20 | import toast from "react-hot-toast"; 21 | import { useMutation, useQueryClient } from "react-query"; 22 | 23 | const HeatRenderer = ({ heat }: { heat: Heat }) => { 24 | const appUrl = useAppUrl(); 25 | const queryClient = useQueryClient(); 26 | const [values, setValues] = useState([]); 27 | const [stringInput, setStringInput] = useState(""); 28 | 29 | const heatMutation = useHeatMutation(); 30 | 31 | const deleteHeatMutation = useMutation( 32 | async () => { 33 | const result = await fetch(`${appUrl}/api/heat/delete?id=${heat.id}`); 34 | const json = await result.json(); 35 | return json; 36 | }, 37 | { 38 | onSuccess: async () => { 39 | await queryClient.refetchQueries(["projects"]); 40 | }, 41 | } 42 | ); 43 | 44 | useEffect(() => { 45 | setValues(heat.values); 46 | }, [heat]); 47 | 48 | if (heat.type === "ENVIRONMENT") { 49 | return ( 50 | 51 | 52 | 53 | Environment 54 | {" "} 55 | 73 | 90 | 91 | setValues(e)} 94 | colorScheme="gray" 95 | > 96 | 97 | Production 98 | Development 99 | Staging 100 | 101 | 102 | 103 | ); 104 | } 105 | 106 | if (heat.type === "USER_INCLUDE" || heat.type === "USER_EXCLUDE") { 107 | return ( 108 | 109 | 110 | 111 | {(heat.type === "USER_EXCLUDE" && "Exclude users with IDs") || 112 | (heat.type === "USER_INCLUDE" && "Include users with IDs")} 113 | 114 | 134 | 151 | 152 | 153 | {heat.values.map((id) => { 154 | return ( 155 | <> 156 | 166 | {id} 167 | { 169 | heatMutation.mutate( 170 | { 171 | id: heat.id, 172 | values: heat.values.filter((userId) => userId !== id), 173 | deleteValues: true, 174 | }, 175 | { 176 | onSuccess: () => { 177 | toast.success("Successfully modified the heat"); 178 | }, 179 | onError: () => { 180 | toast.error("Error happened"); 181 | }, 182 | } 183 | ); 184 | }} 185 | /> 186 | 187 | 188 | ); 189 | })} 190 | 191 | 192 | setStringInput(e.target.value)} 197 | /> 198 | 199 | 200 | ); 201 | } 202 | if (heat.type === "CUSTOM") { 203 | return ( 204 | 205 | 206 | 207 | {heat.name} 208 | 209 | 229 | 246 | 247 | 248 | {heat.values.map((id) => { 249 | return ( 250 | <> 251 | 261 | {id} 262 | { 264 | heatMutation.mutate( 265 | { 266 | id: heat.id, 267 | values: heat.values.filter((userId) => userId !== id), 268 | deleteValues: true, 269 | }, 270 | { 271 | onSuccess: () => { 272 | toast.success("Successfully modified the heat"); 273 | }, 274 | onError: () => { 275 | toast.error("Error happened"); 276 | }, 277 | } 278 | ); 279 | }} 280 | /> 281 | 282 | 283 | ); 284 | })} 285 | 286 | 287 | setStringInput(e.target.value)} 292 | /> 293 | 294 | 295 | ); 296 | } 297 | }; 298 | 299 | export default HeatRenderer; 300 | -------------------------------------------------------------------------------- /components/projects/details-section/heats-selection/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Radio, RadioGroup, VStack } from "@chakra-ui/react"; 2 | import React from "react"; 3 | 4 | const HeatsSelection = ({ 5 | selectedFlag, 6 | selectedHeatOption, 7 | setSelectedHeatOption, 8 | }) => { 9 | return ( 10 | 11 | setSelectedHeatOption(e)} 14 | colorScheme="gray" 15 | > 16 | 17 | heat.type === "ENVIRONMENT" 20 | )} 21 | value="ENVIRONMENT" 22 | > 23 | Environment 24 | 25 | heat.type === "USER_INCLUDE" 28 | )} 29 | value="USER_INCLUDE" 30 | > 31 | Include users with ids 32 | 33 | heat.type === "USER_EXCLUDE" 36 | )} 37 | value="USER_EXCLUDE" 38 | > 39 | Exclude users with ids 40 | 41 | Custom heat 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default HeatsSelection; 49 | -------------------------------------------------------------------------------- /components/projects/details-section/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | HStack, 5 | Divider, 6 | Button, 7 | Switch, 8 | IconButton, 9 | FormControl, 10 | FormLabel, 11 | Tooltip, 12 | Input, 13 | Textarea, 14 | Alert, 15 | AlertIcon, 16 | Fade, 17 | useColorModeValue, 18 | Spinner, 19 | Heading, 20 | Text, 21 | Tabs, 22 | Tab, 23 | TabPanel, 24 | TabPanels, 25 | Modal, 26 | ModalOverlay, 27 | ModalContent, 28 | ModalHeader, 29 | ModalFooter, 30 | ModalBody, 31 | ModalCloseButton, 32 | Icon, 33 | Flex, 34 | SimpleGrid, 35 | TabList, 36 | useDisclosure, 37 | Select, 38 | VStack, 39 | } from "@chakra-ui/react"; 40 | 41 | import { useFlagMutation } from "hooks"; 42 | 43 | import { useRouter } from "next/dist/client/router"; 44 | import { ArrowBackIcon, EditIcon, SettingsIcon } from "@chakra-ui/icons"; 45 | import { truncate } from "lodash"; 46 | 47 | import { HiArchive } from "react-icons/hi"; 48 | import { FaTemperatureHigh } from "react-icons/fa"; 49 | import { useMutation, useQueryClient } from "react-query"; 50 | import { useAppUrl } from "hooks/useAppUrl"; 51 | import HeatRenderer from "./heat-renderer"; 52 | import HeatsSelection from "./heats-selection"; 53 | import { useState } from "react"; 54 | import axios from "axios"; 55 | import { Strategy } from "@prisma/client"; 56 | import AuditLog from "components/audit-log"; 57 | 58 | const DetailsSection = ({ 59 | selectedFlag, 60 | project, 61 | setName, 62 | isEditingFlag, 63 | setEditingFlag, 64 | setSelectedFlag, 65 | usedFlags, 66 | name, 67 | description, 68 | setDescription, 69 | }) => { 70 | const router = useRouter(); 71 | const appUrl = useAppUrl(); 72 | const { isOpen, onOpen, onClose } = useDisclosure(); 73 | const [selectedHeatOption, setSelectedHeatOption] = useState< 74 | "ENVIRONMENT" | "USER_INCLUDE" | "USER_EXCLUDE" | "CUSTOM" 75 | >(); 76 | const [customHeatStrategy, setCustomHeatStrategy] = useState("IN"); 77 | const [customHeatProperty, setCustomHeatProperty] = useState(""); 78 | const [customHeatName, setCustomHeatName] = useState(""); 79 | const queryClient = useQueryClient(); 80 | const flagMutation = useFlagMutation(); 81 | const borderColor = useColorModeValue("gray.200", "gray.700"); 82 | const textColor = useColorModeValue("gray.600", "gray.300"); 83 | const descriptionTextColor = useColorModeValue("gray.400", "gray.500"); 84 | 85 | const heatParamsFactory = () => { 86 | switch (selectedHeatOption) { 87 | case "ENVIRONMENT": 88 | return { 89 | type: selectedHeatOption, 90 | property: "environment", 91 | strategy: "IN", 92 | }; 93 | case "USER_INCLUDE": 94 | return { 95 | type: selectedHeatOption, 96 | property: "user", 97 | strategy: "IN", 98 | }; 99 | case "USER_EXCLUDE": 100 | return { 101 | type: selectedHeatOption, 102 | property: "user", 103 | strategy: "NOT_IN", 104 | }; 105 | case "CUSTOM": 106 | return { 107 | type: selectedHeatOption, 108 | property: customHeatProperty, 109 | strategy: customHeatStrategy, 110 | }; 111 | default: 112 | break; 113 | } 114 | }; 115 | 116 | const createHeatMutation = useMutation( 117 | async () => { 118 | const params = heatParamsFactory(); 119 | const result = await axios.post( 120 | `${appUrl}/api/heat/create?flagId=${selectedFlag.id}`, 121 | { 122 | ...params, 123 | name: customHeatName, 124 | } 125 | ); 126 | 127 | return result; 128 | }, 129 | { 130 | onSuccess: async () => { 131 | await queryClient.refetchQueries(["projects"]); 132 | }, 133 | } 134 | ); 135 | 136 | return ( 137 | <> 138 |
139 | {selectedFlag ? ( 140 | 141 | 142 | 158 | {selectedFlag.name} 159 | {selectedFlag.isArchived && ( 160 | 161 | 162 | 169 | 170 | 171 | )} 172 | 173 | 174 | 175 | Main 176 | Heats 177 | History 178 | Settings 179 | 180 | 181 | 182 | 183 | {isEditingFlag ? ( 184 | setName(e.target.value)} 186 | value={name} 187 | size="sm" 188 | /> 189 | ) : ( 190 | {selectedFlag.name} 191 | )} 192 | {isEditingFlag ? ( 193 | 205 | ) : ( 206 | setEditingFlag(!isEditingFlag)} 208 | size="sm" 209 | aria-label="Edit" 210 | icon={} 211 | /> 212 | )} 213 | 214 | {isEditingFlag ? ( 215 |