Skip to content
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

feat(ui): create environment UI #3280

Merged
merged 4 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 15 additions & 3 deletions packages/server/lib/controllers/v1/environment/postEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
import { asyncWrapper } from '../../../utils/asyncWrapper.js';
import type { PostEnvironment } from '@nangohq/types';
import { environmentService, externalWebhookService } from '@nangohq/shared';
import { accountService, environmentService, externalWebhookService } from '@nangohq/shared';
import { envSchema } from '../../../helpers/validation.js';

const validationBody = z
Expand All @@ -28,9 +28,21 @@ export const postEnvironment = asyncWrapper<PostEnvironment>(async (req, res) =>

const accountId = res.locals.user.account_id;

const exists = await environmentService.getAccountAndEnvironment({ accountId, envName: body.name });
const account = await accountService.getAccountById(accountId);
if (account?.is_capped) {
res.status(400).send({ error: { code: 'feature_disabled', message: 'Creating environment is only available for paying customer' } });
return;
}

const environments = await environmentService.getEnvironmentsByAccountId(accountId);
if (environments.length >= 10) {
res.status(400).send({ error: { code: 'resource_capped', message: "Can't create more environment" } });
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
res.status(400).send({ error: { code: 'resource_capped', message: "Can't create more environment" } });
res.status(400).send({ error: { code: 'resource_capped', message: "Can't create more environments" } });

return;
}

const exists = environments.some((env) => env.name === body.name);
if (exists) {
res.status(409).send({ error: { code: 'invalid_body', message: 'Environment already exists' } });
res.status(409).send({ error: { code: 'conflict', message: 'Environment already exists' } });
return;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/server/lib/helpers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const connectionIdSchema = z
.max(255);
export const envSchema = z
.string()
.regex(/^[a-zA-Z0-9_-]+$/)
.regex(/^[a-z0-9_-]+$/)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It was an oversight, prod has no env with an uppercase

.max(255);
export const connectSessionTokenPrefix = 'nango_connect_session_';
export const connectSessionTokenSchema = z.string().regex(new RegExp(`^${connectSessionTokenPrefix}[a-f0-9]{64}$`));
Expand Down
1 change: 1 addition & 0 deletions packages/types/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ValidationError {

export type ResDefaultErrors =
| ApiError<'not_found'>
| ApiError<'conflict'>
| ApiError<'invalid_query_params', ValidationError[]>
| ApiError<'invalid_body', ValidationError[]>
| ApiError<'invalid_uri_params', ValidationError[]>
Expand Down
84 changes: 75 additions & 9 deletions packages/webapp/src/components/EnvironmentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,33 @@ import { IconCheck, IconChevronDown } from '@tabler/icons-react';
import { Button } from './ui/button/Button';
import { useStore } from '../store';
import { cn } from '../utils/utils';
import { useEnvironment } from '../hooks/useEnvironment';
import { apiPostEnvironment } from '../hooks/useEnvironment';
import { Dialog, DialogContent, DialogFooter, DialogTitle, DialogTrigger, DialogClose } from '../components/ui/Dialog';
import { Input } from './ui/input/Input';
import { Info } from './Info';
import { useToast } from '../hooks/useToast';

export const EnvironmentPicker: React.FC = () => {
const navigate = useNavigate();
const { toast } = useToast();

const env = useStore((state) => state.env);
const setEnv = useStore((state) => state.setEnv);

const { meta } = useMeta();
const { mutate } = useEnvironment(env);
const { meta, mutate } = useMeta();
const [open, setOpen] = useState(false);

const [openDialog, setOpenDialog] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [name, setName] = useState('');

const onSelect = (selected: string) => {
if (selected === env) {
return;
}

setEnv(selected);
void mutate();

const pathSegments = window.location.pathname.split('/').filter(Boolean);

Expand All @@ -43,6 +51,33 @@ export const EnvironmentPicker: React.FC = () => {
navigate(newPath);
};

const onCreate = async () => {
setLoading(true);

const res = await apiPostEnvironment({ name });
if ('error' in res.json) {
const err = res.json.error;
if (err.code === 'conflict') {
toast({ title: 'Environment name already exists', variant: 'error' });
} else if (err.code === 'invalid_body') {
setError(true);
} else if (err.code === 'feature_disabled' || err.code === 'resource_capped') {
toast({ title: err.message, variant: 'error' });
} else {
toast({ title: 'Failed to create environment', variant: 'error' });
}
} else {
navigate(`/${res.json.data.name}`);
setOpen(false);
setOpenDialog(false);
setError(false);
setName('');
void mutate();
}

setLoading(false);
};

if (!meta) {
return;
}
Expand Down Expand Up @@ -79,11 +114,42 @@ export const EnvironmentPicker: React.FC = () => {
))}
</CommandGroup>

{/* <div className="px-2.5 py-2.5">
<Button variant={'tertiary'} className="w-full justify-center">
Create environment
</Button>
</div> */}
<div className="px-2.5 py-2.5">
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogTrigger asChild>
<Button variant={'tertiary'} className="w-full justify-center">
Create environment
</Button>
</DialogTrigger>
<DialogContent className="w-[550px]">
<DialogTitle>Environment Name</DialogTitle>
<div>
<Input
placeholder="my-environment-name"
value={name}
onChange={(e) => setName(e.target.value)}
variant={'black'}
onKeyUp={(e) => e.code === 'Enter' && onCreate()}
/>
<div className={cn('text-xs text-grayscale-500', error && 'text-alert-400')}>
*Must be lowercase letters, numbers, underscores and dashes.
</div>
</div>
<Info>
Only the Prod environment is billed. Other environments are free, with restrictions making them unsuitable for
production.
</Info>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant={'zinc'}>Cancel</Button>
</DialogClose>
<Button variant={'primary'} onClick={onCreate} isLoading={loading} type="submit">
Create environment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CommandList>
</Command>
</PopoverContent>
Expand Down
2 changes: 1 addition & 1 deletion packages/webapp/src/components/ui/Command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const CommandItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.It
<CommandPrimitive.Item
ref={ref}
className={cn(
'px-2 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 text-gray-400 relative flex cursor-pointer rounded select-none items-center py-1.5 pl-2 pr-2 text-sm outline-none transition-colors aria-selected:bg-pure-black aria-selected:text-white',
'px-2 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 text-gray-400 relative flex cursor-pointer rounded select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors aria-selected:bg-pure-black aria-selected:text-white',
className
)}
{...props}
Expand Down
15 changes: 14 additions & 1 deletion packages/webapp/src/hooks/useEnvironment.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import useSWR from 'swr';
import type { EnvironmentAndAccount } from '@nangohq/server';
import { swrFetcher } from '../utils/api';
import { apiFetch, swrFetcher } from '../utils/api';
import type { PostEnvironment } from '@nangohq/types';

export function useEnvironment(env: string) {
const { data, error, mutate } = useSWR<{ environmentAndAccount: EnvironmentAndAccount }>(`/api/v1/environment?env=${env}`, swrFetcher, {});
Expand All @@ -14,3 +15,15 @@ export function useEnvironment(env: string) {
mutate
};
}

export async function apiPostEnvironment(body: PostEnvironment['Body']) {
const res = await apiFetch('/api/v1/environments', {
method: 'POST',
body: JSON.stringify(body)
});

return {
res,
json: (await res.json()) as PostEnvironment['Reply']
};
}
4 changes: 2 additions & 2 deletions packages/webapp/src/pages/Team/components/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const UserAction: React.FC<{ user: ApiUser }> = ({ user }) => {
setLoading(true);
const updated = await apiDeleteTeamUser(env, { id: user.id });

if ('error' in updated) {
if ('error' in updated.json) {
toast({ title: 'An unexpected error occurred', variant: 'error' });
} else {
if (user.id === me!.id) {
Expand Down Expand Up @@ -89,7 +89,7 @@ export const InvitationAction: React.FC<{ invitation: ApiInvitation }> = ({ invi
setLoading(true);
const deleted = await apiDeleteInvite(env, { email: invitation.email });

if ('error' in deleted) {
if ('error' in deleted.json) {
toast({ title: 'An unexpected error occurred', variant: 'error' });
} else {
toast({ title: `${invitation.email}'s invitation has been revoked`, variant: 'success' });
Expand Down
2 changes: 1 addition & 1 deletion packages/webapp/src/pages/Team/components/Info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const TeamInfo: React.FC = () => {
const onSave = async () => {
const updated = await apiPutTeam(env, { name });

if ('error' in updated) {
if ('error' in updated.json) {
toast({ title: 'An unexpected error occurred', variant: 'error' });
} else {
toast({ title: 'Team updated successfully', variant: 'success' });
Expand Down
Loading