Skip to content

Commit

Permalink
feat(core): add user segments and flag rule sets to schema
Browse files Browse the repository at this point in the history
fix(web): update feature flag component to use link and improve data handling
  • Loading branch information
cstrnt committed Feb 15, 2025
1 parent 79eef4b commit 41ab528
Show file tree
Hide file tree
Showing 19 changed files with 1,344 additions and 99 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE `UserSegment` (
`id` VARCHAR(191) NOT NULL,
`projectId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`schema` JSON NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,

INDEX `UserSegment_projectId_idx`(`projectId`),
UNIQUE INDEX `UserSegment_projectId_name_key`(`projectId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `FlagRuleSet` (
`id` VARCHAR(191) NOT NULL,
`flagValueId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`rules` JSON NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,

INDEX `FlagRuleSet_flagValueId_idx`(`flagValueId`),
UNIQUE INDEX `FlagRuleSet_flagValueId_name_key`(`flagValueId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
31 changes: 31 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ model Project {
apiRequests ApiRequest[]
integrations Integration[]
userSegments UserSegment[]
}

model ProjectUser {
Expand Down Expand Up @@ -214,6 +215,8 @@ model FeatureFlagValue {
value String @db.LongText
history FeatureFlagHistory[]
ruleSets FlagRuleSet[]
@@index([flagId])
@@index([environmentId])
@@map("FlagValue")
Expand Down Expand Up @@ -331,3 +334,31 @@ model Integration {
@@unique([projectId, type])
@@index([projectId])
}

model UserSegment {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
schema Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectId, name])
@@index([projectId])
}

model FlagRuleSet {
id String @id @default(cuid())
flagValueId String
flagValue FeatureFlagValue @relation(fields: [flagValueId], references: [id], onDelete: Cascade)
name String
rules Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([flagValueId, name])
@@index([flagValueId])
}
9 changes: 8 additions & 1 deletion apps/web/src/api/routes/v1_project_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ABBY_WINDOW_KEY, type AbbyDataResponse } from "@tryabby/core";
import { type Context, Hono } from "hono";
import { cors } from "hono/cors";
import { endTime, startTime, timing } from "hono/timing";
import type { FlagRuleSet } from "@tryabby/core/schema";
import { transformFlagValue } from "lib/flags";
import { ConfigCache } from "server/common/config-cache";
import { prisma } from "server/db/client";
Expand Down Expand Up @@ -44,7 +45,7 @@ async function getAbbyResponseWithCache({
projectId,
},
},
include: { flag: { select: { name: true, type: true } } },
include: { flag: { select: { name: true, type: true } }, ruleSets: true },
}),
]);
endTime(c, "db");
Expand All @@ -60,6 +61,9 @@ async function getAbbyResponseWithCache({
return {
name: flagValue.flag.name,
value: transformFlagValue(flagValue.value, flagValue.flag.type),
ruleSet: flagValue.ruleSets.at(0)?.rules
? (flagValue.ruleSets.at(0)?.rules as FlagRuleSet)
: undefined,
};
}),
remoteConfig: flags
Expand All @@ -68,6 +72,9 @@ async function getAbbyResponseWithCache({
return {
name: flagValue.flag.name,
value: transformFlagValue(flagValue.value, flagValue.flag.type),
ruleSet: flagValue.ruleSets.at(0)?.rules
? (flagValue.ruleSets.at(0)?.rules as FlagRuleSet)
: undefined,
};
}),
} satisfies AbbyDataResponse;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/FeatureFlag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ChangeFlagForm, type FlagFormValues } from "./AddFeatureFlagModal";
import { Avatar } from "./Avatar";
import { LoadingSpinner } from "./LoadingSpinner";
import { Modal } from "./Modal";
import Link from "next/link";

dayjs.extend(relativeTime);

