Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { useFetchIdentities } from "@/app/(app)/apis/[apiId]/_components/create-
import { createIdentityOptions } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/create-identity-options";
import { FormCombobox } from "@/components/ui/form-combobox";
import { TriangleWarning2 } from "@unkey/icons";
import { Button } from "@unkey/ui";
import { Button, Loading } from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { useState } from "react";
import { useMemo, useState, useTransition } from "react";
import { useSearchIdentities } from "./use-search-identities";

type ExternalIdFieldProps = {
value: string | null;
onChange: (id: string | null) => void;
onChange: (identityId: string | null, externalId: string | null) => void;
error?: string;
disabled?: boolean;
};
Expand All @@ -21,12 +22,31 @@ export const ExternalIdField = ({
disabled = false,
}: ExternalIdFieldProps) => {
const [searchValue, setSearchValue] = useState("");
const [isPending, startTransition] = useTransition();
const { identities, isFetchingNextPage, hasNextPage, loadMore } = useFetchIdentities();
const { searchResults, isSearching } = useSearchIdentities(searchValue);

const createIdentity = useCreateIdentity((data) => {
onChange(data.identityId);
onChange(data.identityId, data.externalId);
});

// Combine loaded identities with search results, prioritizing search when available
const allIdentities = useMemo(() => {
if (searchValue.trim() && searchResults.length > 0) {
// When searching, use search results
return searchResults;
}
if (searchValue.trim() && searchResults.length === 0 && !isSearching) {
// No search results found, filter from loaded identities as fallback
const searchTerm = searchValue.toLowerCase().trim();
return identities.filter((identity) =>
identity.externalId.toLowerCase().includes(searchTerm),
);
}
// No search query, use all loaded identities
return identities;
}, [identities, searchResults, searchValue, isSearching]);
Comment thread
ogzhanolguncu marked this conversation as resolved.
Outdated

const handleCreateIdentity = () => {
if (searchValue.trim()) {
createIdentity.mutate({
Expand All @@ -36,27 +56,30 @@ export const ExternalIdField = ({
}
};

const exactMatch = identities.some(
const exactMatch = allIdentities.some(
(id) => id.externalId.toLowerCase() === searchValue.toLowerCase().trim(),
);

const filteredIdentities = searchValue.trim()
? identities.filter((identity) =>
? allIdentities.filter((identity) =>
identity.externalId.toLowerCase().includes(searchValue.toLowerCase().trim()),
)
: identities;
: allIdentities;

const hasPartialMatches = filteredIdentities.length > 0;

// Don't show load more when actively searching
const showLoadMore = !searchValue.trim() && hasNextPage;

const baseOptions = createIdentityOptions({
identities: filteredIdentities,
hasNextPage,
hasNextPage: showLoadMore,
isFetchingNextPage,
loadMore,
});

const createOption =
searchValue.trim() && !exactMatch && hasPartialMatches
searchValue.trim() && !exactMatch && hasPartialMatches && !isSearching
? {
label: (
<div className="flex items-center gap-2 w-full">
Expand All @@ -82,6 +105,9 @@ export const ExternalIdField = ({

const options = createOption ? [createOption, ...baseOptions] : baseOptions;

// Determine if we're in a transitional state
const isTransitioning = isSearching || isPending;

return (
<FormCombobox
optional
Expand All @@ -90,31 +116,58 @@ export const ExternalIdField = ({
options={options}
key={value}
value={value || ""}
onChange={(e) => setSearchValue(e.currentTarget.value)}
onChange={(e) => {
const newValue = e.currentTarget.value;
startTransition(() => {
setSearchValue(newValue);
});
}}
onSelect={(val) => {
if (val === "__create_new__") {
handleCreateIdentity();
return;
}
const identity = identities.find((id) => id.id === val);
onChange(identity?.id || null);
const identity = allIdentities.find((id) => id.id === val);
onChange(identity?.id || null, identity?.externalId || null);
}}
placeholder={
<div className="flex w-full text-grayA-8 text-xs gap-1.5 items-center py-2">
Select External ID
<div className="relative flex w-full text-grayA-8 text-xs items-center py-2">
<div
className={cn(
"absolute inset-0 flex items-center gap-1.5 transition-opacity duration-200 ease-in-out",
isTransitioning ? "opacity-100" : "opacity-0",
)}
>
<Loading />
<span>Searching...</span>
</div>
<div
className={cn(
"flex items-center transition-opacity duration-200 ease-in-out",
isTransitioning ? "opacity-0" : "opacity-100",
)}
>
Select External ID
</div>
</div>
}
searchPlaceholder="Search External ID..."
emptyMessage={
searchValue.trim() && !exactMatch ? (
<div className="p-0 max-w-[600px]">
<div
className={cn(
"p-0 w-full transition-all duration-300 ease-in-out",
"animate-in fade-in-0 slide-in-from-top-2",
)}
>
<div className="px-3 py-3 w-full">
<div className="flex gap-2 items-center justify-start">
<div
className={cn(
"flex items-center rounded size-5 justify-center",
"bg-warningA-4",
"text-warning-11",
"transition-colors duration-200",
)}
>
<TriangleWarning2 size="sm-regular" />
Expand All @@ -137,7 +190,11 @@ export const ExternalIdField = ({
type="button"
variant="primary"
size="xlg"
className="rounded-lg w-full"
className={cn(
"rounded-lg w-full",
"transition-all duration-200 ease-in-out",
"hover:scale-[1.02] active:scale-[0.98]",
)}
onClick={handleCreateIdentity}
loading={createIdentity.isLoading}
disabled={!searchValue.trim() || createIdentity.isLoading || disabled}
Expand All @@ -147,7 +204,15 @@ export const ExternalIdField = ({
</div>
</div>
) : (
<div className="px-3 py-3 text-gray-10 text-[13px]">No results found</div>
<div
className={cn(
"px-3 mt-2 text-gray-10 text-[13px]",
"transition-all duration-200 ease-in-out",
"animate-in fade-in-0",
)}
>
No results found
</div>
)
}
variant="default"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { trpc } from "@/lib/trpc/client";
import { useEffect, useMemo, useState } from "react";

export const useSearchIdentities = (query: string, debounceMs = 300) => {
const [debouncedQuery, setDebouncedQuery] = useState("");

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query.trim());
}, debounceMs);

return () => clearTimeout(timer);
}, [query, debounceMs]);

const { data, isLoading, error } = trpc.identity.search.useQuery(
{ query: debouncedQuery },
{
enabled: debouncedQuery.length > 0,
staleTime: 30_000,
},
);

const searchResults = useMemo(() => {
return data?.identities || [];
}, [data?.identities]);

const isSearching = query.trim() !== debouncedQuery || (debouncedQuery.length > 0 && isLoading);

return {
searchResults,
isSearching,
searchError: error,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const GeneralSetup = () => {
const {
register,
formState: { errors },
setValue,
control,
} = useFormContext<FormValues>();

Expand All @@ -34,18 +35,23 @@ export const GeneralSetup = () => {
optional
{...register("prefix")}
/>

<Controller
name="externalId"
name="identityId"
control={control}
defaultValue=""
render={({ field }) => (
<ExternalIdField
value={field.value ?? null}
onChange={field.onChange}
onChange={(identityId: string | null, externalId: string | null) => {
field.onChange(identityId);
setValue("externalId", externalId);
}}
error={errors.externalId?.message}
Comment thread
ogzhanolguncu marked this conversation as resolved.
/>
)}
/>

<FormInput
className="[&_input:first-of-type]:h-[36px]"
label="Bytes"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export const generalSchema = z.object({
.max(256, { message: "External ID cannot exceed 256 characters" })
.optional()
.nullish(),
identityId: z
.string()
.trim()
.max(256, { message: "Identity ID cannot exceed 256 characters" })
.optional()
.nullish(),
name: nameSchema,
Comment thread
ogzhanolguncu marked this conversation as resolved.
environment: z
.string()
Expand Down Expand Up @@ -314,6 +320,10 @@ export const createKeyInputSchema = z.object({
.string()
.max(256, { message: "External ID cannot exceed 256 characters" })
.nullish(),
identityId: z
.string()
.max(256, { message: "Identity ID cannot exceed 256 characters" })
.nullish(),
Comment thread
ogzhanolguncu marked this conversation as resolved.
meta: z.record(z.unknown()).optional(),
remaining: z.number().int().positive().optional(),
refill: z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const formValuesToApiInput = (formValues: FormValues, keyAuthId: string):
prefix: formValues.prefix === "" ? undefined : formValues.prefix,
bytes: formValues.bytes,
externalId: formValues.externalId || null,
identityId: formValues.identityId || null,
name: formValues.name === "" ? undefined : formValues.name,
enabled: true,
environment: formValues.environment === "" ? undefined : formValues.name,
Comment thread
ogzhanolguncu marked this conversation as resolved.
Expand Down Expand Up @@ -76,7 +77,10 @@ export const getFieldsFromSchema = (schema: unknown, prefix = ""): string[] => {
return [];
}

const schemaObj = schema as { shape: Record<string, unknown>; _def?: { typeName: string } };
const schemaObj = schema as {
shape: Record<string, unknown>;
_def?: { typeName: string };
};

return Object.keys(schemaObj.shape).flatMap((key) => {
const fullPath = prefix ? `${prefix}.${key}` : key;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const useCreateIdentity = (
});

trpcUtils.identity.query.invalidate();
trpcUtils.identity.search.invalidate();

if (onSuccess) {
onSuccess(data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from "@/components/ui/toaster";
import { trpc } from "@/lib/trpc/client";
import { useMemo } from "react";

export const useFetchIdentities = (limit = 50) => {
export const useFetchIdentities = (limit = 10) => {
const trpcUtils = trpc.useUtils();

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const EditExternalId = ({
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(
keyDetails.identity_id || null,
);
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false);
Comment thread
ogzhanolguncu marked this conversation as resolved.
const clearButtonRef = useRef<HTMLButtonElement>(null);

Expand All @@ -36,6 +37,7 @@ export const EditExternalId = ({
ownerType: "v2",
identity: {
id: selectedIdentityId,
externalId: selectedExternalId,
},
});
};
Expand Down Expand Up @@ -114,7 +116,10 @@ export const EditExternalId = ({
</div>
<ExternalIdField
value={selectedIdentityId}
onChange={setSelectedIdentityId}
onChange={(identityId: string | null, externalId: string | null) => {
setSelectedIdentityId(identityId);
setSelectedExternalId(externalId);
}}
disabled={updateKeyOwner.isLoading || Boolean(originalIdentityId)}
Comment thread
ogzhanolguncu marked this conversation as resolved.
/>
</DialogContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const BatchEditExternalId = ({
onClose,
}: BatchEditExternalIdProps): JSX.Element => {
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false);
const clearButtonRef = useRef<HTMLButtonElement>(null);

Expand All @@ -32,6 +33,7 @@ export const BatchEditExternalId = ({
ownerType: "v2",
identity: {
id: selectedIdentityId,
externalId: selectedExternalId,
},
});
};
Expand All @@ -57,6 +59,7 @@ export const BatchEditExternalId = ({
ownerType: "v2",
identity: {
id: null,
externalId: null,
},
});
};
Expand Down Expand Up @@ -141,7 +144,10 @@ export const BatchEditExternalId = ({
<div className="my-2">
<ExternalIdField
value={selectedIdentityId}
onChange={setSelectedIdentityId}
onChange={(identityId: string | null, externalId: string | null) => {
setSelectedIdentityId(identityId);
setSelectedExternalId(externalId);
}}
disabled={updateKeyOwner.isLoading}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/lib/trpc/routers/identity/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const identitiesQueryPayload = z.object({
limit: z.number().optional().default(50),
});

const IdentityResponseSchema = z.object({
export const IdentityResponseSchema = z.object({
id: z.string(),
externalId: z.string(),
workspaceId: z.string(),
Expand Down
Loading
Loading