Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
engine-strict=true
engine-strict=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
query AdminMemberListContainerMembersByCommunityId($communityId: ObjectID!) {
membersByCommunityId(communityId: $communityId) {
...AdminMemberListContainerMemberFields
}
}

fragment AdminMemberListContainerMemberFields on Member {
id
memberName
isAdmin
accounts {
id
firstName
lastName
statusCode
createdAt
}
profile {
email
avatarDocumentId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useQuery } from '@apollo/client';
import { ComponentQueryLoader } from '@cellix/ui-core';
import { Button } from 'antd';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { AdminMemberListContainerMembersByCommunityIdDocument, type AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx';
import { MemberList, type MemberListProps } from './member-list.tsx';

export const MemberListContainer: React.FC = () => {
const params = useParams();
// biome-ignore lint:useLiteralKeys
const communityId = params['communityId'] ?? '';
const [searchValue, setSearchValue] = useState('');
const navigate = useNavigate();

const { data, loading, error } = useQuery(AdminMemberListContainerMembersByCommunityIdDocument, {
variables: { communityId },
skip: !communityId,
});

const memberListProps: MemberListProps = {
data: (data?.membersByCommunityId ?? []) as AdminMemberListContainerMemberFieldsFragment[],
searchValue,
onSearchChange: setSearchValue,
communityId,
};

return (
<div>
<div className="flex justify-end mb-4">
<Button
type="primary"
onClick={() => navigate('create')}
>
Create Member
</Button>
</div>
<ComponentQueryLoader
loading={loading}
hasData={data?.membersByCommunityId}
hasDataComponent={<MemberList {...memberListProps} />}
error={error}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { fn } from 'storybook/test';
import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx';
import { MemberList } from './member-list.tsx';

const mockMembers: AdminMemberListContainerMemberFieldsFragment[] = [
{
__typename: 'Member',
id: '507f1f77bcf86cd799439011',
memberName: 'Smith Residence',
isAdmin: true,
accounts: [
{
__typename: 'MemberAccount',
id: 'acc-001',
firstName: 'John',
lastName: 'Smith',
statusCode: 'ACCEPTED',
createdAt: '2024-01-10T12:00:00.000Z',
},
],
profile: {
__typename: 'MemberProfile',
email: 'john.smith@example.com',
avatarDocumentId: null,
},
},
{
__typename: 'Member',
id: '507f1f77bcf86cd799439012',
memberName: 'Johnson Residence',
isAdmin: false,
accounts: [
{
__typename: 'MemberAccount',
id: 'acc-002',
firstName: 'Jane',
lastName: 'Johnson',
statusCode: 'CREATED',
createdAt: '2024-02-15T12:00:00.000Z',
},
],
profile: {
__typename: 'MemberProfile',
email: 'jane.johnson@example.com',
avatarDocumentId: null,
},
},
{
__typename: 'Member',
id: '507f1f77bcf86cd799439013',
memberName: 'Williams Residence',
isAdmin: false,
accounts: [
{
__typename: 'MemberAccount',
id: 'acc-003',
firstName: 'Bob',
lastName: 'Williams',
statusCode: 'REJECTED',
createdAt: '2024-03-01T12:00:00.000Z',
},
],
profile: {
__typename: 'MemberProfile',
email: null,
avatarDocumentId: null,
},
},
{
__typename: 'Member',
id: '507f1f77bcf86cd799439014',
memberName: 'Davis Residence',
isAdmin: false,
accounts: [
{
__typename: 'MemberAccount',
id: 'acc-004',
firstName: 'Alice',
lastName: 'Davis',
statusCode: 'ACCEPTED',
createdAt: '2024-01-20T12:00:00.000Z',
},
{
__typename: 'MemberAccount',
id: 'acc-005',
firstName: 'Tom',
lastName: 'Davis',
statusCode: 'ACCEPTED',
createdAt: '2024-01-20T12:00:00.000Z',
},
],
profile: {
__typename: 'MemberProfile',
email: 'alice.davis@example.com',
avatarDocumentId: null,
},
},
];

const meta = {
title: 'Components/Layouts/Admin/MemberList',
component: MemberList,
parameters: {
layout: 'padded',
},
argTypes: {
onSearchChange: { action: 'onSearchChange' },
},
decorators: [
(Story) => (
<MemoryRouter initialEntries={['/community/community-123/admin/member-001/members']}>
<Routes>
<Route
path="/community/:communityId/admin/:adminMemberId/members/*"
element={<Story />}
/>
</Routes>
</MemoryRouter>
),
],
} satisfies Meta<typeof MemberList>;

export default meta;
type Story = StoryObj<typeof MemberList>;

export const Default: Story = {
args: {
data: mockMembers,
searchValue: '',
onSearchChange: fn(),
communityId: 'community-123',
},
};

export const WithSearch: Story = {
args: {
data: mockMembers,
searchValue: 'smith',
onSearchChange: fn(),
communityId: 'community-123',
},
};

export const Empty: Story = {
args: {
data: [],
searchValue: '',
onSearchChange: fn(),
communityId: 'community-123',
},
};

export const NoResults: Story = {
args: {
data: mockMembers,
searchValue: 'zzz-no-match',
onSearchChange: fn(),
communityId: 'community-123',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Badge, Button, Input, Table, Tag } from 'antd';
import dayjs from 'dayjs';
import type React from 'react';
import { useNavigate } from 'react-router-dom';
import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../generated.tsx';

const { Search } = Input;

export interface MemberListProps {
data: AdminMemberListContainerMemberFieldsFragment[];
searchValue: string;
onSearchChange: (value: string) => void;
communityId: string;
}

const getStatusColor = (statusCode?: string | null): 'success' | 'warning' | 'error' | 'default' => {
switch (statusCode) {
case 'ACCEPTED':
return 'success';
case 'CREATED':
return 'warning';
case 'REJECTED':
return 'error';
default:
return 'default';
}
};

const getStatusLabel = (statusCode?: string | null): string => {
switch (statusCode) {
case 'ACCEPTED':
return 'Active';
case 'CREATED':
return 'Pending';
case 'REJECTED':
return 'Rejected';
default:
return statusCode ?? 'Unknown';
}
};

export const MemberList: React.FC<MemberListProps> = (props) => {
const navigate = useNavigate();
const filteredMembers = props.data.filter((member) => {
const search = props.searchValue.toLocaleLowerCase();
if (!search) return true;
const nameMatch = member.memberName?.toLocaleLowerCase().includes(search);
const emailMatch = member.profile?.email?.toLocaleLowerCase().includes(search);
const accountNameMatch = member.accounts.some((account) => account.firstName.toLocaleLowerCase().includes(search) || account.lastName?.toLocaleLowerCase().includes(search));
return nameMatch || emailMatch || accountNameMatch;
});

const columns = [
{
title: 'Action',
key: 'action',
render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => (
<Button
type="primary"
size="small"
onClick={() => navigate(record.id)}
>
Edit
</Button>
),
},
{
title: 'Member Name',
key: 'memberName',
render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => (
<span>
{record.memberName ?? '—'}
{record.isAdmin && (
<Tag
color="blue"
className="ml-2"
>
Admin
</Tag>
)}
</span>
),
},
{
title: 'Email',
key: 'email',
render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => record.profile?.email ?? '—',
},
{
title: 'Accounts',
key: 'accounts',
render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => (
<div>
{record.accounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-2 mb-1"
>
<span>
{account.firstName} {account.lastName ?? ''}
</span>
<Badge
status={getStatusColor(account.statusCode)}
text={getStatusLabel(account.statusCode)}
/>
</div>
))}
</div>
),
},
{
title: 'Member Since',
key: 'createdAt',
render: (_: unknown, record: AdminMemberListContainerMemberFieldsFragment) => {
const earliest = record.accounts
.filter((a) => a.createdAt)
.map((a) => new Date(a.createdAt as string).getTime())
.sort((a, b) => a - b)[0];
return earliest ? dayjs(earliest).format('MM/DD/YYYY') : '—';
},
},
];

const tableData = filteredMembers.map((member) => ({
...member,
key: member.id,
}));

return (
<div>
<div className="flex justify-between items-center mb-4">
<Search
placeholder="Search by name or email"
value={props.searchValue}
onChange={(e) => props.onSearchChange(e.target.value)}
style={{ width: 280 }}
allowClear
/>
</div>
<Table
dataSource={tableData}
columns={columns}
pagination={{ position: ['bottomRight'] }}
size="middle"
locale={{ emptyText: 'No members found.' }}
/>
</div>
);
};
Loading
Loading