From 9786409f7dd9f4e77426e1f4ec2abf8cbb446e3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:28:18 +0000 Subject: [PATCH 01/10] Initial plan From 2bf245ba6db2ec4d0a939e8e35f5866c6d987916 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:44:11 +0000 Subject: [PATCH 02/10] Add member management GraphQL schema, resolvers, and UI components --- .../components/members-list.container.graphql | 14 ++++ .../components/members-list.container.tsx | 40 +++++++++++ .../admin/components/members-list.stories.tsx | 61 +++++++++++++++++ .../layouts/admin/components/members-list.tsx | 67 +++++++++++++++++++ .../src/components/layouts/admin/index.tsx | 15 ++++- .../admin/pages/members-list.stories.tsx | 20 ++++++ .../layouts/admin/pages/members-list.tsx | 35 ++++++++++ .../layouts/admin/pages/members.tsx | 10 +++ .../src/contexts/community/member/index.ts | 3 + .../community/member/query-by-community-id.ts | 19 ++++++ .../graphql/src/schema/types/member.graphql | 29 +++++++- .../src/schema/types/member.resolvers.ts | 6 ++ 12 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-list.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-list.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-list.stories.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-list.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-list.stories.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-list.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members.tsx create mode 100644 packages/ocom/application-services/src/contexts/community/member/query-by-community-id.ts 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..9ec7e327e --- /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'; + +export 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..9f889b2b7 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-list.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from 'react-router-dom'; +import type { AdminMembersListContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import { MembersList } from './members-list.tsx'; + +const meta: Meta = { + title: 'Admin/Components/MembersList', + component: MembersList, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +const mockMembers: AdminMembersListContainerMemberFieldsFragment[] = [ + { + id: '1', + memberName: 'John Doe', + isAdmin: true, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-15T00:00:00.000Z', + }, + { + id: '2', + memberName: 'Jane Smith', + isAdmin: false, + createdAt: '2024-01-05T00:00:00.000Z', + updatedAt: '2024-01-20T00:00:00.000Z', + }, + { + id: '3', + memberName: 'Bob Johnson', + isAdmin: true, + createdAt: '2024-01-10T00:00:00.000Z', + updatedAt: '2024-01-25T00:00:00.000Z', + }, +]; + +export const Default: Story = { + args: { + data: mockMembers, + }, +}; + +export const Empty: Story = { + args: { + data: [], + }, +}; + +export const SingleMember: Story = { + args: { + data: mockMembers.slice(0, 1), + }, +}; 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) => ( + + ), + }, + { + 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/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-list.stories.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-list.stories.tsx new file mode 100644 index 000000000..2fa70581c --- /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: 'Admin/Pages/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..7121b1395 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx @@ -0,0 +1,35 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { 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 { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + + Members + + } + /> + } + > + + + ); +}; 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..7f68dd014 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members.tsx @@ -0,0 +1,10 @@ +import { Route, Routes } from 'react-router-dom'; +import { MembersList } from './members-list.tsx'; + +export const Members: React.FC = () => { + return ( + + } /> + + ); +}; 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..3e7334321 100644 --- a/packages/ocom/application-services/src/contexts/community/member/index.ts +++ b/packages/ocom/application-services/src/contexts/community/member/index.ts @@ -2,10 +2,12 @@ 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 MemberQueryByCommunityIdCommand, queryByCommunityId } from './query-by-community-id.ts'; export interface MemberApplicationService { determineIfAdmin: (command: MemberDetermineIfAdminCommand) => Promise, queryByEndUserExternalId: (command: MemberQueryByEndUserExternalIdCommand) => Promise, + queryByCommunityId: (command: MemberQueryByCommunityIdCommand) => Promise, } export const Member = ( @@ -14,5 +16,6 @@ export const Member = ( return { determineIfAdmin: determineIfAdmin(dataSources), queryByEndUserExternalId: queryByEndUserExternalId(dataSources), + queryByCommunityId: queryByCommunityId(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/graphql/src/schema/types/member.graphql b/packages/ocom/graphql/src/schema/types/member.graphql index 51f98bb35..9f759765e 100644 --- a/packages/ocom/graphql/src/schema/types/member.graphql +++ b/packages/ocom/graphql/src/schema/types/member.graphql @@ -41,7 +41,34 @@ type MemberProfile { extend type Query { # member(id: ObjectID!): Member! - # membersByCommunityId(communityId: ObjectID!): [Member!]! + membersByCommunityId(communityId: ObjectID!): [Member!]! # memberForCurrentCommunity: Member! membersForCurrentEndUser: [Member!]! +} + +extend type Mutation { + memberInvite(input: MemberInviteInput!): MemberMutationResult! + memberRemove(input: MemberRemoveInput!): MemberMutationResult! + memberUpdateRole(input: MemberUpdateRoleInput!): MemberMutationResult! +} + +type MemberMutationResult implements MutationResult { + status: MutationStatus! + member: Member +} + +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..b6c3582ab 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.ts @@ -25,6 +25,12 @@ const member: Resolvers = { 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, + }); } }, }; From 9422394ca0966ad6311772a058a6b6e4d1767d2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:34:23 +0000 Subject: [PATCH 03/10] Add complete member management pages with create, detail, and profile views --- .../members-create.container.graphql | 23 +++ .../components/members-create.container.tsx | 80 ++++++++ .../admin/components/members-create.tsx | 42 ++++ .../members-detail.container.graphql | 29 +++ .../components/members-detail.container.tsx | 74 ++++++++ .../admin/components/members-detail.tsx | 50 +++++ .../members-profile.container.graphql | 17 ++ .../components/members-profile.container.tsx | 44 +++++ .../admin/components/members-profile.tsx | 32 ++++ .../layouts/admin/pages/members-create.tsx | 37 ++++ .../layouts/admin/pages/members-detail.tsx | 57 ++++++ .../layouts/admin/pages/members-general.tsx | 15 ++ .../layouts/admin/pages/members-list.tsx | 2 +- .../layouts/admin/pages/members-profile.tsx | 14 ++ .../layouts/admin/pages/members.tsx | 4 + .../shared/components/vertical-tabs.tsx | 65 +++++++ .../src/contexts/community/member/create.ts | 44 +++++ .../src/contexts/community/member/index.ts | 56 ++++-- .../contexts/community/member/query-by-id.ts | 19 ++ .../src/contexts/community/member/update.ts | 35 ++++ .../graphql/src/schema/types/member.graphql | 13 +- .../src/schema/types/member.resolvers.ts | 179 ++++++++++++++---- 22 files changed, 883 insertions(+), 48 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-create.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-create.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-create.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-detail.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-detail.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-detail.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-profile.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-profile.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-profile.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-create.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-general.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx create mode 100644 apps/ui-community/src/components/layouts/shared/components/vertical-tabs.tsx create mode 100644 packages/ocom/application-services/src/contexts/community/member/create.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/query-by-id.ts create mode 100644 packages/ocom/application-services/src/contexts/community/member/update.ts 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..9ddc21531 --- /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'; + +export 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.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); + }} + > + + + + + + +
+ ); +}; 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..69534fcf6 --- /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'; + +export 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.tsx b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx new file mode 100644 index 000000000..2a1f5f309 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx @@ -0,0 +1,50 @@ +import { Button, Form, Input } from 'antd'; +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.onSave({ + ...values, + id: props.data.member.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.container.tsx b/apps/ui-community/src/components/layouts/admin/components/members-profile.container.tsx new file mode 100644 index 000000000..d32bd2a2c --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/members-profile.container.tsx @@ -0,0 +1,44 @@ +import { useQuery } from '@apollo/client'; +import { + AdminMembersProfileContainerMemberDocument, + type AdminMembersProfileContainerMemberFieldsFragment, +} from '../../../../generated.tsx'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { MembersProfileProps } from './members-profile.tsx'; +import { MembersProfile } from './members-profile.tsx'; + +export interface MembersProfileContainerProps { + data: { + id: string; + }; +} + +export const MembersProfileContainer: React.FC< + MembersProfileContainerProps +> = (props) => { + const { + data: memberData, + loading: memberLoading, + error: memberError, + } = useQuery(AdminMembersProfileContainerMemberDocument, { + variables: { + id: props.data.id, + }, + }); + + const membersProfileProps: MembersProfileProps = { + data: { + member: (memberData?.member ?? + {}) as AdminMembersProfileContainerMemberFieldsFragment, + }, + }; + + return ( + } + error={memberError} + /> + ); +}; 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..657120639 --- /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'; + +export 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/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..5d140b981 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx @@ -0,0 +1,57 @@ +import { IdcardOutlined, ProfileOutlined } from '@ant-design/icons'; +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import type { RouteDefinition } from '../../shared/components/vertical-tabs.tsx'; +import { VerticalTabs } from '../../shared/components/vertical-tabs.tsx'; +import { SubPageLayout } from '../sub-page-layout.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: , + }, + ]; + + 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.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx index 7121b1395..b3f12b83c 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx @@ -28,7 +28,7 @@ export const MembersList: React.FC = () => { } > ); 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..d242c147c --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx @@ -0,0 +1,14 @@ +import { useParams } from 'react-router-dom'; +import { MembersProfileContainer } from '../components/members-profile.container.tsx'; + +export const MembersProfile: React.FC = () => { + const params = useParams(); + + return ( + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/members.tsx b/apps/ui-community/src/components/layouts/admin/pages/members.tsx index 7f68dd014..587e53aa1 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/members.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/members.tsx @@ -1,10 +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/vertical-tabs.tsx b/apps/ui-community/src/components/layouts/shared/components/vertical-tabs.tsx new file mode 100644 index 000000000..b97ba0dfd --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/vertical-tabs.tsx @@ -0,0 +1,65 @@ +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) { + if (matchPath(page.path, 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 3e7334321..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,21 +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 MemberQueryByCommunityIdCommand, queryByCommunityId } from './query-by-community-id.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, - queryByCommunityId: (command: MemberQueryByCommunityIdCommand) => 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), - queryByCommunityId: queryByCommunityId(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-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 9f759765e..0b38debb0 100644 --- a/packages/ocom/graphql/src/schema/types/member.graphql +++ b/packages/ocom/graphql/src/schema/types/member.graphql @@ -40,13 +40,15 @@ type MemberProfile { } extend type Query { - # member(id: 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! memberInvite(input: MemberInviteInput!): MemberMutationResult! memberRemove(input: MemberRemoveInput!): MemberMutationResult! memberUpdateRole(input: MemberUpdateRoleInput!): MemberMutationResult! @@ -57,6 +59,15 @@ type MemberMutationResult implements MutationResult { member: Member } +input MemberCreateInput { + memberName: String! +} + +input MemberUpdateInput { + id: ObjectID! + memberName: String +} + input MemberInviteInput { communityId: ObjectID! firstName: String! diff --git a/packages/ocom/graphql/src/schema/types/member.resolvers.ts b/packages/ocom/graphql/src/schema/types/member.resolvers.ts index b6c3582ab..30d34be4d 100644 --- a/packages/ocom/graphql/src/schema/types/member.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/member.resolvers.ts @@ -1,38 +1,151 @@ -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, - }); - }, - 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: { + 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), + ); + }, + }, }; export default member; \ No newline at end of file From 3ebcd0ad153b459a1aa27d0af5ed2e6a616a5b06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:05:13 +0000 Subject: [PATCH 04/10] Fix member management UI issues: add create button, improve detail form, move VerticalTabs to ui-core, fix story titles --- .../admin/components/members-detail.tsx | 12 +++- .../admin/components/members-list.stories.tsx | 2 +- .../layouts/admin/pages/members-detail.tsx | 4 +- .../admin/pages/members-list.stories.tsx | 2 +- .../layouts/admin/pages/members-list.tsx | 16 ++++- .../cellix/ui-core/src/components/index.ts | 3 +- .../organisms/vertical-tabs/index.tsx | 69 +++++++++++++++++++ 7 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 packages/cellix/ui-core/src/components/organisms/vertical-tabs/index.tsx 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 index 2a1f5f309..83ee3a8ef 100644 --- a/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/members-detail.tsx @@ -1,4 +1,5 @@ -import { Button, Form, Input } from 'antd'; +import { Button, Descriptions, Form, Input } from 'antd'; +import dayjs from 'dayjs'; import type { AdminMembersDetailContainerMemberFieldsFragment, MemberUpdateInput, @@ -17,6 +18,15 @@ export const MembersDetail: React.FC = (props) => { return (
+ + {props.data.member.id} + + {dayjs(props.data.member.createdAt).format('MM/DD/YYYY')} + + + {dayjs(props.data.member.updatedAt).format('MM/DD/YYYY')} + +
= { - title: 'Admin/Components/MembersList', + title: 'Components/Layouts/Admin/MembersList', component: MembersList, decorators: [ (Story) => ( 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 index 5d140b981..dc52eedb9 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx @@ -2,8 +2,8 @@ import { IdcardOutlined, ProfileOutlined } from '@ant-design/icons'; import { PageHeader } from '@ant-design/pro-layout'; import { theme } from 'antd'; import { useNavigate } from 'react-router-dom'; -import type { RouteDefinition } from '../../shared/components/vertical-tabs.tsx'; -import { VerticalTabs } from '../../shared/components/vertical-tabs.tsx'; +import type { RouteDefinition } from '@cellix/ui-core'; +import { VerticalTabs } from '@cellix/ui-core'; import { SubPageLayout } from '../sub-page-layout.tsx'; import { MembersGeneral } from './members-general.tsx'; import { MembersProfile } from './members-profile.tsx'; 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 index 2fa70581c..dd7a597cd 100644 --- 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 @@ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom'; import { MembersList } from './members-list.tsx'; const meta: Meta = { - title: 'Admin/Pages/MembersList', + title: 'Pages/Layouts/Admin/MembersList', component: MembersList, decorators: [ (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 index b3f12b83c..6cf57b253 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/members-list.tsx @@ -1,11 +1,13 @@ +import { UsergroupAddOutlined } from '@ant-design/icons'; import { PageHeader } from '@ant-design/pro-layout'; -import { theme } from 'antd'; -import { useParams } from 'react-router-dom'; +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(); @@ -24,6 +26,16 @@ export const MembersList: React.FC = () => { Members } + extra={[ + , + ]} /> } > 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) => ( + + ))} + + + + ); +}; From 20e0edae354f0283b65569277e59b06380cf799d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:21:45 +0000 Subject: [PATCH 05/10] Add member accounts functionality and fix member profile to use shared components --- .../members-accounts-list.container.graphql | 21 +++++ .../members-accounts-list.container.tsx | 41 ++++++++++ .../components/members-accounts-list.tsx | 78 +++++++++++++++++++ .../layouts/admin/pages/members-accounts.tsx | 19 +++++ .../layouts/admin/pages/members-detail.tsx | 11 ++- .../layouts/admin/pages/members-profile.tsx | 7 +- .../member-profile-details.container.graphql | 38 +++++++++ .../member-profile-details.container.tsx | 69 ++++++++++++++++ .../components/member-profile-details.tsx | 63 +++++++++++++++ .../components/member-profile.container.tsx | 21 +++++ .../shared/components/member-profile.tsx | 16 ++++ .../graphql/src/schema/types/member.graphql | 18 +++++ .../src/schema/types/member.resolvers.ts | 18 +++++ 13 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-accounts-list.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/members-accounts.tsx create mode 100644 apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.graphql create mode 100644 apps/ui-community/src/components/layouts/shared/components/member-profile-details.container.tsx create mode 100644 apps/ui-community/src/components/layouts/shared/components/member-profile-details.tsx create mode 100644 apps/ui-community/src/components/layouts/shared/components/member-profile.container.tsx create mode 100644 apps/ui-community/src/components/layouts/shared/components/member-profile.tsx 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..371799d4e --- /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'; + +export 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.tsx b/apps/ui-community/src/components/layouts/admin/components/members-accounts-list.tsx new file mode 100644 index 000000000..db6dcfb85 --- /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'; + +export interface MembersAccountsListProps { + data: AdminMembersAccountsListContainerMemberAccountFieldsFragment[]; +} + +export const MembersAccountsList: React.FC = ( + props, +) => { + const navigate = useNavigate(); + const columns: TableColumnsType = + [ + { + title: 'Action', + dataIndex: 'id', + render: ( + text: AdminMembersAccountsListContainerMemberAccountFieldsFragment['id'], + ) => ( + + ), + }, + { + 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 ( + <> + +
+
record.id} + /> + + + ); +}; 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-detail.tsx b/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx index dc52eedb9..a63c4b2ea 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/members-detail.tsx @@ -1,4 +1,4 @@ -import { IdcardOutlined, ProfileOutlined } from '@ant-design/icons'; +import { IdcardOutlined, ProfileOutlined, TeamOutlined } from '@ant-design/icons'; import { PageHeader } from '@ant-design/pro-layout'; import { theme } from 'antd'; import { useNavigate } from 'react-router-dom'; @@ -7,6 +7,7 @@ import { VerticalTabs } from '@cellix/ui-core'; import { SubPageLayout } from '../sub-page-layout.tsx'; import { MembersGeneral } from './members-general.tsx'; import { MembersProfile } from './members-profile.tsx'; +import { MembersAccounts } from './members-accounts.tsx'; export const MembersDetail: React.FC = () => { const navigate = useNavigate(); @@ -31,6 +32,14 @@ export const MembersDetail: React.FC = () => { icon: , element: , }, + { + id: '3', + link: 'accounts', + path: 'accounts/*', + title: 'Accounts', + icon: , + element: , + }, ]; return ( 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 index d242c147c..59b9703f0 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/members-profile.tsx @@ -1,14 +1,15 @@ import { useParams } from 'react-router-dom'; -import { MembersProfileContainer } from '../components/members-profile.container.tsx'; +import { MemberProfileContainer } from '../../shared/components/member-profile.container.tsx'; export const MembersProfile: React.FC = () => { const params = useParams(); 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.tsx b/apps/ui-community/src/components/layouts/shared/components/member-profile-details.tsx new file mode 100644 index 000000000..14c962fbf --- /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; + +export 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); + }} + > + + + + + + + +