Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {
Button,
Dialog,
Field,
Flex,
Label,
Select,
Text,
TextArea,
toastManager
} from '@raystack/apsara';
Expand All @@ -30,11 +29,21 @@ import { useTerminology } from '../../../../hooks/useTerminology';
import { handleConnectError } from '~/utils/error';

const inviteSchema = z.object({
role: z.string(),
role: z.string().min(1, { message: 'Role is required' }),
emails: z
.string()
.transform(value => value.split(',').map(str => str.trim()))
.pipe(z.array(z.string().email()))
.min(1, { message: 'Email is required' })
.transform(value =>
value
.split(',')
.map(str => str.trim())
.filter(str => str.length > 0)
)
.pipe(
z
.array(z.string().email({ message: 'Enter valid email address(es)' }))
.min(1, { message: 'Email is required' })
)
});

type InviteSchemaType = z.infer<typeof inviteSchema>;
Expand Down Expand Up @@ -109,6 +118,7 @@ export const InviteUsersDialog = ({ onOpenChange }: InviteUsersDialogProps) => {

const isSubmitting = methods?.formState?.isSubmitting;
const errors = methods?.formState?.errors;

return (
<Dialog open onOpenChange={onOpenChange}>
<Dialog.Content width={600}>
Expand All @@ -119,10 +129,9 @@ export const InviteUsersDialog = ({ onOpenChange }: InviteUsersDialogProps) => {
</Dialog.Header>
<Dialog.Body className={styles['invite-users-dialog-body']}>
<Flex direction="column" gap={7}>
<Flex direction="column" gap={2}>
<Label className={styles['invite-users-dialog-label']}>
Emails
</Label>
<Field
label="Emails"
error={errors?.emails?.message || errors?.emails?.[0]?.message}>
<Controller
name="emails"
control={methods.control}
Expand All @@ -138,17 +147,9 @@ export const InviteUsersDialog = ({ onOpenChange }: InviteUsersDialogProps) => {
);
}}
/>
{errors?.emails?.message || errors?.emails?.length ? (
<Text size="mini" className={styles['form-error-message']}>
{errors?.emails?.message || errors?.emails?.[0]?.message}
</Text>
) : null}
</Flex>
</Field>

<Flex direction="column" gap={2}>
<Label className={styles['invite-users-dialog-label']}>
Role
</Label>
<Field label="Role" error={errors?.role?.message}>
<Controller
name="role"
control={methods.control}
Expand Down Expand Up @@ -177,13 +178,7 @@ export const InviteUsersDialog = ({ onOpenChange }: InviteUsersDialogProps) => {
);
}}
/>

{errors?.role?.message && (
<Text size="mini" className={styles['form-error-message']}>
{errors?.role?.message}
</Text>
)}
</Flex>
</Field>
</Flex>
</Dialog.Body>
<Dialog.Footer>
Expand Down
8 changes: 0 additions & 8 deletions web/sdk/admin/views/users/list/invite-users.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@
gap: var(--rs-space-7);
}

.invite-users-dialog-label {
font-weight: var(--rs-font-weight-medium);
}

.form-error-message {
color: var(--rs-color-foreground-danger-primary);
}

