diff --git a/apps/cdn/src/index.ts b/apps/cdn/src/index.ts index c2eebb28..cc59be7a 100644 --- a/apps/cdn/src/index.ts +++ b/apps/cdn/src/index.ts @@ -1,66 +1,66 @@ -import { Hono } from "hono"; -import { ZoneCache } from "./lib/cache"; -import { ABBY_WINDOW_KEY, AbbyDataResponse } from "@tryabby/core"; +import { Hono } from 'hono' +import { ZoneCache } from './lib/cache' +import { ABBY_WINDOW_KEY, AbbyDataResponse } from '@tryabby/core' -import { cors } from "hono/cors"; -import { timing } from "hono/timing"; -import { logger } from "hono/logger"; -import { ConfigService } from "./lib/config"; +import { cors } from 'hono/cors' +import { timing } from 'hono/timing' +import { logger } from 'hono/logger' +import { ConfigService } from './lib/config' const cache = new ZoneCache<{ - config: AbbyDataResponse; + config: AbbyDataResponse }>({ - cloudflareApiKey: "", - domain: "cache.tryabby.com", + cloudflareApiKey: '', + domain: 'cache.tryabby.com', fresh: 60 * 1000, stale: 60 * 1000, - zoneId: "", -}); + zoneId: '', +}) -const configCache = new ConfigService(cache); +const configCache = new ConfigService(cache) const app = new Hono() .use( - "*", + '*', cors({ - origin: "*", + origin: '*', maxAge: 60 * 60 * 24 * 30, }) ) - .use("*", timing()) - .use("*", logger()) - .get("/:projectId/:environment", async (c) => { - const environment = c.req.param("environment"); - const projectId = c.req.param("projectId"); + .use('*', timing()) + .use('*', logger()) + .get('/:projectId/:environment', async (c) => { + const environment = c.req.param('environment') + const projectId = c.req.param('projectId') const [data, , reason] = await configCache.retrieveConfig({ c, environment, projectId, - }); + }) - c.header("x-abby-cache", reason); - return c.json(data); + c.header('x-abby-cache', reason) + return c.json(data) }) - .get("/:projectId/:environment/script.js", async (c) => { - const environment = c.req.param("environment"); - const projectId = c.req.param("projectId"); + .get('/:projectId/:environment/script.js', async (c) => { + const environment = c.req.param('environment') + const projectId = c.req.param('projectId') const [data, , reason] = await configCache.retrieveConfig({ c, environment, projectId, - }); + }) - c.header("x-abby-cache", reason); + c.header('x-abby-cache', reason) - const script = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify(data)};`; + const script = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify(data)};` return c.text(script, { headers: { - "Content-Type": "application/javascript", + 'Content-Type': 'application/javascript', }, - }); - }); + }) + }) -export default app; +export default app diff --git a/apps/cdn/src/lib/cache.ts b/apps/cdn/src/lib/cache.ts index 705adfa4..760403e8 100644 --- a/apps/cdn/src/lib/cache.ts +++ b/apps/cdn/src/lib/cache.ts @@ -1,65 +1,63 @@ -import type { Context } from "hono"; +import type { Context } from 'hono' export type CacheConfig = { /** * How long an entry should be fresh in milliseconds */ - fresh: number; + fresh: number /** * How long an entry should be stale in milliseconds * * Stale entries are still valid but should be refreshed in the background */ - stale: number; -}; + stale: number +} export type Entry = { - value: TValue; -}; + value: TValue +} export type ZoneCacheConfig = CacheConfig & { - domain: string; - zoneId: string; + domain: string + zoneId: string /** * This token must have at least */ - cloudflareApiKey: string; -}; + cloudflareApiKey: string +} export class ZoneCache> { - private readonly config: ZoneCacheConfig; + private readonly config: ZoneCacheConfig constructor(config: ZoneCacheConfig) { - this.config = config; + this.config = config } private createCacheKey( namespace: TName, key: string, - cacheBuster = "v1" + cacheBuster = 'v1' ): URL { - return new URL( - `https://${this.config.domain}/cache/${cacheBuster}/${String(namespace)}/${key}` - ); + return new URL(`https://${this.config.domain}/cache/${cacheBuster}/${String(namespace)}/${key}`) } public async get( c: Context, namespace: TName, key: string - ): Promise<[TNamespaces[TName] | undefined, "stale" | "hit" | "miss" | "error"]> { + ): Promise<[TNamespaces[TName] | undefined, 'stale' | 'hit' | 'miss' | 'error']> { try { - const res = await caches.default.match(new Request(this.createCacheKey(namespace, key))); + const res = await caches.default.match(new Request(this.createCacheKey(namespace, key))) if (!res) { - return [undefined, "miss"]; + return [undefined, 'miss'] } - const entry = (await res.json()) as Entry; + const entry = (await res.json()) as Entry - return [entry.value, "hit"]; + return [entry.value, 'hit'] } catch (e) { - console.error("zone cache error:", e); - return [undefined, "error"]; + console.error('zone cache error:', e) + return [undefined, 'error'] } } @@ -71,15 +69,15 @@ export class ZoneCache> { ): Promise { const entry: Entry = { value: value, - }; - const req = new Request(this.createCacheKey(namespace, key)); + } + const req = new Request(this.createCacheKey(namespace, key)) const res = new Response(JSON.stringify(entry), { headers: { - "Content-Type": "application/json", - "Cache-Control": `public, s-maxage=60`, + 'Content-Type': 'application/json', + 'Cache-Control': `public, s-maxage=60`, }, - }); + }) - await caches.default.put(req, res); + await caches.default.put(req, res) } } diff --git a/apps/cdn/src/lib/config.ts b/apps/cdn/src/lib/config.ts index 06555095..3c92840b 100644 --- a/apps/cdn/src/lib/config.ts +++ b/apps/cdn/src/lib/config.ts @@ -1,13 +1,13 @@ -import { AbbyDataResponse, HttpService } from "@tryabby/core"; +import { AbbyDataResponse, HttpService } from '@tryabby/core' -import type { ZoneCache } from "./cache"; -import { Context } from "hono"; -import { endTime, startTime } from "hono/timing"; +import type { ZoneCache } from './cache' +import { Context } from 'hono' +import { endTime, startTime } from 'hono/timing' export class ConfigService { constructor( private readonly cache: ZoneCache<{ - config: AbbyDataResponse; + config: AbbyDataResponse }> ) {} @@ -16,35 +16,35 @@ export class ConfigService { projectId, c, }: { - projectId: string; - environment: string; - c: Context; + projectId: string + environment: string + c: Context }) { - const cacheKey = [projectId, environment].join(","); + const cacheKey = [projectId, environment].join(',') - startTime(c, "cacheRead"); - const [cachedData, reason] = await this.cache.get(c, "config", cacheKey); + startTime(c, 'cacheRead') + const [cachedData, reason] = await this.cache.get(c, 'config', cacheKey) - endTime(c, "cacheRead"); + endTime(c, 'cacheRead') if (cachedData) { - return [cachedData, true, reason] as const; + return [cachedData, true, reason] as const } - startTime(c, "remoteRead"); + startTime(c, 'remoteRead') const data = await HttpService.getProjectData({ projectId, environment, - }); + }) if (!data) { - throw new Error("Failed to fetch data"); + throw new Error('Failed to fetch data') } - endTime(c, "remoteRead"); - c.executionCtx.waitUntil(this.cache.set(c, "config", cacheKey, data)); + endTime(c, 'remoteRead') + c.executionCtx.waitUntil(this.cache.set(c, 'config', cacheKey, data)) - return [data, false, reason] as const; + return [data, false, reason] as const } } diff --git a/apps/cdn/tsconfig.json b/apps/cdn/tsconfig.json index 9cd84898..4a2abdbe 100644 --- a/apps/cdn/tsconfig.json +++ b/apps/cdn/tsconfig.json @@ -5,13 +5,9 @@ "moduleResolution": "node", "esModuleInterop": true, "strict": true, - "lib": [ - "esnext" - ], - "types": [ - "@cloudflare/workers-types" - ], + "lib": ["esnext"], + "types": ["@cloudflare/workers-types"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx" - }, -} \ No newline at end of file + } +} diff --git a/apps/docs/next.config.js b/apps/docs/next.config.js index 755c6edb..b3bc0ad7 100644 --- a/apps/docs/next.config.js +++ b/apps/docs/next.config.js @@ -1,10 +1,10 @@ -const withNextra = require("nextra")({ - theme: "nextra-theme-docs", - themeConfig: "./theme.config.jsx", +const withNextra = require('nextra')({ + theme: 'nextra-theme-docs', + themeConfig: './theme.config.jsx', defaultShowCopyCode: true, -}); +}) -const { withPlausibleProxy } = require("next-plausible"); +const { withPlausibleProxy } = require('next-plausible') /** @type {import('next').NextConfig} */ const nextConfig = { @@ -12,9 +12,9 @@ const nextConfig = { swcMinify: true, // use this to add to all pages i18n: { - locales: ["en"], - defaultLocale: "en", + locales: ['en'], + defaultLocale: 'en', }, -}; +} -module.exports = withPlausibleProxy()(withNextra(nextConfig)); +module.exports = withPlausibleProxy()(withNextra(nextConfig)) diff --git a/apps/docs/package.json b/apps/docs/package.json index 99bcf71c..af660f3d 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,7 +13,6 @@ "@types/react": "18.0.26", "@types/react-dom": "^18.0.5", "eslint": "8.29.0", - "eslint-config-next": "13.0.6", "next": "14.0.4", "next-plausible": "^3.11.3", "nextra": "2.13.2", @@ -23,7 +22,6 @@ "typescript": "4.9.3" }, "devDependencies": { - "eslint-config-custom": "workspace:^0.0.0", "tsconfig": "workspace:^0.0.0" } } diff --git a/apps/docs/pages/reference/_meta.json b/apps/docs/pages/reference/_meta.json index 8322e2ba..abf92dd4 100644 --- a/apps/docs/pages/reference/_meta.json +++ b/apps/docs/pages/reference/_meta.json @@ -1,8 +1,8 @@ { - "nextjs": "Next.js", - "react": "React", - "svelte": "Svelte", - "angular": "Angular", - "http": "HTTP API", - "cli": "CLI" -} \ No newline at end of file + "nextjs": "Next.js", + "react": "React", + "svelte": "Svelte", + "angular": "Angular", + "http": "HTTP API", + "cli": "CLI" +} diff --git a/apps/docs/theme.config.jsx b/apps/docs/theme.config.jsx index 4f35f11e..80697784 100644 --- a/apps/docs/theme.config.jsx +++ b/apps/docs/theme.config.jsx @@ -1,43 +1,43 @@ /* eslint-disable import/no-anonymous-default-export */ -import { useRouter } from "next/router"; +import { useRouter } from 'next/router' export default { useNextSeoProps() { - const router = useRouter(); - const currentPageUrl = `https://docs.tryabby.com${router.asPath}`; + const router = useRouter() + const currentPageUrl = `https://docs.tryabby.com${router.asPath}` return { - titleTemplate: "%s – Abby Docs", + titleTemplate: '%s – Abby Docs', description: - "Abby is a SaaS tool for developers to streamline A/B testing and feature flagging. Make data-driven decisions and improve user experience with ease.", + 'Abby is a SaaS tool for developers to streamline A/B testing and feature flagging. Make data-driven decisions and improve user experience with ease.', openGraph: { url: currentPageUrl, - title: "Abby Docs", - type: "website", + title: 'Abby Docs', + type: 'website', description: - "Abby is a SaaS tool for developers to streamline A/B testing and feature flagging. Make data-driven decisions and improve user experience with ease.", + 'Abby is a SaaS tool for developers to streamline A/B testing and feature flagging. Make data-driven decisions and improve user experience with ease.', images: [ { - url: "https://www.tryabby.com/og.png", + url: 'https://www.tryabby.com/og.png', width: 1200, height: 630, - alt: "Abby", - type: "image/png", + alt: 'Abby', + type: 'image/png', }, ], - siteName: "Abby Docs", + siteName: 'Abby Docs', }, - }; + } }, search: { - loading: "Loading...", - placeholder: "Search...", + loading: 'Loading...', + placeholder: 'Search...', }, head: ( <> - + ), - logo: Abby, + logo: Abby, // docsRepositoryBase: 'https://github.com/cstrnt/abby/blob/main/apps/docs/pages', project: { // link: "https://github.com/cstrnt/abby", @@ -55,4 +55,4 @@ export default { component: null, }, // ... -}; +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 735e1de8..e6e7302a 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -2,4 +2,4 @@ "extends": "tsconfig/nextjs.json", "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "theme.config.jsx"], "exclude": ["node_modules"] -} \ No newline at end of file +} diff --git a/apps/web/abby.config.ts b/apps/web/abby.config.ts index 2fcd4947..ccbef7a5 100644 --- a/apps/web/abby.config.ts +++ b/apps/web/abby.config.ts @@ -1,5 +1,5 @@ /* eslint-disable turbo/no-undeclared-env-vars */ -import { defineConfig } from "@tryabby/core"; +import { defineConfig } from '@tryabby/core' export default defineConfig( { @@ -7,21 +7,21 @@ export default defineConfig( currentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV, apiUrl: process.env.NEXT_PUBLIC_ABBY_API_URL, __experimentalCdnUrl: process.env.NEXT_PUBLIC_ABBY_CDN_URL, - debug: process.env.NEXT_PUBLIC_ABBY_DEBUG === "true", + debug: process.env.NEXT_PUBLIC_ABBY_DEBUG === 'true', }, { - environments: ["development", "production"], + environments: ['development', 'production'], tests: { SignupButton: { - variants: ["A", "B"], + variants: ['A', 'B'], }, TipsAndTricks: { - variants: ["Blog"], + variants: ['Blog'], }, }, - flags: ["AdvancedTestStats", "showFooter", "test"], + flags: ['AdvancedTestStats', 'showFooter', 'test'], remoteConfig: { - abc: "JSON", + abc: 'JSON', }, } -); +) diff --git a/apps/web/emails/ContactFormularEmail.tsx b/apps/web/emails/ContactFormularEmail.tsx index dcf5845f..f8b5e854 100644 --- a/apps/web/emails/ContactFormularEmail.tsx +++ b/apps/web/emails/ContactFormularEmail.tsx @@ -1,36 +1,27 @@ - -import { Container } from "@react-email/container"; -import { Head } from "@react-email/head"; -import { Html } from "@react-email/html"; -import { Preview } from "@react-email/preview"; -import { Section } from "@react-email/section"; -import * as React from "react"; -import { ABBY_BASE_URL } from "@tryabby/core"; +import { Container } from '@react-email/container' +import { Head } from '@react-email/head' +import { Html } from '@react-email/html' +import { Preview } from '@react-email/preview' +import { Section } from '@react-email/section' +import * as React from 'react' +import { ABBY_BASE_URL } from '@tryabby/core' export type Props = { - surname: string; - name: string; - mailadress: string; - message: string; -}; + surname: string + name: string + mailadress: string + message: string +} -export default function ContactFormularEmail({ - surname, - mailadress, - name, - message, -}: Props) { - const baseUrl = - process.env.NODE_ENV === "development" - ? "http://localhost:3000/" - : ABBY_BASE_URL; +export default function ContactFormularEmail({ surname, mailadress, name, message }: Props) { + const baseUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:3000/' : ABBY_BASE_URL return ( {/* @ts-ignore types are off */} - {name} {surname} tried to contact{" "} + {name} {surname} tried to contact{' '}
@@ -44,75 +35,75 @@ export default function ContactFormularEmail({
- ); + ) } const main = { - backgroundColor: "#ffffff", - margin: "0 auto", -}; + backgroundColor: '#ffffff', + margin: '0 auto', +} const container = { - border: "1px solid #eaeaea", - borderRadius: "5px", - margin: "40px auto", - padding: "20px", - width: "465px", -}; + border: '1px solid #eaeaea', + borderRadius: '5px', + margin: '40px auto', + padding: '20px', + width: '465px', +} const logo = { - margin: "0 auto", -}; + margin: '0 auto', +} const h1 = { - color: "#000", + color: '#000', fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "24px", - fontWeight: "normal", - textAlign: "center" as const, - margin: "30px 0", - padding: "0", -}; + fontSize: '24px', + fontWeight: 'normal', + textAlign: 'center' as const, + margin: '30px 0', + padding: '0', +} const avatar = { - borderRadius: "100%", -}; + borderRadius: '100%', +} const link = { - color: "#067df7", - textDecoration: "none", -}; + color: '#067df7', + textDecoration: 'none', +} const text = { - color: "#000", + color: '#000', fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", - lineHeight: "24px", -}; + fontSize: '14px', + lineHeight: '24px', +} const black = { - color: "black", -}; + color: 'black', +} const center = { - verticalAlign: "middle", -}; + verticalAlign: 'middle', +} const btn = { - backgroundColor: "#000", - borderRadius: "5px", - color: "#fff", + backgroundColor: '#000', + borderRadius: '5px', + color: '#fff', fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "12px", + fontSize: '12px', fontWeight: 500, - lineHeight: "50px", - textDecoration: "none", - textAlign: "center" as const, -}; + lineHeight: '50px', + textDecoration: 'none', + textAlign: 'center' as const, +} const spacing = { - marginBottom: "26px", -}; + marginBottom: '26px', +} diff --git a/apps/web/emails/index.tsx b/apps/web/emails/index.tsx index 7976ef34..5e19d1f4 100644 --- a/apps/web/emails/index.tsx +++ b/apps/web/emails/index.tsx @@ -1,35 +1,33 @@ -import { render } from "@react-email/render"; -import { env } from "env/server.mjs"; -import { createTransport } from "nodemailer"; -import InviteEmail, { Props as InviteEmailProps } from "./invite"; -import ContactFormularEmail, { - Props as ContactMailProps, -} from "./ContactFormularEmail"; +import { render } from '@react-email/render' +import { env } from 'env/server.mjs' +import { createTransport } from 'nodemailer' +import InviteEmail, { Props as InviteEmailProps } from './invite' +import ContactFormularEmail, { Props as ContactMailProps } from './ContactFormularEmail' const transporter = createTransport({ pool: true, url: env.EMAIL_SERVER, from: `Abby <${env.ABBY_FROM_EMAIL}>`, -}); +}) export function sendInviteEmail(props: InviteEmailProps) { - const email = render(); + const email = render() return transporter.sendMail({ to: props.invitee.email, from: `Abby <${env.ABBY_FROM_EMAIL}>`, subject: `Join ${props.inviter.name} on Abby`, html: email, - }); + }) } export function sendContactFormularEmail(props: ContactMailProps) { - const email = render(); - const abbyContactAdress = "tim@tryabby.com"; + const email = render() + const abbyContactAdress = 'tim@tryabby.com' return transporter.sendMail({ to: abbyContactAdress, from: `Abby <${env.ABBY_FROM_EMAIL}>`, subject: `New Message from ${props.name} ${props.surname}`, html: email, - }); + }) } diff --git a/apps/web/emails/invite.tsx b/apps/web/emails/invite.tsx index 7d1bd503..18e5c6de 100644 --- a/apps/web/emails/invite.tsx +++ b/apps/web/emails/invite.tsx @@ -1,29 +1,26 @@ -import { Project } from "@prisma/client"; -import { Button } from "@react-email/button"; -import { Container } from "@react-email/container"; -import { Head } from "@react-email/head"; -import { Hr } from "@react-email/hr"; -import { Html } from "@react-email/html"; -import { Img } from "@react-email/img"; -import { Link } from "@react-email/link"; -import { Preview } from "@react-email/preview"; -import { Section } from "@react-email/section"; -import { Text } from "@react-email/text"; -import * as React from "react"; -import { ABBY_BASE_URL } from "@tryabby/core"; +import { Project } from '@prisma/client' +import { Button } from '@react-email/button' +import { Container } from '@react-email/container' +import { Head } from '@react-email/head' +import { Hr } from '@react-email/hr' +import { Html } from '@react-email/html' +import { Img } from '@react-email/img' +import { Link } from '@react-email/link' +import { Preview } from '@react-email/preview' +import { Section } from '@react-email/section' +import { Text } from '@react-email/text' +import * as React from 'react' +import { ABBY_BASE_URL } from '@tryabby/core' export type Props = { - inviteId: string; - invitee: { name?: string; email: string }; - inviter: { name: string; email: string }; - project: Project; -}; + inviteId: string + invitee: { name?: string; email: string } + inviter: { name: string; email: string } + project: Project +} export default function Email({ inviteId, invitee, inviter, project }: Props) { - const baseUrl = - process.env.NODE_ENV === "development" - ? "http://localhost:3000/" - : ABBY_BASE_URL; + const baseUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:3000/' : ABBY_BASE_URL return ( @@ -32,7 +29,7 @@ export default function Email({ inviteId, invitee, inviter, project }: Props) { Join {inviter.name} on Abby
-
+

Abby

@@ -44,27 +41,21 @@ export default function Email({ inviteId, invitee, inviter, project }: Props) { {inviter.email} - ) has invited you to the {project.name} team on{" "} - Abby. + ) has invited you to the {project.name} team on Abby. -
-

