Skip to content

Commit f13e935

Browse files
authored
Merge pull request #16688 from Budibase/fix/user-empty-line-import
Fix user CSV import to ignore empty lines
2 parents 956c8a2 + a809583 commit f13e935

File tree

6 files changed

+147
-29
lines changed

6 files changed

+147
-29
lines changed

packages/bbui/src/Form/RadioGroup.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
import RadioGroup from "./Core/RadioGroup.svelte"
44
import { createEventDispatcher } from "svelte"
55
6-
export let value = null
7-
export let label = null
6+
export let value = undefined
7+
export let label = undefined
88
export let disabled = false
99
export let labelPosition = "above"
10-
export let error = null
10+
export let error = undefined
1111
export let options = []
1212
export let direction = "vertical"
1313
export let getOptionLabel = option => extractProperty(option, "label")
1414
export let getOptionValue = option => extractProperty(option, "value")
1515
export let getOptionTitle = option => extractProperty(option, "label")
16-
export let helpText = null
16+
export let helpText = undefined
1717
1818
const dispatch = createEventDispatcher()
1919
const onChange = e => {

packages/builder/src/helpers/csv.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function parseUserEmailsFromCSV(csvString: string): string[] {
2+
return csvString.split(/\r?\n/).filter(email => email.trim())
3+
}

packages/builder/src/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * as featureFlag from "./featureFlags"
1313
export * as bindings from "./bindings"
1414
export * from "./confirm"
1515
export * from "./date"
16+
export * from "./csv"
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect } from "vitest"
2+
import { parseUserEmailsFromCSV } from "../csv"
3+
4+
describe("parseUserEmailsFromCSV", () => {
5+
it("should filter out empty lines from CSV content", () => {
6+
const csvContent =
7+
8+
9+
const result = parseUserEmailsFromCSV(csvContent)
10+
11+
expect(result).toEqual([
12+
13+
14+
15+
])
16+
})
17+
18+
it("should handle CSV with only whitespace lines", () => {
19+
const csvContent = "[email protected]\n \n\t\[email protected]\n \n"
20+
21+
const result = parseUserEmailsFromCSV(csvContent)
22+
23+
expect(result).toEqual(["[email protected]", "[email protected]"])
24+
})
25+
26+
it("should handle CSV with Windows line endings (\\r\\n)", () => {
27+
const csvContent = "[email protected]\r\n\r\[email protected]\r\n"
28+
29+
const result = parseUserEmailsFromCSV(csvContent)
30+
31+
expect(result).toEqual(["[email protected]", "[email protected]"])
32+
})
33+
34+
it("should handle CSV that ends with empty lines", () => {
35+
const csvContent = "[email protected]\[email protected]\n\n\n\n"
36+
37+
const result = parseUserEmailsFromCSV(csvContent)
38+
39+
expect(result).toEqual(["[email protected]", "[email protected]"])
40+
})
41+
42+
it("should handle CSV that starts with empty lines", () => {
43+
const csvContent = "\n\n\[email protected]\[email protected]"
44+
45+
const result = parseUserEmailsFromCSV(csvContent)
46+
47+
expect(result).toEqual(["[email protected]", "[email protected]"])
48+
})
49+
50+
it("should handle a file with only empty lines", () => {
51+
const csvContent = "\n\n\n \n\t\n"
52+
53+
const result = parseUserEmailsFromCSV(csvContent)
54+
55+
expect(result).toEqual([])
56+
})
57+
58+
it("should handle mixed valid and empty lines", () => {
59+
const csvContent = "\n\[email protected]\n \[email protected]\n\t\n\n"
60+
61+
const result = parseUserEmailsFromCSV(csvContent)
62+
63+
expect(result).toEqual(["[email protected]", "[email protected]"])
64+
})
65+
66+
it("should handle empty string input", () => {
67+
const csvContent = ""
68+
69+
const result = parseUserEmailsFromCSV(csvContent)
70+
71+
expect(result).toEqual([])
72+
})
73+
74+
it("should handle mixed line endings", () => {
75+
const csvContent =
76+
77+
78+
const result = parseUserEmailsFromCSV(csvContent)
79+
80+
expect(result).toEqual([
81+
82+
83+
"[email protected]\[email protected]", // Note: \r alone doesn't split, only \r\n and \n
84+
])
85+
})
86+
87+
it("should preserve whitespace within email addresses", () => {
88+
const csvContent =
89+
90+
91+
const result = parseUserEmailsFromCSV(csvContent)
92+
93+
expect(result).toEqual([
94+
95+
" [email protected] ", // Preserves leading/trailing spaces in email addresses
96+
97+
])
98+
})
99+
})