.invite-users-emails-textarea {
resize: vertical;
width: 100%;
Expand Down
184 changes: 86 additions & 98 deletions web/sdk/admin/views/users/list/invite-users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { useMemo, useState, useEffect } from "react";
import {
Button,
Dialog,
Field,
Flex,
Label,
Select,
Text,
TextArea,
Skeleton,
toastManager,
} from "@raystack/apsara";
import { PlusIcon } from "@radix-ui/react-icons";
Expand All @@ -15,7 +15,6 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { SCOPES, DEFAULT_ROLES } from "../../../utils/constants";
import styles from "./invite-users.module.css";
import Skeleton from "react-loading-skeleton";
import { useMutation, useQuery } from "@connectrpc/connect-query";
import {
AdminServiceQueries,
Expand All @@ -29,12 +28,22 @@ import { handleConnectError } from "~/utils/error";
import { useTerminology } from "../../../hooks/useTerminology";

const inviteSchema = z.object({
role: z.string(),
organizationId: z.string(),
role: z.string().min(1, { message: "Role is required" }),
organizationId: z.string().min(1, { message: "Organization is required" }),
emails: z
.string()
.transform(value => value.split(",").map(str => str.trim()))
.pipe(z.array(z.string().email())),
.min(1, { message: "Email is required" })
.transform(value =>
value
.split(",")
.map(str => str.trim())
.filter(str => str.length > 0)
)
.pipe(
z
.array(z.string().email({ message: "Enter valid email address(es)" }))
.min(1, { message: "Email is required" })
),
});

type InviteSchemaType = z.infer<typeof inviteSchema>;
Expand Down Expand Up @@ -67,6 +76,8 @@ export const InviteUser = () => {
}
);

const isLoading = isRolesLoading || isOrganizationsLoading;

useEffect(() => {
if (organizationsError) {
console.error("Failed to fetch organizations:", organizationsError);
Expand Down Expand Up @@ -149,46 +160,41 @@ export const InviteUser = () => {
</Dialog.Header>
<Dialog.Body className={styles["invite-users-dialog-body"]}>
<Flex direction="column" gap={7}>
<Flex direction="column" gap={2}>
<Label className={styles["invite-users-dialog-label"]}>
Emails
</Label>
<Controller
name="emails"
control={control}
render={({ field }) => {
const { value, ...rest } = field;
return (
<TextArea
{...rest}
value={Array.isArray(value) ? value.join(", ") : (value ?? "") as string}
placeholder="abc@example.com, xyz@example.com"
className={styles["invite-users-emails-textarea"]}
/>
);
}}
/>
{(errors?.emails?.message || errors?.emails?.length) && (
<Text size="mini" className={styles["form-error-message"]}>
{errors?.emails?.message || errors?.emails?.[0]?.message}
</Text>
<Field
label="Emails"
error={errors?.emails?.message || errors?.emails?.[0]?.message}>
{isLoading ? (
Comment on lines +163 to +166

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Emails error message only surfaces for the first invalid entry.

errors?.emails?.[0]?.message only catches a validation failure at array index 0. Since the schema validates each comma-separated email independently (z.array(z.string().email())), an invalid email at any later position (e.g. "valid@example.com, bad-email") sets the error at errors.emails[1], which this fallback never reads. Submission stays blocked but the user sees no error message — contrary to the "visible error messages" approach agreed on in prior review discussion.

🐛 Suggested fix
-              <Field
-                label="Emails"
-                error={errors?.emails?.message || errors?.emails?.[0]?.message}>
+              <Field
+                label="Emails"
+                error={
+                  errors?.emails?.message ||
+                  (Array.isArray(errors?.emails)
+                    ? errors.emails.find(e => e?.message)?.message
+                    : undefined)
+                }>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Field
label="Emails"
error={errors?.emails?.message || errors?.emails?.[0]?.message}>
{isLoading ? (
<Field
label="Emails"
error={
errors?.emails?.message ||
(Array.isArray(errors?.emails)
? errors.emails.find(e => e?.message)?.message
: undefined)
}>
{isLoading ? (

<Skeleton height="80px" />
) : (
<Controller
name="emails"
control={control}
render={({ field }) => {
const { value, ...rest } = field;
return (
<TextArea
{...rest}
value={Array.isArray(value) ? value.join(", ") : (value ?? "") as string}
placeholder="abc@example.com, xyz@example.com"
className={styles["invite-users-emails-textarea"]}
/>
);
}}
/>
)}
</Flex>
</Field>

<Flex direction="column" gap={2}>
<Label className={styles["invite-users-dialog-label"]}>
Invite as
</Label>
<Controller
name="role"
defaultValue={defaultRoleId}
disabled={isRolesLoading}
control={control}
render={({ field, fieldState: { error } }) => {
const { ref, ...rest } = field;
if (isRolesLoading) return <Skeleton height={33} />;
Comment thread
rohanchkrabrty marked this conversation as resolved.
return (
<>
<Field label="Invite as" error={errors?.role?.message}>
{isLoading ? (
<Skeleton height="36px" />
) : (
<Controller
name="role"
defaultValue={defaultRoleId}
control={control}
render={({ field }) => {
const { ref, ...rest } = field;
return (
<Select
{...rest}
onValueChange={value => field.onChange(value)}>
Expand All @@ -203,72 +209,54 @@ export const InviteUser = () => {
))}
</Select.Content>
</Select>
{error && (
<Text
size="mini"
className={styles["form-error-message"]}>
{error?.message}
</Text>
)}
</>
);
}}
/>
</Flex>
);
}}
/>
)}
</Field>

<Flex direction="column" gap={2}>
<Label className={styles["invite-users-dialog-label"]}>
{t.organization({ case: "capital" })}
</Label>
<Controller
<Field
label={t.organization({ case: "capital" })}
error={errors?.organizationId?.message}>
{isLoading ? (<Skeleton height="36px" />) : (<Controller
name="organizationId"
disabled={isOrganizationsLoading}
control={control}
render={({ field, fieldState: { error } }) => {
render={({ field }) => {
const { ref, ...rest } = field;
if (isOrganizationsLoading) return <Skeleton height={33} />;
return (
<>
<Select
{...rest}
autocomplete
onValueChange={value => field.onChange(value)}>
<Select.Trigger ref={ref}>
<Select.Value
placeholder={`Select ${t.organization({ case: "capital" })}`}
/>
</Select.Trigger>
<Select.Content
searchPlaceholder={`Search ${t.organization({ case: "lower" })}`}
style={{
maxHeight: 280,
overflowY: "auto",
}}>
{organizations?.map(org => (
<Select.Item key={org.id} value={org.id ?? ""}>
{org.title || org.name}
</Select.Item>
))}
</Select.Content>
</Select>
{error && (
<Text
size="mini"
className={styles["form-error-message"]}>
{error?.message}
</Text>
)}
</>
<Select
{...rest}
autocomplete
onValueChange={value => field.onChange(value)}>
<Select.Trigger ref={ref}>
<Select.Value
placeholder={`Select ${t.organization({ case: "capital" })}`}
/>
</Select.Trigger>
<Select.Content
searchPlaceholder={`Search ${t.organization({ case: "lower" })}`}
style={{
maxHeight: 280,
overflowY: "auto",
}}>
{organizations?.map(org => (
<Select.Item key={org.id} value={org.id ?? ""}>
{org.title || org.name}
</Select.Item>
))}
</Select.Content>
</Select>
);
}}
/>
</Flex>
/>)}
</Field>
</Flex>
</Dialog.Body>
<Dialog.Footer>
<Button
data-test-id="users-list-invite-user-submit-btn"
type="submit"
disabled={isLoading}
loading={isSubmitting}
loaderText="Sending...">
Send invite
Expand Down
Loading
Loading