Expand Down Expand Up @@ -208,7 +209,7 @@ export function FeatureFlag({
}

return (
<>
<Link href={`/projects/${projectId}/flags/${flagValueId}`}>
<span className="flex w-full items-center justify-between space-x-3 rounded-lg bg-card py-3 pl-3 pr-4 text-sm font-medium text-gray-300">
<div className="flex items-center space-x-2">
<p>{environmentName}</p>
Expand Down Expand Up @@ -248,6 +249,6 @@ export function FeatureFlag({
type={flag.type}
currentValue={currentFlagValue}
/>
</>
</Link>
);
}
237 changes: 237 additions & 0 deletions apps/web/src/components/flags/FlagRuleEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import {
type FlagRuleSet,
getDisplayNameForOperator,
getOperatorsForType,
} from "@tryabby/core/schema";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/ui/select";
import { Input } from "components/ui/input";
import { Button } from "components/ui/button";
import { Switch } from "components/ui/switch";
import type { ValidatorType } from "@tryabby/core";
import type { FeatureFlagType } from "@prisma/client";
import { useCallback } from "react";
import { match } from "ts-pattern";
import { Label } from "components/ui/label";
import { JSONEditor } from "components/JSONEditor";

interface FlagRuleEditorProps {
rule: FlagRuleSet[number];
onChange: (rule: FlagRuleSet[number]) => void;
onRemove: () => void;
userSchema: Record<string, ValidatorType>;
flagType: FeatureFlagType;
flagName: string;
flagValue: string;
}

export function FlagRuleEditor({
rule,
onChange,
onRemove,
flagType,
userSchema,
flagName,
flagValue,
}: FlagRuleEditorProps) {
const renderThenValueComponent = useCallback(() => {
if ("rules" in rule) return null;
return match(flagType)
.with("BOOLEAN", () => (
<Switch
checked={rule.thenValue === "true"}
onCheckedChange={(checked) => {
onChange({ ...rule, thenValue: checked ? "true" : "false" });
}}
/>
))
.with("NUMBER", () => (
<Input
value={rule.thenValue.toString()}
onChange={(e) => onChange({ ...rule, thenValue: e.target.value })}
type="number"
/>
))
.with("STRING", () => (
<Input
value={rule.thenValue.toString()}
onChange={(e) => onChange({ ...rule, thenValue: e.target.value })}
type="string"
/>
))
.with("JSON", () => (
<JSONEditor
value={rule.thenValue.toString()}
onChange={(e) => onChange({ ...rule, thenValue: e })}
/>
))
.exhaustive();
}, [flagType, onChange, rule]);

if ("rules" in rule) {
return (
<div className="border p-4 my-2 rounded-md flex flex-col gap-3">
<Select
value={rule.operator}
onValueChange={(value: "and" | "or") =>
onChange({ ...rule, operator: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">AND</SelectItem>
<SelectItem value="or">OR</SelectItem>
</SelectContent>
</Select>
{rule.rules.map((subRule, index) => (
<FlagRuleEditor
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={index}
rule={subRule}
onChange={(updatedRule) => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const newRules = [...rule.rules] as any[];
newRules[index] = updatedRule;
onChange({ ...rule, rules: newRules });
}}
onRemove={() => {
const newRules = [...rule.rules];
newRules.splice(index, 1);
onChange({ ...rule, rules: newRules });
}}
userSchema={userSchema}
flagType={flagType}
flagName={flagName}
flagValue={flagValue}
/>
))}
<div>
<Button
onClick={() =>
onChange({
...rule,
rules: [
...rule.rules,
{
propertyName: "",
propertyType: "string",
operator: "eq",
value: "",
thenValue: flagValue,
},
],
})
}
>
Add Sub-Rule
</Button>
<Button variant="destructive" onClick={onRemove} className="ml-2">
Remove Group
</Button>
</div>
</div>
);
}

return (
<div className="flex items-center space-x-2 my-2">
<div className="flex flex-col gap-1 w-full">
<Label>Property</Label>
<Select
value={rule.propertyName}
onValueChange={(value) => {
if (!userSchema[value]) return;
onChange({
...rule,
propertyName: value,
propertyType: userSchema[value].type,
} as FlagRuleSet[number]);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(userSchema).map((key) => (
<SelectItem key={key} value={key}>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 w-full">
<Label>Operator</Label>
<Select
value={rule.operator}
onValueChange={(value) =>
onChange({
...rule,
operator: value as FlagRuleSet[number]["operator"],
} as FlagRuleSet[number])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{getOperatorsForType(rule.propertyType).map((op) => (
<SelectItem key={op} value={op}>
{getDisplayNameForOperator(op)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 w-full">
<Label>Condition</Label>
{rule.propertyType === "boolean" ? (
<Select
value={rule.value.toString()}
onValueChange={(value) =>
onChange({
...rule,
value: value === "true",
} as FlagRuleSet[number])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">TRUE</SelectItem>
<SelectItem value="false">FALSE</SelectItem>
</SelectContent>
</Select>
) : rule.propertyType === "number" ? (
<Input
type="number"
value={rule.value as number}
onChange={(e) =>
onChange({ ...rule, value: Number.parseFloat(e.target.value) })
}
/>
) : (
<Input
value={rule.value as string}
onChange={(e) => onChange({ ...rule, value: e.target.value })}
/>
)}
</div>
<div className="flex flex-col gap-1 w-full">
<Label>Value</Label>
{renderThenValueComponent()}
</div>
<Button variant="destructive" onClick={onRemove}>
Remove
</Button>
</div>
);
}
Loading

0 comments on commit 41ab528

Please sign in to comment.