-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add link feature filters to links UI and API #3247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { Prisma } from "@dub/prisma/client"; | ||
|
|
||
| export function buildLinkFeaturesWhere( | ||
| linkFeatures?: string[], | ||
| ): Record<string, unknown> | undefined { | ||
| if (!linkFeatures || linkFeatures.length === 0) { | ||
| return undefined; | ||
| } | ||
|
|
||
| return { | ||
| OR: linkFeatures.map((feature) => { | ||
| switch (feature) { | ||
| case "conversionTracking": | ||
| return { trackConversion: true }; | ||
| case "customLinkPreview": | ||
| return { proxy: true }; | ||
| case "geoTargeting": | ||
| return { geo: { not: Prisma.DbNull } }; | ||
| case "utmTags": | ||
| return { | ||
| OR: [ | ||
| { utm_source: { not: null } }, | ||
| { utm_medium: { not: null } }, | ||
| { utm_campaign: { not: null } }, | ||
| { utm_term: { not: null } }, | ||
| { utm_content: { not: null } }, | ||
| ], | ||
| }; | ||
| case "abTest": | ||
| return { testVariants: { not: Prisma.DbNull } }; | ||
| case "tags": | ||
| return { tags: { some: {} } }; | ||
| case "comments": | ||
| return { comments: { not: null } }; | ||
| case "iosTargeting": | ||
| return { ios: { not: null } }; | ||
| case "androidTargeting": | ||
| return { android: { not: null } }; | ||
| case "expiration": | ||
| return { expiresAt: { not: null } }; | ||
| case "password": | ||
| return { password: { not: null } }; | ||
| case "linkCloaking": | ||
| return { rewrite: true }; | ||
| case "searchEngineIndexing": | ||
| return { doIndex: true }; | ||
| default: | ||
| return {}; | ||
| } | ||
| }), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -148,6 +148,43 @@ const LinksQuerySchema = z.object({ | |
| "DEPRECATED. Filter for links that have at least one tag assigned to them.", | ||
| ) | ||
| .openapi({ deprecated: true }), | ||
| linkFeatures: z | ||
| .union([z.string(), z.array(z.string())]) | ||
| .transform((v) => (Array.isArray(v) ? v : v.split(","))) | ||
| .optional() | ||
| .describe("Filter links by enabled features (comma-separated)") | ||
| .openapi({ | ||
| param: { | ||
| style: "form", | ||
| explode: false, | ||
| }, | ||
| anyOf: [ | ||
| { | ||
| type: "string", | ||
| }, | ||
| { | ||
| type: "array", | ||
| items: { | ||
| type: "string", | ||
| enum: [ | ||
| "conversionTracking", | ||
| "customLinkPreview", | ||
| "geoTargeting", | ||
| "utmTags", | ||
| "abTest", | ||
| "tags", | ||
| "comments", | ||
| "iosTargeting", | ||
| "androidTargeting", | ||
| "expiration", | ||
| "password", | ||
| "linkCloaking", | ||
| "searchEngineIndexing", | ||
| ], | ||
| }, | ||
| }, | ||
| ], | ||
| }), | ||
|
Comment on lines
+151
to
+187
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Search for linkFeatures usage and filtering implementation
echo "=== Finding linkFeatures references in codebase ==="
rg -n "linkFeatures" --type ts -A 3 -B 1 | head -100
echo -e "\n=== Searching for feature filtering implementation ==="
rg -n "conversionTracking|customLinkPreview|geoTargeting" --type ts -A 2 -B 1 | head -80Repository: dubinc/dub Length of output: 12661 🏁 Script executed: #!/bin/bash
# Get full implementation of build-link-features-where.ts
echo "=== Full build-link-features-where.ts implementation ==="
cat apps/web/lib/api/links/utils/build-link-features-where.ts
echo -e "\n=== Check if there's error handling for invalid features ==="
rg -n "linkFeatures" apps/web/lib/api/links --type ts -A 5 -B 2Repository: dubinc/dub Length of output: 5349 Add runtime validation for feature names. The Define allowed features as a constant and validate with Zod: +const LINK_FEATURES = [
+ "conversionTracking",
+ "customLinkPreview",
+ "geoTargeting",
+ "utmTags",
+ "abTest",
+ "tags",
+ "comments",
+ "iosTargeting",
+ "androidTargeting",
+ "expiration",
+ "password",
+ "linkCloaking",
+ "searchEngineIndexing",
+] as const;
+
linkFeatures: z
.union([z.string(), z.array(z.string())])
.transform((v) => (Array.isArray(v) ? v : v.split(",")))
+ .refine(
+ (arr) => arr.every((feature) => LINK_FEATURES.includes(feature as any)),
+ {
+ message: `Invalid feature. Allowed features: ${LINK_FEATURES.join(", ")}`,
+ }
+ )
.optional()
.describe("Filter links by enabled features (comma-separated)")
.openapi({
param: {
style: "form",
explode: false,
},
anyOf: [
{
type: "string",
+ enum: LINK_FEATURES,
},
{
type: "array",
items: {
type: "string",
- enum: [
- "conversionTracking",
- "customLinkPreview",
- "geoTargeting",
- "utmTags",
- "abTest",
- "tags",
- "comments",
- "iosTargeting",
- "androidTargeting",
- "expiration",
- "password",
- "linkCloaking",
- "searchEngineIndexing",
- ],
+ enum: LINK_FEATURES,
},
},
],
}),
|
||
| }); | ||
|
|
||
| const sortBy = z | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,20 @@ import useWorkspaceUsers from "@/lib/swr/use-workspace-users"; | |
| import { TagProps } from "@/lib/types"; | ||
| import { TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/tags"; | ||
| import { Avatar, BlurImage, Globe, Tag, User, useRouterStuff } from "@dub/ui"; | ||
| import { | ||
| AndroidLogo, | ||
| AppleLogo, | ||
| Bolt, | ||
| CircleCheck, | ||
| CircleHalfDottedClock, | ||
| DiamondTurnRight, | ||
| Flask, | ||
| Incognito, | ||
| InputPassword, | ||
| Page2, | ||
| PenWriting, | ||
| WindowSearch, | ||
| } from "@dub/ui/icons"; | ||
| import { GOOGLE_FAVICON_URL, nFormatter } from "@dub/utils"; | ||
| import { useContext, useMemo, useState } from "react"; | ||
| import { useDebounce } from "use-debounce"; | ||
|
|
@@ -31,6 +45,10 @@ export function useLinkFilters() { | |
| folderId: folderId ?? "", | ||
| }); | ||
|
|
||
| const linkFeatures = useLinkFeatureFilterOptions({ | ||
| folderId: folderId ?? "", | ||
| }); | ||
|
|
||
| const { queryParams, searchParamsObj } = useRouterStuff(); | ||
|
|
||
| const filters = useMemo(() => { | ||
|
|
@@ -99,22 +117,37 @@ export function useLinkFilters() { | |
| right: nFormatter(count, { full: true }), | ||
| })) ?? null, | ||
| }, | ||
| { | ||
| key: "linkFeatures", | ||
| icon: CircleCheck, | ||
| label: "Link feature", | ||
| multiple: true, | ||
| options: linkFeatures, | ||
| }, | ||
| ]; | ||
| }, [domains, tags, users]); | ||
| }, [domains, tags, users, linkFeatures]); | ||
|
|
||
| const selectedTagIds = useMemo( | ||
| () => searchParamsObj["tagIds"]?.split(",")?.filter(Boolean) ?? [], | ||
| [searchParamsObj], | ||
| ); | ||
|
|
||
| const selectedLinkFeatures = useMemo( | ||
| () => searchParamsObj["linkFeatures"]?.split(",")?.filter(Boolean) ?? [], | ||
| [searchParamsObj], | ||
| ); | ||
|
|
||
| const activeFilters = useMemo(() => { | ||
| const { domain, tagIds, userId } = searchParamsObj; | ||
| const { domain, tagIds, userId, linkFeatures } = searchParamsObj; | ||
| return [ | ||
| ...(domain ? [{ key: "domain", value: domain }] : []), | ||
| ...(tagIds ? [{ key: "tagIds", value: selectedTagIds }] : []), | ||
| ...(userId ? [{ key: "userId", value: userId }] : []), | ||
| ...(linkFeatures | ||
| ? [{ key: "linkFeatures", value: selectedLinkFeatures }] | ||
| : []), | ||
| ]; | ||
| }, [searchParamsObj]); | ||
| }, [searchParamsObj, selectedTagIds, selectedLinkFeatures]); | ||
|
|
||
| const onSelect = (key: string, value: any) => { | ||
| if (key === "tagIds") { | ||
|
|
@@ -124,6 +157,13 @@ export function useLinkFilters() { | |
| }, | ||
| del: "page", | ||
| }); | ||
| } else if (key === "linkFeatures") { | ||
| queryParams({ | ||
| set: { | ||
| linkFeatures: selectedLinkFeatures.concat(value).join(","), | ||
| }, | ||
| del: "page", | ||
| }); | ||
| } else { | ||
| queryParams({ | ||
| set: { | ||
|
|
@@ -145,6 +185,18 @@ export function useLinkFilters() { | |
| }, | ||
| del: "page", | ||
| }); | ||
| } else if ( | ||
| key === "linkFeatures" && | ||
| !(selectedLinkFeatures.length === 1 && selectedLinkFeatures[0] === value) | ||
| ) { | ||
| queryParams({ | ||
| set: { | ||
| linkFeatures: selectedLinkFeatures | ||
| .filter((feature) => feature !== value) | ||
| .join(","), | ||
| }, | ||
| del: "page", | ||
| }); | ||
| } else { | ||
| queryParams({ | ||
| del: [key, "page"], | ||
|
|
@@ -154,7 +206,7 @@ export function useLinkFilters() { | |
|
|
||
| const onRemoveAll = () => { | ||
| queryParams({ | ||
| del: ["domain", "tagIds", "userId", "search"], | ||
| del: ["domain", "tagIds", "userId", "linkFeatures", "search"], | ||
| }); | ||
| }; | ||
|
|
||
|
|
@@ -293,3 +345,107 @@ function useUserFilterOptions({ folderId }: { folderId: string }) { | |
| [users, usersCount], | ||
| ); | ||
| } | ||
|
|
||
| const FEATURE_OPTIONS = [ | ||
| { | ||
| value: "conversionTracking", | ||
| label: "Conversion Tracking", | ||
| icon: Bolt, | ||
| }, | ||
| { | ||
| value: "customLinkPreview", | ||
| label: "Custom Link Preview", | ||
| icon: PenWriting, | ||
| }, | ||
| { | ||
| value: "geoTargeting", | ||
| label: "Geo Targeting", | ||
| icon: Globe, | ||
| }, | ||
| { | ||
| value: "utmTags", | ||
| label: "UTM Tags", | ||
| icon: DiamondTurnRight, | ||
| }, | ||
| { | ||
| value: "abTest", | ||
| label: "A/B Test", | ||
| icon: Flask, | ||
| }, | ||
| { | ||
| value: "tags", | ||
| label: "Tags", | ||
| icon: Tag, | ||
| }, | ||
| { | ||
| value: "comments", | ||
| label: "Comments", | ||
| icon: Page2, | ||
| }, | ||
| { | ||
| value: "iosTargeting", | ||
| label: "iOS Targeting", | ||
| icon: AppleLogo, | ||
| }, | ||
| { | ||
| value: "androidTargeting", | ||
| label: "Android Targeting", | ||
| icon: AndroidLogo, | ||
| }, | ||
| { | ||
| value: "expiration", | ||
| label: "Expiration", | ||
| icon: CircleHalfDottedClock, | ||
| }, | ||
| { | ||
| value: "password", | ||
| label: "Password", | ||
| icon: InputPassword, | ||
| }, | ||
| { | ||
| value: "linkCloaking", | ||
| label: "Link Cloaking", | ||
| icon: Incognito, | ||
| }, | ||
| { | ||
| value: "searchEngineIndexing", | ||
| label: "Search Engine Indexing", | ||
| icon: WindowSearch, | ||
| }, | ||
| ] as const; | ||
|
|
||
| function useLinkFeatureFilterOptions({ folderId }: { folderId: string }) { | ||
| const { showArchived } = useContext(LinksDisplayContext); | ||
|
|
||
| const counts = FEATURE_OPTIONS.map((feature) => | ||
| useLinksCount<number>({ | ||
| query: { | ||
| linkFeatures: [feature.value], | ||
| showArchived, | ||
| folderId, | ||
| }, | ||
| }), | ||
| ); | ||
|
Comment on lines
+420
to
+428
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: Hook called inside
Consider restructuring to call the hook once with all features, or use a single aggregated query: 🔎 Proposed fix - use a single query with feature countsOne approach is to add a backend endpoint that returns counts for all features in a single request, or restructure to avoid multiple hook calls: function useLinkFeatureFilterOptions({ folderId }: { folderId: string }) {
const { showArchived } = useContext(LinksDisplayContext);
- const counts = FEATURE_OPTIONS.map((feature) =>
- useLinksCount<number>({
- query: {
- linkFeatures: [feature.value],
- showArchived,
- folderId,
- },
- }),
- );
+ // Option 1: Fetch counts for each feature individually at top level
+ const conversionTrackingCount = useLinksCount<number>({
+ query: { linkFeatures: ["conversionTracking"], showArchived, folderId },
+ });
+ const customLinkPreviewCount = useLinksCount<number>({
+ query: { linkFeatures: ["customLinkPreview"], showArchived, folderId },
+ });
+ // ... repeat for each feature
+
+ const counts = [
+ conversionTrackingCount,
+ customLinkPreviewCount,
+ // ... all other counts
+ ];
const isLoading = counts.some(({ loading }) => loading);
const countValues = counts.map(({ data }) => data ?? 0);Alternatively, consider adding a
🧰 Tools🪛 Biome (2.1.2)[error] 421-421: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component. For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order. (lint/correctness/useHookAtTopLevel) |
||
|
|
||
| const isLoading = counts.some(({ loading }) => loading); | ||
| const countValues = counts.map(({ data }) => data ?? 0); | ||
|
|
||
| return useMemo(() => { | ||
| if (isLoading) return null; | ||
|
|
||
| return FEATURE_OPTIONS.map((feature, index) => { | ||
| const count = countValues[index]; | ||
| const Icon = feature.icon; | ||
| return { | ||
| value: feature.value, | ||
| label: feature.label, | ||
| icon: <Icon className="h-4 w-4" />, | ||
| right: nFormatter(count, { full: true }), | ||
| }; | ||
| }).sort((a, b) => { | ||
| const countA = parseInt(a.right?.replace(/,/g, "") || "0"); | ||
| const countB = parseInt(b.right?.replace(/,/g, "") || "0"); | ||
| return countB - countA; | ||
| }); | ||
| }, [isLoading, countValues]); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Default case returns empty object, which matches all records in an OR clause.
When an unknown feature string is passed, the
defaultcase returns{}. In Prisma, an empty object in awhereclause matches all records. Combined withOR, this meansOR: [{ validCondition }, {}]effectively bypasses the filter entirely.Consider filtering out invalid features or returning a condition that matches nothing:
🔎 Proposed fix
🤖 Prompt for AI Agents