Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,195 @@
import { FeatureFlag, FeatureFlagFilter, Role, Tenant } from "@toolctrl/db/prisma-browser";
import { useNavigation, useNavigate, Form } from "react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText from "~/components/ui/input/InputText";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import SessionFilterModal from "~/modules/shared/components/SessionFilterModal";
import { FeatureFlagsFilterType, FeatureFlagsFilterTypes } from "../dtos/FeatureFlagsFilterTypes";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { UserSimple } from "~/utils/db/users.db.server";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { useLocaleFormatters } from "~/locale/format";
export default function FeatureFlagForm({
item,
metadata,
onDelete,
}: {
item?: FeatureFlag & { filters: FeatureFlagFilter[] };
metadata: {
users: UserSimple[];
tenants: Tenant[];
subscriptionProducts: SubscriptionProductDto[];
roles: Role[];
analytics: {
via: { name: string; count: number }[];
httpReferrer: { name: string; count: number }[];
browser: { name: string; count: number }[];
os: { name: string; count: number }[];
source: { name: string; count: number }[];
medium: { name: string; count: number }[];
campaign: { name: string; count: number }[];
};
};
onDelete?: () => void;
}) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const navigation = useNavigation();
const navigate = useNavigate();
const [showFilterModal, setShowFilterModal] = useState<{
item?: { type: FeatureFlagsFilterType; value: string | null; action: string | null };
idx?: number;
}>();
const [name, setName] = useState(item?.name || "");
const [description, setDescription] = useState(item?.description || "");
const [filters, setFilters] = useState<{ type: FeatureFlagsFilterType; value: string | null; action: string | null }[]>(
item?.filters.map((f) => {
return {
type: f.type as FeatureFlagsFilterType,
value: f.value,
action: f.action,
};
}) || []
);
useEffect(() => {
if (!item) {
// setName(StringUtils.toCamelCase(description.toLowerCase()));
if (name === "currency") {
setDescription(t("featureFlags.form.defaults.currencyDescription"));
}
}
}, [item, name, t]);
function onSaveFilter(item: { type: FeatureFlagsFilterType; value: string | null; action: string | null }) {
const idx = showFilterModal?.idx;
if (idx !== undefined) {
filters[idx] = item;
} else {
filters.push(item);
}
setFilters([...filters]);
setShowFilterModal(undefined);
}
function getActionName() {
return item ? "edit" : "new";
}
// const hasDefaultFlag = ["currency"].includes(name);
return (
<div>
<SlideOverWideEmpty
title={item ? t("featureFlags.form.editTitle") : t("featureFlags.form.createTitle")}
description={item ? t("featureFlags.form.editDescription") : t("featureFlags.form.createDescription")}
open={true}
onClose={() => {
navigate(adminPath("feature-flags/flags", { mode: "host-aware" }));
}}
className="sm:max-w-sm"
overflowYScroll={true}
>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={getActionName()} />
{filters.map((filter, index) => {
return <input type="hidden" name="filters[]" value={JSON.stringify(filter)} key={index} />;
})}
<div className="space-y-2">
<InputText
name="name"
title={t("shared.name")}
value={name}
setValue={setName}
required
hint={<span>{t("featureFlags.form.hints.internalName")}</span>}
/>
<InputText autoFocus name="description" title={t("shared.title")} value={description} setValue={setDescription} required />
<div>
<div className="mb-1 flex items-center justify-between space-x-2 text-xs">
<label className="text-muted-foreground font-medium">{t("featureFlags.filters")}</label>
<button type="button" onClick={() => setFilters([])} className="text-muted-foreground hover:text-muted-foreground">
{t("shared.clear")}
</button>
</div>
<div className="space-y-2 text-gray-800">
{filters.map((filter, idx) => {
return (
<button
key={idx}
type="button"
onClick={() => setShowFilterModal({ item: filter, idx })}
className="border-border hover:border-border relative block w-full rounded-lg border-2 border-dashed p-3 text-center focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none"
>
<div className="flex items-center space-x-1 truncate">
<div className="truncate font-medium">{filter.type}</div>
<div className="truncate italic">
{filter.value === null ? (
<span className="text-red-500">null</span>
) : filter.type === "user.createdAfter" || filter.type === "user.createdBefore" ? (
formatters.date(filter.value, { dateStyle: "medium" })
) : (
filter.value
)}
{filter.action && ` → ${filter.action}`}
</div>
</div>
</button>
);
})}
<div className="">
<button
type="button"
onClick={() => setShowFilterModal({ item: undefined, idx: undefined })}
className="border-border hover:border-border relative block w-full rounded-lg border-2 border-dashed p-3 text-center focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none"
>
<span className="block text-sm font-medium text-gray-900">{t("featureFlags.form.actions.addFilter")}</span>
</button>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath("feature-flags/flags", { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={getActionName()} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
<SessionFilterModal
flagName={name}
filters={FeatureFlagsFilterTypes.map((f) => f.toString())}
open={showFilterModal !== undefined}
item={showFilterModal?.item}
idx={showFilterModal?.idx}
onClose={() => setShowFilterModal(undefined)}
onSave={({ type, value, action }) => onSaveFilter({ type: type as FeatureFlagsFilterType, value, action: action ?? null })}
metadata={metadata}
onRemove={(idx) => {
filters.splice(idx, 1);
setFilters([...filters]);
}}
/>
</SlideOverWideEmpty>
</div>
);
}
@@ -0,0 +1,36 @@
import { useLocation, useMatches } from "react-router";
import { Fragment, ReactNode, useEffect } from "react";
import { useRootData } from "~/utils/data/useRootData";
import AnalyticsHelper from "~/utils/helpers/AnalyticsHelper";
interface Props {
flag: string;
children: ReactNode;
}
export default function FeatureFlagTracker({ flag, children }: Props) {
let location = useLocation();
const rootData = useRootData();
const matches = useMatches();
useEffect(() => {
async function track() {
const routeMatch = matches.find((m) => m.pathname == location.pathname);
AnalyticsHelper.addEvent({
url: location.pathname,
route: routeMatch?.id,
rootData,
action: flag,
category: "featureFlag",
label: "",
value: "",
});
}
if (rootData?.featureFlags.includes(flag)) {
track();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flag]);
return <Fragment>{children}</Fragment>;
}
@@ -0,0 +1,162 @@
import { db } from "~/utils/db.server";
import { FeatureFlagsFilterType } from "../dtos/FeatureFlagsFilterTypes";
import { AnalyticsEvent, FeatureFlag, FeatureFlagFilter, Prisma } from "@toolctrl/db/prisma-browser";
import { clearCacheKeyPrefix } from "~/utils/cache.server";
export type FeatureFlagWithDetails = FeatureFlag & {
filters: FeatureFlagFilter[];
};
export type FeatureFlagWithEvents = FeatureFlagWithDetails & {
events: AnalyticsEvent[];
};
export type FeatureFlagScopeLookup = {
enabled?: boolean | undefined;
forcedFlags?: string[];
tenantId?: string | null;
userId?: string | null;
scope?: string | null;
};
export async function getFeatureFlag(where: { name?: string; id?: string; description?: string; enabled?: boolean | undefined }) {
return db.featureFlag.findFirst({
where,
include: {
filters: true,
},
});
}
export async function getAllFeatureFlags(): Promise<FeatureFlagWithDetails[]> {
return db.featureFlag.findMany({
include: {
filters: true,
},
orderBy: {
createdAt: "desc",
},
});
}
export async function getFeatureFlags(where: FeatureFlagScopeLookup): Promise<FeatureFlagWithDetails[]> {
const forcedFlags = where.forcedFlags ?? [];
const scopedOverrides: Prisma.FeatureFlagWhereInput[] = [];
scopedOverrides.push({ scope: "global", tenantId: null, userId: null });
if (where.tenantId) scopedOverrides.push({ scope: "tenant", tenantId: where.tenantId, userId: null });
if (where.userId) scopedOverrides.push({ scope: "user", tenantId: null, userId: where.userId });
if (where.tenantId && where.userId) scopedOverrides.push({ scope: "user", tenantId: where.tenantId, userId: where.userId });
return db.featureFlag.findMany({
where: {
AND: [
{
OR: [{ enabled: where.enabled }, { name: { in: forcedFlags } }, ...scopedOverrides],
},
{ OR: scopedOverrides },
],
},
include: {
filters: true,
},
orderBy: {
createdAt: "desc",
},
});
}
export async function getFeatureFlagsWithEvents(where: { enabled: boolean | undefined }): Promise<FeatureFlagWithEvents[]> {
return db.featureFlag.findMany({
where: {
enabled: where.enabled,
},
include: {
filters: true,
events: true,
},
orderBy: {
createdAt: "desc",
},
});
}
export async function createFeatureFlag(data: {
name: string;
description: string;
enabled: boolean;
filters: {
type: FeatureFlagsFilterType;
value: string | null;
action?: string | null;
}[];
}) {
return await db.featureFlag
.create({
data: {
name: data.name,
description: data.description,
enabled: data.enabled,
filters: {
create: data.filters.map((f) => ({
type: f.type.toString(),
value: f.value ?? null,
action: f.action ?? null,
})),
},
},
})
.then((item) => {
clearCacheKeyPrefix(`featureFlags:enabled`);
return item;
});
}
export async function updateFeatureFlag(
id: string,
{
name,
description,
filters,
enabled,
}: {
name?: string;
description?: string;
filters?: { type: FeatureFlagsFilterType; value: string | null; action?: string | null }[];
enabled?: boolean;
}
) {
return await db.featureFlag
.update({
where: { id },
data: {
name,
description,
enabled,
filters: !filters
? undefined
: {
deleteMany: {},
create: filters.map((f) => ({
type: f.type.toString(),
value: f.value ?? null,
action: f.action ?? null,
})),
},
},
})
.then((flag) => {
clearCacheKeyPrefix(`featureFlags:enabled`);
return flag;
});
}
export async function deleteFeatureFlag(id: string) {
return await db.featureFlag
.delete({
where: { id },
})
.then((item) => {
clearCacheKeyPrefix(`featureFlags:enabled`);
return item;
});
}
@@ -0,0 +1,30 @@
export const FeatureFlagsFilterTypes = [
"env",
"percentage",
"session.darkMode",
"session.language",
"session.logged",
"page.startsWith",
"analytics.via",
"analytics.httpReferrer",
"analytics.browser",
"analytics.os",
"analytics.source",
"analytics.medium",
"analytics.campaign",
"analytics.countryCode",
"analytics.city",
"user.createdAfter",
"user.createdBefore",
"user.is",
"user.language",
"user.roles.contains",
"user.roles.notContains",
"tenant.is",
"tenant.subscription.products.has",
"tenant.subscription.active",
"tenant.subscription.inactive",
"tenant.api.used",
"workspace-roles",
] as const;
export type FeatureFlagsFilterType = (typeof FeatureFlagsFilterTypes)[number];