packages/builder/src/pages/builder/portal/users/users/_components/ImportUsersModal.svelte

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<script>
1+
<script lang="ts">
22
import {
33
Body,
44
ModalContent,
@@ -9,22 +9,25 @@
99
} from "@budibase/bbui"
1010
import { groups, licensing, admin } from "@/stores/portal"
1111
import { emailValidator, Constants } from "@budibase/frontend-core"
12-
import { capitalise } from "@/helpers"
13-
12+
import { capitalise, parseUserEmailsFromCSV } from "@/helpers"
1413
const BYTES_IN_MB = 1000000
1514
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
1615
const MAX_USERS_UPLOAD_LIMIT = 1000
1716
18-
export let createUsersFromCsv
17+
export let createUsersFromCsv: (_data: {
18+
userEmails: string[]
19+
usersRole: string
20+
userGroups: string[]
21+
}) => void
1922
20-
let files = []
21-
let csvString = undefined
22-
let userEmails = []
23-
let userGroups = []
24-
let usersRole = null
23+
let files: File[] = []
24+
let csvString: string | undefined = undefined
25+
let userEmails: string[] = []
26+
let userGroups: string[] = []
27+
let usersRole: string | undefined = undefined
28+
let invalidEmails: string[] = []
2529
26-
$: invalidEmails = []
27-
$: userCount = $licensing.userCount + userEmails.length
30+
$: userCount = ($licensing?.userCount || 0) + userEmails.length
2831
$: exceed = licensing.usersLimitExceeded(userCount)
2932
$: importDisabled =
3033
!userEmails.length || !validEmails(userEmails) || !usersRole || exceed
@@ -35,7 +38,8 @@
3538
3639
$: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync)
3740
38-
const validEmails = userEmails => {
41+
const validEmails = (userEmails: string[]): boolean => {
42+
invalidEmails = [] // Reset invalid emails
3943
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
4044
notifications.error(
4145
`Max limit for upload is 1000 users. Please reduce file size and try again.`
@@ -57,8 +61,11 @@
5761
return false
5862
}
5963
60-
async function handleFile(evt) {
61-
const fileArray = Array.from(evt.target.files)
64+
async function handleFile(evt: Event): Promise<void> {
65+
const target = evt.target as HTMLInputElement
66+
if (!target.files) return
67+
68+
const fileArray = Array.from(target.files)
6269
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) {
6370
notifications.error(
6471
`Files cannot exceed ${
@@ -69,12 +76,14 @@
6976
}
7077
7178
// Read CSV as plain text to upload alongside schema
72-
let reader = new FileReader()
79+
const reader = new FileReader()
7380
reader.addEventListener("load", function (e) {
74-
csvString = e.target.result
75-
files = fileArray
76-
77-
userEmails = csvString.split(/\r?\n/)
81+
const result = e.target?.result
82+
if (typeof result === "string") {
83+
csvString = result
84+
files = fileArray
85+
userEmails = parseUserEmailsFromCSV(csvString)
86+
}
7887
})
7988
reader.readAsText(fileArray[0])
8089
}
@@ -86,7 +95,8 @@
8695
confirmText="Done"
8796
cancelText="Cancel"
8897
showCloseIcon={false}
89-
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })}
98+
onConfirm={() =>
99+
createUsersFromCsv({ userEmails, usersRole: usersRole || "", userGroups })}
90100
disabled={importDisabled}
91101
>
92102
<Body size="S">Import your users email addresses from a CSV file</Body>
@@ -101,20 +111,20 @@
101111
{#if exceed}
102112
<div class="user-notification">
103113
<Icon name="info" />
104-
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}
114+
{capitalise($licensing?.license?.plan?.type || "")} plan is limited to {$licensing?.userLimit}
105115
users. Upgrade your plan to add more users
106116
</div>
107117
{/if}
108118
<RadioGroup bind:value={usersRole} options={roleOptions} />
109119

110-
{#if $licensing.groupsEnabled && internalGroups?.length}
120+
{#if $licensing?.groupsEnabled && internalGroups?.length}
111121
<Multiselect
112122
bind:value={userGroups}
113123
placeholder="No groups"
114124
label="Groups"
115125
options={internalGroups}
116-
getOptionLabel={option => option.name}
117-
getOptionValue={option => option._id}
126+
getOptionLabel={option => option?.name || ""}
127+
getOptionValue={option => option?._id || ""}
118128
/>
119129
{/if}
120130
</ModalContent>

packages/builder/src/pages/builder/portal/users/users/index.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,13 @@
253253
254254
const users: UserInfo[] = []
255255
for (const email of userEmails) {
256+
// Skip empty or whitespace-only emails
257+
if (!email || !email.trim()) {
258+
continue
259+
}
260+
256261
const newUser = {
257-
email: email,
262+
email: email.trim(),
258263
role: usersRole,
259264
password: generatePassword(12),
260265
forceResetPassword: true,

0 commit comments

Comments
 (0)