diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d95d078d4..bd9fc77a0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -62,7 +62,12 @@ pnpm run gen # Generate code (e.g., GraphQL types) - Use `pnpm run verify` to ensure code quality before commits - *Note*: Be patient when you run the `verify` command; wait at least 6 minutes for all checks to complete before assuming unexpected failure. - Use `pnpm run snyk` to run security scans before commits -cop- If you encounter this error when attempting to push changes `hook git error: Command failed with exit code 1: git commit -m REDACTED`, it indicates that pre-commit hooks have failed due to unmet code quality or security standards. Review the output from the hooks, fix the reported issues, and try committing again. Be aware that this error has nothing to do with firewall rules or network connectivity; it is expected to fail if your changes do not meet the project's standards. +- **CRITICAL**: If you encounter the error `hook git error: Command failed with exit code 1: git commit -m REDACTED`, this indicates that pre-commit hooks (which run the `verify` script) have failed due to linting, type-checking, or security issues. **Do NOT abort the session or assume a system/network failure.** This is an expected outcome when code does not meet the project's strict quality standards. You MUST: + 1. Read the terminal output/logs from the failed commit command. + 2. Identify the specific errors (e.g., TypeScript errors, linting violations, security vulnerabilities). + 3. Fix the reported issues in the source files. + 4. Attempt the commit/push again. + 5. Iterate until the verification passes. - Address any reported issues across all packages before pushing changes (if something fails, it was due to your changes and is considered to be "your code"). ### Security Scanning Workflow diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index 1171f239a..5ce962fdd 100644 --- a/apps/ui-community/package.json +++ b/apps/ui-community/package.json @@ -30,7 +30,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-oidc-context": "^3.3.0", - "react-router-dom": "^7.0.2" + "react-router-dom": "^7.12.0" }, "devDependencies": { "@cellix/typescript-config": "workspace:*", diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql new file mode 100644 index 000000000..d3bbbbbea --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql @@ -0,0 +1,21 @@ +query AdminMembersAccountsListContainerMember($id: ObjectID!) { + member(id: $id) { + ...AdminMembersAccountsListContainerMemberFields + } +} + +fragment AdminMembersAccountsListContainerMemberFields on Member { + accounts { + ...AdminMembersAccountsListContainerMemberAccountFields + } + id +} + +fragment AdminMembersAccountsListContainerMemberAccountFields on MemberAccount { + id + firstName + lastName + statusCode + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx new file mode 100644 index 000000000..771b4bf62 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx @@ -0,0 +1,41 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { + type AdminMembersAccountsListContainerMemberAccountFieldsFragment, + AdminMembersAccountsListContainerMemberDocument, +} from '../../../../generated.tsx'; +import { MembersAccountsList } from './members-accounts-list.tsx'; + +interface MembersAccountsListContainerProps { + data: { + id: string; + }; +} + +export const MembersAccountsListContainer: React.FC< + MembersAccountsListContainerProps +> = (props) => { + const { + data: memberData, + loading: memberLoading, + error: memberError, + } = useQuery(AdminMembersAccountsListContainerMemberDocument, { + variables: { + id: props.data.id, + }, + }); + + const membersAccountsListProps = { + data: (memberData?.member?.accounts ?? + []) as AdminMembersAccountsListContainerMemberAccountFieldsFragment[], + }; + + return ( + } + error={memberError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.stories.tsx new file mode 100644 index 000000000..e7100eb8b --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; +import { BrowserRouter } from 'react-router-dom'; +import type { AdminMembersAccountsListContainerMemberAccountFieldsFragment } from '../../../../generated.tsx'; +import { MembersAccountsList } from './members-accounts-list.tsx'; + +const mockAccounts: AdminMembersAccountsListContainerMemberAccountFieldsFragment[] = + [ + { + __typename: 'MemberAccount', + id: '1', + firstName: 'John', + lastName: 'Doe', + statusCode: 'Active', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', + }, + { + __typename: 'MemberAccount', + id: '2', + firstName: 'Jane', + lastName: 'Smith', + statusCode: 'Pending', + createdAt: '2024-01-05T12:00:00.000Z', + updatedAt: '2024-01-20T12:00:00.000Z', + }, + { + __typename: 'MemberAccount', + id: '3', + firstName: 'Bob', + lastName: 'Johnson', + statusCode: 'Active', + createdAt: '2024-01-10T12:00:00.000Z', + updatedAt: '2024-01-25T12:00:00.000Z', + }, + ]; + +const meta: Meta = { + title: 'Components/Layouts/Admin/MembersAccountsList', + component: MembersAccountsList, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockAccounts, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify add button is present + expect(canvas.getByRole('button', { name: /Add Account/i })).toBeInTheDocument(); + + // Verify table headers + expect(canvas.getByText('Action')).toBeInTheDocument(); + expect(canvas.getByText('First Name')).toBeInTheDocument(); + expect(canvas.getByText('Last Name')).toBeInTheDocument(); + expect(canvas.getByText('Status')).toBeInTheDocument(); + expect(canvas.getByText('Created')).toBeInTheDocument(); + expect(canvas.getByText('Updated')).toBeInTheDocument(); + + // Verify data is rendered + expect(canvas.getByText('John')).toBeInTheDocument(); + expect(canvas.getByText('Doe')).toBeInTheDocument(); + + // Verify Active status appears in table cells + const cells = canvas.getAllByText('Active'); + expect(cells.length).toBeGreaterThan(0); + }, +}; + +export const Empty: Story = { + args: { + data: [], + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify add button is present + expect(canvas.getByRole('button', { name: /Add Account/i })).toBeInTheDocument(); + + // Verify empty state message (in the ant-empty-description div, not the SVG title) + const emptyDescription = canvas.getByText('No data', { selector: '.ant-empty-description' }); + expect(emptyDescription).toBeInTheDocument(); + }, +}; + +export const SingleAccount: Story = { + args: { + data: mockAccounts.slice(0, 1), + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify single account is rendered + expect(canvas.getByText('John')).toBeInTheDocument(); + expect(canvas.getByText('Doe')).toBeInTheDocument(); + + // Verify only one edit button + const editButtons = canvas.getAllByRole('button', { name: 'Edit' }); + expect(editButtons).toHaveLength(1); + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx new file mode 100644 index 000000000..95f8af7ff --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx @@ -0,0 +1,78 @@ +import { UsergroupAddOutlined } from '@ant-design/icons'; +import { Button, Table, type TableColumnsType } from 'antd'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; +import type { AdminMembersAccountsListContainerMemberAccountFieldsFragment } from '../../../../generated.tsx'; + +interface MembersAccountsListProps { + data: AdminMembersAccountsListContainerMemberAccountFieldsFragment[]; +} + +export const MembersAccountsList: React.FC = ( + props, +) => { + const navigate = useNavigate(); + const columns: TableColumnsType = + [ + { + title: 'Action', + dataIndex: 'id', + render: ( + text: AdminMembersAccountsListContainerMemberAccountFieldsFragment['id'], + ) => ( + navigate(text)}> + Edit + + ), + }, + { + title: 'First Name', + dataIndex: 'firstName', + key: 'firstName', + }, + { + title: 'Last Name', + dataIndex: 'lastName', + key: 'lastName', + }, + { + title: 'Status', + dataIndex: 'statusCode', + key: 'statusCode', + }, + { + title: 'Updated', + dataIndex: 'updatedAt', + key: 'updatedAt', + render: ( + text: AdminMembersAccountsListContainerMemberAccountFieldsFragment['updatedAt'], + ) => {dayjs(text).format('MM/DD/YYYY')}, + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + render: ( + text: AdminMembersAccountsListContainerMemberAccountFieldsFragment['createdAt'], + ) => {dayjs(text).format('MM/DD/YYYY')}, + }, + ]; + + return ( + <> + navigate('./add')} + icon={} + > + Add Account + + + record.id} + /> + + > + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql new file mode 100644 index 000000000..cc88e19cf --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql @@ -0,0 +1,23 @@ +mutation AdminMembersCreateContainerMemberCreate($input: MemberCreateInput!) { + memberCreate(input: $input) { + ...AdminMembersCreateContainerMemberMutationResultFields + } +} + +fragment AdminMembersCreateContainerMemberMutationResultFields on MemberMutationResult { + status { + success + errorMessage + } + member { + ...AdminMembersCreateContainerMember + } +} + +fragment AdminMembersCreateContainerMember on Member { + memberName + + id + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx new file mode 100644 index 000000000..d3bd555ef --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx @@ -0,0 +1,80 @@ +import { App } from 'antd'; +import { useMutation } from '@apollo/client'; +import { useNavigate } from 'react-router-dom'; +import { + AdminMembersCreateContainerMemberCreateDocument, + AdminMembersListContainerMembersByCommunityIdDocument, + type MemberCreateInput, +} from '../../../../generated.tsx'; +import type { MembersCreateProps } from './members-create.tsx'; +import { MembersCreate } from './members-create.tsx'; + +interface MembersCreateContainerProps { + data: { + communityId: string; + }; +} + +export const MembersCreateContainer: React.FC = ( + props, +) => { + const navigate = useNavigate(); + const { message } = App.useApp(); + const [memberCreate, { loading }] = useMutation( + AdminMembersCreateContainerMemberCreateDocument, + { + update(cache, { data }) { + // update the list with the new item + const newMember = data?.memberCreate.member; + const members = cache.readQuery({ + query: AdminMembersListContainerMembersByCommunityIdDocument, + variables: { communityId: props.data.communityId ?? '' }, + })?.membersByCommunityId; + if (newMember && members) { + cache.writeQuery({ + query: AdminMembersListContainerMembersByCommunityIdDocument, + variables: { communityId: props.data.communityId ?? '' }, + data: { + membersByCommunityId: [...members, newMember], + }, + }); + } + }, + }, + ); + + const defaultValues: MemberCreateInput = { + memberName: '', + }; + + const handleSave = async (values: MemberCreateInput) => { + try { + const result = await memberCreate({ + variables: { + input: values, + }, + }); + + if (result.data?.memberCreate.status.success) { + message.success('Member Created'); + navigate(`../${result.data?.memberCreate.member?.id}`, { + replace: true, + }); + } else { + message.error( + `Error creating Member: ${result.data?.memberCreate.status.errorMessage}`, + ); + } + } catch (error) { + message.error(`Error creating Member: ${JSON.stringify(error)}`); + } + }; + + const membersCreateProps: MembersCreateProps = { + data: defaultValues, + onSave: handleSave, + loading, + }; + + return ; +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/members-create.stories.tsx new file mode 100644 index 000000000..838e007fd --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import { MembersCreate } from './members-create.tsx'; + +const meta: Meta = { + title: 'Components/Layouts/Admin/MembersCreate', + component: MembersCreate, + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +const mockOnSave = fn(); + +export const Default: Story = { + args: { + data: { + memberName: '', + }, + onSave: mockOnSave, + loading: false, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify form is rendered + expect(canvas.getByLabelText('Member Name')).toBeInTheDocument(); + expect(canvas.getByRole('button', { name: 'Create Member' })).toBeInTheDocument(); + }, +}; + +export const WithPrefilledData: Story = { + args: { + data: { + memberName: 'John Doe', + }, + onSave: mockOnSave, + loading: false, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify prefilled data + const input = canvas.getByLabelText('Member Name') as HTMLInputElement; + expect(input.value).toBe('John Doe'); + }, +}; + +export const Loading: Story = { + args: { + data: { + memberName: 'Test Member', + }, + onSave: mockOnSave, + loading: true, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify loading state - button text is still present but button has loading class + const button = canvas.getByText('Create Member').closest('button'); + expect(button).toHaveClass('ant-btn-loading'); + }, +}; + +export const FormSubmission: Story = { + args: { + data: { + memberName: '', + }, + onSave: mockOnSave, + loading: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in the form + const input = canvas.getByLabelText('Member Name'); + await userEvent.clear(input); + await userEvent.type(input, 'New Member'); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Create Member' }); + await userEvent.click(submitButton); + + // Verify onSave was called with correct data + expect(mockOnSave).toHaveBeenCalledWith({ memberName: 'New Member' }); + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-create.tsx b/apps/ui-community/src/components/layouts/admin/components/members-create.tsx new file mode 100644 index 000000000..5dcef8fbe --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-create.tsx @@ -0,0 +1,42 @@ +import { Button, Form, Input } from 'antd'; +import type { MemberCreateInput } from '../../../../generated.tsx'; + +export interface MembersCreateProps { + data: MemberCreateInput; + onSave: (member: MemberCreateInput) => void; + loading?: boolean; +} + +export const MembersCreate: React.FC = (props) => { + const [form] = Form.useForm(); + + return ( + + { + props.onSave(values); + }} + > + + + + + + Create Member + + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql new file mode 100644 index 000000000..f2128c8d1 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql @@ -0,0 +1,29 @@ +query AdminMembersDetailContainerMember($id: ObjectID!) { + member(id: $id) { + ...AdminMembersDetailContainerMemberFields + } +} + +mutation AdminMembersDetailContainerMemberUpdate($input: MemberUpdateInput!) { + memberUpdate(input: $input) { + ...AdminMembersDetailContainerMemberMutationResultFields + } +} + +fragment AdminMembersDetailContainerMemberMutationResultFields on MemberMutationResult { + status { + success + errorMessage + } + member { + ...AdminMembersDetailContainerMemberFields + } +} + +fragment AdminMembersDetailContainerMemberFields on Member { + memberName + + id + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx new file mode 100644 index 000000000..52e01a083 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx @@ -0,0 +1,74 @@ +import { App } from 'antd'; +import { useMutation, useQuery } from '@apollo/client'; +import { + AdminMembersDetailContainerMemberDocument, + type AdminMembersDetailContainerMemberFieldsFragment, + AdminMembersDetailContainerMemberUpdateDocument, + type MemberUpdateInput, +} from '../../../../generated.tsx'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { MembersDetailProps } from './members-detail.tsx'; +import { MembersDetail } from './members-detail.tsx'; + +interface MembersDetailContainerProps { + data: { + id: string; + communityId: string; + }; +} + +export const MembersDetailContainer: React.FC = ( + props, +) => { + const { message } = App.useApp(); + const [updateMember, { loading: updateLoading }] = useMutation( + AdminMembersDetailContainerMemberUpdateDocument, + ); + const { + data: memberData, + loading: memberLoading, + error: memberError, + } = useQuery(AdminMembersDetailContainerMemberDocument, { + variables: { + id: props.data.id, + }, + }); + + const handleSave = async (values: MemberUpdateInput) => { + try { + const result = await updateMember({ + variables: { + input: values, + }, + }); + + if (result.data?.memberUpdate.status.success) { + message.success('Saved'); + } else { + message.error( + `Error updating Member: ${result.data?.memberUpdate.status.errorMessage}`, + ); + } + } catch (error) { + message.error(`Error updating Member: ${JSON.stringify(error)}`); + } + }; + + const membersDetailProps: MembersDetailProps = { + data: { + member: (memberData?.member ?? + {}) as AdminMembersDetailContainerMemberFieldsFragment, + }, + onSave: handleSave, + loading: updateLoading, + }; + + return ( + } + error={memberError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/members-detail.stories.tsx new file mode 100644 index 000000000..89449f228 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import type { AdminMembersDetailContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import { MembersDetail } from './members-detail.tsx'; + +const mockMember: AdminMembersDetailContainerMemberFieldsFragment = { + __typename: 'Member', + id: '507f1f77bcf86cd799439011', + memberName: 'John Doe', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', +}; + +const meta: Meta = { + title: 'Components/Layouts/Admin/MembersDetail', + component: MembersDetail, + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +const mockOnSave = fn(); + +export const Default: Story = { + args: { + data: { + member: mockMember, + }, + onSave: mockOnSave, + loading: false, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify member info is displayed + expect(canvas.getByText(mockMember.id)).toBeInTheDocument(); + expect(canvas.getByText('01/01/2024')).toBeInTheDocument(); + expect(canvas.getByText('01/15/2024')).toBeInTheDocument(); + + // Verify form is rendered with correct value + const input = canvas.getByLabelText('Member Name') as HTMLInputElement; + expect(input.value).toBe(mockMember.memberName); + }, +}; + +export const Loading: Story = { + args: { + data: { + member: mockMember, + }, + onSave: mockOnSave, + loading: true, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify loading state - button text is still present but button has loading class + const button = canvas.getByText('Save').closest('button'); + expect(button).toHaveClass('ant-btn-loading'); + }, +}; + +export const FormSubmission: Story = { + args: { + data: { + member: mockMember, + }, + onSave: mockOnSave, + loading: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Update the member name + const input = canvas.getByLabelText('Member Name'); + await userEvent.clear(input); + await userEvent.type(input, 'Updated Name'); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Save' }); + await userEvent.click(submitButton); + + // Verify onSave was called with correct data + expect(mockOnSave).toHaveBeenCalledWith({ + id: mockMember.id, + memberName: 'Updated Name', + }); + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx new file mode 100644 index 000000000..83ee3a8ef --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx @@ -0,0 +1,60 @@ +import { Button, Descriptions, Form, Input } from 'antd'; +import dayjs from 'dayjs'; +import type { + AdminMembersDetailContainerMemberFieldsFragment, + MemberUpdateInput, +} from '../../../../generated.tsx'; + +export interface MembersDetailProps { + data: { + member: AdminMembersDetailContainerMemberFieldsFragment; + }; + onSave: (member: MemberUpdateInput) => void; + loading?: boolean; +} + +export const MembersDetail: React.FC = (props) => { + const [form] = Form.useForm(); + + return ( + + + {props.data.member.id} + + {dayjs(props.data.member.createdAt).format('MM/DD/YYYY')} + + + {dayjs(props.data.member.updatedAt).format('MM/DD/YYYY')} + + + { + props.onSave({ + ...values, + id: props.data.member.id, + }); + }} + > + + + + + + Save + + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-list.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-list.container.graphql new file mode 100644 index 000000000..e50970a96 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-list.container.graphql @@ -0,0 +1,14 @@ +query AdminMembersListContainerMembersByCommunityId($communityId: ObjectID!) { + membersByCommunityId(communityId: $communityId) { + ...AdminMembersListContainerMemberFields + } +} + +fragment AdminMembersListContainerMemberFields on Member { + memberName + isAdmin + + id + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-list.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-list.container.tsx new file mode 100644 index 000000000..db630cc86 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-list.container.tsx @@ -0,0 +1,40 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { + type AdminMembersListContainerMemberFieldsFragment, + AdminMembersListContainerMembersByCommunityIdDocument, +} from '../../../../generated.tsx'; +import type { MembersListProps } from './members-list.tsx'; +import { MembersList } from './members-list.tsx'; + +interface MembersListContainerProps { + data: { + communityId: string; + }; +} + +export const MembersListContainer: React.FC = ( + props, +) => { + const { + data: memberData, + loading: memberLoading, + error: memberError, + } = useQuery(AdminMembersListContainerMembersByCommunityIdDocument, { + variables: { communityId: props.data.communityId }, + }); + + const membersListProps: MembersListProps = { + data: (memberData?.membersByCommunityId ?? + []) as AdminMembersListContainerMemberFieldsFragment[], + }; + + return ( + } + error={memberError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-list.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/members-list.stories.tsx new file mode 100644 index 000000000..e24e3453a --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-list.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; +import { BrowserRouter } from 'react-router-dom'; +import type { AdminMembersListContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import { MembersList } from './members-list.tsx'; + +const mockMembers: AdminMembersListContainerMemberFieldsFragment[] = [ + { + __typename: 'Member', + id: '1', + memberName: 'John Doe', + isAdmin: true, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-15T00:00:00.000Z', + }, + { + __typename: 'Member', + id: '2', + memberName: 'Jane Smith', + isAdmin: false, + createdAt: '2024-01-05T00:00:00.000Z', + updatedAt: '2024-01-20T00:00:00.000Z', + }, + { + __typename: 'Member', + id: '3', + memberName: 'Bob Johnson', + isAdmin: true, + createdAt: '2024-01-10T00:00:00.000Z', + updatedAt: '2024-01-25T00:00:00.000Z', + }, +]; + +const meta = { + title: 'Components/Layouts/Admin/MembersList', + component: MembersList, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockMembers, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify table is rendered + expect(canvas.getByRole('table')).toBeInTheDocument(); + + // Verify column headers + expect(canvas.getByText('Action')).toBeInTheDocument(); + expect(canvas.getByText('Member')).toBeInTheDocument(); + expect(canvas.getByText('Is Admin')).toBeInTheDocument(); + expect(canvas.getByText('Updated')).toBeInTheDocument(); + expect(canvas.getByText('Created')).toBeInTheDocument(); + + // Verify member data is displayed + expect(canvas.getByText('John Doe')).toBeInTheDocument(); + expect(canvas.getByText('Jane Smith')).toBeInTheDocument(); + expect(canvas.getByText('Bob Johnson')).toBeInTheDocument(); + + // Verify admin status + const yesTexts = canvas.getAllByText('Yes'); + expect(yesTexts).toHaveLength(2); // John and Bob are admins + + // Verify dates are formatted correctly + expect(canvas.getByText('01/01/2024')).toBeInTheDocument(); + expect(canvas.getByText('01/15/2024')).toBeInTheDocument(); + }, +}; + +export const Empty: Story = { + args: { + data: [], + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify table is rendered but with no data rows + expect(canvas.getByRole('table')).toBeInTheDocument(); + + // Verify empty state message (in the ant-empty-description div, not the SVG title) + const emptyDescription = canvas.getByText('No data', { selector: '.ant-empty-description' }); + expect(emptyDescription).toBeInTheDocument(); + }, +}; + +export const SingleMember: Story = { + args: { + data: mockMembers.slice(0, 1), + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify only one member is displayed + expect(canvas.getByText('John Doe')).toBeInTheDocument(); + expect(canvas.queryByText('Jane Smith')).not.toBeInTheDocument(); + + // Verify Edit button is present + expect(canvas.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-list.tsx b/apps/ui-community/src/components/layouts/admin/components/members-list.tsx new file mode 100644 index 000000000..72c5539a9 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-list.tsx @@ -0,0 +1,67 @@ +import { Button, Table, type TableColumnsType } from 'antd'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; +import type { AdminMembersListContainerMemberFieldsFragment } from '../../../../generated.tsx'; + +export interface MembersListProps { + data: AdminMembersListContainerMemberFieldsFragment[]; +} + +export const MembersList: React.FC = (props) => { + const navigate = useNavigate(); + const columns: TableColumnsType = + [ + { + title: 'Action', + dataIndex: 'id', + render: (text: string) => ( + navigate(text)} + > + Edit + + ), + }, + { + title: 'Member', + dataIndex: 'memberName', + key: 'memberName', + }, + { + title: 'Is Admin', + dataIndex: 'isAdmin', + key: 'isAdmin', + render: (text: boolean) => {text ? 'Yes' : 'No'}, + }, + { + title: 'Updated', + dataIndex: 'updatedAt', + key: 'updatedAt', + render: (text: string) => ( + {dayjs(text).format('MM/DD/YYYY')} + ), + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + render: (text: string) => ( + {dayjs(text).format('MM/DD/YYYY')} + ), + }, + ]; + + return ( + + record.id} + /> + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-profile.container.graphql b/apps/ui-community/src/components/layouts/admin/components/members-profile.container.graphql new file mode 100644 index 000000000..4e1b028fd --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-profile.container.graphql @@ -0,0 +1,17 @@ +query AdminMembersProfileContainerMember($id: ObjectID!) { + member(id: $id) { + ...AdminMembersProfileContainerMemberFields + } +} + +fragment AdminMembersProfileContainerMemberFields on Member { + profile { + name + email + bio + showEmail + showProfile + } + + id +} diff --git a/apps/ui-community/src/components/layouts/admin/components/members-profile.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/members-profile.stories.tsx new file mode 100644 index 000000000..1d711e29f --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-profile.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; +import type { AdminMembersProfileContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import { MembersProfile } from './members-profile.tsx'; + +const mockMemberWithProfile: AdminMembersProfileContainerMemberFieldsFragment = + { + __typename: 'Member', + id: '507f1f77bcf86cd799439011', + profile: { + __typename: 'MemberProfile', + name: 'John Doe', + email: 'john.doe@example.com', + bio: 'Software developer with 10 years of experience', + showEmail: true, + showProfile: true, + }, + }; + +const mockMemberWithoutProfile: AdminMembersProfileContainerMemberFieldsFragment = + { + __typename: 'Member', + id: '507f1f77bcf86cd799439012', + profile: null, + }; + +const meta: Meta = { + title: 'Components/Layouts/Admin/MembersProfile', + component: MembersProfile, + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithProfile: Story = { + args: { + data: { + member: mockMemberWithProfile, + }, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify profile data is displayed + expect(canvas.getByText('John Doe')).toBeInTheDocument(); + expect(canvas.getByText('john.doe@example.com')).toBeInTheDocument(); + expect( + canvas.getByText('Software developer with 10 years of experience'), + ).toBeInTheDocument(); + expect(canvas.getAllByText('Yes')).toHaveLength(2); // showEmail and showProfile + }, +}; + +export const WithoutProfile: Story = { + args: { + data: { + member: mockMemberWithoutProfile, + }, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify N/A is displayed for missing data + expect(canvas.getAllByText('N/A')).toHaveLength(3); // name, email, bio + expect(canvas.getAllByText('No')).toHaveLength(2); // showEmail, showProfile + }, +}; + +export const PartialProfile: Story = { + args: { + data: { + member: { + id: '507f1f77bcf86cd799439013', + profile: { + name: 'Jane Smith', + email: null, + bio: null, + showEmail: false, + showProfile: true, + }, + }, + }, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify partial data is displayed correctly + expect(canvas.getByText('Jane Smith')).toBeInTheDocument(); + expect(canvas.getAllByText('N/A')).toHaveLength(2); // email, bio + expect(canvas.getByText('Yes')).toBeInTheDocument(); // showProfile + expect(canvas.getByText('No')).toBeInTheDocument(); // showEmail + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/members-profile.tsx b/apps/ui-community/src/components/layouts/admin/components/members-profile.tsx new file mode 100644 index 000000000..bd13ec308 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-profile.tsx @@ -0,0 +1,32 @@ +import { Descriptions } from 'antd'; +import type { AdminMembersProfileContainerMemberFieldsFragment } from '../../../../generated.tsx'; + +interface MembersProfileProps { + data: { + member: AdminMembersProfileContainerMemberFieldsFragment; + }; +} + +export const MembersProfile: React.FC = (props) => { + return ( + + + + {props.data.member.profile?.name || 'N/A'} + + + {props.data.member.profile?.email || 'N/A'} + + + {props.data.member.profile?.bio || 'N/A'} + + + {props.data.member.profile?.showEmail ? 'Yes' : 'No'} + + + {props.data.member.profile?.showProfile ? 'Yes' : 'No'} + + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx index 6a1b73b92..adccd3d60 100644 --- a/apps/ui-community/src/components/layouts/admin/index.tsx +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -1,7 +1,12 @@ -import { HomeOutlined, SettingOutlined } from '@ant-design/icons'; +import { + ContactsOutlined, + HomeOutlined, + SettingOutlined, +} from '@ant-design/icons'; import { Route, Routes } from 'react-router-dom'; import type { Member } from '../../../generated.tsx'; import { Home } from './pages/home.tsx'; +import { Members } from './pages/members.tsx'; import { Settings } from './pages/settings.tsx'; import { SectionLayoutContainer } from './section-layout.container.tsx'; @@ -22,6 +27,13 @@ export const Admin: React.FC = () => { icon: , id: 'ROOT', }, + { + path: '/community/:communityId/admin/:memberId/members/*', + title: 'Members', + icon: , + id: 1, + parent: 'ROOT', + }, { path: '/community/:communityId/admin/:memberId/settings/*', title: 'Settings', @@ -41,6 +53,7 @@ export const Admin: React.FC = () => { element={} > } /> + } /> } /> diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-accounts.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-accounts.tsx new file mode 100644 index 000000000..e65961eba --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-accounts.tsx @@ -0,0 +1,19 @@ +import { Route, Routes, useParams } from 'react-router-dom'; +import { MembersAccountsListContainer } from '../components/members-accounts-list.container.tsx'; + +export const MembersAccounts: React.FC = () => { + const params = useParams(); + return ( + + Members Accounts + + + } + /> + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-create.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-create.tsx new file mode 100644 index 000000000..77d386029 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-create.tsx @@ -0,0 +1,37 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { useNavigate, useParams } from 'react-router-dom'; +import { MembersCreateContainer } from '../components/members-create.container.tsx'; +import { SubPageLayout } from '../sub-page-layout.tsx'; + +export const MembersCreate: React.FC = () => { + const navigate = useNavigate(); + const params = useParams(); + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + + Create Member + + } + onBack={() => navigate('../')} + /> + } + > + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx new file mode 100644 index 000000000..f86ea03d3 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx @@ -0,0 +1,70 @@ +import { + IdcardOutlined, + ProfileOutlined, + TeamOutlined, +} from '@ant-design/icons'; +import { PageHeader } from '@ant-design/pro-layout'; +import type { RouteDefinition } from '@cellix/ui-core'; +import { VerticalTabs } from '@cellix/ui-core'; +import { theme } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { SubPageLayout } from '../sub-page-layout.tsx'; +import { MembersAccounts } from './members-accounts.tsx'; +import { MembersGeneral } from './members-general.tsx'; +import { MembersProfile } from './members-profile.tsx'; + +export const MembersDetail: React.FC = () => { + const navigate = useNavigate(); + const { + token: { colorTextBase }, + } = theme.useToken(); + + const pages: RouteDefinition[] = [ + { + id: '1', + link: '', + path: '', + title: 'General', + icon: , + element: , + }, + { + id: '2', + link: 'profile', + path: 'profile/*', + title: 'Profile', + icon: , + element: , + }, + { + id: '3', + link: 'accounts', + path: 'accounts/*', + title: 'Accounts', + icon: , + element: , + }, + ]; + + return ( + + Member Detail + + } + onBack={() => navigate('../')} + /> + } + > + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-general.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-general.tsx new file mode 100644 index 000000000..60346138a --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-general.tsx @@ -0,0 +1,15 @@ +import { useParams } from 'react-router-dom'; +import { MembersDetailContainer } from '../components/members-detail.container.tsx'; + +export const MembersGeneral: React.FC = () => { + const params = useParams(); + + return ( + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-list.stories.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-list.stories.tsx new file mode 100644 index 000000000..dd7a597cd --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-list.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from 'react-router-dom'; +import { MembersList } from './members-list.tsx'; + +const meta: Meta = { + title: 'Pages/Layouts/Admin/MembersList', + component: MembersList, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx new file mode 100644 index 000000000..d54ef7038 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx @@ -0,0 +1,45 @@ +import { UsergroupAddOutlined } from '@ant-design/icons'; +import { PageHeader } from '@ant-design/pro-layout'; +import { Button, theme } from 'antd'; +import { useNavigate, useParams } from 'react-router-dom'; +import { MembersListContainer } from '../components/members-list.container.tsx'; +import { SubPageLayout } from '../sub-page-layout.tsx'; + +export const MembersList: React.FC = () => { + const params = useParams(); + const navigate = useNavigate(); + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + + Members + + } + extra={[ + navigate('create')} + icon={} + > + Create Member + , + ]} + /> + } + > + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx new file mode 100644 index 000000000..25d08d029 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx @@ -0,0 +1,18 @@ +import { useParams } from 'react-router-dom'; +import { MemberProfileContainer } from '../../shared/components/member-profile.container.tsx'; + +export const MembersProfile: React.FC = () => { + const params = useParams(); + + return ( + + Members Profile + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members.tsx b/apps/ui-community/src/components/layouts/admin/pages/members.tsx new file mode 100644 index 000000000..587e53aa1 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members.tsx @@ -0,0 +1,14 @@ +import { Route, Routes } from 'react-router-dom'; +import { MembersCreate } from './members-create.tsx'; +import { MembersDetail } from './members-detail.tsx'; +import { MembersList } from './members-list.tsx'; + +export const Members: React.FC = () => { + return ( + + } /> + } /> + } /> + + ); +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.graphql b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.graphql new file mode 100644 index 000000000..698c07fa8 --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.graphql @@ -0,0 +1,38 @@ +query SharedMemberProfileDetailsContainerMember($id: ObjectID!) { + member(id: $id) { + ...SharedMemberProfileDetailsContainerMemberFields + } +} + +mutation SharedMemberProfileDetailsContainerMemberProfileUpdate( + $input: MemberProfileUpdateInput! +) { + memberProfileUpdate(input: $input) { + ...SharedMemberProfileDetailsContainerMemberProfileMutationResultFields + } +} + +fragment SharedMemberProfileDetailsContainerMemberProfileMutationResultFields on MemberMutationResult { + status { + success + errorMessage + } + member { + ...SharedMemberProfileDetailsContainerMemberFields + } +} + +fragment SharedMemberProfileDetailsContainerMemberFields on Member { + profile { + name + email + bio + interests + showInterests + showEmail + showLocation + showProfile + showProperties + } + id +} diff --git a/apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.tsx b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.tsx new file mode 100644 index 000000000..91ebcd3eb --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.tsx @@ -0,0 +1,69 @@ +import { App } from 'antd'; +import { useMutation, useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { + type MemberProfileInput, + SharedMemberProfileDetailsContainerMemberDocument, + SharedMemberProfileDetailsContainerMemberProfileUpdateDocument, +} from '../../../../generated.tsx'; +import { MemberProfileDetails } from './member-profile-details.tsx'; + +interface MemberProfileDetailsContainerProps { + data: { + id: string; + }; +} + +export const MemberProfileDetailsContainer: React.FC< + MemberProfileDetailsContainerProps +> = (props) => { + const { message } = App.useApp(); + const [updateMember] = useMutation( + SharedMemberProfileDetailsContainerMemberProfileUpdateDocument, + ); + const { + data: memberData, + loading: memberLoading, + error: memberError, + } = useQuery(SharedMemberProfileDetailsContainerMemberDocument, { + variables: { + id: props.data.id, + }, + }); + + const handleSave = async (values: MemberProfileInput) => { + try { + const result = await updateMember({ + variables: { + input: { + memberId: props.data.id, + profile: values, + }, + }, + }); + if (result.data?.memberProfileUpdate.status.success) { + message.success('Saved'); + } else { + message.error( + `Error updating Member: ${result.data?.memberProfileUpdate.status.errorMessage}`, + ); + } + } catch (error) { + message.error(`Error updating Member: ${JSON.stringify(error)}`); + } + }; + + return ( + + } + error={memberError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/member-profile-details.stories.tsx b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.stories.tsx new file mode 100644 index 000000000..4a5c2ae19 --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import { MemberProfileDetails } from './member-profile-details.tsx'; + +const meta: Meta = { + title: 'Components/Layouts/Shared/MemberProfileDetails', + component: MemberProfileDetails, + parameters: { + layout: 'padded', + }, +}; + +export default meta; +type Story = StoryObj; + +const mockOnSave = fn(); + +export const Default: Story = { + args: { + data: null, + onSave: mockOnSave, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify all form fields are present + expect(canvas.getByLabelText('Name')).toBeInTheDocument(); + expect(canvas.getByLabelText('Email')).toBeInTheDocument(); + expect(canvas.getByLabelText('Bio')).toBeInTheDocument(); + expect(canvas.getByText('Show Interests')).toBeInTheDocument(); + expect(canvas.getByText('Show Email')).toBeInTheDocument(); + expect(canvas.getByText('Show Location')).toBeInTheDocument(); + expect(canvas.getByText('Show Profile')).toBeInTheDocument(); + expect(canvas.getByText('Show Properties')).toBeInTheDocument(); + expect(canvas.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + }, +}; + +export const WithData: Story = { + args: { + data: { + name: 'John Doe', + email: 'john.doe@example.com', + bio: 'Software developer with 10 years of experience', + showInterests: true, + showEmail: true, + showLocation: false, + showProfile: true, + showProperties: false, + }, + onSave: mockOnSave, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify form is populated with data + const nameInput = canvas.getByLabelText('Name') as HTMLInputElement; + expect(nameInput.value).toBe('John Doe'); + + const emailInput = canvas.getByLabelText('Email') as HTMLInputElement; + expect(emailInput.value).toBe('john.doe@example.com'); + + const bioInput = canvas.getByLabelText('Bio') as HTMLTextAreaElement; + expect(bioInput.value).toBe( + 'Software developer with 10 years of experience', + ); + + // Verify checkboxes + const showInterestsCheckbox = canvas.getByRole('checkbox', { + name: 'Show Interests', + }) as HTMLInputElement; + expect(showInterestsCheckbox.checked).toBe(true); + + const showEmailCheckbox = canvas.getByRole('checkbox', { + name: 'Show Email', + }) as HTMLInputElement; + expect(showEmailCheckbox.checked).toBe(true); + + const showLocationCheckbox = canvas.getByRole('checkbox', { + name: 'Show Location', + }) as HTMLInputElement; + expect(showLocationCheckbox.checked).toBe(false); + }, +}; + +export const FormSubmission: Story = { + args: { + data: { + name: '', + email: '', + bio: '', + showInterests: false, + showEmail: false, + showLocation: false, + showProfile: false, + showProperties: false, + }, + onSave: mockOnSave, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in the form + const nameInput = canvas.getByLabelText('Name'); + await userEvent.type(nameInput, 'Test User'); + + const emailInput = canvas.getByLabelText('Email'); + await userEvent.type(emailInput, 'test@example.com'); + + const bioInput = canvas.getByLabelText('Bio'); + await userEvent.type(bioInput, 'Test bio'); + + // Toggle some checkboxes + const showEmailCheckbox = canvas.getByRole('checkbox', { + name: 'Show Email', + }); + await userEvent.click(showEmailCheckbox); + + const showProfileCheckbox = canvas.getByRole('checkbox', { + name: 'Show Profile', + }); + await userEvent.click(showProfileCheckbox); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Save' }); + await userEvent.click(submitButton); + + // Verify onSave was called with correct data + expect(mockOnSave).toHaveBeenCalledWith({ + name: 'Test User', + email: 'test@example.com', + bio: 'Test bio', + showInterests: false, + showEmail: true, + showLocation: false, + showProfile: true, + showProperties: false, + }); + }, +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/member-profile-details.tsx b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.tsx new file mode 100644 index 000000000..fca31f50d --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.tsx @@ -0,0 +1,63 @@ +import { Button, Checkbox, Form, Input } from 'antd'; +import React from 'react'; +import type { MemberProfileInput } from '../../../../generated.tsx'; + +const { TextArea } = Input; + +interface MemberProfileDetailsProps { + data?: MemberProfileInput | null; + onSave: (values: MemberProfileInput) => void; +} + +export const MemberProfileDetails: React.FC = ( + props, +) => { + const [form] = Form.useForm(); + const [formLoading, setFormLoading] = React.useState(false); + return ( + { + setFormLoading(true); + props.onSave(values); + setFormLoading(false); + }} + > + + + + + + + + + + + Show Interests + + + Show Email + + + Show Location + + + Show Profile + + + Show Properties + + + + Save + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/member-profile.container.tsx b/apps/ui-community/src/components/layouts/shared/components/member-profile.container.tsx new file mode 100644 index 000000000..9b17a3dc6 --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/member-profile.container.tsx @@ -0,0 +1,21 @@ +import { useParams } from 'react-router-dom'; +import { MemberProfile } from './member-profile.tsx'; + +interface MemberProfileContainerProps { + data: { + communityId: string; + }; + isAdmin?: boolean; +} + +export const MemberProfileContainer: React.FC< + MemberProfileContainerProps +> = (props) => { + const { id: memberId } = useParams(); + + return ( + + ); +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/member-profile.tsx b/apps/ui-community/src/components/layouts/shared/components/member-profile.tsx new file mode 100644 index 000000000..2a645055a --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/member-profile.tsx @@ -0,0 +1,20 @@ +import { MemberProfileDetailsContainer } from './member-profile-details.container.tsx'; +import { ProfilePhotoUploadContainer } from './profile-photo-upload.container.tsx'; + +interface MemberProfileProps { + data: { + id: string; + communityId: string; + }; +} + +export const MemberProfile: React.FC = (props) => { + return ( + <> + + + > + ); +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/profile-photo-upload.container.tsx b/apps/ui-community/src/components/layouts/shared/components/profile-photo-upload.container.tsx new file mode 100644 index 000000000..0bf4fa051 --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/profile-photo-upload.container.tsx @@ -0,0 +1,28 @@ +import { Button, message } from 'antd'; + +interface ProfilePhotoUploadContainerProps { + data: { + id: string; + communityId: string; + }; +} + +export const ProfilePhotoUploadContainer: React.FC< + ProfilePhotoUploadContainerProps +> = (_props) => { + // Placeholder implementation - full blob storage integration deferred + const handleUpload = () => { + message.info('Photo upload functionality coming soon'); + }; + + return ( + + + Upload Profile Photo + + Profile photo upload (Azure Blob Storage integration pending) + + + + ); +}; diff --git a/packages/cellix/ui-core/src/components/index.ts b/packages/cellix/ui-core/src/components/index.ts index c5e315dbf..4a7502d8c 100644 --- a/packages/cellix/ui-core/src/components/index.ts +++ b/packages/cellix/ui-core/src/components/index.ts @@ -1 +1,2 @@ -export * from './molecules/index.ts'; \ No newline at end of file +export * from './molecules/index.ts'; +export * from './organisms/vertical-tabs/index.tsx'; \ No newline at end of file diff --git a/packages/cellix/ui-core/src/components/organisms/vertical-tabs/index.tsx b/packages/cellix/ui-core/src/components/organisms/vertical-tabs/index.tsx new file mode 100644 index 000000000..71e0bcd4a --- /dev/null +++ b/packages/cellix/ui-core/src/components/organisms/vertical-tabs/index.tsx @@ -0,0 +1,69 @@ +import { Col, Menu, Row, theme } from 'antd'; +import { + Link, + Route, + Routes, + useLocation, + matchPath, +} from 'react-router-dom'; +import { useMemo } from 'react'; + +export interface RouteDefinition { + id: string; + link: string; + path: string; + title: string; + icon: React.ReactNode; + element: React.ReactNode; +} + +export const VerticalTabs: React.FC<{ pages: RouteDefinition[] }> = ({ + pages, +}) => { + const location = useLocation(); + + // Find which page matches the current location + const matchedIds = useMemo(() => { + const matched: string[] = []; + for (const page of pages) { + // Match both the exact path and nested paths + const pathToMatch = page.path.endsWith('/*') + ? page.path.slice(0, -2) + : page.path; + if (matchPath({ path: pathToMatch, end: false }, location.pathname)) { + matched.push(page.id); + } + } + return matched; + }, [pages, location.pathname]); + + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + + + + {pages.map((page) => ( + + {page.title} + + ))} + + + + + {pages.map((page) => ( + + ))} + + + + ); +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/create.ts b/packages/ocom/application-services/src/contexts/community/member/create.ts new file mode 100644 index 000000000..70436037d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/create.ts @@ -0,0 +1,44 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberCreateCommand { + memberName: string; + communityId?: string; +} + +export const create = (dataSources: DataSources) => { + return async ( + command: MemberCreateCommand, + ): Promise => { + // Get the community - use current community from context if not provided + const communityId = command.communityId; + if (!communityId) { + throw new Error('Community ID is required'); + } + + const community = + await dataSources.readonlyDataSource.Community.Community.CommunityReadRepo.getById( + communityId, + ); + if (!community) { + throw new Error(`Community not found for id ${communityId}`); + } + + let memberToReturn: + | Domain.Contexts.Community.Member.MemberEntityReference + | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction( + async (repo) => { + const newMember = await repo.getNewInstance( + command.memberName, + community, + ); + memberToReturn = await repo.save(newMember); + }, + ); + if (!memberToReturn) { + throw new Error('Member not created'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/member/index.ts b/packages/ocom/application-services/src/contexts/community/member/index.ts index 5b575d398..1eff5138a 100644 --- a/packages/ocom/application-services/src/contexts/community/member/index.ts +++ b/packages/ocom/application-services/src/contexts/community/member/index.ts @@ -1,18 +1,51 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; -import { type MemberQueryByEndUserExternalIdCommand, queryByEndUserExternalId } from './query-by-end-user-external-id.ts'; -import { determineIfAdmin, type MemberDetermineIfAdminCommand } from './determine-if-admin.ts'; +import { + type MemberQueryByEndUserExternalIdCommand, + queryByEndUserExternalId, +} from './query-by-end-user-external-id.ts'; +import { + determineIfAdmin, + type MemberDetermineIfAdminCommand, +} from './determine-if-admin.ts'; +import { + type MemberQueryByCommunityIdCommand, + queryByCommunityId, +} from './query-by-community-id.ts'; +import { type MemberQueryByIdCommand, queryById } from './query-by-id.ts'; +import { type MemberCreateCommand, create } from './create.ts'; +import { type MemberUpdateCommand, update } from './update.ts'; export interface MemberApplicationService { - determineIfAdmin: (command: MemberDetermineIfAdminCommand) => Promise, - queryByEndUserExternalId: (command: MemberQueryByEndUserExternalIdCommand) => Promise, + determineIfAdmin: ( + command: MemberDetermineIfAdminCommand, + ) => Promise; + queryByEndUserExternalId: ( + command: MemberQueryByEndUserExternalIdCommand, + ) => Promise; + queryByCommunityId: ( + command: MemberQueryByCommunityIdCommand, + ) => Promise; + queryById: ( + command: MemberQueryByIdCommand, + ) => Promise; + create: ( + command: MemberCreateCommand, + ) => Promise; + update: ( + command: MemberUpdateCommand, + ) => Promise; } export const Member = ( - dataSources: DataSources + dataSources: DataSources, ): MemberApplicationService => { - return { - determineIfAdmin: determineIfAdmin(dataSources), - queryByEndUserExternalId: queryByEndUserExternalId(dataSources), - } -} \ No newline at end of file + return { + determineIfAdmin: determineIfAdmin(dataSources), + queryByEndUserExternalId: queryByEndUserExternalId(dataSources), + queryByCommunityId: queryByCommunityId(dataSources), + queryById: queryById(dataSources), + create: create(dataSources), + update: update(dataSources), + }; +}; \ No newline at end of file diff --git a/packages/ocom/application-services/src/contexts/community/member/query-by-community-id.ts b/packages/ocom/application-services/src/contexts/community/member/query-by-community-id.ts new file mode 100644 index 000000000..263620573 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/query-by-community-id.ts @@ -0,0 +1,19 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberQueryByCommunityIdCommand { + communityId: string; + fields?: string[]; +}; + +export const queryByCommunityId = ( + dataSources: DataSources, +) => { + return async ( + command: MemberQueryByCommunityIdCommand, + ): Promise => { + return await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getByCommunityId( + command.communityId, + ) + } +} diff --git a/packages/ocom/application-services/src/contexts/community/member/query-by-id.ts b/packages/ocom/application-services/src/contexts/community/member/query-by-id.ts new file mode 100644 index 000000000..b38530ae3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/query-by-id.ts @@ -0,0 +1,19 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberQueryByIdCommand { + id: string; + fields?: string[]; +}; + +export const queryById = ( + dataSources: DataSources, +) => { + return async ( + command: MemberQueryByIdCommand, + ): Promise => { + return await dataSources.readonlyDataSource.Community.Member.MemberReadRepo.getById( + command.id, + ) + } +} diff --git a/packages/ocom/application-services/src/contexts/community/member/update.ts b/packages/ocom/application-services/src/contexts/community/member/update.ts new file mode 100644 index 000000000..eec7268ad --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/member/update.ts @@ -0,0 +1,35 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface MemberUpdateCommand { + id: string; + memberName?: string; +} + +export const update = (dataSources: DataSources) => { + return async ( + command: MemberUpdateCommand, + ): Promise => { + let memberToReturn: + | Domain.Contexts.Community.Member.MemberEntityReference + | undefined; + await dataSources.domainDataSource.Community.Member.MemberUnitOfWork.withScopedTransaction( + async (repo) => { + const member = await repo.getById(command.id); + if (!member) { + throw new Error(`Member not found for id ${command.id}`); + } + + if (command.memberName !== undefined) { + member.memberName = command.memberName; + } + + memberToReturn = await repo.save(member); + }, + ); + if (!memberToReturn) { + throw new Error('Member not updated'); + } + return memberToReturn; + }; +}; diff --git a/packages/ocom/graphql/src/schema/types/member.graphql b/packages/ocom/graphql/src/schema/types/member.graphql index 51f98bb35..c7939b9d3 100644 --- a/packages/ocom/graphql/src/schema/types/member.graphql +++ b/packages/ocom/graphql/src/schema/types/member.graphql @@ -40,8 +40,64 @@ type MemberProfile { } extend type Query { - # member(id: ObjectID!): Member! - # membersByCommunityId(communityId: ObjectID!): [Member!]! + member(id: ObjectID!): Member + membersByCommunityId(communityId: ObjectID!): [Member!]! # memberForCurrentCommunity: Member! membersForCurrentEndUser: [Member!]! +} + +extend type Mutation { + memberCreate(input: MemberCreateInput!): MemberMutationResult! + memberUpdate(input: MemberUpdateInput!): MemberMutationResult! + memberProfileUpdate(input: MemberProfileUpdateInput!): MemberMutationResult! + memberInvite(input: MemberInviteInput!): MemberMutationResult! + memberRemove(input: MemberRemoveInput!): MemberMutationResult! + memberUpdateRole(input: MemberUpdateRoleInput!): MemberMutationResult! +} + +type MemberMutationResult implements MutationResult { + status: MutationStatus! + member: Member +} + +input MemberCreateInput { + memberName: String! +} + +input MemberUpdateInput { + id: ObjectID! + memberName: String +} + +input MemberProfileUpdateInput { + memberId: ObjectID! + profile: MemberProfileInput! +} + +input MemberProfileInput { + name: String + email: String + bio: String + interests: [String] + showInterests: Boolean + showEmail: Boolean + showProfile: Boolean + showLocation: Boolean + showProperties: Boolean +} + +input MemberInviteInput { + communityId: ObjectID! + firstName: String! + lastName: String + email: String! +} + +input MemberRemoveInput { + id: ObjectID! +} + +input MemberUpdateRoleInput { + memberId: ObjectID! + roleId: ObjectID! } \ No newline at end of file diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.ts index 5b0bdd67b..be6292c60 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.ts @@ -1,32 +1,169 @@ -import type { GraphQLResolveInfo } from "graphql"; -import type { GraphContext } from "../context.ts"; -import type { Resolvers } from "../builder/generated.ts"; +import type { Domain } from '@ocom/domain'; +import type { GraphQLResolveInfo } from 'graphql'; +import type { GraphContext } from '../context.ts'; +import type { + MemberCreateInput, + MemberUpdateInput, + Resolvers, +} from '../builder/generated.ts'; + +const MemberMutationResolver = async ( + getMember: Promise, +) => { + try { + return { + status: { success: true }, + member: await getMember, + }; + } catch (error) { + console.error('Member > Mutation : ', error); + const { message } = error as Error; + return { + status: { success: false, errorMessage: message }, + }; + } +}; const member: Resolvers = { - Member: { - community: async (parent, _args: unknown, context: GraphContext, _info: GraphQLResolveInfo) => { - return await context.applicationServices.Community.Community.queryById({ - id: parent.communityId - }); - }, - // role: async (parent, _args: unknown, _context: GraphContext, _info: GraphQLResolveInfo) => { - // return await parent.loadRole(); - // }, - isAdmin: async (parent, _args: unknown, context: GraphContext, _info: GraphQLResolveInfo) => { - return await context.applicationServices.Community.Member.determineIfAdmin({ - memberId: parent.id, - }); - } - }, - Query: { - membersForCurrentEndUser: async (_parent, _args: unknown, context: GraphContext, _info: GraphQLResolveInfo) => { - if (!context.applicationServices.verifiedUser?.verifiedJwt) { throw new Error('Unauthorized'); } - const externalId = context.applicationServices.verifiedUser.verifiedJwt.sub; - return await context.applicationServices.Community.Member.queryByEndUserExternalId({ - externalId, - }); - } - }, + Member: { + community: async ( + parent, + _args: unknown, + context: GraphContext, + _info: GraphQLResolveInfo, + ) => { + return await context.applicationServices.Community.Community.queryById({ + id: parent.communityId, + }); + }, + // role: async (parent, _args: unknown, _context: GraphContext, _info: GraphQLResolveInfo) => { + // return await parent.loadRole(); + // }, + isAdmin: async ( + parent, + _args: unknown, + context: GraphContext, + _info: GraphQLResolveInfo, + ) => { + return await context.applicationServices.Community.Member.determineIfAdmin( + { + memberId: parent.id, + }, + ); + }, + }, + Query: { + membersForCurrentEndUser: async ( + _parent, + _args: unknown, + context: GraphContext, + _info: GraphQLResolveInfo, + ) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + const externalId = context.applicationServices.verifiedUser.verifiedJwt.sub; + return await context.applicationServices.Community.Member.queryByEndUserExternalId( + { + externalId, + }, + ); + }, + membersByCommunityId: async ( + _parent, + args: { communityId: string }, + context: GraphContext, + _info: GraphQLResolveInfo, + ) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.Community.Member.queryByCommunityId( + { + communityId: args.communityId, + }, + ); + }, + member: async ( + _parent, + args: { id: string }, + context: GraphContext, + _info: GraphQLResolveInfo, + ) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.Community.Member.queryById({ + id: args.id, + }); + }, + }, + Mutation: { + memberCreate: async ( + _parent, + args: { input: MemberCreateInput }, + context: GraphContext, + ) => { + if (!context.applicationServices?.verifiedUser?.verifiedJwt?.sub) { + throw new Error('Unauthorized'); + } + // Get the current community from hints if not provided + const communityId = + context.applicationServices.verifiedUser?.hints?.communityId; + if (!communityId) { + throw new Error('Community ID is required'); + } + + return await MemberMutationResolver( + context.applicationServices.Community.Member.create({ + memberName: args.input.memberName, + communityId, + }), + ); + }, + memberUpdate: async ( + _parent, + args: { input: MemberUpdateInput }, + context: GraphContext, + ) => { + if (!context.applicationServices?.verifiedUser?.verifiedJwt?.sub) { + throw new Error('Unauthorized'); + } + + const updateCommand: { + id: string; + memberName?: string; + } = { + id: args.input.id, + }; + + if (args.input.memberName !== undefined && args.input.memberName !== null) { + updateCommand.memberName = args.input.memberName; + } + + return await MemberMutationResolver( + context.applicationServices.Community.Member.update(updateCommand), + ); + }, + memberProfileUpdate: async ( + _parent, + args: { input: { memberId: string; profile: unknown } }, + context: GraphContext, + ) => { + if (!context.applicationServices?.verifiedUser?.verifiedJwt?.sub) { + throw new Error('Unauthorized'); + } + + // TODO: Implement profile update in domain and application service + // For now, return success to allow UI testing + return { + status: { success: true }, + member: await context.applicationServices.Community.Member.queryById({ + id: args.input.memberId, + }), + }; + }, + }, }; export default member; \ No newline at end of file diff --git a/packages/ocom/ui-components/package.json b/packages/ocom/ui-components/package.json index 65f75481e..616ffca9a 100644 --- a/packages/ocom/ui-components/package.json +++ b/packages/ocom/ui-components/package.json @@ -33,7 +33,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-oidc-context": "^3.3.0", - "react-router-dom": "^7.8.2" + "react-router-dom": "^7.12.0" }, "devDependencies": { "@cellix/typescript-config": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f01c5734..85281caea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,8 +288,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: - specifier: ^7.0.2 - version: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^7.12.0 + version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/typescript-config': specifier: workspace:* @@ -1107,8 +1107,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: - specifier: ^7.8.2 - version: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^7.12.0 + version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/typescript-config': specifier: workspace:* @@ -9662,6 +9662,13 @@ packages: peerDependencies: react: '>=15' + react-router-dom@7.12.0: + resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + react-router-dom@7.9.6: resolution: {integrity: sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==} engines: {node: '>=20.0.0'} @@ -9674,6 +9681,16 @@ packages: peerDependencies: react: '>=15' + react-router@7.12.0: + resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-router@7.9.6: resolution: {integrity: sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==} engines: {node: '>=20.0.0'} @@ -11269,6 +11286,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -22095,6 +22113,12 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 + react-router-dom@7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-router: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-router-dom@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -22114,6 +22138,14 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 + react-router@7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + cookie: 1.1.1 + react: 19.2.0 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.0(react@19.2.0) + react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: cookie: 1.1.1
+ Profile photo upload (Azure Blob Storage integration pending) +