Skip to content

feat: orgs IDP sync - add combobox to select claim field value when sync field is set #16335

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

Merged
merged 9 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions site/e2e/tests/deployment/idpOrgSync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,20 @@ test.describe("IdpOrgSyncPage", () => {
waitUntil: "domcontentloaded",
});

const syncField = page.getByRole("textbox", {
name: "Organization sync field",
});
await syncField.fill("");

const idpOrgInput = page.getByLabel("IdP organization name");
const addButton = page.getByRole("button", {
name: /Add IdP organization/i,
});

await expect(addButton).toBeDisabled();

await idpOrgInput.fill("new-idp-org");
const idpOrgName = randomName();
await idpOrgInput.fill(idpOrgName);

// Select Coder organization from combobox
const orgSelector = page.getByPlaceholder("Select organization");
Expand All @@ -177,11 +183,9 @@ test.describe("IdpOrgSyncPage", () => {
await addButton.click();

// Verify new mapping appears in table
const newRow = page.getByTestId("idp-org-new-idp-org");
const newRow = page.getByTestId(`idp-org-${idpOrgName}`);
await expect(newRow).toBeVisible();
await expect(
newRow.getByRole("cell", { name: "new-idp-org" }),
).toBeVisible();
await expect(newRow.getByRole("cell", { name: idpOrgName })).toBeVisible();
await expect(newRow.getByRole("cell", { name: orgName })).toBeVisible();

await expect(
Expand Down
17 changes: 17 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,23 @@ class ApiMethods {
return response.data;
};

getIdpSyncClaimFieldValues = async (claimField: string) => {
const response = await this.axios.get<string[]>(
`/api/v2/settings/idpsync/field-values?claimField=${claimField}`,
);
return response.data;
};

getIdpSyncClaimFieldValuesByOrganization = async (
organization: string,
claimField: string,
) => {
const response = await this.axios.get<TypesGen.Response>(
`/api/v2/organizations/${organization}/settings/idpsync/field-values?claimField=${claimField}`,
);
return response.data;
};

getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
const response = await this.axios.get<TypesGen.Template>(
`/api/v2/templates/${templateId}`,
Expand Down
32 changes: 32 additions & 0 deletions site/src/api/queries/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,35 @@ export const organizationsPermissions = (
},
};
};

export const getOrganizationIdpSyncClaimFieldValuesKey = (
organization: string,
claimField: string,
) => [organization, claimField, "organizationIdpSyncClaimFieldValues"];

export const organizationIdpSyncClaimFieldValues = (
organization: string,
claimField: string,
) => {
return {
queryKey: getOrganizationIdpSyncClaimFieldValuesKey(
organization,
claimField,
),
queryFn: () =>
API.getIdpSyncClaimFieldValuesByOrganization(organization, claimField),
};
};

export const getIdpSyncClaimFieldValuesKey = (claimField: string) => [
claimField,
"idpSyncClaimFieldValues",
];

export const idpSyncClaimFieldValues = (claimField: string) => {
return {
queryKey: getIdpSyncClaimFieldValuesKey(claimField),
queryFn: () => API.getIdpSyncClaimFieldValues(claimField),
enabled: !!claimField,
};
};
93 changes: 93 additions & 0 deletions site/src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Button } from "components/Button/Button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "components/Command/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { Check, ChevronDown, CornerDownLeft } from "lucide-react";
import type { FC, KeyboardEventHandler } from "react";
import { cn } from "utils/cn";

interface ComboboxProps {
value: string;
options?: string[];
placeholder?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
inputValue: string;
onInputChange: (value: string) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
onSelect: (value: string) => void;
}

export const Combobox: FC<ComboboxProps> = ({
value,
options = [],
placeholder = "Select option",
open,
onOpenChange,
inputValue,
onInputChange,
onKeyDown,
onSelect,
}) => {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-72 justify-between group"
>
<span className={cn(!value && "text-content-secondary")}>
{value || placeholder}
</span>
<ChevronDown className="size-icon-sm text-content-secondary group-hover:text-content-primary" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<Command>
<CommandInput
placeholder="Search or enter custom value"
value={inputValue}
onValueChange={onInputChange}
onKeyDown={onKeyDown}
/>
<CommandList>
<CommandEmpty>
<p>No results found</p>
<span className="flex flex-row items-center justify-center gap-1">
Enter custom value
<CornerDownLeft className="size-icon-sm bg-surface-tertiary rounded-sm p-1" />
</span>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={(currentValue) => {
onSelect(currentValue === value ? "" : currentValue);
}}
>
{option}
{value === option && (
<Check className="size-icon-sm ml-auto" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
7 changes: 5 additions & 2 deletions site/src/components/Command/Command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const CommandInput = forwardRef<
<CommandPrimitive.Input
ref={ref}
className={cn(
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none
placeholder:text-content-secondary
disabled:cursor-not-allowed disabled:opacity-50`,
className,
Expand All @@ -69,7 +69,10 @@ export const CommandList = forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-96 overflow-y-auto overflow-x-hidden", className)}
className={cn(
"max-h-96 overflow-y-auto overflow-x-hidden border-0 border-t border-solid border-border",
className,
)}
{...props}
/>
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ export const MultiSelectCombobox = forwardRef<
>
<X className="h-5 w-5" />
</button>
<ChevronDown className="h-5 w-5 cursor-pointer text-content-secondary hover:text-content-primary" />
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</div>
</div>
</div>
Expand Down
13 changes: 7 additions & 6 deletions site/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@ export const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md ",
"border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm ",
"ring-offset-background text-content-secondary placeholder:text-content-secondary focus:outline-none ",
"focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
`flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm
ring-offset-background text-content-secondary placeholder:text-content-secondary focus:outline-none,
focus:ring-2 focus:ring-content-link disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link`,
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-icon-sm opacity-50" />
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
Expand Down Expand Up @@ -65,7 +66,7 @@ export const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
<ChevronDown className="size-icon-sm" />
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lot of duplication between this and the ChevronDown 35 lines up

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats sort of the way of Tailwind, duplication of styles is ok to a certain extent. It would feel strange to create a separate component just because 2 icons have the same styles.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't need to be a component, it could be a string constant or something

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracting classNames into strings is a pattern that I would argue we don't want to go down. I follow the idealogy that it is sometimes ok to have repeated classNames until it makes sense to use some technique like creating a new component to manage the duplication. https://v3.tailwindcss.com/docs/reusing-styles

</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
Expand Down
9 changes: 8 additions & 1 deletion site/src/modules/management/OrganizationSidebarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
SettingsSidebarNavItem,
} from "components/Sidebar/Sidebar";
import type { Permissions } from "contexts/auth/permissions";
import { ChevronDown, Plus } from "lucide-react";
import { Check, ChevronDown, Plus } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { type FC, useState } from "react";
import { useNavigate } from "react-router-dom";
Expand Down Expand Up @@ -147,6 +147,13 @@ const OrganizationsSettingsNavigation: FC<
<span className="truncate">
{organization?.display_name || organization?.name}
</span>
{activeOrganization.name === organization.name && (
<Check
size={16}
strokeWidth={2}
className="ml-auto"
/>
)}
</CommandItem>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
organizationIdpSyncSettings,
patchOrganizationSyncSettings,
} from "api/queries/idpsync";
import { idpSyncClaimFieldValues } from "api/queries/organizations";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { displayError } from "components/GlobalSnackbar/utils";
import { displaySuccess } from "components/GlobalSnackbar/utils";
Expand All @@ -11,7 +12,7 @@ import { Loader } from "components/Loader/Loader";
import { Paywall } from "components/Paywall/Paywall";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { type FC, useEffect } from "react";
import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { docs } from "utils/docs";
Expand All @@ -20,6 +21,7 @@ import { ExportPolicyButton } from "./ExportPolicyButton";
import IdpOrgSyncPageView from "./IdpOrgSyncPageView";

export const IdpOrgSyncPage: FC = () => {
const [claimField, setClaimField] = useState("");
const queryClient = useQueryClient();
// IdP sync does not have its own entitlement and is based on templace_rbac
const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility();
Expand All @@ -28,7 +30,18 @@ export const IdpOrgSyncPage: FC = () => {
data: orgSyncSettingsData,
isLoading,
error,
} = useQuery(organizationIdpSyncSettings(isIdpSyncEnabled));
} = useQuery({
...organizationIdpSyncSettings(isIdpSyncEnabled),
onSuccess: (data) => {
if (data?.field) {
setClaimField(data.field);
}
},
});

const { data: claimFieldValues } = useQuery(
idpSyncClaimFieldValues(claimField),
);

const patchOrganizationSyncSettingsMutation = useMutation(
patchOrganizationSyncSettings(queryClient),
Expand All @@ -49,6 +62,10 @@ export const IdpOrgSyncPage: FC = () => {
return <Loader />;
}

const handleSyncFieldChange = (value: string) => {
setClaimField(value);
};

return (
<>
<Helmet>
Expand Down Expand Up @@ -94,6 +111,8 @@ export const IdpOrgSyncPage: FC = () => {
);
}
}}
onSyncFieldChange={handleSyncFieldChange}
claimFieldValues={claimFieldValues}
error={error || patchOrganizationSyncSettingsMutation.error}
/>
</Cond>
Expand Down
Loading
Loading