- or copy and paste this URL into your browser:{" "} + or copy and paste this URL into your browser:{' '} {baseUrl}invites/{inviteId} @@ -72,75 +63,75 @@ export default function Email({ inviteId, invitee, inviter, project }: Props) {
- ); + ) } const main = { - backgroundColor: "#ffffff", - margin: "0 auto", -}; + backgroundColor: '#ffffff', + margin: '0 auto', +} const container = { - border: "1px solid #eaeaea", - borderRadius: "5px", - margin: "40px auto", - padding: "20px", - width: "465px", -}; + border: '1px solid #eaeaea', + borderRadius: '5px', + margin: '40px auto', + padding: '20px', + width: '465px', +} const logo = { - margin: "0 auto", -}; + margin: '0 auto', +} const h1 = { - color: "#000", + color: '#000', fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "24px", - fontWeight: "normal", - textAlign: "center" as const, - margin: "30px 0", - padding: "0", -}; + fontSize: '24px', + fontWeight: 'normal', + textAlign: 'center' as const, + margin: '30px 0', + padding: '0', +} const avatar = { - borderRadius: "100%", -}; + borderRadius: '100%', +} const link = { - color: "#067df7", - textDecoration: "none", -}; + color: '#067df7', + textDecoration: 'none', +} const text = { - color: "#000", + color: '#000', fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", - lineHeight: "24px", -}; + fontSize: '14px', + lineHeight: '24px', +} const black = { - color: "black", -}; + color: 'black', +} const center = { - verticalAlign: "middle", -}; + verticalAlign: 'middle', +} const btn = { - backgroundColor: "#000", - borderRadius: "5px", - color: "#fff", + backgroundColor: '#000', + borderRadius: '5px', + color: '#fff', fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "12px", + fontSize: '12px', fontWeight: 500, - lineHeight: "50px", - textDecoration: "none", - textAlign: "center" as const, -}; + lineHeight: '50px', + textDecoration: 'none', + textAlign: 'center' as const, +} const spacing = { - marginBottom: "26px", -}; + marginBottom: '26px', +} diff --git a/apps/web/next-sitemap.config.js b/apps/web/next-sitemap.config.js index 19c72c4b..b4469c79 100644 --- a/apps/web/next-sitemap.config.js +++ b/apps/web/next-sitemap.config.js @@ -1,17 +1,17 @@ /** @type {import('next-sitemap').IConfig} */ module.exports = { - siteUrl: process.env.SITE_URL || "https://www.tryabby.com", + siteUrl: process.env.SITE_URL || 'https://www.tryabby.com', generateRobotsTxt: true, // (optional) exclude: [ - "/test", - "/checkout", - "/projects", - "/invites", - "/marketing/*", - "/redeem", - "/profile", - "/profile/*", - "/welcome", + '/test', + '/checkout', + '/projects', + '/invites', + '/marketing/*', + '/redeem', + '/profile', + '/profile/*', + '/welcome', ], // ...other options -}; +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 1bc5fc38..04bf5bdc 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,9 +1,9 @@ // @ts-check -import bundleAnalzyer from "@next/bundle-analyzer"; -import { withPlausibleProxy } from "next-plausible"; -import mdx from "@next/mdx"; -import { remarkCodeHike } from "@code-hike/mdx"; -import theme from "shiki/themes/poimandres.json" assert { type: "json" }; +import bundleAnalzyer from '@next/bundle-analyzer' +import { withPlausibleProxy } from 'next-plausible' +import mdx from '@next/mdx' +import { remarkCodeHike } from '@code-hike/mdx' +import theme from 'shiki/themes/poimandres.json' assert { type: 'json' } const withMDX = mdx({ extension: /\.mdx?$/, @@ -11,41 +11,39 @@ const withMDX = mdx({ // If you use remark-gfm, you'll need to use next.config.mjs // as the package is ESM only // https://github.com/remarkjs/remark-gfm#install - remarkPlugins: [ - [remarkCodeHike, { theme, lineNumbers: true, showCopyButton: true }], - ], + remarkPlugins: [[remarkCodeHike, { theme, lineNumbers: true, showCopyButton: true }]], rehypePlugins: [], // If you use `MDXProvider`, uncomment the following line. // providerImportSource: "@mdx-js/react", }, -}); +}) const withBundleAnalyzer = bundleAnalzyer({ - enabled: process.env.ANALYZE === "true", -}); + enabled: process.env.ANALYZE === 'true', +}) /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. * This is especially useful for Docker builds. */ -!process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs")); +!process.env.SKIP_ENV_VALIDATION && (await import('./src/env/server.mjs')) /** @type {import("next").NextConfig} */ const config = { reactStrictMode: true, - output: "standalone", + output: 'standalone', swcMinify: true, - pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"], - transpilePackages: ["lodash-es"], + pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], + transpilePackages: ['lodash-es'], i18n: { - locales: ["en"], - defaultLocale: "en", + locales: ['en'], + defaultLocale: 'en', }, -}; +} export default withPlausibleProxy()( withBundleAnalyzer( // @ts-ignore withMDX(config) ) -); +) diff --git a/apps/web/package.json b/apps/web/package.json index 9b1fb62b..e0061296 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -136,7 +136,6 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.14", "eslint": "^8.38.0", - "eslint-config-next": "13.0.2", "jsdom": "^20.0.3", "postcss": "^8.4.21", "prettier": "^2.8.7", @@ -152,4 +151,4 @@ "ct3aMetadata": { "initVersion": "6.11.1" } -} \ No newline at end of file +} diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs index 12a703d9..33ad091d 100644 --- a/apps/web/postcss.config.cjs +++ b/apps/web/postcss.config.cjs @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -}; +} diff --git a/apps/web/prettier.config.cjs b/apps/web/prettier.config.cjs index 58b0aee2..09275543 100644 --- a/apps/web/prettier.config.cjs +++ b/apps/web/prettier.config.cjs @@ -1,4 +1,4 @@ /** @type {import("prettier").Config} */ module.exports = { - plugins: [require.resolve("prettier-plugin-tailwindcss")], -}; + plugins: [require.resolve('prettier-plugin-tailwindcss')], +} diff --git a/apps/web/prisma/generateCoupons.ts b/apps/web/prisma/generateCoupons.ts index 3c487cb5..dd63ce71 100644 --- a/apps/web/prisma/generateCoupons.ts +++ b/apps/web/prisma/generateCoupons.ts @@ -1,29 +1,29 @@ -import { PrismaClient } from "@prisma/client"; -import fs from "node:fs/promises"; -import path from "node:path"; +import { PrismaClient } from '@prisma/client' +import fs from 'node:fs/promises' +import path from 'node:path' -const prisma = new PrismaClient(); +const prisma = new PrismaClient() -const COUPON_CODE_AMOUNT = 200; +const COUPON_CODE_AMOUNT = 200 async function main() { - const fileName = path.join(__dirname, "./coupons.csv"); + const fileName = path.join(__dirname, './coupons.csv') const items = Array.from({ length: COUPON_CODE_AMOUNT }).map(() => ({ stripePriceId: // eslint-disable-next-line turbo/no-undeclared-env-vars - "STARTUP_LIFETIME", - })); + 'STARTUP_LIFETIME', + })) const codes = await prisma.$transaction( items.map((item) => prisma.couponCodes.create({ data: item })) - ); + ) - const csv = codes.map((code) => code.code).join("\n"); + const csv = codes.map((code) => code.code).join('\n') - await fs.writeFile(fileName, csv); + await fs.writeFile(fileName, csv) - console.log(`Wrote ${COUPON_CODE_AMOUNT} codes to ${fileName}`); + console.log(`Wrote ${COUPON_CODE_AMOUNT} codes to ${fileName}`) } -main(); +main() diff --git a/apps/web/prisma/seedEvents.ts b/apps/web/prisma/seedEvents.ts index dd6f1449..0e3b2ce2 100644 --- a/apps/web/prisma/seedEvents.ts +++ b/apps/web/prisma/seedEvents.ts @@ -1,75 +1,73 @@ -import { PrismaClient, Prisma } from "@prisma/client"; -import { AbbyEventType } from "@tryabby/core"; +import { PrismaClient, Prisma } from '@prisma/client' +import { AbbyEventType } from '@tryabby/core' -const prisma = new PrismaClient(); +const prisma = new PrismaClient() async function main() { const user = await prisma.user.findFirst({ where: {}, include: { projects: true }, - }); + }) if (!user) { - throw new Error( - "User for email not found. Please create an account first." - ); + throw new Error('User for email not found. Please create an account first.') } if (!user.projects[0]) { - throw new Error("User has no projects. Please create a project first."); + throw new Error('User has no projects. Please create a project first.') } const footerTest = await prisma.test.upsert({ where: { projectId_name: { - name: "Footer", + name: 'Footer', projectId: user.projects[0].projectId, }, }, - create: { name: "Footer", projectId: user.projects[0].projectId }, + create: { name: 'Footer', projectId: user.projects[0].projectId }, update: {}, - }); + }) const ctaButtonTest = await prisma.test.upsert({ where: { projectId_name: { - name: "CTA Button", + name: 'CTA Button', projectId: user.projects[0].projectId, }, }, - create: { name: "CTA Button", projectId: user.projects[0].projectId }, + create: { name: 'CTA Button', projectId: user.projects[0].projectId }, update: {}, - }); + }) await prisma.option.createMany({ data: [ { - identifier: "oldFooter", + identifier: 'oldFooter', chance: 0.5, testId: footerTest.id, }, { - identifier: "newFooter", + identifier: 'newFooter', chance: 0.5, testId: footerTest.id, }, { - identifier: "dark", + identifier: 'dark', chance: 0.33, testId: ctaButtonTest.id, }, { - identifier: "light", + identifier: 'light', chance: 0.33, testId: ctaButtonTest.id, }, { - identifier: "cyperpunk", + identifier: 'cyperpunk', chance: 0.33, testId: ctaButtonTest.id, }, ], - }); + }) await prisma.event.createMany({ data: [ @@ -78,51 +76,51 @@ async function main() { }).map( () => ({ - selectedVariant: "oldFooter", + selectedVariant: 'oldFooter', testId: footerTest.id, type: AbbyEventType.PING, - } as Prisma.EventCreateManyInput) + }) as Prisma.EventCreateManyInput ), ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => ({ - selectedVariant: "newFooter", + selectedVariant: 'newFooter', testId: footerTest.id, type: AbbyEventType.PING, - } as Prisma.EventCreateManyInput) + }) as Prisma.EventCreateManyInput ), ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => ({ - selectedVariant: "oldFooter", + selectedVariant: 'oldFooter', testId: footerTest.id, type: AbbyEventType.ACT, - } as Prisma.EventCreateManyInput) + }) as Prisma.EventCreateManyInput ), ...Array.from({ length: Math.floor(Math.random() * 200), }).map( () => ({ - selectedVariant: "newFooter", + selectedVariant: 'newFooter', testId: footerTest.id, type: AbbyEventType.ACT, - } as Prisma.EventCreateManyInput) + }) as Prisma.EventCreateManyInput ), ], - }); + }) } main() .then(async () => { - await prisma.$disconnect(); + await prisma.$disconnect() }) .catch(async (e) => { - console.error(e); - await prisma.$disconnect(); - process.exit(1); - }); + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts index ba18b331..0521c2b2 100644 --- a/apps/web/src/api/index.ts +++ b/apps/web/src/api/index.ts @@ -1,22 +1,22 @@ -import { makeConfigRoute } from "api/routes/v1_config"; -import { makeProjectDataRoute } from "api/routes/v1_project_data"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { logger } from "hono/logger"; -import { makeHealthRoute } from "./routes/health"; -import { makeEventRoute } from "./routes/v1_event"; -import { makeLegacyProjectDataRoute } from "./routes/legacy_project_data"; +import { makeConfigRoute } from 'api/routes/v1_config' +import { makeProjectDataRoute } from 'api/routes/v1_project_data' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { makeHealthRoute } from './routes/health' +import { makeEventRoute } from './routes/v1_event' +import { makeLegacyProjectDataRoute } from './routes/legacy_project_data' export const app = new Hono() - .basePath("/api") + .basePath('/api') // base middleware - .use("*", logger()) - .use("*", cors({ origin: "*", maxAge: 86400 })) - .route("/health", makeHealthRoute()) + .use('*', logger()) + .use('*', cors({ origin: '*', maxAge: 86400 })) + .route('/health', makeHealthRoute()) // legacy routes - .route("/data", makeEventRoute()) - .route("/dashboard", makeLegacyProjectDataRoute()) + .route('/data', makeEventRoute()) + .route('/dashboard', makeLegacyProjectDataRoute()) // v1 routes - .route("/v1/config", makeConfigRoute()) - .route("/v1/data", makeProjectDataRoute()) - .route("/v1/track", makeEventRoute()); + .route('/v1/config', makeConfigRoute()) + .route('/v1/data', makeProjectDataRoute()) + .route('/v1/track', makeEventRoute()) diff --git a/apps/web/src/api/routes/health.test.ts b/apps/web/src/api/routes/health.test.ts index bc414e0b..941b532b 100644 --- a/apps/web/src/api/routes/health.test.ts +++ b/apps/web/src/api/routes/health.test.ts @@ -1,25 +1,25 @@ -import { testClient } from "hono/testing"; -import { makeHealthRoute } from "./health"; +import { testClient } from 'hono/testing' +import { makeHealthRoute } from './health' -vi.mock("server/db/client", () => ({ +vi.mock('server/db/client', () => ({ prisma: { verificationToken: { count: vi.fn(async () => 1), }, }, -})); +})) -vi.mock("server/db/redis", () => ({ +vi.mock('server/db/redis', () => ({ redis: { - get: vi.fn(async () => "test"), + get: vi.fn(async () => 'test'), }, -})); +})) -it("should work", async () => { - const app = makeHealthRoute(); +it('should work', async () => { + const app = makeHealthRoute() - const res = await testClient(app).index.$get(); + const res = await testClient(app).index.$get() - expect(res.status).toEqual(200); - expect(await res.json()).toEqual({ status: "ok" }); -}); + expect(res.status).toEqual(200) + expect(await res.json()).toEqual({ status: 'ok' }) +}) diff --git a/apps/web/src/api/routes/health.ts b/apps/web/src/api/routes/health.ts index 184c3848..15e6c5af 100644 --- a/apps/web/src/api/routes/health.ts +++ b/apps/web/src/api/routes/health.ts @@ -1,14 +1,11 @@ -import { Hono } from "hono"; -import { prisma } from "server/db/client"; -import { redis } from "server/db/redis"; +import { Hono } from 'hono' +import { prisma } from 'server/db/client' +import { redis } from 'server/db/redis' export function makeHealthRoute() { - const app = new Hono().get("/", async (c) => { - await Promise.allSettled([ - await prisma.verificationToken.count(), - await redis.get("test"), - ]); - return c.json({ status: "ok" }); - }); - return app; + const app = new Hono().get('/', async (c) => { + await Promise.allSettled([await prisma.verificationToken.count(), await redis.get('test')]) + return c.json({ status: 'ok' }) + }) + return app } diff --git a/apps/web/src/api/routes/legacy_project_data.test.ts b/apps/web/src/api/routes/legacy_project_data.test.ts index 31e11bf7..b7fe56a8 100644 --- a/apps/web/src/api/routes/legacy_project_data.test.ts +++ b/apps/web/src/api/routes/legacy_project_data.test.ts @@ -1,42 +1,42 @@ -import { testClient } from "hono/testing"; -import { makeLegacyProjectDataRoute } from "./legacy_project_data"; -import { jobManager } from "server/queue/Manager"; -import { FeatureFlag, FeatureFlagValue, Option, Test } from "@prisma/client"; -import { Decimal } from "@prisma/client/runtime/library"; +import { testClient } from 'hono/testing' +import { makeLegacyProjectDataRoute } from './legacy_project_data' +import { jobManager } from 'server/queue/Manager' +import { FeatureFlag, FeatureFlagValue, Option, Test } from '@prisma/client' +import { Decimal } from '@prisma/client/runtime/library' -vi.mock("../../env/server.mjs", () => ({ +vi.mock('../../env/server.mjs', () => ({ env: {}, -})); +})) -vi.mock("server/queue/Manager", () => ({ +vi.mock('server/queue/Manager', () => ({ jobManager: { emit: vi.fn().mockResolvedValue(null), }, -})); +})) -vi.mock("server/db/client", () => ({ +vi.mock('server/db/client', () => ({ prisma: { featureFlagValue: { findMany: vi.fn().mockResolvedValue([ { - environmentId: "", + environmentId: '', flag: { - name: "First Flag", - type: "BOOLEAN", + name: 'First Flag', + type: 'BOOLEAN', }, - flagId: "", - id: "", - value: "true", + flagId: '', + id: '', + value: 'true', }, - ] satisfies Array }>), + ] satisfies Array }>), }, test: { findMany: vi.fn().mockResolvedValue([ { - id: "", - name: "First Test", + id: '', + name: 'First Test', createdAt: new Date(), - projectId: "", + projectId: '', updatedAt: new Date(), options: [ { @@ -55,57 +55,57 @@ vi.mock("server/db/client", () => ({ }, ] satisfies Array< Test & { - options: Array>; + options: Array> } >), }, }, -})); +})) -vi.mock("server/db/redis", () => ({ +vi.mock('server/db/redis', () => ({ redis: { get: vi.fn(async () => {}), incr: vi.fn(async () => {}), }, -})); +})) afterEach(() => { - vi.clearAllMocks(); -}); + vi.clearAllMocks() +}) -describe("Get Config", () => { - it("should return the correct config", async () => { - const app = makeLegacyProjectDataRoute(); +describe('Get Config', () => { + it('should return the correct config', async () => { + const app = makeLegacyProjectDataRoute() - const res = await testClient(app)[":projectId"].data.$get({ + const res = await testClient(app)[':projectId'].data.$get({ param: { - projectId: "test", + projectId: 'test', }, query: { - environment: "test", + environment: 'test', }, - }); - expect(res.status).toBe(200); - const data = await res.json(); + }) + expect(res.status).toBe(200) + const data = await res.json() // typeguard to make test fail if data is not AbbyDataResponse - if ("error" in data) { - throw new Error("Expected data to not have an error key"); + if ('error' in data) { + throw new Error('Expected data to not have an error key') } - expect((data as any).error).toBeUndefined(); + expect((data as any).error).toBeUndefined() - expect(data.tests).toHaveLength(1); - expect(data.tests?.[0]?.name).toBe("First Test"); - expect(data.tests?.[0]?.weights).toEqual([0.25, 0.25, 0.25, 0.25]); + expect(data.tests).toHaveLength(1) + expect(data.tests?.[0]?.name).toBe('First Test') + expect(data.tests?.[0]?.weights).toEqual([0.25, 0.25, 0.25, 0.25]) - expect(data.flags).toHaveLength(1); - expect(data.flags?.[0]?.name).toBe("First Flag"); - expect(data.flags?.[0]?.isEnabled).toBe(true); + expect(data.flags).toHaveLength(1) + expect(data.flags?.[0]?.name).toBe('First Flag') + expect(data.flags?.[0]?.isEnabled).toBe(true) - expect(vi.mocked(jobManager.emit)).toHaveBeenCalledTimes(1); + expect(vi.mocked(jobManager.emit)).toHaveBeenCalledTimes(1) expect(vi.mocked(jobManager.emit)).toHaveBeenCalledWith( - "after-data-request", + 'after-data-request', expect.objectContaining({}) - ); - }); -}); + ) + }) +}) diff --git a/apps/web/src/api/routes/legacy_project_data.ts b/apps/web/src/api/routes/legacy_project_data.ts index a66b3f04..78fe8128 100644 --- a/apps/web/src/api/routes/legacy_project_data.ts +++ b/apps/web/src/api/routes/legacy_project_data.ts @@ -1,44 +1,44 @@ -import { Context, Hono } from "hono"; -import { endTime, startTime, timing } from "hono/timing"; +import { Context, Hono } from 'hono' +import { endTime, startTime, timing } from 'hono/timing' -import { zValidator } from "@hono/zod-validator"; -import { cors } from "hono/cors"; -import { prisma } from "server/db/client"; +import { zValidator } from '@hono/zod-validator' +import { cors } from 'hono/cors' +import { prisma } from 'server/db/client' -import { LegacyAbbyDataResponse } from "@tryabby/core"; -import { transformFlagValue } from "lib/flags"; -import { trackPlanOverage } from "lib/logsnag"; -import createCache from "server/common/memory-cache"; -import { EventService } from "server/services/EventService"; -import { RequestCache } from "server/services/RequestCache"; -import { RequestService } from "server/services/RequestService"; -import { z } from "zod"; -import { jobManager } from "server/queue/Manager"; +import { LegacyAbbyDataResponse } from '@tryabby/core' +import { transformFlagValue } from 'lib/flags' +import { trackPlanOverage } from 'lib/logsnag' +import createCache from 'server/common/memory-cache' +import { EventService } from 'server/services/EventService' +import { RequestCache } from 'server/services/RequestCache' +import { RequestService } from 'server/services/RequestService' +import { z } from 'zod' +import { jobManager } from 'server/queue/Manager' const configCache = createCache({ - name: "legacyConfigCache", + name: 'legacyConfigCache', expireAfterMilliseconds: 1000 * 10, -}); +}) async function getAbbyResponseWithCache({ environment, projectId, c, }: { - environment: string; - projectId: string; - c: Context; + environment: string + projectId: string + c: Context }) { - startTime(c, "readCache"); - const cachedConfig = configCache.get(projectId + environment); - endTime(c, "readCache"); + startTime(c, 'readCache') + const cachedConfig = configCache.get(projectId + environment) + endTime(c, 'readCache') - c.header("X-Abby-Cache", cachedConfig !== undefined ? "HIT" : "MISS"); + c.header('X-Abby-Cache', cachedConfig !== undefined ? 'HIT' : 'MISS') if (cachedConfig) { - return cachedConfig; + return cachedConfig } - startTime(c, "db"); + startTime(c, 'db') const [tests, flags] = await Promise.all([ prisma.test.findMany({ where: { @@ -53,74 +53,73 @@ async function getAbbyResponseWithCache({ projectId, }, flag: { - type: "BOOLEAN", + type: 'BOOLEAN', }, }, include: { flag: { select: { name: true, type: true } } }, }), - ]); + ]) - endTime(c, "db"); + endTime(c, 'db') const response = { tests: tests.map((test) => ({ name: test.name, weights: test.options.map((o) => o.chance.toNumber()), })), flags: flags.map((flagValue) => { - const value = transformFlagValue(flagValue.value, flagValue.flag.type); + const value = transformFlagValue(flagValue.value, flagValue.flag.type) return { name: flagValue.flag.name, - isEnabled: - flagValue.flag.type === "BOOLEAN" ? value === true : value !== null, - }; + isEnabled: flagValue.flag.type === 'BOOLEAN' ? value === true : value !== null, + } }), - } satisfies LegacyAbbyDataResponse; + } satisfies LegacyAbbyDataResponse - configCache.set(projectId + environment, response); - return response; + configCache.set(projectId + environment, response) + return response } export function makeLegacyProjectDataRoute() { const app = new Hono().get( - "/:projectId/data", + '/:projectId/data', cors({ - origin: "*", + origin: '*', maxAge: 86400, }), zValidator( - "query", + 'query', z.object({ environment: z.string(), }) ), timing(), async (c) => { - const projectId = c.req.param("projectId"); - const { environment } = c.req.valid("query"); + const projectId = c.req.param('projectId') + const { environment } = c.req.valid('query') - const now = performance.now(); + const now = performance.now() try { - startTime(c, "getAbbyResponseWithCache"); + startTime(c, 'getAbbyResponseWithCache') const response = await getAbbyResponseWithCache({ projectId, environment, c, - }); - endTime(c, "getAbbyResponseWithCache"); + }) + endTime(c, 'getAbbyResponseWithCache') - jobManager.emit("after-data-request", { + jobManager.emit('after-data-request', { projectId, - apiVersion: "V0", + apiVersion: 'V0', functionDuration: performance.now() - now, - }); + }) - return c.json(response); + return c.json(response) } catch (e) { - console.error(e); - return c.json({ error: "Internal server error" }, { status: 500 }); + console.error(e) + return c.json({ error: 'Internal server error' }, { status: 500 }) } } - ); - return app; + ) + return app } diff --git a/apps/web/src/api/routes/v1_config.test.ts b/apps/web/src/api/routes/v1_config.test.ts index d6ae3777..80bf656b 100644 --- a/apps/web/src/api/routes/v1_config.test.ts +++ b/apps/web/src/api/routes/v1_config.test.ts @@ -1,33 +1,33 @@ -import { testClient } from "hono/testing"; -import { makeConfigRoute } from "./v1_config"; -import { handleGET, handlePUT } from "server/services/ConfigService"; -import { prisma } from "server/db/client"; +import { testClient } from 'hono/testing' +import { makeConfigRoute } from './v1_config' +import { handleGET, handlePUT } from 'server/services/ConfigService' +import { prisma } from 'server/db/client' -vi.mock("../../env/server.mjs", () => ({ +vi.mock('../../env/server.mjs', () => ({ env: { - HASHING_SECRET: "test", + HASHING_SECRET: 'test', }, -})); +})) const mockConfig = { - environments: ["test"], + environments: ['test'], flags: [], remoteConfig: {}, tests: {}, -} satisfies Awaited>; +} satisfies Awaited> -vi.mock("server/services/ConfigService", () => ({ +vi.mock('server/services/ConfigService', () => ({ handleGET: vi.fn(() => mockConfig), handlePUT: vi.fn(() => mockConfig), -})); +})) -vi.mock("server/db/redis", () => ({ +vi.mock('server/db/redis', () => ({ redis: { - incr: vi.fn(async () => "test"), + incr: vi.fn(async () => 'test'), }, -})); +})) -vi.mock("server/db/client", () => ({ +vi.mock('server/db/client', () => ({ prisma: { apiKey: { findUnique: vi.fn().mockResolvedValue({ @@ -36,187 +36,187 @@ vi.mock("server/db/client", () => ({ }), }, }, -})); +})) -describe("Retreive Config", () => { - it("should work with correct with an API provided", async () => { - const app = makeConfigRoute(); +describe('Retreive Config', () => { + it('should work with correct with an API provided', async () => { + const app = makeConfigRoute() - const res = await testClient(app)[":projectId"].$get( + const res = await testClient(app)[':projectId'].$get( { param: { - projectId: "test", + projectId: 'test', }, }, { headers: { - Authorization: "Bearer test", + Authorization: 'Bearer test', }, } - ); - const data = await res.json(); + ) + const data = await res.json() - expect(res.status).toEqual(200); - expect(data).toEqual(mockConfig); - }); + expect(res.status).toEqual(200) + expect(data).toEqual(mockConfig) + }) - it("should return an error if the API key is not provided", async () => { - const app = makeConfigRoute(); + it('should return an error if the API key is not provided', async () => { + const app = makeConfigRoute() - const res = await testClient(app)[":projectId"].$get({ + const res = await testClient(app)[':projectId'].$get({ param: { - projectId: "test", + projectId: 'test', }, - }); + }) - expect(res.status).toEqual(401); - }); + expect(res.status).toEqual(401) + }) - it("should return an error if the API key is not found", async () => { - const app = makeConfigRoute(); - vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(null); + it('should return an error if the API key is not found', async () => { + const app = makeConfigRoute() + vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(null) - const res = await testClient(app)[":projectId"].$get( + const res = await testClient(app)[':projectId'].$get( { param: { - projectId: "test", + projectId: 'test', }, }, { headers: { - Authorization: "Bearer test", + Authorization: 'Bearer test', }, } - ); + ) - expect(res.status).toEqual(401); - }); + expect(res.status).toEqual(401) + }) - it("should return an error if the API key is outdated", async () => { - const app = makeConfigRoute(); + it('should return an error if the API key is outdated', async () => { + const app = makeConfigRoute() vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({ validUntil: new Date(Date.now() - 1000), revokedAt: null, - } as any); + } as any) - const res = await testClient(app)[":projectId"].$get( + const res = await testClient(app)[':projectId'].$get( { param: { - projectId: "test", + projectId: 'test', }, }, { headers: { - Authorization: "Bearer test", + Authorization: 'Bearer test', }, } - ); + ) - expect(res.status).toEqual(401); - }); + expect(res.status).toEqual(401) + }) - it("should return an error if the API key is revoked", async () => { - const app = makeConfigRoute(); + it('should return an error if the API key is revoked', async () => { + const app = makeConfigRoute() vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({ validUntil: new Date(Date.now() - 1000), revokedAt: new Date(), - } as any); + } as any) - const res = await testClient(app)[":projectId"].$get( + const res = await testClient(app)[':projectId'].$get( { param: { - projectId: "test", + projectId: 'test', }, }, { headers: { - Authorization: "Bearer test", + Authorization: 'Bearer test', }, } - ); + ) - expect(res.status).toEqual(401); - }); -}); + expect(res.status).toEqual(401) + }) +}) -describe("Update Config", () => { - it("should work with correct with an API provided", async () => { - const app = makeConfigRoute(); +describe('Update Config', () => { + it('should work with correct with an API provided', async () => { + const app = makeConfigRoute() - const res = await testClient(app)[":projectId"].$put( + const res = await testClient(app)[':projectId'].$put( { param: { - projectId: "test", + projectId: 'test', }, json: { - environments: ["test"], + environments: ['test'], flags: [], remoteConfig: {}, tests: {}, - projectId: "test", - apiUrl: "test", + projectId: 'test', + apiUrl: 'test', }, }, { headers: { - Authorization: "Bearer test", + Authorization: 'Bearer test', }, } - ); + ) - expect(res.status).toEqual(200); - expect(vi.mocked(handlePUT)).toHaveBeenCalledTimes(1); - }); + expect(res.status).toEqual(200) + expect(vi.mocked(handlePUT)).toHaveBeenCalledTimes(1) + }) - it("should not work with invalid api keys", async () => { - const app = makeConfigRoute(); + it('should not work with invalid api keys', async () => { + const app = makeConfigRoute() const makeRequest = () => - testClient(app)[":projectId"].$put( + testClient(app)[':projectId'].$put( { param: { - projectId: "test", + projectId: 'test', }, json: { - environments: ["test"], + environments: ['test'], flags: [], remoteConfig: {}, tests: {}, - projectId: "test", - apiUrl: "test", + projectId: 'test', + apiUrl: 'test', }, }, { headers: { - Authorization: "Bearer test", + Authorization: 'Bearer test', }, } - ); + ) - vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(null); + vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(null) - let res = await makeRequest(); + let res = await makeRequest() - expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeGreaterThanOrEqual(400) vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({ validUntil: new Date(Date.now() - 1000), revokedAt: null, - } as any); + } as any) - res = await makeRequest(); + res = await makeRequest() - expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeGreaterThanOrEqual(400) vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({ validUntil: new Date(), revokedAt: new Date(), - } as any); + } as any) - res = await makeRequest(); + res = await makeRequest() - expect(res.status).toBeGreaterThanOrEqual(400); - }); -}); + expect(res.status).toBeGreaterThanOrEqual(400) + }) +}) diff --git a/apps/web/src/api/routes/v1_config.ts b/apps/web/src/api/routes/v1_config.ts index b8080da3..314b1e81 100644 --- a/apps/web/src/api/routes/v1_config.ts +++ b/apps/web/src/api/routes/v1_config.ts @@ -1,83 +1,78 @@ -import { Hono, MiddlewareHandler } from "hono"; -import { zValidator } from "@hono/zod-validator"; -import { prisma } from "server/db/client"; -import { hashString } from "utils/apiKey"; -import * as ConfigService from "server/services/ConfigService"; -import { ApiKey } from "@prisma/client"; -import { abbyConfigSchema } from "@tryabby/core"; +import { Hono, MiddlewareHandler } from 'hono' +import { zValidator } from '@hono/zod-validator' +import { prisma } from 'server/db/client' +import { hashString } from 'utils/apiKey' +import * as ConfigService from 'server/services/ConfigService' +import { ApiKey } from '@prisma/client' +import { abbyConfigSchema } from '@tryabby/core' const apiKeyMiddleware: MiddlewareHandler<{ Variables: { - apiKey: ApiKey; - }; + apiKey: ApiKey + } }> = async (c, next) => { - const apiKey = c.req.header("authorization")?.split(" ")[1]; + const apiKey = c.req.header('authorization')?.split(' ')[1] if (!apiKey) { - return c.json({ error: "API key not provided" }, { status: 401 }); + return c.json({ error: 'API key not provided' }, { status: 401 }) } - const hashedApiKey = hashString(apiKey); + const hashedApiKey = hashString(apiKey) const apiKeyEntry = await prisma.apiKey.findUnique({ where: { hashedKey: hashedApiKey, }, - }); + }) if (!apiKeyEntry || apiKeyEntry.revokedAt !== null) { return c.json( - { error: "API key revoked" }, + { error: 'API key revoked' }, { status: 401, } - ); + ) } if (apiKeyEntry.validUntil.getTime() < Date.now()) { return c.json( - { error: "API key expired" }, + { error: 'API key expired' }, { status: 401, } - ); + ) } - c.set("apiKey", apiKeyEntry); - await next(); -}; + c.set('apiKey', apiKeyEntry) + await next() +} export function makeConfigRoute() { return new Hono() - .get("/:projectId", apiKeyMiddleware, async (c) => { + .get('/:projectId', apiKeyMiddleware, async (c) => { try { const config = await ConfigService.handleGET({ - projectId: c.req.param("projectId"), - }); - return c.json(config); + projectId: c.req.param('projectId'), + }) + return c.json(config) } catch (error) { - console.error(error); - return c.json({ error: "Internal server error" }, { status: 500 }); + console.error(error) + return c.json({ error: 'Internal server error' }, { status: 500 }) } }) - .put( - "/:projectId", - apiKeyMiddleware, - zValidator("json", abbyConfigSchema), - async (c) => { - try { - const config = c.req.valid("json"); + .put('/:projectId', apiKeyMiddleware, zValidator('json', abbyConfigSchema), async (c) => { + try { + const config = c.req.valid('json') - await ConfigService.handlePUT({ - config: config, - projectId: c.req.param("projectId"), - userId: c.get("apiKey").userId, - }); + await ConfigService.handlePUT({ + config: config, + projectId: c.req.param('projectId'), + userId: c.get('apiKey').userId, + }) - return c.json({ message: "Config updated" }); - } catch (error) { - console.error(error); - return c.json({ error: "Could not Update Config" }, { status: 500 }); - } + return c.json({ message: 'Config updated' }) + } catch (error) { + console.error(error) + return c.json({ error: 'Could not Update Config' }, { status: 500 }) } - ); + }) } diff --git a/apps/web/src/api/routes/v1_event.test.ts b/apps/web/src/api/routes/v1_event.test.ts index 65b76318..fbe30708 100644 --- a/apps/web/src/api/routes/v1_event.test.ts +++ b/apps/web/src/api/routes/v1_event.test.ts @@ -1,16 +1,16 @@ -import { testClient } from "hono/testing"; -import { makeEventRoute } from "./v1_event"; -import { AbbyEventType } from "@tryabby/core"; -import { prisma } from "server/db/client"; -import { redis } from "server/db/redis"; +import { testClient } from 'hono/testing' +import { makeEventRoute } from './v1_event' +import { AbbyEventType } from '@tryabby/core' +import { prisma } from 'server/db/client' +import { redis } from 'server/db/redis' -vi.mock("server/common/plans", () => ({ +vi.mock('server/common/plans', () => ({ getLimitByPlan: vi.fn(() => {}), -})); +})) -vi.mock("../../env/client.mjs", () => ({})); +vi.mock('../../env/client.mjs', () => ({})) -vi.mock("server/db/client", () => ({ +vi.mock('server/db/client', () => ({ prisma: { event: { create: vi.fn(), @@ -19,67 +19,67 @@ vi.mock("server/db/client", () => ({ create: vi.fn(), }, }, -})); +})) -vi.mock("server/db/redis", () => ({ +vi.mock('server/db/redis', () => ({ redis: { - incr: vi.fn(async () => "test"), + incr: vi.fn(async () => 'test'), }, -})); +})) afterEach(() => { - vi.resetAllMocks(); -}); + vi.resetAllMocks() +}) -it("should work with correct PING events", async () => { - const app = makeEventRoute(); +it('should work with correct PING events', async () => { + const app = makeEventRoute() const res = await testClient(app).index.$post({ json: { - projectId: "test", - selectedVariant: "test", - testName: "test", + projectId: 'test', + selectedVariant: 'test', + testName: 'test', type: AbbyEventType.PING, }, - }); + }) - expect(prisma.event.create).toHaveBeenCalledTimes(1); - expect(prisma.apiRequest.create).toHaveBeenCalledTimes(1); - expect(redis.incr).toHaveBeenCalledTimes(1); -}); + expect(prisma.event.create).toHaveBeenCalledTimes(1) + expect(prisma.apiRequest.create).toHaveBeenCalledTimes(1) + expect(redis.incr).toHaveBeenCalledTimes(1) +}) -it("should work with correct ACT events", async () => { - const app = makeEventRoute(); +it('should work with correct ACT events', async () => { + const app = makeEventRoute() const res = await testClient(app).index.$post({ json: { - projectId: "test", - selectedVariant: "test", - testName: "test", + projectId: 'test', + selectedVariant: 'test', + testName: 'test', type: AbbyEventType.ACT, }, - }); + }) - expect(res.status).toEqual(200); - expect(prisma.event.create).toHaveBeenCalledTimes(1); - expect(prisma.apiRequest.create).toHaveBeenCalledTimes(1); - expect(redis.incr).toHaveBeenCalledTimes(1); -}); + expect(res.status).toEqual(200) + expect(prisma.event.create).toHaveBeenCalledTimes(1) + expect(prisma.apiRequest.create).toHaveBeenCalledTimes(1) + expect(redis.incr).toHaveBeenCalledTimes(1) +}) -it("should ignore unknown events", async () => { - const app = makeEventRoute(); +it('should ignore unknown events', async () => { + const app = makeEventRoute() const res = await testClient(app).index.$post({ json: { - projectId: "test", - selectedVariant: "test", - testName: "test", + projectId: 'test', + selectedVariant: 'test', + testName: 'test', type: 3 as any, }, - }); + }) - expect(res.status).toBeGreaterThanOrEqual(400); - expect(prisma.event.create).toHaveBeenCalledTimes(0); - expect(prisma.apiRequest.create).toHaveBeenCalledTimes(0); - expect(redis.incr).toHaveBeenCalledTimes(0); -}); + expect(res.status).toBeGreaterThanOrEqual(400) + expect(prisma.event.create).toHaveBeenCalledTimes(0) + expect(prisma.apiRequest.create).toHaveBeenCalledTimes(0) + expect(redis.incr).toHaveBeenCalledTimes(0) +}) diff --git a/apps/web/src/api/routes/v1_event.ts b/apps/web/src/api/routes/v1_event.ts index 8b343449..638ae8d5 100644 --- a/apps/web/src/api/routes/v1_event.ts +++ b/apps/web/src/api/routes/v1_event.ts @@ -1,85 +1,78 @@ -import { zValidator } from "@hono/zod-validator"; -import { abbyEventSchema, AbbyEventType } from "@tryabby/core"; -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { Hono } from "hono"; +import { zValidator } from '@hono/zod-validator' +import { abbyEventSchema, AbbyEventType } from '@tryabby/core' +import { Ratelimit } from '@upstash/ratelimit' +import { Redis } from '@upstash/redis' +import { Hono } from 'hono' -import isbot from "isbot"; -import { EventService } from "server/services/EventService"; -import { RequestCache } from "server/services/RequestCache"; -import { RequestService } from "server/services/RequestService"; +import isbot from 'isbot' +import { EventService } from 'server/services/EventService' +import { RequestCache } from 'server/services/RequestCache' +import { RequestService } from 'server/services/RequestService' export function makeEventRoute() { - const app = new Hono().post( - "/", - zValidator("json", abbyEventSchema), - async (c) => { - const now = performance.now(); - // filter out bot users - if (isbot(c.req.header("user-agent"))) { - return c.text("Forbidden", 403); - } + const app = new Hono().post('/', zValidator('json', abbyEventSchema), async (c) => { + const now = performance.now() + // filter out bot users + if (isbot(c.req.header('user-agent'))) { + return c.text('Forbidden', 403) + } - const event = c.req.valid("json"); + const event = c.req.valid('json') - try { - // // we only want to rate limit in production - if (process.env.NODE_ENV === "production") { - // Create a new ratelimiter, that allows 10 requests per 10 seconds - const ratelimit = new Ratelimit({ - redis: Redis.fromEnv(), - limiter: Ratelimit.slidingWindow(10, "10 s"), - }); + try { + // // we only want to rate limit in production + if (process.env.NODE_ENV === 'production') { + // Create a new ratelimiter, that allows 10 requests per 10 seconds + const ratelimit = new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(10, '10 s'), + }) - const clientIp = (c.req.header("x-forwarded-for") || "") - .split(",") - .pop() - ?.trim(); + const clientIp = (c.req.header('x-forwarded-for') || '').split(',').pop()?.trim() - if (!clientIp) { - console.error("Unable to get client IP"); + if (!clientIp) { + console.error('Unable to get client IP') - return c.text("Internal Server Error", 500); - } + return c.text('Internal Server Error', 500) + } - const { success } = await ratelimit.limit(clientIp); + const { success } = await ratelimit.limit(clientIp) - if (!success) { - return c.text("Too Many Requests", 429); - } + if (!success) { + return c.text('Too Many Requests', 429) } + } - const duration = performance.now() - now; + const duration = performance.now() - now - // TODO: add those to a queue and process them in a background job as they are not critical - switch (event.type) { - case AbbyEventType.PING: - case AbbyEventType.ACT: { - await EventService.createEvent(event); - break; - } - default: { - event.type satisfies never; - } + // TODO: add those to a queue and process them in a background job as they are not critical + switch (event.type) { + case AbbyEventType.PING: + case AbbyEventType.ACT: { + await EventService.createEvent(event) + break } + default: { + event.type satisfies never + } + } - await RequestCache.increment(event.projectId); - await RequestService.storeRequest({ - projectId: event.projectId, - type: "TRACK_VIEW", - durationInMs: duration, - apiVersion: "V1", - }).catch((e) => { - console.error("Unable to store request", e); - }); + await RequestCache.increment(event.projectId) + await RequestService.storeRequest({ + projectId: event.projectId, + type: 'TRACK_VIEW', + durationInMs: duration, + apiVersion: 'V1', + }).catch((e) => { + console.error('Unable to store request', e) + }) - return c.text("OK", 200); - } catch (err) { - console.error(err); - return c.text("Internal Server Error", 500); - } + return c.text('OK', 200) + } catch (err) { + console.error(err) + return c.text('Internal Server Error', 500) } - ); + }) - return app; + return app } diff --git a/apps/web/src/api/routes/v1_project_data.test.ts b/apps/web/src/api/routes/v1_project_data.test.ts index 158cbeb0..3bdcd5e4 100644 --- a/apps/web/src/api/routes/v1_project_data.test.ts +++ b/apps/web/src/api/routes/v1_project_data.test.ts @@ -1,52 +1,52 @@ -import { testClient } from "hono/testing"; -import { makeProjectDataRoute } from "./v1_project_data"; -import { jobManager } from "server/queue/Manager"; -import { FeatureFlag, FeatureFlagValue, Option, Test } from "@prisma/client"; -import { Decimal } from "@prisma/client/runtime/library"; +import { testClient } from 'hono/testing' +import { makeProjectDataRoute } from './v1_project_data' +import { jobManager } from 'server/queue/Manager' +import { FeatureFlag, FeatureFlagValue, Option, Test } from '@prisma/client' +import { Decimal } from '@prisma/client/runtime/library' -vi.mock("../../env/server.mjs", () => ({ +vi.mock('../../env/server.mjs', () => ({ env: {}, -})); +})) -vi.mock("server/queue/Manager", () => ({ +vi.mock('server/queue/Manager', () => ({ jobManager: { emit: vi.fn().mockResolvedValue(null), }, -})); +})) -vi.mock("server/db/client", () => ({ +vi.mock('server/db/client', () => ({ prisma: { featureFlagValue: { findMany: vi.fn().mockResolvedValue([ { - environmentId: "", + environmentId: '', flag: { - name: "First Flag", - type: "BOOLEAN", + name: 'First Flag', + type: 'BOOLEAN', }, - flagId: "", - id: "", - value: "true", + flagId: '', + id: '', + value: 'true', }, { - environmentId: "", + environmentId: '', flag: { - name: "First Config", - type: "NUMBER", + name: 'First Config', + type: 'NUMBER', }, - flagId: "", - id: "", - value: "2", + flagId: '', + id: '', + value: '2', }, - ] satisfies Array }>), + ] satisfies Array }>), }, test: { findMany: vi.fn().mockResolvedValue([ { - id: "", - name: "First Test", + id: '', + name: 'First Test', createdAt: new Date(), - projectId: "", + projectId: '', updatedAt: new Date(), options: [ { @@ -65,88 +65,88 @@ vi.mock("server/db/client", () => ({ }, ] satisfies Array< Test & { - options: Array>; + options: Array> } >), }, }, -})); +})) -vi.mock("server/db/redis", () => ({ +vi.mock('server/db/redis', () => ({ redis: { get: vi.fn(async () => {}), incr: vi.fn(async () => {}), }, -})); +})) afterEach(() => { - vi.clearAllMocks(); -}); + vi.clearAllMocks() +}) -describe("Get Config", () => { - it("should return the correct config", async () => { - const app = makeProjectDataRoute(); +describe('Get Config', () => { + it('should return the correct config', async () => { + const app = makeProjectDataRoute() - const res = await testClient(app)[":projectId"].$get({ + const res = await testClient(app)[':projectId'].$get({ param: { - projectId: "test", + projectId: 'test', }, query: { - environment: "test", + environment: 'test', }, - }); - expect(res.status).toBe(200); - const data = await res.json(); + }) + expect(res.status).toBe(200) + const data = await res.json() // typeguard to make test fail if data is not AbbyDataResponse - if ("error" in data) { - throw new Error("Expected data to not have an error key"); + if ('error' in data) { + throw new Error('Expected data to not have an error key') } - expect((data as any).error).toBeUndefined(); + expect((data as any).error).toBeUndefined() - expect(data.tests).toHaveLength(1); - expect(data.tests?.[0]?.name).toBe("First Test"); - expect(data.tests?.[0]?.weights).toEqual([0.25, 0.25, 0.25, 0.25]); + expect(data.tests).toHaveLength(1) + expect(data.tests?.[0]?.name).toBe('First Test') + expect(data.tests?.[0]?.weights).toEqual([0.25, 0.25, 0.25, 0.25]) - expect(data.flags).toHaveLength(1); - expect(data.flags?.[0]?.name).toBe("First Flag"); - expect(data.flags?.[0]?.value).toBe(true); + expect(data.flags).toHaveLength(1) + expect(data.flags?.[0]?.name).toBe('First Flag') + expect(data.flags?.[0]?.value).toBe(true) - expect(data.remoteConfig).toHaveLength(1); - expect(data.remoteConfig?.[0]?.name).toBe("First Config"); - expect(data.remoteConfig?.[0]?.value).toBe(2); + expect(data.remoteConfig).toHaveLength(1) + expect(data.remoteConfig?.[0]?.name).toBe('First Config') + expect(data.remoteConfig?.[0]?.value).toBe(2) - expect(vi.mocked(jobManager.emit)).toHaveBeenCalledTimes(1); + expect(vi.mocked(jobManager.emit)).toHaveBeenCalledTimes(1) expect(vi.mocked(jobManager.emit)).toHaveBeenCalledWith( - "after-data-request", + 'after-data-request', expect.objectContaining({}) - ); - }); -}); + ) + }) +}) -describe("Get Config Script", () => { - it("should return the correct config script", async () => { - const app = makeProjectDataRoute(); +describe('Get Config Script', () => { + it('should return the correct config script', async () => { + const app = makeProjectDataRoute() - const res = await testClient(app)[":projectId"]["script.js"].$get({ + const res = await testClient(app)[':projectId']['script.js'].$get({ param: { - projectId: "test", + projectId: 'test', }, query: { - environment: "test", + environment: 'test', }, - }); - expect(res.status).toBe(200); - const data = await res.text(); + }) + expect(res.status).toBe(200) + const data = await res.text() expect(data).toMatchInlineSnapshot( '"window.__abby_data__ = {\\"tests\\":[{\\"name\\":\\"First Test\\",\\"weights\\":[0.25,0.25,0.25,0.25]}],\\"flags\\":[{\\"name\\":\\"First Flag\\",\\"value\\":true}],\\"remoteConfig\\":[{\\"name\\":\\"First Config\\",\\"value\\":2}]}"' - ); + ) - expect(vi.mocked(jobManager.emit)).toHaveBeenCalledTimes(1); + expect(vi.mocked(jobManager.emit)).toHaveBeenCalledTimes(1) expect(vi.mocked(jobManager.emit)).toHaveBeenCalledWith( - "after-data-request", + 'after-data-request', expect.objectContaining({}) - ); - }); -}); + ) + }) +}) diff --git a/apps/web/src/api/routes/v1_project_data.ts b/apps/web/src/api/routes/v1_project_data.ts index 2733ca7c..95474f93 100644 --- a/apps/web/src/api/routes/v1_project_data.ts +++ b/apps/web/src/api/routes/v1_project_data.ts @@ -1,40 +1,40 @@ -import { Context, Hono } from "hono"; -import { timing, startTime, endTime } from "hono/timing"; -import { cors } from "hono/cors"; -import { zValidator } from "@hono/zod-validator"; -import { prisma } from "server/db/client"; -import { ABBY_WINDOW_KEY, AbbyDataResponse } from "@tryabby/core"; -import { z } from "zod"; -import createCache from "server/common/memory-cache"; -import { transformFlagValue } from "lib/flags"; -import { jobManager } from "server/queue/Manager"; - -export const X_ABBY_CACHE_HEADER = "X-Abby-Cache"; +import { Context, Hono } from 'hono' +import { timing, startTime, endTime } from 'hono/timing' +import { cors } from 'hono/cors' +import { zValidator } from '@hono/zod-validator' +import { prisma } from 'server/db/client' +import { ABBY_WINDOW_KEY, AbbyDataResponse } from '@tryabby/core' +import { z } from 'zod' +import createCache from 'server/common/memory-cache' +import { transformFlagValue } from 'lib/flags' +import { jobManager } from 'server/queue/Manager' + +export const X_ABBY_CACHE_HEADER = 'X-Abby-Cache' const configCache = createCache({ - name: "configCache", + name: 'configCache', expireAfterMilliseconds: 1000 * 10, -}); +}) async function getAbbyResponseWithCache({ environment, projectId, c, }: { - environment: string; - projectId: string; - c: Context; + environment: string + projectId: string + c: Context }) { - startTime(c, "readCache"); - const cachedConfig = configCache.get(projectId + environment); - endTime(c, "readCache"); + startTime(c, 'readCache') + const cachedConfig = configCache.get(projectId + environment) + endTime(c, 'readCache') - c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? "HIT" : "MISS"); + c.header(X_ABBY_CACHE_HEADER, cachedConfig !== undefined ? 'HIT' : 'MISS') if (cachedConfig) { - return cachedConfig; + return cachedConfig } - startTime(c, "db"); + startTime(c, 'db') const [tests, flags] = await Promise.all([ prisma.test.findMany({ where: { @@ -51,8 +51,8 @@ async function getAbbyResponseWithCache({ }, include: { flag: { select: { name: true, type: true } } }, }), - ]); - endTime(c, "db"); + ]) + endTime(c, 'db') const response = { tests: tests.map((test) => ({ @@ -60,122 +60,120 @@ async function getAbbyResponseWithCache({ weights: test.options.map((o) => o.chance.toNumber()), })), flags: flags - .filter(({ flag }) => flag.type === "BOOLEAN") + .filter(({ flag }) => flag.type === 'BOOLEAN') .map((flagValue) => { return { name: flagValue.flag.name, value: transformFlagValue(flagValue.value, flagValue.flag.type), - }; + } }), remoteConfig: flags - .filter(({ flag }) => flag.type !== "BOOLEAN") + .filter(({ flag }) => flag.type !== 'BOOLEAN') .map((flagValue) => { return { name: flagValue.flag.name, value: transformFlagValue(flagValue.value, flagValue.flag.type), - }; + } }), - } satisfies AbbyDataResponse; + } satisfies AbbyDataResponse - configCache.set(projectId + environment, response); - return response; + configCache.set(projectId + environment, response) + return response } export function makeProjectDataRoute() { const app = new Hono() .get( - "/:projectId", + '/:projectId', cors({ - origin: "*", + origin: '*', maxAge: 86400, }), zValidator( - "query", + 'query', z.object({ environment: z.string(), }) ), timing(), async (c) => { - const projectId = c.req.param("projectId"); - const { environment } = c.req.valid("query"); + const projectId = c.req.param('projectId') + const { environment } = c.req.valid('query') - const now = performance.now(); + const now = performance.now() try { - startTime(c, "getAbbyResponseWithCache"); + startTime(c, 'getAbbyResponseWithCache') const response = await getAbbyResponseWithCache({ projectId, environment, c, - }); - endTime(c, "getAbbyResponseWithCache"); + }) + endTime(c, 'getAbbyResponseWithCache') - const duration = performance.now() - now; + const duration = performance.now() - now - jobManager.emit("after-data-request", { - apiVersion: "V1", + jobManager.emit('after-data-request', { + apiVersion: 'V1', functionDuration: duration, projectId, - }); + }) - return c.json(response); + return c.json(response) } catch (e) { - console.error(e); - return c.json({ error: "Internal server error" }, { status: 500 }); + console.error(e) + return c.json({ error: 'Internal server error' }, { status: 500 }) } } ) .get( - "/:projectId/script.js", + '/:projectId/script.js', cors({ - origin: "*", + origin: '*', maxAge: 86400, }), zValidator( - "query", + 'query', z.object({ environment: z.string(), }) ), timing(), async (c) => { - const projectId = c.req.param("projectId"); - const { environment } = c.req.valid("query"); + const projectId = c.req.param('projectId') + const { environment } = c.req.valid('query') - const now = performance.now(); + const now = performance.now() try { - startTime(c, "getAbbyResponseWithCache"); + startTime(c, 'getAbbyResponseWithCache') const response = await getAbbyResponseWithCache({ projectId, environment, c, - }); - endTime(c, "getAbbyResponseWithCache"); + }) + endTime(c, 'getAbbyResponseWithCache') - const jsContent = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify( - response - )}`; + const jsContent = `window.${ABBY_WINDOW_KEY} = ${JSON.stringify(response)}` - const duration = performance.now() - now; + const duration = performance.now() - now - jobManager.emit("after-data-request", { - apiVersion: "V1", + jobManager.emit('after-data-request', { + apiVersion: 'V1', functionDuration: duration, projectId, - }); + }) return c.text(jsContent, { headers: { - "Content-Type": "application/javascript", + 'Content-Type': 'application/javascript', }, - }); + }) } catch (e) { - console.error(e); - return c.json({ error: "Internal server error" }, { status: 500 }); + console.error(e) + return c.json({ error: 'Internal server error' }, { status: 500 }) } } - ); - return app; + ) + return app } diff --git a/apps/web/src/components/AddABTestModal.tsx b/apps/web/src/components/AddABTestModal.tsx index 488dcf04..ecdae09d 100644 --- a/apps/web/src/components/AddABTestModal.tsx +++ b/apps/web/src/components/AddABTestModal.tsx @@ -1,18 +1,15 @@ -import { TRPCClientError } from "@trpc/client"; -import { TRPC_ERROR_CODES_BY_KEY } from "@trpc/server/rpc"; - -import { useState } from "react"; -import { toast } from "react-hot-toast"; -import { PlausibleEvents } from "types/plausible-events"; -import { trpc } from "utils/trpc"; -import { Modal } from "./Modal"; -import { - CreateTestSection, - DEFAULT_NEW_VARIANT_PREFIX, -} from "./Test/CreateTestSection"; -import { useTracking } from "lib/tracking"; - -type UIVariant = { name: string; weight: number }; +import { TRPCClientError } from '@trpc/client' +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc' + +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import { PlausibleEvents } from 'types/plausible-events' +import { trpc } from 'utils/trpc' +import { Modal } from './Modal' +import { CreateTestSection, DEFAULT_NEW_VARIANT_PREFIX } from './Test/CreateTestSection' +import { useTracking } from 'lib/tracking' + +type UIVariant = { name: string; weight: number } const INITIAL_VARIANTS: Array = [ { @@ -22,44 +19,43 @@ const INITIAL_VARIANTS: Array = [ name: `${DEFAULT_NEW_VARIANT_PREFIX}2`, }, // give each variant a weight of 100 / number of variants -].map((v, _, array) => ({ ...v, weight: 100 / array.length })); +].map((v, _, array) => ({ ...v, weight: 100 / array.length })) -const INITIAL_TEST_NAME = "New Test"; +const INITIAL_TEST_NAME = 'New Test' type Props = { - onClose: () => void; - isOpen: boolean; - projectId: string; -}; + onClose: () => void + isOpen: boolean + projectId: string +} export const AddABTestModal = ({ onClose, isOpen, projectId }: Props) => { - const [testName, setTestName] = useState(INITIAL_TEST_NAME); + const [testName, setTestName] = useState(INITIAL_TEST_NAME) const [variants, setVariants] = - useState>(INITIAL_VARIANTS); + useState>(INITIAL_VARIANTS) const variantsIncludeDuplicates = - new Set(variants.map((variant) => variant.name)).size !== variants.length; + new Set(variants.map((variant) => variant.name)).size !== variants.length const variantsWeightSum = variants .map(({ weight }) => weight) - .reduce((sum, weight) => (sum += weight), 0); + .reduce((sum, weight) => (sum += weight), 0) - const isConfirmButtonDisabled = - variantsIncludeDuplicates || variantsWeightSum !== 100; + const isConfirmButtonDisabled = variantsIncludeDuplicates || variantsWeightSum !== 100 - const createTestMutation = trpc.tests.createTest.useMutation(); + const createTestMutation = trpc.tests.createTest.useMutation() - const trpcContext = trpc.useContext(); + const trpcContext = trpc.useContext() - const trackEvent = useTracking(); + const trackEvent = useTracking() const onCreateClick = async () => { try { - if (!variants.length || !variants[0]) throw new Error(); + if (!variants.length || !variants[0]) throw new Error() if (variants.reduce((acc, curr) => acc + curr.weight, 0) !== 100) { - toast.error("Weights must add up to 100"); - return; + toast.error('Weights must add up to 100') + return } await createTestMutation.mutateAsync({ @@ -69,38 +65,37 @@ export const AddABTestModal = ({ onClose, isOpen, projectId }: Props) => { weight: v.weight / 100, })), projectId: projectId, - }); + }) trpcContext.project.getProjectData.invalidate({ projectId: projectId, - }); + }) - setTestName(INITIAL_TEST_NAME); - setVariants(INITIAL_VARIANTS); + setTestName(INITIAL_TEST_NAME) + setVariants(INITIAL_VARIANTS) - onClose(); - trackEvent("AB-Test Created", { - props: { "Amount Of Variants": variants.length }, - }); - toast.success("Test created"); + onClose() + trackEvent('AB-Test Created', { + props: { 'Amount Of Variants': variants.length }, + }) + toast.success('Test created') } catch (e) { toast.error( - e instanceof TRPCClientError && - e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN + e instanceof TRPCClientError && e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN ? e.message - : "Could not create test" - ); + : 'Could not create test' + ) } - }; + } return ( @@ -111,5 +106,5 @@ export const AddABTestModal = ({ onClose, isOpen, projectId }: Props) => { variants={variants} /> - ); -}; + ) +} diff --git a/apps/web/src/components/AddFeatureFlagModal.tsx b/apps/web/src/components/AddFeatureFlagModal.tsx index 7c1dd212..a6dbcd33 100644 --- a/apps/web/src/components/AddFeatureFlagModal.tsx +++ b/apps/web/src/components/AddFeatureFlagModal.tsx @@ -1,33 +1,33 @@ -import { FeatureFlagType } from "@prisma/client"; -import { TRPCClientError } from "@trpc/client"; -import { TRPC_ERROR_CODES_BY_KEY } from "@trpc/server/rpc"; -import { getFlagTypeClassName, transformDBFlagTypeToclient } from "lib/flags"; -import { cn } from "lib/utils"; -import { useRef, useState } from "react"; -import { toast } from "react-hot-toast"; -import { trpc } from "utils/trpc"; -import { FlagIcon } from "./FlagIcon"; -import { JSONEditor } from "./JSONEditor"; -import { Modal } from "./Modal"; -import { RadioSelect } from "./RadioSelect"; - -import { Toggle } from "./Toggle"; - -import { useTracking } from "lib/tracking"; -import { Input } from "./ui/input"; +import { FeatureFlagType } from '@prisma/client' +import { TRPCClientError } from '@trpc/client' +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc' +import { getFlagTypeClassName, transformDBFlagTypeToclient } from 'lib/flags' +import { cn } from 'lib/utils' +import { useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import { trpc } from 'utils/trpc' +import { FlagIcon } from './FlagIcon' +import { JSONEditor } from './JSONEditor' +import { Modal } from './Modal' +import { RadioSelect } from './RadioSelect' + +import { Toggle } from './Toggle' + +import { useTracking } from 'lib/tracking' +import { Input } from './ui/input' type Props = { - onClose: () => void; - isOpen: boolean; - projectId: string; - isRemoteConfig?: boolean; -}; + onClose: () => void + isOpen: boolean + projectId: string + isRemoteConfig?: boolean +} export type FlagFormValues = { - name: string; - value: string; - type: FeatureFlagType; -}; + name: string + value: string + type: FeatureFlagType +} export function ChangeFlagForm({ initialValues, @@ -36,175 +36,152 @@ export function ChangeFlagForm({ canChangeType = true, isRemoteConfig, }: { - initialValues: FlagFormValues; - onChange: (values: FlagFormValues) => void; - errors: Partial; - canChangeType?: boolean; - isRemoteConfig?: boolean; + initialValues: FlagFormValues + onChange: (values: FlagFormValues) => void + errors: Partial + canChangeType?: boolean + isRemoteConfig?: boolean }) { - const inputRef = useRef(null); + const inputRef = useRef(null) - const [state, setState] = useState(initialValues); + const [state, setState] = useState(initialValues) const valueRef = useRef>({ - [FeatureFlagType.BOOLEAN]: "false", - [FeatureFlagType.STRING]: "", - [FeatureFlagType.NUMBER]: "", - [FeatureFlagType.JSON]: "", - }); + [FeatureFlagType.BOOLEAN]: 'false', + [FeatureFlagType.STRING]: '', + [FeatureFlagType.NUMBER]: '', + [FeatureFlagType.JSON]: '', + }) const onChange = (values: Partial) => { - const newState = { ...state, ...values }; + const newState = { ...state, ...values } // if type changed, save the value - if (values.type != null && values.type !== state.type) { - valueRef.current[state.type] = state.value; - newState.value = valueRef.current[newState.type] ?? ""; + if (values.type !== null && values.type !== state.type) { + valueRef.current[state.type] = state.value + newState.value = valueRef.current[newState.type] ?? '' } - setState(newState); - onChangeHandler(newState); - }; + setState(newState) + onChangeHandler(newState) + } return ( -
+
- + onChange({ name: e.target.value })} - placeholder={isRemoteConfig ? "My Remote Config" : "My Feature Flag"} + placeholder={isRemoteConfig ? 'My Remote Config' : 'My Feature Flag'} /> - {errors.name && ( -

{errors.name}

- )} + {errors.name &&

{errors.name}

}
{isRemoteConfig && (
- + isRemoteConfig && flagType !== "BOOLEAN" - ) + .filter(([, flagType]) => isRemoteConfig && flagType !== 'BOOLEAN') .map(([key, flagType]) => ({ label: ( -
- +
+ {transformDBFlagTypeToclient(flagType)}
), value: flagType, }))} onChange={(value) => { - if (!canChangeType) return; + if (!canChangeType) return onChange({ type: value, - value: value === "BOOLEAN" ? "false" : "", - }); + value: value === 'BOOLEAN' ? 'false' : '', + }) }} initialValue={initialValues.type} />
)}
- - {state.type === "BOOLEAN" && ( + + {state.type === 'BOOLEAN' && ( onChange({ value: String(newState) })} - label={state.value === "true" ? "Enabled" : "Disabled"} + label={state.value === 'true' ? 'Enabled' : 'Disabled'} /> )} - {state.type === "STRING" && ( + {state.type === 'STRING' && ( onChange({ value: e.target.value })} - placeholder={ - isRemoteConfig ? "My Remote Config" : "My Feature Flag" - } + placeholder={isRemoteConfig ? 'My Remote Config' : 'My Feature Flag'} /> )} - {state.type === "NUMBER" && ( + {state.type === 'NUMBER' && ( onChange({ value: e.target.value })} onKeyDown={(e) => { // prevent e, E, +, - - ["e", "E", "+", "-"].includes(e.key) && e.preventDefault(); + ;['e', 'E', '+', '-'].includes(e.key) && e.preventDefault() }} - placeholder="123" - /> - )} - {state.type === "JSON" && ( - onChange({ value: e })} + placeholder='123' /> )} - {errors.value && ( -

{errors.value}

+ {state.type === 'JSON' && ( + onChange({ value: e })} /> )} + {errors.value &&

{errors.value}

}
- ); + ) } -export const AddFeatureFlagModal = ({ - onClose, - isOpen, - projectId, - isRemoteConfig, -}: Props) => { - const inputRef = useRef(null); - const ctx = trpc.useContext(); - const stateRef = useRef(); - const trackEvent = useTracking(); +export const AddFeatureFlagModal = ({ onClose, isOpen, projectId, isRemoteConfig }: Props) => { + const inputRef = useRef(null) + const ctx = trpc.useContext() + const stateRef = useRef() + const trackEvent = useTracking() - const [errors, setErrors] = useState>({}); + const [errors, setErrors] = useState>({}) const { mutateAsync } = trpc.flags.addFlag.useMutation({ onSuccess() { - ctx.flags.getFlags.invalidate({ projectId }); + ctx.flags.getFlags.invalidate({ projectId }) }, - }); + }) return ( { - const errors: Partial = {}; - if (!stateRef.current) return; + const errors: Partial = {} + if (!stateRef.current) return - const trimmedName = stateRef.current.name.trim(); + const trimmedName = stateRef.current.name.trim() if (!trimmedName) { - errors.name = "Name is required"; + errors.name = 'Name is required' } if (!stateRef.current?.value) { - errors.value = "Value is required"; + errors.value = 'Value is required' } if (Object.keys(errors).length > 0) { - setErrors(errors); - return; + setErrors(errors) + return } try { @@ -212,33 +189,32 @@ export const AddFeatureFlagModal = ({ ...stateRef.current, name: trimmedName, projectId, - }); - toast.success("Flag created"); - trackEvent("Feature Flag Created", { - props: { "Feature Flag Type": stateRef.current.type }, - }); - onClose(); + }) + toast.success('Flag created') + trackEvent('Feature Flag Created', { + props: { 'Feature Flag Type': stateRef.current.type }, + }) + onClose() } catch (e) { toast.error( - e instanceof TRPCClientError && - e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN + e instanceof TRPCClientError && e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN ? e.message - : "Error creating flag" - ); + : 'Error creating flag' + ) } }} > (stateRef.current = newState)} canChangeType isRemoteConfig={isRemoteConfig} /> - ); -}; + ) +} diff --git a/apps/web/src/components/AsyncCodeExample.tsx b/apps/web/src/components/AsyncCodeExample.tsx index a1a3b06d..23fbf224 100644 --- a/apps/web/src/components/AsyncCodeExample.tsx +++ b/apps/web/src/components/AsyncCodeExample.tsx @@ -1,18 +1,18 @@ -import { trpc } from "utils/trpc"; -import { BaseCodeSnippet } from "./CodeSnippet"; -import { LoadingSpinner } from "./LoadingSpinner"; +import { trpc } from 'utils/trpc' +import { BaseCodeSnippet } from './CodeSnippet' +import { LoadingSpinner } from './LoadingSpinner' export function AsyncCodeExample() { - const { data, isLoading } = trpc.example.exampleSnippet.useQuery(); + const { data, isLoading } = trpc.example.exampleSnippet.useQuery() if (isLoading) { return ( -
- +
+
- ); + ) } - if (!data) return null; + if (!data) return null - return ; + return } diff --git a/apps/web/src/components/Avatar.tsx b/apps/web/src/components/Avatar.tsx index 2f5c6766..d2b50353 100644 --- a/apps/web/src/components/Avatar.tsx +++ b/apps/web/src/components/Avatar.tsx @@ -1,42 +1,42 @@ -import * as RadixAvatar from "@radix-ui/react-avatar"; -import { ComponentProps } from "react"; -import { twMerge } from "tailwind-merge"; +import * as RadixAvatar from '@radix-ui/react-avatar' +import { ComponentProps } from 'react' +import { twMerge } from 'tailwind-merge' function getNameFromEmail(email: string) { - const [name] = email.split("@"); - if (name?.includes(".")) { - const [firstName, lastName] = name.split("."); - return `${firstName} ${lastName}`; + const [name] = email.split('@') + if (name?.includes('.')) { + const [firstName, lastName] = name.split('.') + return `${firstName} ${lastName}` } - return name; + return name } type Props = { - imageUrl?: string; - userName?: string; -} & ComponentProps<(typeof RadixAvatar)["Root"]>; + imageUrl?: string + userName?: string +} & ComponentProps<(typeof RadixAvatar)['Root']> export const Avatar = ({ imageUrl, userName, className, ...props }: Props) => ( - {(userName?.includes("@") ? getNameFromEmail(userName) : userName) - ?.split(" ") + {(userName?.includes('@') ? getNameFromEmail(userName) : userName) + ?.split(' ') .map((name) => name[0])} -); +) diff --git a/apps/web/src/components/BlogLayout.tsx b/apps/web/src/components/BlogLayout.tsx index bb161d21..1a6049fc 100644 --- a/apps/web/src/components/BlogLayout.tsx +++ b/apps/web/src/components/BlogLayout.tsx @@ -1,14 +1,14 @@ -import type { PostMeta } from "pages/tips-and-insights"; -import { MarketingLayout, MarketingLayoutProps } from "./MarketingLayout"; -import dayjs from "dayjs"; -import Image from "next/image"; -import { SignupButton } from "./SignupButton"; -import { Divider } from "./Divider"; -import { NextSeo } from "next-seo"; +import type { PostMeta } from 'pages/tips-and-insights' +import { MarketingLayout, MarketingLayoutProps } from './MarketingLayout' +import dayjs from 'dayjs' +import Image from 'next/image' +import { SignupButton } from './SignupButton' +import { Divider } from './Divider' +import { NextSeo } from 'next-seo' -type Props = Pick & { - meta: PostMeta; -}; +type Props = Pick & { + meta: PostMeta +} export function BlogLayout({ children, seoTitle, meta }: Props) { return ( @@ -20,44 +20,41 @@ export function BlogLayout({ children, seoTitle, meta }: Props) { url: `${ process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` - : "https://www.tryabby.com" + : 'https://www.tryabby.com' }${meta.imageUrl}`, }, ], }} /> - -
-

- Published on {dayjs(meta.publishedAt).format("MMMM DD, YYYY")} + +

+

+ Published on {dayjs(meta.publishedAt).format('MMMM DD, YYYY')}

-

{meta.title}

-
+

{meta.title}

+
{meta.title}
-
+
{children}
- -
- Abby is an Open Source SaaS for developers to streamline A/B testing - and feature flagging. + +
+ Abby is an Open Source SaaS for developers to streamline A/B testing and feature + flagging.
Make data-driven decisions and improve user experience with ease.
Made for developers, by developers. - +
- ); + ) } diff --git a/apps/web/src/components/Button.tsx b/apps/web/src/components/Button.tsx index f9ad580b..88ab2f38 100644 --- a/apps/web/src/components/Button.tsx +++ b/apps/web/src/components/Button.tsx @@ -1,31 +1,27 @@ -import React, { - type PropsWithChildren, - type ComponentPropsWithoutRef, -} from "react"; -import { twMerge } from "tailwind-merge"; +import React, { type PropsWithChildren, type ComponentPropsWithoutRef } from 'react' +import { twMerge } from 'tailwind-merge' interface ButtonProps { - as?: T; - children?: React.ReactNode; + as?: T + children?: React.ReactNode } -export function Button({ +export function Button({ children, className, as, ...props -}: ButtonProps & - Omit, keyof ButtonProps>) { - const Component = as || "button"; +}: ButtonProps & Omit, keyof ButtonProps>) { + const Component = as || 'button' return ( {children} - ); + ) } diff --git a/apps/web/src/components/CodeSnippet.tsx b/apps/web/src/components/CodeSnippet.tsx index 2a81b82c..3efee79d 100644 --- a/apps/web/src/components/CodeSnippet.tsx +++ b/apps/web/src/components/CodeSnippet.tsx @@ -1,77 +1,77 @@ -import clsx from "clsx"; -import { useState } from "react"; -import toast from "react-hot-toast"; -import { FaAngular, FaCopy, FaReact } from "react-icons/fa"; -import { TbBrandAngular, TbBrandSvelte } from "react-icons/tb"; -import { TbBrandNextjs } from "react-icons/tb"; -import type { CodeSnippetData, Integrations } from "utils/snippets"; -import { trpc } from "utils/trpc"; -import { LoadingSpinner } from "./LoadingSpinner"; -import { twMerge } from "tailwind-merge"; +import clsx from 'clsx' +import { useState } from 'react' +import toast from 'react-hot-toast' +import { FaAngular, FaCopy, FaReact } from 'react-icons/fa' +import { TbBrandAngular, TbBrandSvelte } from 'react-icons/tb' +import { TbBrandNextjs } from 'react-icons/tb' +import type { CodeSnippetData, Integrations } from 'utils/snippets' +import { trpc } from 'utils/trpc' +import { LoadingSpinner } from './LoadingSpinner' +import { twMerge } from 'tailwind-merge' const INTEGRATIONS: Record< Integrations, { - name: string; - icon: React.ComponentType<{ className?: string }>; + name: string + icon: React.ComponentType<{ className?: string }> } > = { nextjs: { - name: "Next.js", + name: 'Next.js', icon: TbBrandNextjs, }, react: { - name: "React", + name: 'React', icon: FaReact, }, svelte: { - name: "Svelte", + name: 'Svelte', icon: TbBrandSvelte, }, angular: { - name: "Angular", + name: 'Angular', icon: TbBrandAngular, }, -}; +} type Props = { - projectId: string; -}; + projectId: string +} export function BaseCodeSnippet( props: Record & { - className?: string; + className?: string } ) { const [currentIntegration, setCurrentIntegration] = useState( Object.keys(INTEGRATIONS)[0] as Integrations - ); + ) const onCopyClick = () => { - toast.success("Successfully copied code snippet!"); - navigator.clipboard.writeText(props[currentIntegration].code); - }; + toast.success('Successfully copied code snippet!') + navigator.clipboard.writeText(props[currentIntegration].code) + } return ( -
-
-
-
-
-
+
+
+
+
+
+
-
+
{Object.entries(INTEGRATIONS).map(([key, { icon: Icon, name }]) => (
setCurrentIntegration(key as Integrations)} className={clsx( - "flex cursor-pointer items-center px-3 py-2 font-semibold text-white transition-colors ease-in-out hover:bg-gray-900", - key === currentIntegration && "bg-gray-900" + 'flex cursor-pointer items-center px-3 py-2 font-semibold text-white transition-colors ease-in-out hover:bg-gray-900', + key === currentIntegration && 'bg-gray-900' )} > - + {name}
))} @@ -79,34 +79,32 @@ export function BaseCodeSnippet(
-
+
- ); + ) } export function CodeSnippet({ projectId }: Props) { const { data, isLoading } = trpc.project.getCodeSnippet.useQuery({ projectId, - }); + }) if (isLoading) { return ( -
- +
+
- ); + ) } - if (!data) return null; + if (!data) return null - return ; + return } diff --git a/apps/web/src/components/CodeSnippetModalButton.tsx b/apps/web/src/components/CodeSnippetModalButton.tsx index f118d813..8cfed4d9 100644 --- a/apps/web/src/components/CodeSnippetModalButton.tsx +++ b/apps/web/src/components/CodeSnippetModalButton.tsx @@ -1,57 +1,57 @@ -import { Dialog } from "@headlessui/react"; -import { useRouter } from "next/router"; -import { useState } from "react"; -import { BsCodeSlash } from "react-icons/bs"; -import { CodeSnippet } from "./CodeSnippet"; +import { Dialog } from '@headlessui/react' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { BsCodeSlash } from 'react-icons/bs' +import { CodeSnippet } from './CodeSnippet' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "./DropdownMenu"; -import { Code, Copy } from "lucide-react"; -import { useProjectId } from "lib/hooks/useProjectId"; -import { toast } from "react-hot-toast"; -import { useTracking } from "lib/tracking"; -import { Button } from "./ui/button"; +} from './DropdownMenu' +import { Code, Copy } from 'lucide-react' +import { useProjectId } from 'lib/hooks/useProjectId' +import { toast } from 'react-hot-toast' +import { useTracking } from 'lib/tracking' +import { Button } from './ui/button' function CodeSnippetModal({ isOpen, onClose, }: { - isOpen: boolean; - onClose: () => void; + isOpen: boolean + onClose: () => void }) { - const projectId = useRouter().query.projectId as string; + const projectId = useRouter().query.projectId as string return ( - + {/* The backdrop, rendered as a fixed sibling to the panel container */} - - ); + ) } export function CodeSnippetModalButton() { - const trackEvent = useTracking(); - const projectId = useProjectId(); - const [isOpen, setIsOpen] = useState(false); + const trackEvent = useTracking() + const projectId = useProjectId() + const [isOpen, setIsOpen] = useState(false) const onCopyProjectId = async () => { toast.promise(navigator.clipboard.writeText(projectId), { - loading: "Copying to clipboard...", - error: "Failed to copy to clipboard", - success: "Copied Project ID to clipboard", - }); - }; + loading: 'Copying to clipboard...', + error: 'Failed to copy to clipboard', + success: 'Copied Project ID to clipboard', + }) + } return ( @@ -59,24 +59,24 @@ export function CodeSnippetModalButton() { asChild // all other events are prevented by radix :( onPointerDown={() => { - trackEvent("Dashboard Code Clicked"); + trackEvent('Dashboard Code Clicked') }} > - - + - + Copy Project ID setIsOpen(true)}> - + Generate Code Snippet setIsOpen(false)} /> - ); + ) } diff --git a/apps/web/src/components/CreateAPIKeyModal.tsx b/apps/web/src/components/CreateAPIKeyModal.tsx index 392a94ad..30bdef7b 100644 --- a/apps/web/src/components/CreateAPIKeyModal.tsx +++ b/apps/web/src/components/CreateAPIKeyModal.tsx @@ -1,62 +1,61 @@ -import { TRPCClientError } from "@trpc/client"; -import { TRPC_ERROR_CODES_BY_KEY } from "@trpc/server/rpc"; -import { useRef, useState } from "react"; -import { toast } from "react-hot-toast"; -import { trpc } from "utils/trpc"; -import { Modal } from "./Modal"; -import { Input } from "./ui/input"; +import { TRPCClientError } from '@trpc/client' +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc' +import { useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import { trpc } from 'utils/trpc' +import { Modal } from './Modal' +import { Input } from './ui/input' type Props = { - onClose: () => void; - isOpen: boolean; - projectId: string; -}; + onClose: () => void + isOpen: boolean + projectId: string +} export const CreateAPIKeyModal = ({ onClose, isOpen, projectId }: Props) => { - const inputRef = useRef(null); - const ctx = trpc.useContext(); - const [name, setName] = useState(""); - const trimmedName = name.trim(); + const inputRef = useRef(null) + const ctx = trpc.useContext() + const [name, setName] = useState('') + const trimmedName = name.trim() const { mutate: createApiKey } = trpc.apikey.createApiKey.useMutation({ onSuccess() { - toast.success("API Key created"); + toast.success('API Key created') }, - }); + }) return ( { if (!trimmedName) { - toast.error("Name is required"); - return; + toast.error('Name is required') + return } try { - setName(""); - toast.success("API Key created"); - onClose(); + setName('') + toast.success('API Key created') + onClose() } catch (e) { toast.error( - e instanceof TRPCClientError && - e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN + e instanceof TRPCClientError && e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN ? e.message - : "Error creating API Key" - ); + : 'Error creating API Key' + ) } }} > - + setName(e.target.value)} - placeholder="Name of the Application" + placeholder='Name of the Application' /> - ); -}; + ) +} diff --git a/apps/web/src/components/CreateEnvironmentModal.tsx b/apps/web/src/components/CreateEnvironmentModal.tsx index 879244ab..9c2c591d 100644 --- a/apps/web/src/components/CreateEnvironmentModal.tsx +++ b/apps/web/src/components/CreateEnvironmentModal.tsx @@ -1,73 +1,68 @@ -import { TRPCClientError } from "@trpc/client"; -import { TRPC_ERROR_CODES_BY_KEY } from "@trpc/server/rpc"; -import { useRef, useState } from "react"; -import { toast } from "react-hot-toast"; -import { PlausibleEvents } from "types/plausible-events"; -import { trpc } from "utils/trpc"; -import { Modal } from "./Modal"; -import { useTracking } from "lib/tracking"; -import { Input } from "./ui/input"; +import { TRPCClientError } from '@trpc/client' +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc' +import { useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import { PlausibleEvents } from 'types/plausible-events' +import { trpc } from 'utils/trpc' +import { Modal } from './Modal' +import { useTracking } from 'lib/tracking' +import { Input } from './ui/input' type Props = { - onClose: () => void; - isOpen: boolean; - projectId: string; -}; + onClose: () => void + isOpen: boolean + projectId: string +} -export const CreateEnvironmentModal = ({ - onClose, - isOpen, - projectId, -}: Props) => { - const inputRef = useRef(null); - const ctx = trpc.useContext(); - const [name, setName] = useState(""); - const trimmedName = name.trim(); +export const CreateEnvironmentModal = ({ onClose, isOpen, projectId }: Props) => { + const inputRef = useRef(null) + const ctx = trpc.useContext() + const [name, setName] = useState('') + const trimmedName = name.trim() const { mutateAsync } = trpc.environments.addEnvironment.useMutation({ onSuccess() { - ctx.flags.getFlags.invalidate({ projectId }); + ctx.flags.getFlags.invalidate({ projectId }) }, - }); - const trackEvent = useTracking(); + }) + const trackEvent = useTracking() return ( { if (!trimmedName) { - toast.error("Name is required"); - return; + toast.error('Name is required') + return } try { await mutateAsync({ name, projectId, - }); - setName(""); - toast.success("Environment created"); - trackEvent("Environment Created"); - onClose(); + }) + setName('') + toast.success('Environment created') + trackEvent('Environment Created') + onClose() } catch (e) { toast.error( - e instanceof TRPCClientError && - e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN + e instanceof TRPCClientError && e.shape.code === TRPC_ERROR_CODES_BY_KEY.FORBIDDEN ? e.message - : "Error creating environment" - ); + : 'Error creating environment' + ) } }} > - + setName(e.target.value)} - type="text" - placeholder="production" + type='text' + placeholder='production' /> - ); -}; + ) +} diff --git a/apps/web/src/components/CreateProjectModal.tsx b/apps/web/src/components/CreateProjectModal.tsx index 8e1f6d4f..339027d1 100644 --- a/apps/web/src/components/CreateProjectModal.tsx +++ b/apps/web/src/components/CreateProjectModal.tsx @@ -1,78 +1,70 @@ -import { useRouter } from "next/router"; -import { ChangeEvent, useRef, useState } from "react"; -import toast from "react-hot-toast"; -import { trpc } from "utils/trpc"; -import { Modal } from "./Modal"; -import { useSession } from "next-auth/react"; -import { useTracking } from "lib/tracking"; -import { Input } from "./ui/input"; +import { useRouter } from 'next/router' +import { ChangeEvent, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { trpc } from 'utils/trpc' +import { Modal } from './Modal' +import { useSession } from 'next-auth/react' +import { useTracking } from 'lib/tracking' +import { Input } from './ui/input' type Props = { - onClose: () => void; -}; + onClose: () => void +} export const CreateProjectModal = ({ onClose }: Props) => { - const router = useRouter(); - const projectName = useRef(""); - const [isValidName, setIsValidName] = useState(true); - const trpcContext = trpc.useContext(); - const session = useSession(); - const trackEvent = useTracking(); + const router = useRouter() + const projectName = useRef('') + const [isValidName, setIsValidName] = useState(true) + const trpcContext = trpc.useContext() + const session = useSession() + const trackEvent = useTracking() - const { mutateAsync: createProjectTRPC } = - trpc.project.createProject.useMutation({ - onSuccess() { - toast.success("Project created"); - trpcContext.project.getProjectData.invalidate(); - trpcContext.user.getProjects.invalidate(); - }, - }); + const { mutateAsync: createProjectTRPC } = trpc.project.createProject.useMutation({ + onSuccess() { + toast.success('Project created') + trpcContext.project.getProjectData.invalidate() + trpcContext.user.getProjects.invalidate() + }, + }) const createProject = async () => { if (projectName.current.length < 3) { - setIsValidName(false); - return; + setIsValidName(false) + return } const project = await createProjectTRPC({ projectName: projectName.current, - }); + }) - onClose(); - if (!project) return; + onClose() + if (!project) return await session.update({ projectIds: [...(session.data?.user?.projectIds ?? []), project.id], lastOpenProjectId: project.id, - }); - trackEvent("Project Created"); - router.push(`/projects/${project.id}`); - }; + }) + trackEvent('Project Created') + router.push(`/projects/${project.id}`) + } const handleChange = (e: ChangeEvent) => { - projectName.current = e.currentTarget.value; - setIsValidName(projectName.current.length >= 3); - }; + projectName.current = e.currentTarget.value + setIsValidName(projectName.current.length >= 3) + } return ( - + {!isValidName && ( -
- Please enter a name with atleast 3 characters{" "} -
+
Please enter a name with atleast 3 characters
)}
- ); -}; + ) +} diff --git a/apps/web/src/components/DashboardButton.tsx b/apps/web/src/components/DashboardButton.tsx index 9b2fbf2e..3bffa6d3 100644 --- a/apps/web/src/components/DashboardButton.tsx +++ b/apps/web/src/components/DashboardButton.tsx @@ -1,8 +1,8 @@ -import { ComponentPropsWithRef } from "react"; -import { Button } from "./ui/button"; +import { ComponentPropsWithRef } from 'react' +import { Button } from './ui/button' -type Props = ComponentPropsWithRef<"button">; +type Props = ComponentPropsWithRef<'button'> export function DashboardButton({ className, ...props }: Props) { - return - + Bold @@ -115,7 +123,7 @@ const MenuBar = ({ editor }: { editor: TipTapEditor | null }) => { - + Align Left @@ -162,9 +170,9 @@ const MenuBar = ({ editor }: { editor: TipTapEditor | null }) => {
- ); -}; + ) +} export const Editor = ({ className, content, onUpdate }: Props) => { const editor = useEditor({ @@ -230,37 +238,37 @@ export const Editor = ({ className, content, onUpdate }: Props) => { StarterKit.configure({ orderedList: { HTMLAttributes: { - class: "list-decimal list-outside", + class: 'list-decimal list-outside', }, }, bulletList: { HTMLAttributes: { - class: "list-disc list-outside", + class: 'list-disc list-outside', }, }, }), TextAlign.configure({ - types: ["heading", "paragraph"], + types: ['heading', 'paragraph'], }), ], editorProps: { attributes: { class: - "prose-invert prose-base leading-none my-5 focus:outline-none border-pink-50/60 border rounded-lg py-3 px-2 h-64 overflow-y-auto", + 'prose-invert prose-base leading-none my-5 focus:outline-none border-pink-50/60 border rounded-lg py-3 px-2 h-64 overflow-y-auto', }, }, content, onUpdate: ({ editor }) => { - onUpdate?.(editor.getHTML()); + onUpdate?.(editor.getHTML()) }, - }); + }) return ( <> -
+
- ); -}; + ) +} diff --git a/apps/web/src/components/Feature.tsx b/apps/web/src/components/Feature.tsx index 83bc8c81..e24c2af2 100644 --- a/apps/web/src/components/Feature.tsx +++ b/apps/web/src/components/Feature.tsx @@ -1,24 +1,22 @@ -import type { LucideIcon } from "lucide-react"; -import type { IconType } from "react-icons"; +import type { LucideIcon } from 'lucide-react' +import type { IconType } from 'react-icons' type Props = { - children: React.ReactNode; - title: string; - subtitle: string; - icon: IconType | LucideIcon; -}; + children: React.ReactNode + title: string + subtitle: string + icon: IconType | LucideIcon +} export function Feature({ children, icon: Icon, subtitle, title }: Props) { return ( -
-
+
+
-

{title}

-

- {subtitle} -

-

{children}

+

{title}

+

{subtitle}

+

{children}

- ); + ) } diff --git a/apps/web/src/components/FeatureFlag.tsx b/apps/web/src/components/FeatureFlag.tsx index a61e021e..7299fbd6 100644 --- a/apps/web/src/components/FeatureFlag.tsx +++ b/apps/web/src/components/FeatureFlag.tsx @@ -1,23 +1,23 @@ -import { Popover, PopoverContent, PopoverTrigger } from "components/ui/popover"; -import dayjs from "dayjs"; -import { FaHistory } from "react-icons/fa"; -import { match, P } from "ts-pattern"; - -import { FeatureFlagHistory, FeatureFlagType } from "@prisma/client"; -import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { useState } from "react"; -import { toast } from "react-hot-toast"; -import { RouterOutputs, trpc } from "utils/trpc"; -import { Avatar } from "./Avatar"; -import { LoadingSpinner } from "./LoadingSpinner"; -import { Modal } from "./Modal"; -import { Edit } from "lucide-react"; -import { ChangeFlagForm, FlagFormValues } from "./AddFeatureFlagModal"; -import { Switch } from "./ui/switch"; -import { cn } from "lib/utils"; - -dayjs.extend(relativeTime); +import { Popover, PopoverContent, PopoverTrigger } from 'components/ui/popover' +import dayjs from 'dayjs' +import { FaHistory } from 'react-icons/fa' +import { match, P } from 'ts-pattern' + +import { FeatureFlagHistory, FeatureFlagType } from '@prisma/client' +import { Tooltip, TooltipContent, TooltipTrigger } from 'components/Tooltip' +import relativeTime from 'dayjs/plugin/relativeTime' +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import { RouterOutputs, trpc } from 'utils/trpc' +import { Avatar } from './Avatar' +import { LoadingSpinner } from './LoadingSpinner' +import { Modal } from './Modal' +import { Edit } from 'lucide-react' +import { ChangeFlagForm, FlagFormValues } from './AddFeatureFlagModal' +import { Switch } from './ui/switch' +import { cn } from 'lib/utils' + +dayjs.extend(relativeTime) const getHistoryEventDescription = (event: FeatureFlagHistory) => { return match(event) @@ -26,24 +26,24 @@ const getHistoryEventDescription = (event: FeatureFlagHistory) => { newValue: P.not(P.nullish), oldValue: null, }, - () => "created" as const + () => 'created' as const ) .with( { newValue: P.not(P.nullish), oldValue: P.not(P.nullish), }, - () => "updated" as const + () => 'updated' as const ) - .run(); -}; + .run() +} const HistoryButton = ({ flagValueId }: { flagValueId: string }) => { const { data, isLoading, refetch: loadHistory, - } = trpc.flags.getHistory.useQuery({ flagValueId }, { enabled: false }); + } = trpc.flags.getHistory.useQuery({ flagValueId }, { enabled: false }) return ( @@ -52,39 +52,31 @@ const HistoryButton = ({ flagValueId }: { flagValueId: string }) => { - - + {isLoading && } {data !== undefined && ( <> -

Edited {data.length} times

-
-
+

Edited {data.length} times

+
+
{data.map((history) => ( -
+
- {history.user.name ?? history.user.email}{" "} - {getHistoryEventDescription(history)} this flag{" "} + {history.user.name ?? history.user.email}{' '} + {getHistoryEventDescription(history)} this flag{' '} {dayjs(history.createdAt).fromNow()}
@@ -95,8 +87,8 @@ const HistoryButton = ({ flagValueId }: { flagValueId: string }) => { - ); -}; + ) +} const ConfirmUpdateModal = ({ isOpen, @@ -108,57 +100,55 @@ const ConfirmUpdateModal = ({ type, flagValueId, }: { - isOpen: boolean; - onClose: () => void; - currentValue: string; - type: FeatureFlagType; - description: string; - projectId: string; - flagName: string; - flagValueId: string; + isOpen: boolean + onClose: () => void + currentValue: string + type: FeatureFlagType + description: string + projectId: string + flagName: string + flagValueId: string }) => { const [state, setState] = useState({ name: flagName, value: currentValue, type, - }); - const trpcContext = trpc.useContext(); + }) + const trpcContext = trpc.useContext() const { mutate: updateFlag } = trpc.flags.updateFlag.useMutation({ onSuccess(_, { value, flagValueId }) { trpcContext.flags.getFlags.setData({ projectId, types: [] }, (prev) => { - if (!prev) return prev; + if (!prev) return prev const flagToUpdate = prev.flags.find((flag) => flag.values.some((value) => value.id === flagValueId) - ); - if (!flagToUpdate) return; + ) + if (!flagToUpdate) return - const valueToUpdate = flagToUpdate.values.find( - (value) => value.id === flagValueId - ); - if (!valueToUpdate) return; + const valueToUpdate = flagToUpdate.values.find((value) => value.id === flagValueId) + if (!valueToUpdate) return - valueToUpdate.value = value.toString(); - return prev; - }); + valueToUpdate.value = value.toString() + return prev + }) - trpcContext.flags.getFlags.invalidate({ projectId }); - onClose(); + trpcContext.flags.getFlags.invalidate({ projectId }) + onClose() }, onError() { - toast.error(`Failed to update ${type === "BOOLEAN" ? "flag" : "value"}`); + toast.error(`Failed to update ${type === 'BOOLEAN' ? 'flag' : 'value'}`) }, - }); + }) return ( updateFlag({ ...state, flagValueId })} isOpen={isOpen} onClose={onClose} - size="full" + size='full' > setState(newState)} errors={{}} canChangeType={false} - isRemoteConfig={type !== "BOOLEAN"} + isRemoteConfig={type !== 'BOOLEAN'} /> -

Description:

+

Description:

{!description ? ( - "No description provided" + 'No description provided' ) : (

)} - ); -}; + ) +} type Props = { - flag: RouterOutputs["flags"]["getFlags"]["flags"][number]; - projectId: string; - environmentName: string; - flagValueId: string; - type: FeatureFlagType; -}; - -export function FeatureFlag({ - flag, - projectId, - environmentName, - flagValueId, - type, -}: Props) { - const [isUpdateConfirmationModalOpen, setIsUpdateConfirmationModalOpen] = - useState(false); + flag: RouterOutputs['flags']['getFlags']['flags'][number] + projectId: string + environmentName: string + flagValueId: string + type: FeatureFlagType +} + +export function FeatureFlag({ flag, projectId, environmentName, flagValueId, type }: Props) { + const [isUpdateConfirmationModalOpen, setIsUpdateConfirmationModalOpen] = useState(false) - const currentFlagValue = flag.values.find((f) => f.id === flagValueId)?.value; + const currentFlagValue = flag.values.find((f) => f.id === flagValueId)?.value if (currentFlagValue == null) { - return null; + return null } return ( <> - -

+ +

{environmentName}

- {typeof currentFlagValue === "string" && - currentFlagValue.trim() === "" - ? "Empty String" + {typeof currentFlagValue === 'string' && currentFlagValue.trim() === '' + ? 'Empty String' : currentFlagValue}
-
+
@@ -238,12 +218,12 @@ export function FeatureFlag({ isOpen={isUpdateConfirmationModalOpen} onClose={() => setIsUpdateConfirmationModalOpen(false)} flagValueId={flagValueId} - description={flag.description ?? ""} + description={flag.description ?? ''} projectId={projectId} flagName={flag.name} type={flag.type} currentValue={currentFlagValue} /> - ); + ) } diff --git a/apps/web/src/components/FlagIcon.tsx b/apps/web/src/components/FlagIcon.tsx index bf7fea4c..591abbea 100644 --- a/apps/web/src/components/FlagIcon.tsx +++ b/apps/web/src/components/FlagIcon.tsx @@ -1,18 +1,12 @@ -import { FeatureFlagType } from "@prisma/client"; -import { - Baseline, - Hash, - LucideProps, - ToggleLeft, - CurlyBraces, -} from "lucide-react"; -import { match } from "ts-pattern"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip"; +import { FeatureFlagType } from '@prisma/client' +import { Baseline, Hash, LucideProps, ToggleLeft, CurlyBraces } from 'lucide-react' +import { match } from 'ts-pattern' +import { Tooltip, TooltipContent, TooltipTrigger } from './Tooltip' type Props = { - type: FeatureFlagType; - className?: string; -}; + type: FeatureFlagType + className?: string +} export function FlagIcon({ type, ...iconProps }: Props) { return ( @@ -20,18 +14,18 @@ export function FlagIcon({ type, ...iconProps }: Props) { - This Flag has the type {type} + This Flag has the type {type} {match(type) - .with("BOOLEAN", () => ) - .with("NUMBER", () => ) - .with("STRING", () => ) - .with("JSON", () => ) + .with('BOOLEAN', () => ) + .with('NUMBER', () => ) + .with('STRING', () => ) + .with('JSON', () => ) .exhaustive()} - ); + ) } diff --git a/apps/web/src/components/FlagPage.tsx b/apps/web/src/components/FlagPage.tsx index b4d45c84..f47072fc 100644 --- a/apps/web/src/components/FlagPage.tsx +++ b/apps/web/src/components/FlagPage.tsx @@ -1,28 +1,28 @@ -import { FeatureFlagType } from "@prisma/client"; -import { InferQueryResult } from "@trpc/react-query/dist/utils/inferReactQueryProcedure"; -import { AddFeatureFlagModal } from "components/AddFeatureFlagModal"; -import { CreateEnvironmentModal } from "components/CreateEnvironmentModal"; +import { FeatureFlagType } from '@prisma/client' +import { InferQueryResult } from '@trpc/react-query/dist/utils/inferReactQueryProcedure' +import { AddFeatureFlagModal } from 'components/AddFeatureFlagModal' +import { CreateEnvironmentModal } from 'components/CreateEnvironmentModal' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "components/DropdownMenu"; -import { Editor } from "components/Editor"; -import { FeatureFlag } from "components/FeatureFlag"; -import { Input } from "components/ui/input"; -import { Modal } from "components/Modal"; -import { Tooltip, TooltipContent, TooltipTrigger } from "components/Tooltip"; -import { useProjectId } from "lib/hooks/useProjectId"; -import { EditIcon, FileEditIcon, TrashIcon } from "lucide-react"; -import { useState } from "react"; -import { toast } from "react-hot-toast"; -import { AiOutlinePlus } from "react-icons/ai"; -import { BiInfoCircle } from "react-icons/bi"; -import { BsThreeDotsVertical } from "react-icons/bs"; -import { appRouter } from "server/trpc/router/_app"; -import { trpc } from "utils/trpc"; -import { Button } from "./ui/button"; +} from 'components/DropdownMenu' +import { Editor } from 'components/Editor' +import { FeatureFlag } from 'components/FeatureFlag' +import { Input } from 'components/ui/input' +import { Modal } from 'components/Modal' +import { Tooltip, TooltipContent, TooltipTrigger } from 'components/Tooltip' +import { useProjectId } from 'lib/hooks/useProjectId' +import { EditIcon, FileEditIcon, TrashIcon } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import { AiOutlinePlus } from 'react-icons/ai' +import { BiInfoCircle } from 'react-icons/bi' +import { BsThreeDotsVertical } from 'react-icons/bs' +import { appRouter } from 'server/trpc/router/_app' +import { trpc } from 'utils/trpc' +import { Button } from './ui/button' const EditTitleModal = ({ flagId, @@ -30,40 +30,36 @@ const EditTitleModal = ({ onClose, title, }: { - isOpen: boolean; - onClose: () => void; - title: string; - flagId: string; + isOpen: boolean + onClose: () => void + title: string + flagId: string }) => { - const [newTitle, setNewTitle] = useState(title); - const trpcContext = trpc.useContext(); + const [newTitle, setNewTitle] = useState(title) + const trpcContext = trpc.useContext() const { mutate: updateTitle } = trpc.flags.updateFlagTitle.useMutation({ onSuccess() { - toast.success("Successfully updated the title"); - trpcContext.flags.getFlags.invalidate(); - onClose(); + toast.success('Successfully updated the title') + trpcContext.flags.getFlags.invalidate() + onClose() }, onError() { - toast.error("Failed to update the title"); + toast.error('Failed to update the title') }, - }); + }) return ( updateTitle({ flagId, title: newTitle })} isOpen={isOpen} onClose={onClose} - subtitle="The title is used to identify the flag in the UI." + subtitle='The title is used to identify the flag in the UI.' > - setNewTitle(e.target.value)} - placeholder="MyFlag" - /> + setNewTitle(e.target.value)} placeholder='MyFlag' /> - ); -}; + ) +} const EditDescriptionModal = ({ isOpen, @@ -71,44 +67,37 @@ const EditDescriptionModal = ({ flagId, description, }: { - isOpen: boolean; - onClose: () => void; - flagId: string; - description: string; + isOpen: boolean + onClose: () => void + flagId: string + description: string }) => { - const [newDescription, setNewDescription] = useState(description); - const trpcContext = trpc.useContext(); - const { mutate: updateDescription } = - trpc.flags.updateDescription.useMutation({ - onSuccess() { - toast.success("Successfully updated description"); - trpcContext.flags.getFlags.invalidate(); - onClose(); - }, - onError() { - toast.error("Failed to update description"); - }, - }); + const [newDescription, setNewDescription] = useState(description) + const trpcContext = trpc.useContext() + const { mutate: updateDescription } = trpc.flags.updateDescription.useMutation({ + onSuccess() { + toast.success('Successfully updated description') + trpcContext.flags.getFlags.invalidate() + onClose() + }, + onError() { + toast.error('Failed to update description') + }, + }) return ( - updateDescription({ flagId, description: newDescription }) - } - size="full" + title='Update Description' + confirmText='Save' + onConfirm={() => updateDescription({ flagId, description: newDescription })} + size='full' isOpen={isOpen} onClose={onClose} > - + - ); -}; + ) +} const DeleteFlagModal = ({ isOpen, @@ -117,28 +106,28 @@ const DeleteFlagModal = ({ flagName, type, }: { - isOpen: boolean; - onClose: () => void; - projectId: string; - flagName: string; - type: FeatureFlagType; + isOpen: boolean + onClose: () => void + projectId: string + flagName: string + type: FeatureFlagType }) => { - const trpcContext = trpc.useContext(); + const trpcContext = trpc.useContext() const { mutate: deleteFlag } = trpc.flags.removeFlag.useMutation({ onSuccess() { - toast.success(`Deleted ${type === "BOOLEAN" ? "flag" : "config"}`); - trpcContext.flags.getFlags.invalidate(); - onClose(); + toast.success(`Deleted ${type === 'BOOLEAN' ? 'flag' : 'config'}`) + trpcContext.flags.getFlags.invalidate() + onClose() }, onError() { - toast.error(`Failed to delete ${type === "BOOLEAN" ? "flag" : "config"}`); + toast.error(`Failed to delete ${type === 'BOOLEAN' ? 'flag' : 'config'}`) }, - }); + }) return ( deleteFlag({ name: flagName, projectId })} isOpen={isOpen} onClose={onClose} @@ -147,45 +136,37 @@ const DeleteFlagModal = ({ Are you sure that you want to delete the Flag {flagName}?

- ); -}; + ) +} export const FeatureFlagPageContent = ({ data, type, }: { - data: NonNullable< - InferQueryResult<(typeof appRouter)["flags"]["getFlags"]>["data"] - >; - type: "Flags" | "Remote Config"; + data: NonNullable['data']> + type: 'Flags' | 'Remote Config' }) => { - const [isCreateFlagModalOpen, setIsCreateFlagModalOpen] = useState(false); + const [isCreateFlagModalOpen, setIsCreateFlagModalOpen] = useState(false) const [activeFlagInfo, setActiveFlagInfo] = useState<{ - id: string; - action: "editDescription" | "editName" | "delete"; - } | null>(null); + id: string + action: 'editDescription' | 'editName' | 'delete' + } | null>(null) - const [isCreateEnvironmentModalOpen, setIsCreateEnvironmentModalOpen] = - useState(false); + const [isCreateEnvironmentModalOpen, setIsCreateEnvironmentModalOpen] = useState(false) - const projectId = useProjectId(); + const projectId = useProjectId() - const activeFlag = data.flags.find((flag) => flag.id === activeFlagInfo?.id); + const activeFlag = data.flags.find((flag) => flag.id === activeFlagInfo?.id) if (data.environments.length === 0) return ( -
-

- You don't have any environments set up! -

+
+

You don't have any environments set up!

- You need to have at least one environment to set up{" "} - {type === "Flags" ? "feature flags" : "remote config"} + You need to have at least one environment to set up{' '} + {type === 'Flags' ? 'feature flags' : 'remote config'}

-
- ); + ) return ( <>
-
+
setIsCreateFlagModalOpen(false)} projectId={projectId} - isRemoteConfig={type === "Remote Config"} + isRemoteConfig={type === 'Remote Config'} />
-
+
{data.flags.map((currentFlag) => { return ( -
-
-
-

- {currentFlag.name} -

+
+
+
+

{currentFlag.name}

@@ -248,18 +223,16 @@ export const FeatureFlagPageContent = ({ -

Description:

+

Description:

No description

", + __html: currentFlag.description ?? '

No description

', }} />
@@ -269,52 +242,49 @@ export const FeatureFlagPageContent = ({ - + { setActiveFlagInfo({ id: currentFlag.id, - action: "editName", - }); + action: 'editName', + }) }} > - + Edit Name { setActiveFlagInfo({ id: currentFlag.id, - action: "editDescription", - }); + action: 'editDescription', + }) }} > - + Edit Description { setActiveFlagInfo({ id: currentFlag.id, - action: "delete", - }); + action: 'delete', + }) }} > - - Delete {type === "Flags" ? "Flag" : "Config"} + + Delete {type === 'Flags' ? 'Flag' : 'Config'}
-
+
{currentFlag.values - .sort( - (a, b) => - a.environment.sortIndex - b.environment.sortIndex - ) + .sort((a, b) => a.environment.sortIndex - b.environment.sortIndex) .map((flagValue) => (
- ); + ) })}
@@ -336,26 +306,26 @@ export const FeatureFlagPageContent = ({ setActiveFlagInfo(null)} /> setActiveFlagInfo(null)} /> setActiveFlagInfo(null)} type={activeFlag.type} /> )} - ); -}; + ) +} diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index 302f3dc1..871392a6 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -1,31 +1,27 @@ -import { Github } from "lucide-react"; -import { BsDiscord, BsLinkedin } from "react-icons/bs"; -import { RiTwitterXLine } from "react-icons/ri"; -import Link from "next/link"; -import { DOCS_URL } from "@tryabby/core"; +import { Github } from 'lucide-react' +import { BsDiscord, BsLinkedin } from 'react-icons/bs' +import { RiTwitterXLine } from 'react-icons/ri' +import Link from 'next/link' +import { DOCS_URL } from '@tryabby/core' -const GITHUB_URL = "https://github.com/tryabby/abby"; -const LINKEDIN_URL = "https://www.linkedin.com/company/tryabby/"; -const TWITTER_URL = "https://twitter.com/tryabby"; -export const DISCORD_INVITE_URL = "https://discord.gg/nk7wKf7Pv2"; +const GITHUB_URL = 'https://github.com/tryabby/abby' +const LINKEDIN_URL = 'https://www.linkedin.com/company/tryabby/' +const TWITTER_URL = 'https://twitter.com/tryabby' +export const DISCORD_INVITE_URL = 'https://discord.gg/nk7wKf7Pv2' export function Footer() { return ( -