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
13 changes: 9 additions & 4 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,11 @@
"description_placeholder": "Enter description",
"pick_a_date": "Pick a date",
"select_user_or_group": {
"add_to_split_pro": "Add to Split Pro",
"add_to_split_pro": "Add to Expense",
"note": "Note: sending invite is disabled for now because of spam",
"only_one_group_time": "You can have only one group at a time",
"send_invite": "Send invite to user",
"warning": "Warning: Don't use send invite if it's invalid email. use add to Split Pro instead. Your account will be blocked if this feature is misused"
"warning": "Warning: Don't use send invite if it's invalid email. Use add to expense instead. Your account will be blocked if this feature is misused"
},
"split_type_section": {
"direction": {
Expand Down Expand Up @@ -324,12 +324,12 @@
"no_members": {
"add_members": "Add members",
"add_members_details": {
"add_to_split_pro": "Add to Split Pro",
"add_to_split_pro": "Add to Group",
"note": "Note: sending email invites is disabled",
"placeholder": "Enter name or email",
"send_invite": "Send invite to user",
"title": "Add members",
"warning": "Warning: Don't use send invite if it's invalid email. use add to Split Pro instead. Your account will be blocked if this feature is misused"
"warning": "Warning: Don't use send invite if it's invalid email. Use add to group instead. Your account will be blocked if this feature is misused"
},
"invite_link": "Invite link",
"no_members": "No members in the group yet."
Expand Down Expand Up @@ -441,6 +441,11 @@
"on": "on",
"or": "or",
"outstanding_balances": "Outstanding balances",
"people_list_empty": {
"no_contacts": "You have not shared expenses with anyone",
"no_matches": "No one matches that name",
"all_added": "Everyone you share expenses with is already in this group"
},
"total_balance": "Total balance",
"select_currency": "Select currency",
"select_balance": "Select balance",
Expand Down
15 changes: 14 additions & 1 deletion src/components/AddExpense/SelectUserOrGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { z } from 'zod';
import { useAddExpenseStore } from '~/store/addStore';
import { api } from '~/utils/api';
import { deserializeDefaultSplit } from '~/lib/defaultSplit';
import { peopleListEmptyReason } from '~/lib/peopleList';

import { EntityAvatar } from '../ui/avatar';
import { Button } from '../ui/button';
Expand Down Expand Up @@ -45,6 +46,13 @@ export const SelectUserOrGroup: React.FC<{
(f.name ?? f.email)?.toLowerCase().includes(nameOrEmail.toLowerCase()),
);

/* Both friends and groups populate this list, so either one counts as having contacts. */
const emptyReason = peopleListEmptyReason({
isLoading: friendsQuery.isPending || groupsQuery.isPending,
hasAnyContacts: 0 < (friendsQuery.data?.length ?? 0) || 0 < (groupsQuery.data?.length ?? 0),
isFiltering: '' !== nameOrEmail.trim(),
});

const onAddEmailClick = useCallback(
(invite = false) => {
if (isEmail.success) {
Expand Down Expand Up @@ -212,8 +220,13 @@ export const SelectUserOrGroup: React.FC<{
) : null}

{0 === filteredFriends?.length && 0 === filteredGroups?.length ? (
<div className="mt-[30%] flex flex-col items-center justify-center gap-20 transition-discrete starting:opacity-0">
<div className="mt-[30%] flex flex-col items-center justify-center gap-8 transition-discrete starting:opacity-0">
<Image alt="empty user image" src="/empty_img.svg" width={250} height={250} />
{emptyReason ? (
<p className="text-center text-sm text-gray-400">
{t(`ui.people_list_empty.${emptyReason}`)}
</p>
) : null}
</div>
) : null}
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/components/group/AddMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from 'zod';

import { Button } from '~/components/ui/button';
import { AppDrawer } from '~/components/ui/drawer';
import { peopleListEmptyReason } from '~/lib/peopleList';
import { api } from '~/utils/api';

import { EntityAvatar } from '../ui/avatar';
Expand Down Expand Up @@ -45,6 +46,15 @@ const AddMembers: React.FC<{
(friend.name ?? friend.email)?.toLowerCase().includes(inputValue.toLowerCase()),
);

/* Says why the list is blank instead of rendering nothing, which reads as a broken dialog. */
const emptyReason = filteredUsers?.length
? null
: peopleListEmptyReason({
isLoading: friendsQuery.isPending,
hasAnyContacts: 0 < (friendsQuery.data?.length ?? 0),
isFiltering: '' !== inputValue.trim(),
});

function onUserSelect(userId: number) {
setUserIds((prev) => ({ ...prev, [userId]: !prev[userId] }));
}
Expand Down Expand Up @@ -160,6 +170,11 @@ const AddMembers: React.FC<{
</div>
</div>
<div className="mt-4 flex flex-col gap-4">
{emptyReason ? (
<p className="text-center text-sm text-gray-400">
{t(`ui.people_list_empty.${emptyReason}`)}
</p>
) : null}
{filteredUsers?.map((friend) => (
<Button
variant="ghost"
Expand Down
48 changes: 48 additions & 0 deletions src/lib/peopleList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { peopleListEmptyReason } from '~/lib/peopleList';

describe('peopleListEmptyReason', () => {
describe('Loading', () => {
it('should say nothing while contacts are still loading', () => {
expect(
peopleListEmptyReason({ isLoading: true, hasAnyContacts: false, isFiltering: false }),
).toBeNull();
});

it('should stay silent while loading even once a filter is typed', () => {
expect(
peopleListEmptyReason({ isLoading: true, hasAnyContacts: true, isFiltering: true }),
).toBeNull();
});
});

describe('NoContacts', () => {
it('should report having no contacts at all', () => {
expect(
peopleListEmptyReason({ isLoading: false, hasAnyContacts: false, isFiltering: false }),
).toBe('no_contacts');
});

it('should report having no contacts even when a filter is typed', () => {
// The filter is irrelevant: there was never anything to match against.
expect(
peopleListEmptyReason({ isLoading: false, hasAnyContacts: false, isFiltering: true }),
).toBe('no_contacts');
});
});

describe('Filtering', () => {
it('should report that the filter matched nobody', () => {
expect(
peopleListEmptyReason({ isLoading: false, hasAnyContacts: true, isFiltering: true }),
).toBe('no_matches');
});
});

describe('AllAdded', () => {
it('should report that everyone has already been added', () => {
expect(
peopleListEmptyReason({ isLoading: false, hasAnyContacts: true, isFiltering: false }),
).toBe('all_added');
});
});
});
32 changes: 32 additions & 0 deletions src/lib/peopleList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type PeopleListEmptyReason = 'no_contacts' | 'no_matches' | 'all_added';

export interface PeopleListEmptyOptions {
isLoading: boolean;
/** Whether the user has any contacts at all, before the search box filters them. */
hasAnyContacts: boolean;
/** Whether the search box currently narrows the list. */
isFiltering: boolean;
}

/**
* Explains why a list of people came out empty, so the UI can say something true rather
* than rendering nothing.
*
* Returns null while the contacts are still loading: showing "you have not shared expenses
* with anyone" before the query resolves would be the same misleading blank in a new form.
*/
export const peopleListEmptyReason = ({
isLoading,
hasAnyContacts,
isFiltering,
}: PeopleListEmptyOptions): PeopleListEmptyReason | null => {
if (isLoading) {
return null;
}

if (!hasAnyContacts) {
return 'no_contacts';
}

return isFiltering ? 'no_matches' : 'all_added';
};