Track HiveGrid Cloud planning reference
This commit is contained in:
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];
|
||||
Reference in new issue
Block a user