From 64181e3f45a32814437c65523703b6033ebec533 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:35:21 +0000 Subject: [PATCH 01/32] Initial plan From 3f11e8b9d70d7534ade4eeede228228abc709727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:47:12 +0000 Subject: [PATCH 02/32] Add application service and GraphQL mutation for updating community settings --- .../src/contexts/community/community/index.ts | 4 ++ .../community/community/update-settings.ts | 45 +++++++++++++++++++ .../src/contexts/community/index.ts | 4 +- .../ocom/application-services/src/index.ts | 4 +- .../src/schema/types/community.graphql | 9 ++++ .../src/schema/types/community.resolvers.ts | 24 +++++++++- 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 packages/ocom/application-services/src/contexts/community/community/update-settings.ts diff --git a/packages/ocom/application-services/src/contexts/community/community/index.ts b/packages/ocom/application-services/src/contexts/community/community/index.ts index 525a35c91..42d55bbca 100644 --- a/packages/ocom/application-services/src/contexts/community/community/index.ts +++ b/packages/ocom/application-services/src/contexts/community/community/index.ts @@ -3,12 +3,15 @@ import type { DataSources } from '@ocom/persistence'; import { type CommunityCreateCommand, create, } from './create.ts'; import { type CommunityQueryByEndUserExternalIdCommand, queryByEndUserExternalId } from './query-by-end-user-external-id.ts'; import { type CommunityQueryByIdCommand, queryById } from './query-by-id.ts'; +import { type CommunityUpdateSettingsCommand, updateSettings } from './update-settings.ts'; +export type { CommunityUpdateSettingsCommand }; export interface CommunityApplicationService { create: (command: CommunityCreateCommand) => Promise, queryById: (command: CommunityQueryByIdCommand) => Promise, queryByEndUserExternalId: (command: CommunityQueryByEndUserExternalIdCommand) => Promise, + updateSettings: (command: CommunityUpdateSettingsCommand) => Promise, } export const Community = ( @@ -18,5 +21,6 @@ export const Community = ( create: create(dataSources), queryById: queryById(dataSources), queryByEndUserExternalId: queryByEndUserExternalId(dataSources), + updateSettings: updateSettings(dataSources), } } \ No newline at end of file diff --git a/packages/ocom/application-services/src/contexts/community/community/update-settings.ts b/packages/ocom/application-services/src/contexts/community/community/update-settings.ts new file mode 100644 index 000000000..8652f527b --- /dev/null +++ b/packages/ocom/application-services/src/contexts/community/community/update-settings.ts @@ -0,0 +1,45 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface CommunityUpdateSettingsCommand { + id: string; + name?: string; + domain?: string; + whiteLabelDomain?: string | null; + handle?: string | null; +} + +export const updateSettings = ( + dataSources: DataSources +) => { + return async ( + command: CommunityUpdateSettingsCommand, + ): Promise => { + let communityToReturn: Domain.Contexts.Community.Community.CommunityEntityReference | undefined; + await dataSources.domainDataSource.Community.Community.CommunityUnitOfWork.withScopedTransaction( + async (repo) => { + const community = await repo.get(command.id); + if (!community) { + throw new Error(`Community not found for id ${command.id}`); + } + + if (command.name !== undefined) { + community.name = command.name; + } + if (command.domain !== undefined) { + community.domain = command.domain; + } + if (command.whiteLabelDomain !== undefined) { + community.whiteLabelDomain = command.whiteLabelDomain; + } + if (command.handle !== undefined) { + community.handle = command.handle; + } + + communityToReturn = await repo.save(community); + }, + ); + if (!communityToReturn) { throw new Error('community not found'); } + return communityToReturn; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/community/index.ts b/packages/ocom/application-services/src/contexts/community/index.ts index ab12899d1..3bf944528 100644 --- a/packages/ocom/application-services/src/contexts/community/index.ts +++ b/packages/ocom/application-services/src/contexts/community/index.ts @@ -1,7 +1,9 @@ import type { DataSources } from '@ocom/persistence'; -import { Community as CommunityApi, type CommunityApplicationService } from './community/index.ts'; +import { Community as CommunityApi, type CommunityApplicationService, type CommunityUpdateSettingsCommand } from './community/index.ts'; import { Member as MemberApi, type MemberApplicationService } from './member/index.ts'; +export type { CommunityUpdateSettingsCommand }; + export interface CommunityContextApplicationService { Community: CommunityApplicationService; Member: MemberApplicationService; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index d41ef528c..e33c5834e 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -1,9 +1,11 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { Domain } from '@ocom/domain'; -import { Community, type CommunityContextApplicationService } from './contexts/community/index.ts'; +import { Community, type CommunityContextApplicationService, type CommunityUpdateSettingsCommand } from './contexts/community/index.ts'; import { Service, type ServiceContextApplicationService } from './contexts/service/index.ts'; import { User, type UserContextApplicationService } from './contexts/user/index.ts'; +export type { CommunityUpdateSettingsCommand }; + export interface ApplicationServices { Community: CommunityContextApplicationService; Service: ServiceContextApplicationService; diff --git a/packages/ocom/graphql/src/schema/types/community.graphql b/packages/ocom/graphql/src/schema/types/community.graphql index feb276521..961e82927 100644 --- a/packages/ocom/graphql/src/schema/types/community.graphql +++ b/packages/ocom/graphql/src/schema/types/community.graphql @@ -25,6 +25,7 @@ extend type Query { extend type Mutation { communityCreate(input: CommunityCreateInput!): CommunityMutationResult! + communityUpdateSettings(input: CommunityUpdateSettingsInput!): CommunityMutationResult! } type CommunityMutationResult implements MutationResult { @@ -36,3 +37,11 @@ input CommunityCreateInput { name: String! } +input CommunityUpdateSettingsInput { + id: ObjectID! + name: String + domain: String + whiteLabelDomain: String + handle: String +} + diff --git a/packages/ocom/graphql/src/schema/types/community.resolvers.ts b/packages/ocom/graphql/src/schema/types/community.resolvers.ts index f1b2fa7e1..ca1e62eca 100644 --- a/packages/ocom/graphql/src/schema/types/community.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/community.resolvers.ts @@ -1,7 +1,8 @@ import type { Domain } from "@ocom/domain"; +import type { CommunityUpdateSettingsCommand } from "@ocom/application-services"; import type { GraphQLResolveInfo } from "graphql"; import type { GraphContext } from "../context.ts"; -import type { CommunityCreateInput, Resolvers } from "../builder/generated.ts"; +import type { CommunityCreateInput, CommunityUpdateSettingsInput, Resolvers } from "../builder/generated.ts"; const CommunityMutationResolver = async (getCommunity: Promise) => { try { @@ -47,6 +48,27 @@ const community: Resolvers = { endUserExternalId: context.applicationServices.verifiedUser?.verifiedJwt.sub }) ); + }, + communityUpdateSettings: async (_parent, args: { input: CommunityUpdateSettingsInput }, context: GraphContext) => { + if (!context.applicationServices?.verifiedUser?.verifiedJwt?.sub) { throw new Error('Unauthorized'); } + const updateCommand: CommunityUpdateSettingsCommand = { + id: args.input.id, + }; + if (args.input.name !== null && args.input.name !== undefined) { + updateCommand.name = args.input.name; + } + if (args.input.domain !== null && args.input.domain !== undefined) { + updateCommand.domain = args.input.domain; + } + if (args.input.whiteLabelDomain !== undefined) { + updateCommand.whiteLabelDomain = args.input.whiteLabelDomain; + } + if (args.input.handle !== undefined) { + updateCommand.handle = args.input.handle; + } + return await CommunityMutationResolver( + context.applicationServices.Community.Community.updateSettings(updateCommand) + ); } } }; From 4290e5e0652245adbd45c2391aeafb682639b869 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:54:55 +0000 Subject: [PATCH 03/32] Add admin layout with community settings page --- apps/ui-community/src/App.tsx | 2 + .../settings-general.container.graphql | 27 ++++++ .../components/settings-general.container.tsx | 57 ++++++++++++ .../admin/components/settings-general.tsx | 90 +++++++++++++++++++ .../src/components/layouts/admin/index.tsx | 33 +++++++ .../layouts/admin/pages/settings-general.tsx | 10 +++ .../layouts/admin/pages/settings.tsx | 31 +++++++ .../layouts/admin/section-layout.tsx | 81 +++++++++++++++++ .../layouts/admin/sub-page-layout.tsx | 36 ++++++++ 9 files changed, 367 insertions(+) create mode 100644 apps/ui-community/src/components/layouts/admin/components/settings-general.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/settings-general.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/index.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/settings-general.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/pages/settings.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/section-layout.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx diff --git a/apps/ui-community/src/App.tsx b/apps/ui-community/src/App.tsx index 27ef147cd..70b19bb14 100644 --- a/apps/ui-community/src/App.tsx +++ b/apps/ui-community/src/App.tsx @@ -2,6 +2,7 @@ import { RequireAuth } from '@cellix/ui-core'; import { Route, Routes } from 'react-router-dom'; import './App.css'; import { Accounts } from './components/layouts/accounts/index.tsx'; +import { Admin } from './components/layouts/admin/index.tsx'; import { Root } from './components/layouts/root/index.tsx'; import { AuthLanding } from './components/ui/molecules/auth-landing/index.tsx'; import { ApolloConnection } from './components/ui/organisms/apollo-connection/index.tsx'; @@ -20,6 +21,7 @@ export default function App() { } /> } /> + } /> ); diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.graphql b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.graphql new file mode 100644 index 000000000..43828e88a --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.graphql @@ -0,0 +1,27 @@ +query AdminSettingsGeneralContainerCurrentCommunity { + currentCommunity { + ...AdminSettingsGeneralContainerCommunityFields + } +} + +mutation AdminSettingsGeneralContainerCommunityUpdateSettings($input: CommunityUpdateSettingsInput!) { + communityUpdateSettings(input: $input) { + status { + success + errorMessage + } + community { + ...AdminSettingsGeneralContainerCommunityFields + } + } +} + +fragment AdminSettingsGeneralContainerCommunityFields on Community { + id + name + domain + whiteLabelDomain + handle + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx new file mode 100644 index 000000000..1c6a297d5 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx @@ -0,0 +1,57 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { message } from 'antd'; +import { + AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, + AdminSettingsGeneralContainerCurrentCommunityDocument, + type CommunityUpdateSettingsInput, +} from '../../../../generated.tsx'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { SettingsGeneral } from './settings-general.tsx'; + +export const SettingsGeneralContainer: React.FC = () => { + const [communityUpdate, { error: mutationError }] = useMutation( + AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, + ); + const { + data: communityData, + loading: communityLoading, + error: communityError, + } = useQuery(AdminSettingsGeneralContainerCurrentCommunityDocument); + + const handleSave = async (values: CommunityUpdateSettingsInput) => { + if (!communityData?.currentCommunity?.id) { + message.error('Community not found'); + return; + } + + values.id = communityData.currentCommunity.id; + try { + await communityUpdate({ + variables: { + input: values, + }, + }).then((result) => { + if (result.data?.communityUpdateSettings?.status?.success) { + message.success('Saved'); + } else { + message.error( + result.data?.communityUpdateSettings.status.errorMessage ?? 'Unknown error', + ); + } + }); + } catch (saveError) { + message.error(`Error updating community: ${JSON.stringify(saveError)}`); + } + }; + + return ( + + } + error={communityError ?? mutationError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx new file mode 100644 index 000000000..627a7c218 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -0,0 +1,90 @@ +import { Button, Descriptions, Form, Input } from 'antd'; +import React from 'react'; +import type { CommunityUpdateSettingsInput } from '../../../../generated.tsx'; + +interface SettingsGeneralProps { + data: { + id: string; + name: string; + domain?: string | null; + whiteLabelDomain?: string | null; + handle?: string | null; + createdAt?: string; + updatedAt?: string; + }; + onSave: (values: CommunityUpdateSettingsInput) => void; +} + +export const SettingsGeneral: React.FC = (props) => { + const [form] = Form.useForm(); + const [formLoading, setFormLoading] = React.useState(false); + + return ( + <> + + {props.data.id} + + {props.data.createdAt ? new Date(props.data.createdAt).toLocaleDateString() : 'N/A'} + + + {props.data.updatedAt ? new Date(props.data.updatedAt).toLocaleDateString() : 'N/A'} + + + +
{ + setFormLoading(true); + props.onSave(values); + setFormLoading(false); + }} + > + + + + + + +
+ The white label domain is used to allow users to access your public community + website. +
+ + + + +
+ The domain is used to apply a custom domain to the public facing website. +
+ You must have a domain name registered with us before you can use this feature. +
+
+ + + + + +
+ + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx new file mode 100644 index 000000000..3c8aa7e40 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -0,0 +1,33 @@ +import { SettingOutlined } from '@ant-design/icons'; +import { Route, Routes } from 'react-router-dom'; +import { SectionLayout } from './section-layout.tsx'; +import { Settings } from './pages/settings.tsx'; + +export interface PageLayoutProps { + path: string; + title: string; + icon: React.JSX.Element; + id: string | number; + parent?: string; +} + +export const Admin: React.FC = () => { + const pageLayouts: PageLayoutProps[] = [ + { path: '', title: 'Home', icon: , id: 'ROOT' }, + { + path: 'settings/*', + title: 'Settings', + icon: , + id: 2, + parent: 'ROOT', + }, + ]; + + return ( + + }> + } /> + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/pages/settings-general.tsx new file mode 100644 index 000000000..e680bb41d --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/settings-general.tsx @@ -0,0 +1,10 @@ +import { SettingsGeneralContainer } from '../components/settings-general.container.tsx'; + +export const SettingsGeneral: React.FC = () => { + return ( + <> +

General

+ + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/pages/settings.tsx b/apps/ui-community/src/components/layouts/admin/pages/settings.tsx new file mode 100644 index 000000000..c4d85c254 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/settings.tsx @@ -0,0 +1,31 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { SubPageLayout } from '../sub-page-layout.tsx'; +import { SettingsGeneral } from './settings-general.tsx'; + +export const Settings: React.FC = () => { + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + + Community Settings + + } + /> + } + > + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.tsx new file mode 100644 index 000000000..6a8516daf --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/section-layout.tsx @@ -0,0 +1,81 @@ +import { LoggedInUserContainer } from '@ocom/ui-components'; +import { Layout, Menu, theme } from 'antd'; +import { useState } from 'react'; +import { Outlet, useNavigate } from 'react-router-dom'; +import type { PageLayoutProps } from './index.tsx'; + +const { Header, Sider } = Layout; + +interface SectionLayoutProps { + pageLayouts: PageLayoutProps[]; +} + +export const SectionLayout: React.FC = ({ pageLayouts }) => { + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + const { + token: { colorBgContainer }, + } = theme.useToken(); + + const menuItems = pageLayouts + .filter((layout) => layout.parent === 'ROOT') + .map((layout) => ({ + key: layout.path, + icon: layout.icon, + label: layout.title, + })); + + const handleMenuClick = (e: { key: string }) => { + navigate(e.key); + }; + + return ( + +
+
+ +
+
+ + + setCollapsed(value)} + style={{ + background: colorBgContainer, + }} + > + + + + + + + + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx b/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx new file mode 100644 index 000000000..7a999d2cd --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx @@ -0,0 +1,36 @@ + +import { Layout, theme } from 'antd'; +import type React from "react"; + +const { Header, Content, Footer } = Layout; + +interface SubPageLayoutProps { + header: React.JSX.Element; + fixedHeader?: boolean; + children?: React.ReactNode; +} + +export const SubPageLayout: React.FC = (props) => { + const overFlow = props.fixedHeader ? 'scroll' : 'unset'; + const { + token: {colorTextBase, colorBgContainer } + }=theme.useToken(); + return ( + <> +
+ + {props.header} +
+
+ +
+ {props.children} +
+
+
+ Owner Community +
+
+ + ); +}; \ No newline at end of file From 0ffbfebf07413222c7d24cc99647038c47c304f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:56:38 +0000 Subject: [PATCH 04/32] Fix linting issue in settings-general container --- .../layouts/admin/components/settings-general.container.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx index 1c6a297d5..b16c9ff50 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx @@ -49,7 +49,9 @@ export const SettingsGeneralContainer: React.FC = () => { loading={communityLoading} hasData={communityData?.currentCommunity} hasDataComponent={ - + communityData?.currentCommunity ? ( + + ) : null } error={communityError ?? mutationError} /> From e20e3352cc93b70c7c95b52e251a52fb011fc802 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Mon, 22 Dec 2025 09:34:03 -0500 Subject: [PATCH 05/32] Update container and presentational component implementation guidelines for GraphQL integration --- .github/instructions/ui/container-components.instructions.md | 3 +++ .github/instructions/ui/graphql-ui.instructions.md | 1 + .../instructions/ui/presentational-components.instructions.md | 3 +++ 3 files changed, 7 insertions(+) diff --git a/.github/instructions/ui/container-components.instructions.md b/.github/instructions/ui/container-components.instructions.md index a35acf6ca..fd79e5e0b 100644 --- a/.github/instructions/ui/container-components.instructions.md +++ b/.github/instructions/ui/container-components.instructions.md @@ -21,6 +21,9 @@ applyTo: "packages/ui-*/src/components/**/*.container.tsx" - Component name must match file name in PascalCase. - Each container must define a `{ComponentName}ContainerProps` type for its props. - Use strict TypeScript types for all state, props, and API responses. +- Pass GraphQL query results directly to the presentational component's props without explicit mapping or transformation. Rendering logic and data formatting should be handled by the presentational component. +- When performing mutations or queries, pass the `loading` state from the Apollo hooks (`useQuery`, `useMutation`) directly to the presentational component to ensure accurate UI feedback. Avoid creating redundant local state for loading. +- Handle user feedback (e.g., success/error notifications using `antd`'s `message`) within the container's handler functions (e.g., `onSave`, `onDelete`) after an operation completes. - Use kebab-case for file and directory names. - Provide handler functions through display component props for all relevant actions (e.g., handleClick, handleChange, handleSubmit, handleSave). diff --git a/.github/instructions/ui/graphql-ui.instructions.md b/.github/instructions/ui/graphql-ui.instructions.md index 64b4497e2..87cc29657 100644 --- a/.github/instructions/ui/graphql-ui.instructions.md +++ b/.github/instructions/ui/graphql-ui.instructions.md @@ -31,6 +31,7 @@ applyTo: "**/ui-*/src/components/**/*.graphql" - Import `.graphql` files into TypeScript/JS files using codegen-generated types for type safety. +- Presentational components should use the generated fragment types from their corresponding `.container.graphql` file to type the data they expect in their props. The presentational component is responsible for any necessary data conversion or formatting (e.g., date formatting) for display. - Use Apollo Client hooks (`useQuery`, `useMutation`, etc.) with imported queries/mutations. - Co-locate fragments with the components that use them for maintainability. diff --git a/.github/instructions/ui/presentational-components.instructions.md b/.github/instructions/ui/presentational-components.instructions.md index 65cb75c1b..9f319fe70 100644 --- a/.github/instructions/ui/presentational-components.instructions.md +++ b/.github/instructions/ui/presentational-components.instructions.md @@ -21,6 +21,9 @@ applyTo: "**/ui-*/src/components/**/!(*.container).tsx" - Use functional components and React hooks for local UI state only. - Component name must match file name in PascalCase. - Define a `{ComponentName}Props` type for all props. +- When a component receives data from a container's GraphQL query, use the generated fragment type from the corresponding `.container.graphql` file to type the data property in `{ComponentName}Props`. The presentational component is responsible for any necessary data conversion or formatting (e.g., date formatting) for display. +- For components containing forms, use the `data` prop (typed with the GraphQL fragment) to populate the form's `initialValues`. +- Avoid managing local loading state for operations triggered by handler props (e.g., `onSave`). Instead, accept a `loading` prop from the container to reflect the actual state of the operation (e.g., from `useMutation`). - Use strict TypeScript types for all props and local state. - Use kebab-case for file and directory names. - Do not perform side effects, API calls, or business logic in presentational components. From d9d4132c56620810d4ce4ad81a2852a75da8be8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:42:13 +0000 Subject: [PATCH 06/32] Update components to follow GraphQL integration guidelines from instruction files --- .../components/settings-general.container.tsx | 13 +++++++---- .../admin/components/settings-general.tsx | 23 +++++++------------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx index b16c9ff50..24bfda02c 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx @@ -3,13 +3,14 @@ import { message } from 'antd'; import { AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, AdminSettingsGeneralContainerCurrentCommunityDocument, + type AdminSettingsGeneralContainerCommunityFieldsFragment, type CommunityUpdateSettingsInput, } from '../../../../generated.tsx'; import { ComponentQueryLoader } from '@cellix/ui-core'; import { SettingsGeneral } from './settings-general.tsx'; export const SettingsGeneralContainer: React.FC = () => { - const [communityUpdate, { error: mutationError }] = useMutation( + const [communityUpdate, { loading: mutationLoading, error: mutationError }] = useMutation( AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, ); const { @@ -49,9 +50,13 @@ export const SettingsGeneralContainer: React.FC = () => { loading={communityLoading} hasData={communityData?.currentCommunity} hasDataComponent={ - communityData?.currentCommunity ? ( - - ) : null + } error={communityError ?? mutationError} /> diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx index 627a7c218..2d25aa920 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -1,23 +1,18 @@ import { Button, Descriptions, Form, Input } from 'antd'; -import React from 'react'; -import type { CommunityUpdateSettingsInput } from '../../../../generated.tsx'; +import type React from 'react'; +import type { + AdminSettingsGeneralContainerCommunityFieldsFragment, + CommunityUpdateSettingsInput, +} from '../../../../generated.tsx'; interface SettingsGeneralProps { - data: { - id: string; - name: string; - domain?: string | null; - whiteLabelDomain?: string | null; - handle?: string | null; - createdAt?: string; - updatedAt?: string; - }; + data: AdminSettingsGeneralContainerCommunityFieldsFragment; onSave: (values: CommunityUpdateSettingsInput) => void; + loading?: boolean; } export const SettingsGeneral: React.FC = (props) => { const [form] = Form.useForm(); - const [formLoading, setFormLoading] = React.useState(false); return ( <> @@ -36,9 +31,7 @@ export const SettingsGeneral: React.FC = (props) => { form={form} initialValues={props.data} onFinish={(values) => { - setFormLoading(true); props.onSave(values); - setFormLoading(false); }} > = (props) => { defaultValue={props.data.handle ?? undefined} /> - From 5c4ae78757d37721840d98e0cf8faea00c2bbc2b Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Mon, 22 Dec 2025 11:15:14 -0500 Subject: [PATCH 07/32] Update container and presentational component guidelines for GraphQL integration --- .github/instructions/ui/container-components.instructions.md | 4 +++- .../instructions/ui/presentational-components.instructions.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/instructions/ui/container-components.instructions.md b/.github/instructions/ui/container-components.instructions.md index fd79e5e0b..4ea242fd0 100644 --- a/.github/instructions/ui/container-components.instructions.md +++ b/.github/instructions/ui/container-components.instructions.md @@ -23,6 +23,8 @@ applyTo: "packages/ui-*/src/components/**/*.container.tsx" - Use strict TypeScript types for all state, props, and API responses. - Pass GraphQL query results directly to the presentational component's props without explicit mapping or transformation. Rendering logic and data formatting should be handled by the presentational component. - When performing mutations or queries, pass the `loading` state from the Apollo hooks (`useQuery`, `useMutation`) directly to the presentational component to ensure accurate UI feedback. Avoid creating redundant local state for loading. +- After a mutation that creates, updates, or deletes data, ensure the Apollo cache is updated so the UI reflects the changes. Note that Apollo automatically handles cache updates for single documents when the `id` and `__typename` match. Manual cache updates via the `update` function are typically only required for queries/mutations involving lists of documents (e.g., adding/removing items). Prefer manual updates over `refetchQueries` for better performance and immediate UI updates in these scenarios. +- When handling mutations or async operations, use `async/await` consistently. Avoid mixing `.then()` with `await`. - Handle user feedback (e.g., success/error notifications using `antd`'s `message`) within the container's handler functions (e.g., `onSave`, `onDelete`) after an operation completes. - Use kebab-case for file and directory names. - Provide handler functions through display component props for all relevant actions (e.g., handleClick, handleChange, handleSubmit, handleSave). @@ -34,7 +36,7 @@ applyTo: "packages/ui-*/src/components/**/*.container.tsx" ## Data Fetching - Use Apollo Client hooks for GraphQL queries and mutations. -- Leverage the shared `ComponentQueryLoader` component for consistent data fetching, loading, and error handling. +- Leverage the shared `ComponentQueryLoader` component for consistent data fetching, loading, and error handling. Ensure `ComponentQueryLoader` is used for all data-fetching containers, providing a `noDataComponent` where appropriate. ## Error Handling diff --git a/.github/instructions/ui/presentational-components.instructions.md b/.github/instructions/ui/presentational-components.instructions.md index 9f319fe70..97c0b2894 100644 --- a/.github/instructions/ui/presentational-components.instructions.md +++ b/.github/instructions/ui/presentational-components.instructions.md @@ -22,8 +22,10 @@ applyTo: "**/ui-*/src/components/**/!(*.container).tsx" - Component name must match file name in PascalCase. - Define a `{ComponentName}Props` type for all props. - When a component receives data from a container's GraphQL query, use the generated fragment type from the corresponding `.container.graphql` file to type the data property in `{ComponentName}Props`. The presentational component is responsible for any necessary data conversion or formatting (e.g., date formatting) for display. -- For components containing forms, use the `data` prop (typed with the GraphQL fragment) to populate the form's `initialValues`. +- For components containing forms, use the `data` prop (typed with the GraphQL fragment) to populate the form's `initialValues`. Do not use `defaultValue` on individual `Input` or `Form.Item` components if `initialValues` is provided at the `Form` level. +- Prefer derived state over `useState` for data that can be computed from props. If you must use local state for filtering or searching, ensure the component correctly reacts to prop changes (e.g., by using the props directly in the render logic or using `useMemo`). - Avoid managing local loading state for operations triggered by handler props (e.g., `onSave`). Instead, accept a `loading` prop from the container to reflect the actual state of the operation (e.g., from `useMutation`). +- REQUIRED: Every presentational component MUST have a corresponding `.stories.tsx` file and a `.test.tsx` file. Do not consider a component complete without these. - Use strict TypeScript types for all props and local state. - Use kebab-case for file and directory names. - Do not perform side effects, API calls, or business logic in presentational components. From 193b915c5ea52cb0a7cb16366f16f4d6240eed99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:23:59 +0000 Subject: [PATCH 08/32] Fix NodeEventBus broadcast to await listener promises --- .../cellix/event-bus-seedwork-node/src/node-event-bus.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts b/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts index 76d2099be..b87646803 100644 --- a/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts +++ b/packages/cellix/event-bus-seedwork-node/src/node-event-bus.ts @@ -17,14 +17,14 @@ class BroadCaster { this.eventEmitter = new EventEmitter(); } - public broadcast(event: string, data: unknown): void { + public async broadcast(event: string, data: unknown): Promise { // Collect all listeners for the event const listeners = this.eventEmitter.listeners(event) as Array< (data: unknown) => Promise | void >; - // Fire and forget for each listener + // Execute all listeners sequentially and await each one for (const listener of listeners) { - void listener(data); + await listener(data); } } public on( From 63c587fabfdcecfd96ee51eed824c38e2b2e47fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:29:33 +0000 Subject: [PATCH 09/32] Add Storybook stories and fix async/await pattern in container --- .../components/settings-general.container.tsx | 18 +-- .../components/settings-general.stories.tsx | 112 ++++++++++++++++++ .../admin/components/settings-general.tsx | 2 + 3 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx index 24bfda02c..6c2679082 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx @@ -27,19 +27,19 @@ export const SettingsGeneralContainer: React.FC = () => { values.id = communityData.currentCommunity.id; try { - await communityUpdate({ + const result = await communityUpdate({ variables: { input: values, }, - }).then((result) => { - if (result.data?.communityUpdateSettings?.status?.success) { - message.success('Saved'); - } else { - message.error( - result.data?.communityUpdateSettings.status.errorMessage ?? 'Unknown error', - ); - } }); + + if (result.data?.communityUpdateSettings?.status?.success) { + message.success('Saved'); + } else { + message.error( + result.data?.communityUpdateSettings.status.errorMessage ?? 'Unknown error', + ); + } } catch (saveError) { message.error(`Error updating community: ${JSON.stringify(saveError)}`); } diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx new file mode 100644 index 000000000..3000a2517 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from 'storybook/test'; +import { SettingsGeneral } from './settings-general.tsx'; +import type { AdminSettingsGeneralContainerCommunityFieldsFragment } from '../../../../generated.tsx'; + +const mockData: AdminSettingsGeneralContainerCommunityFieldsFragment = { + __typename: 'Community', + id: '507f1f77bcf86cd799439011', + name: 'Test Community', + domain: 'test.com', + whiteLabelDomain: 'wl.test.com', + handle: 'testcommunity', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-15T00:00:00.000Z', +}; + +const meta = { + title: 'Components/Layouts/Admin/SettingsGeneral', + component: SettingsGeneral, + parameters: { + layout: 'padded', + }, + argTypes: { + onSave: { action: 'onSave' }, + loading: { control: 'boolean' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockData, + onSave: () => { + // Action handler for Storybook + }, + loading: false, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify community info is displayed + expect(canvas.getByText(mockData.id)).toBeInTheDocument(); + expect(canvas.getByText(/1\/1\/2024/)).toBeInTheDocument(); // Created date + + // Verify form fields have correct values + const nameInput = canvas.getByPlaceholderText('Name') as HTMLInputElement; + expect(nameInput.value).toBe(mockData.name); + }, +}; + +export const Loading: Story = { + args: { + data: mockData, + onSave: () => { + // Action handler for Storybook + }, + loading: true, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify save button is in loading state + const saveButton = canvas.getByRole('button', { name: /save/i }); + expect(saveButton).toHaveClass('ant-btn-loading'); + }, +}; + +export const WithMinimalData: Story = { + args: { + data: { + __typename: 'Community', + id: '507f1f77bcf86cd799439011', + name: 'Minimal Community', + domain: null, + whiteLabelDomain: null, + handle: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + onSave: () => { + // Action handler for Storybook + }, + loading: false, + }, +}; + +export const FormSubmission: Story = { + args: { + data: mockData, + onSave: () => { + // Action handler for Storybook + }, + loading: false, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + // Update the name field + const nameInput = canvas.getByPlaceholderText('Name'); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'Updated Community Name'); + + // Submit the form + const saveButton = canvas.getByRole('button', { name: /save/i }); + await userEvent.click(saveButton); + + // Verify onSave was called (action will be logged in Storybook) + expect(args.onSave).toHaveBeenCalled(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx index 2d25aa920..e95b5e010 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -11,6 +11,8 @@ interface SettingsGeneralProps { loading?: boolean; } +export type { SettingsGeneralProps }; + export const SettingsGeneral: React.FC = (props) => { const [form] = Form.useForm(); From 2863933ad926668a67148d1e11c693d7a1b4d664 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:55:26 +0000 Subject: [PATCH 10/32] Fix member creation by pre-populating community in adapter --- .../datasources/domain/community/member/member.repository.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts index 683107864..642187d8e 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member.repository.ts @@ -47,6 +47,10 @@ export class MemberRepository //< community: Domain.Contexts.Community.Community.CommunityEntityReference ): Promise> { const adapter = this.typeConverter.toAdapter(new this.model()); + // Set the community on the adapter before creating the domain instance + // This ensures the community is available when the Member constructor + // tries to create the visa + adapter.community = community; return Promise.resolve( Domain.Contexts.Community.Member.Member.getNewInstance( adapter, From 626e5b868c9e0c808ef9cc520ee2000beae8c97a Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Mon, 22 Dec 2025 16:17:04 -0500 Subject: [PATCH 11/32] Refactor community handling in MemberDomainAdapter and EndUserRoleDomainAdapter to return CommunityEntityReference with only object ID if not populated; fixed unit tests --- .../ui/container-components.instructions.md | 1 + .../components/community-create.container.tsx | 36 +++++++++---------- .../components/settings-general.container.tsx | 28 +++++++-------- .../components/settings-general.stories.tsx | 26 +++++--------- .../admin/components/settings-general.tsx | 2 +- .../features/member.domain-adapter.feature | 2 +- .../member/member.domain-adapter.test.ts | 12 +++---- .../community/member/member.domain-adapter.ts | 2 +- .../end-user-role.domain-adapter.test.ts | 12 +++---- .../end-user-role.domain-adapter.ts | 4 ++- .../end-user-role.domain-adapter.feature | 2 +- .../member/member.read-repository.test.ts | 4 +-- .../member/member.read-repository.ts | 2 +- 13 files changed, 61 insertions(+), 72 deletions(-) diff --git a/.github/instructions/ui/container-components.instructions.md b/.github/instructions/ui/container-components.instructions.md index 4ea242fd0..fd2706380 100644 --- a/.github/instructions/ui/container-components.instructions.md +++ b/.github/instructions/ui/container-components.instructions.md @@ -28,6 +28,7 @@ applyTo: "packages/ui-*/src/components/**/*.container.tsx" - Handle user feedback (e.g., success/error notifications using `antd`'s `message`) within the container's handler functions (e.g., `onSave`, `onDelete`) after an operation completes. - Use kebab-case for file and directory names. - Provide handler functions through display component props for all relevant actions (e.g., handleClick, handleChange, handleSubmit, handleSave). +- **Knip Compliance**: To satisfy `knip` (unused export detection) while maintaining exports for Storybook/Testing, use the presentational component's exported `Props` type to define a typed object before passing it to the component. Prefer `` with a typed `props` object over inline casting like ``. ## State Management diff --git a/apps/ui-community/src/components/layouts/accounts/components/community-create.container.tsx b/apps/ui-community/src/components/layouts/accounts/components/community-create.container.tsx index dbfd37f88..d4e1cea03 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/community-create.container.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/community-create.container.tsx @@ -4,30 +4,30 @@ import { useNavigate } from 'react-router-dom'; import { AccountsCommunityCreateContainerCommunityCreateDocument, type CommunityCreateInput, -// AccountsCommunityListContainerCommunitiesDocument, + AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, } from '../../../../generated.tsx'; import { CommunityCreate } from './community-create.tsx'; export const CommunityCreateContainer: React.FC = () => { const [createCommunity, { loading, error }] = useMutation( AccountsCommunityCreateContainerCommunityCreateDocument, - // { - // update(cache, { data }) { - // // update the list with the new item - // const newCommunity = data?.communityCreate?.community; - // const communities = cache.readQuery({ - // query: AccountsCommunityListContainerCommunitiesDocument, - // })?.communities; - // if (newCommunity && communities) { - // cache.writeQuery({ - // query: AccountsCommunityListContainerCommunitiesDocument, - // data: { - // communities: [...communities, newCommunity], - // }, - // }); - // } - // }, - // } + { + update(cache, { data }) { + // update the list with the new item + const newCommunity = data?.communityCreate?.community; + const communities = cache.readQuery({ + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + })?.communitiesForCurrentEndUser; + if (newCommunity && communities) { + cache.writeQuery({ + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + data: { + communitiesForCurrentEndUser: [...communities, newCommunity], + }, + }); + } + }, + } ); const navigate = useNavigate(); diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx index 6c2679082..046e8f4ae 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx @@ -1,13 +1,13 @@ import { useMutation, useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; import { message } from 'antd'; import { - AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, - AdminSettingsGeneralContainerCurrentCommunityDocument, - type AdminSettingsGeneralContainerCommunityFieldsFragment, - type CommunityUpdateSettingsInput, + type AdminSettingsGeneralContainerCommunityFieldsFragment, + AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, + AdminSettingsGeneralContainerCurrentCommunityDocument, + type CommunityUpdateSettingsInput, } from '../../../../generated.tsx'; -import { ComponentQueryLoader } from '@cellix/ui-core'; -import { SettingsGeneral } from './settings-general.tsx'; +import { SettingsGeneral, type SettingsGeneralProps } from './settings-general.tsx'; export const SettingsGeneralContainer: React.FC = () => { const [communityUpdate, { loading: mutationLoading, error: mutationError }] = useMutation( @@ -45,19 +45,17 @@ export const SettingsGeneralContainer: React.FC = () => { } }; + const settingsProps: SettingsGeneralProps = { + onSave: handleSave, + data: communityData?.currentCommunity as AdminSettingsGeneralContainerCommunityFieldsFragment, + loading: mutationLoading, + }; + return ( - } + hasDataComponent={} error={communityError ?? mutationError} /> ); diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx index 3000a2517..c720a2981 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { SettingsGeneral } from './settings-general.tsx'; import type { AdminSettingsGeneralContainerCommunityFieldsFragment } from '../../../../generated.tsx'; @@ -10,8 +10,8 @@ const mockData: AdminSettingsGeneralContainerCommunityFieldsFragment = { domain: 'test.com', whiteLabelDomain: 'wl.test.com', handle: 'testcommunity', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-15T00:00:00.000Z', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', }; const meta = { @@ -32,9 +32,7 @@ type Story = StoryObj; export const Default: Story = { args: { data: mockData, - onSave: () => { - // Action handler for Storybook - }, + onSave: fn(), loading: false, }, play: ({ canvasElement }) => { @@ -53,9 +51,7 @@ export const Default: Story = { export const Loading: Story = { args: { data: mockData, - onSave: () => { - // Action handler for Storybook - }, + onSave: fn(), loading: true, }, play: ({ canvasElement }) => { @@ -76,12 +72,10 @@ export const WithMinimalData: Story = { domain: null, whiteLabelDomain: null, handle: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }, - onSave: () => { - // Action handler for Storybook + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', }, + onSave: fn(), loading: false, }, }; @@ -89,9 +83,7 @@ export const WithMinimalData: Story = { export const FormSubmission: Story = { args: { data: mockData, - onSave: () => { - // Action handler for Storybook - }, + onSave: fn(), loading: false, }, play: async ({ args, canvasElement }) => { diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx index e95b5e010..4c6a7a80f 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -7,7 +7,7 @@ import type { interface SettingsGeneralProps { data: AdminSettingsGeneralContainerCommunityFieldsFragment; - onSave: (values: CommunityUpdateSettingsInput) => void; + onSave: (values: CommunityUpdateSettingsInput) => Promise; loading?: boolean; } diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/features/member.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/community/member/features/member.domain-adapter.feature index 7632bbffc..3b7d545b5 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/features/member.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/community/member/features/member.domain-adapter.feature @@ -30,7 +30,7 @@ Feature: MemberDomainAdapter Scenario: Getting the community property when not populated Given a MemberDomainAdapter for a document with community as an ObjectId When I get the community property - Then an error should be thrown indicating "community is not populated or is not of the correct type" + Then it should return a CommunityEntityReference stub with the correct ID Scenario: Setting the community property with a valid Community domain object Given a MemberDomainAdapter for the document diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.test.ts index 777b35820..42a927f74 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.test.ts @@ -249,19 +249,17 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => }); Scenario('Getting the community property when not populated', ({ Given, When, Then }) => { - let gettingCommunityWhenNotPopulated: () => void; Given('a MemberDomainAdapter for a document with community as an ObjectId', () => { doc = makeMemberDoc({ community: new MongooseSeedwork.ObjectId() }); adapter = new MemberDomainAdapter(doc); }); When('I get the community property', () => { - gettingCommunityWhenNotPopulated = () => { - result = adapter.community; - }; + result = adapter.community; }); - Then('an error should be thrown indicating "community is not populated or is not of the correct type"', () => { - expect(gettingCommunityWhenNotPopulated).toThrow(); - expect(gettingCommunityWhenNotPopulated).throws(/community is not populated or is not of the correct type/); + Then('it should return a CommunityEntityReference stub with the correct ID', () => { + expect(result).not.toBeInstanceOf(CommunityDomainAdapter); + expect(result).toHaveProperty('id'); + expect((result as { id: string }).id).toBe(doc.community?.toString()); }); }); diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.ts index c9e48e101..f6f9e6059 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member.domain-adapter.ts @@ -62,7 +62,7 @@ export class MemberDomainAdapter extends MongooseSeedwork.MongooseDomainAdapter< throw new Error('community is not populated'); } if (this.doc.community instanceof MongooseSeedwork.ObjectId) { - throw new Error('community is not populated or is not of the correct type'); + return { id: this.doc.community.toString() } as Domain.Contexts.Community.Community.CommunityEntityReference; } return new CommunityDomainAdapter(this.doc.community as Community); } diff --git a/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.test.ts index a3bb4519e..fd801c7a0 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.test.ts @@ -178,19 +178,17 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => }); Scenario('Getting the community property when not populated', ({ Given, When, Then }) => { - let gettingCommunityWhenNotPopulated: () => void; Given('an EndUserRoleDomainAdapter for a document with community as an ObjectId', () => { doc = makeEndUserRoleDoc({ community: new MongooseSeedwork.ObjectId() }); adapter = new EndUserRoleDomainAdapter(doc); }); When('I get the community property', () => { - gettingCommunityWhenNotPopulated = () => { - result = adapter.community; - }; + result = adapter.community; }); - Then('an error should be thrown indicating "community is not populated or is not of the correct type"', () => { - expect(gettingCommunityWhenNotPopulated).toThrow(); - expect(gettingCommunityWhenNotPopulated).throws(/community is not populated/); + Then('it should return a CommunityEntityReference stub with the correct ID', () => { + expect(result).not.toBeInstanceOf(CommunityDomainAdapter); + expect(result).toHaveProperty('id'); + expect((result as { id: string }).id).toBe(doc.community?.toString()); }); }); diff --git a/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.ts index e730543ee..29bfeb8c9 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/end-user-role.domain-adapter.ts @@ -36,7 +36,9 @@ export class EndUserRoleDomainAdapter throw new Error('community is not populated'); } if (this.doc.community instanceof MongooseSeedwork.ObjectId) { - throw new Error('community is not populated or is not of the correct type'); + return { + id: this.doc.community.toString(), + } as Domain.Contexts.Community.Community.CommunityEntityReference; } return new CommunityDomainAdapter(this.doc.community as Community); } diff --git a/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/features/end-user-role.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/features/end-user-role.domain-adapter.feature index 051a1362f..c21f21ee1 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/features/end-user-role.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/community/role/end-user-role/features/end-user-role.domain-adapter.feature @@ -25,7 +25,7 @@ Feature: EndUserRoleDomainAdapter Scenario: Getting the community property when not populated Given an EndUserRoleDomainAdapter for a document with community as an ObjectId When I get the community property - Then an error should be thrown indicating "community is not populated or is not of the correct type" + Then it should return a CommunityEntityReference stub with the correct ID Scenario: Setting the community property with a valid Community domain object Given an EndUserRoleDomainAdapter for the document diff --git a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts index 62838a906..e792c280b 100644 --- a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.test.ts @@ -183,7 +183,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { Then('I should receive the MemberEntityReference object with role populated', () => { expect(mockDataSource.findById).toHaveBeenCalledWith('member-123', { - populateFields: ['role'] + populateFields: ['role', 'role.community'] }); expect(mockConverter.toDomain).toHaveBeenCalledWith(mockMemberDoc, passport); }); @@ -243,7 +243,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { Then('I should receive a boolean indicating admin status', () => { expect(mockDataSource.findById).toHaveBeenCalledWith('admin-member', { - populateFields: ['role'] + populateFields: ['role', 'role.community'] }); }); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts index 12e66a49e..94d3f5c37 100644 --- a/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts +++ b/packages/ocom/persistence/src/datasources/readonly/community/member/member.read-repository.ts @@ -70,7 +70,7 @@ export class MemberReadRepositoryImpl implements MemberReadRepository { async getByIdWithRole(id: string, options?: FindOneOptions): Promise { const finalOptions: FindOneOptions = { ...options, - populateFields: ['role'] + populateFields: ['role', 'role.community'] }; const result = await this.mongoDataSource.findById(id, finalOptions); if (!result) { return null; } From d486a09fb2f4463d4432fb65976b97bf62bb4f5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:25:28 +0000 Subject: [PATCH 12/32] Align admin routing and layout with legacy codebase structure --- apps/ui-community/src/App.tsx | 2 +- .../src/components/layouts/admin/index.tsx | 15 +++-- .../components/layouts/admin/pages/home.tsx | 25 +++++++ .../layouts/admin/sub-page-layout.tsx | 67 +++++++++++-------- 4 files changed, 76 insertions(+), 33 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/pages/home.tsx diff --git a/apps/ui-community/src/App.tsx b/apps/ui-community/src/App.tsx index 70b19bb14..b90220303 100644 --- a/apps/ui-community/src/App.tsx +++ b/apps/ui-community/src/App.tsx @@ -21,7 +21,7 @@ export default function App() { } /> } /> - } /> + } /> ); diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx index 3c8aa7e40..24ddbf7a9 100644 --- a/apps/ui-community/src/components/layouts/admin/index.tsx +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -1,7 +1,8 @@ -import { SettingOutlined } from '@ant-design/icons'; +import { HomeOutlined, SettingOutlined } from '@ant-design/icons'; import { Route, Routes } from 'react-router-dom'; import { SectionLayout } from './section-layout.tsx'; import { Settings } from './pages/settings.tsx'; +import { Home } from './pages/home.tsx'; export interface PageLayoutProps { path: string; @@ -12,10 +13,15 @@ export interface PageLayoutProps { } export const Admin: React.FC = () => { + const pathLocations = { + home: '', + settings: 'settings/*', + }; + const pageLayouts: PageLayoutProps[] = [ - { path: '', title: 'Home', icon: , id: 'ROOT' }, + { path: pathLocations.home, title: 'Home', icon: , id: 'ROOT' }, { - path: 'settings/*', + path: pathLocations.settings, title: 'Settings', icon: , id: 2, @@ -26,7 +32,8 @@ export const Admin: React.FC = () => { return ( }> - } /> + } /> + } /> ); diff --git a/apps/ui-community/src/components/layouts/admin/pages/home.tsx b/apps/ui-community/src/components/layouts/admin/pages/home.tsx new file mode 100644 index 000000000..25359d104 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/home.tsx @@ -0,0 +1,25 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { SubPageLayout } from '../sub-page-layout.tsx'; + +export const Home: React.FC = () => { + const { + token: { colorTextBase }, + } = theme.useToken(); + + return ( + Home} + /> + } + > +
+

Welcome to Community Admin

+

Use the menu on the left to navigate to different sections.

+
+
+ ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx b/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx index 7a999d2cd..274f93721 100644 --- a/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx +++ b/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx @@ -1,36 +1,47 @@ - import { Layout, theme } from 'antd'; -import type React from "react"; +import type React from 'react'; const { Header, Content, Footer } = Layout; interface SubPageLayoutProps { - header: React.JSX.Element; - fixedHeader?: boolean; - children?: React.ReactNode; + header: React.JSX.Element; + fixedHeader?: boolean; + children?: React.ReactNode; } export const SubPageLayout: React.FC = (props) => { - const overFlow = props.fixedHeader ? 'scroll' : 'unset'; - const { - token: {colorTextBase, colorBgContainer } - }=theme.useToken(); - return ( - <> -
- - {props.header} -
-
- -
- {props.children} -
-
-
- Owner Community -
-
- - ); -}; \ No newline at end of file + const { + token: { colorText, colorBgContainer }, + } = theme.useToken(); + const overFlow = props.fixedHeader ? 'scroll' : 'unset'; + return ( + <> +
+ {props.header} +
+
+ +
{props.children}
+
+
+ Owner Community +
+
+ + ); +}; From 6acf123386ab34a193b6e6e7e84e439255cd9c8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:09:55 +0000 Subject: [PATCH 13/32] Align admin components with legacy Owner Community implementation --- apps/ui-community/package.json | 1 + .../community-detail.container.graphql | 15 ++++ .../components/community-detail.container.tsx | 38 +++++++++ .../admin/components/community-detail.tsx | 82 +++++++++++++++++++ .../admin/components/settings-general.tsx | 40 ++++++--- .../components/layouts/admin/pages/home.tsx | 10 +-- pnpm-lock.yaml | 3 + 7 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/admin/components/community-detail.container.graphql create mode 100644 apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx create mode 100644 apps/ui-community/src/components/layouts/admin/components/community-detail.tsx diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index dc54fd859..80bab4312 100644 --- a/apps/ui-community/package.json +++ b/apps/ui-community/package.json @@ -24,6 +24,7 @@ "@ocom/ui-components": "workspace:*", "antd": "^5.27.0", "apollo-link-rest": "^0.9.0", + "dayjs": "^1.11.19", "less": "^4.4.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.container.graphql b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.graphql new file mode 100644 index 000000000..a8c36a091 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.graphql @@ -0,0 +1,15 @@ +query AdminCommunityDetailContainerCommunityById($id: ObjectID!) { + communityById(id: $id) { + ...AdminCommunityDetailContainerCommunityFields + } +} + +fragment AdminCommunityDetailContainerCommunityFields on Community { + id + name + domain + whiteLabelDomain + handle + createdAt + updatedAt +} diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx new file mode 100644 index 000000000..3ab7e65e6 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { AdminCommunityDetailContainerCommunityFieldsFragment } from '../../../../generated.tsx'; +import { + AdminCommunityDetailContainerCommunityByIdDocument, +} from '../../../../generated.tsx'; +import { CommunityDetail } from './community-detail.tsx'; + +export interface CommunityDetailContainerProps { + data: { id?: string }; +} + +export const CommunityDetailContainer: React.FC = ( + props, +) => { + const { + data: communityData, + loading: communityLoading, + error: communityError, + } = useQuery(AdminCommunityDetailContainerCommunityByIdDocument, { + variables: { id: props.data.id ?? '' }, + }); + + return ( + + } + error={communityError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx new file mode 100644 index 000000000..9d27ad09c --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx @@ -0,0 +1,82 @@ +import { Descriptions, Typography, theme } from 'antd'; +import type { AdminCommunityDetailContainerCommunityFieldsFragment } from '../../../../generated.tsx'; + +const { Text, Title } = Typography; + +export interface CommunityDetailProps { + data: AdminCommunityDetailContainerCommunityFieldsFragment; +} + +export const CommunityDetail: React.FC = (props) => { + const whiteLabelDetails = () => { + if (props.data.whiteLabelDomain) { + return ( + + {props.data.whiteLabelDomain} + + ); + } + return <>; + }; + + const domainDetails = () => { + if (props.data.domain) { + return ( + + {props.data.domain} + + ); + } + return <>; + }; + + const handleDetails = () => { + if (props.data.handle) { + return ( + + {props.data.handle} + + ); + } + return <>; + }; + + const { + token: { colorText, colorBgContainer }, + } = theme.useToken(); + + return ( +
+
+ Community Admin +

+ You can manage different aspects of your community here. The items in + menu to the left reflect the permissions you have in managing this + community. +

+
+ + + + + {props.data.id} + + + + + {props.data.name} + + + {whiteLabelDetails()} + {domainDetails()} + {handleDetails()} + +
+ ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx index 4c6a7a80f..8dd6776b0 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -1,10 +1,13 @@ -import { Button, Descriptions, Form, Input } from 'antd'; +import { Button, Descriptions, Form, Input, Typography } from 'antd'; +import dayjs from 'dayjs'; import type React from 'react'; import type { AdminSettingsGeneralContainerCommunityFieldsFragment, CommunityUpdateSettingsInput, } from '../../../../generated.tsx'; +const { Text } = Typography; + interface SettingsGeneralProps { data: AdminSettingsGeneralContainerCommunityFieldsFragment; onSave: (values: CommunityUpdateSettingsInput) => Promise; @@ -15,16 +18,17 @@ export type { SettingsGeneralProps }; export const SettingsGeneral: React.FC = (props) => { const [form] = Form.useForm(); + const data = props.data; return ( <> {props.data.id} - {props.data.createdAt ? new Date(props.data.createdAt).toLocaleDateString() : 'N/A'} + {dayjs(props.data.createdAt).format('MM/DD/YYYY')} - {props.data.updatedAt ? new Date(props.data.updatedAt).toLocaleDateString() : 'N/A'} + {dayjs(props.data.updatedAt).format('MM/DD/YYYY')} @@ -46,26 +50,40 @@ export const SettingsGeneral: React.FC = (props) => {
- The white label domain is used to allow users to access your public community + The white domain is used to allow users to access your public community website. +
+ They will be able access it at: https:// + {data.whiteLabelDomain}.owner.community +
+ This is necessary to allow users to access + your community website unless you have a custom domain you own. (see + below)
- The domain is used to apply a custom domain to the public facing website. + The domain is used to apply a custom domain to the public facing + website. +
+ You must have a domain name registered with us before you can use this + feature. +
+ Assign the CNAME of "www" to "cname.vercel-dns.com" in your DNS + settings.
- You must have a domain name registered with us before you can use this feature. + Once added, you can use the domain name in the white label field above.
@@ -73,7 +91,7 @@ export const SettingsGeneral: React.FC = (props) => { - - - ); + + + + ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/components/community-list.container.graphql b/apps/ui-community/src/components/layouts/accounts/components/community-list.container.graphql index 987a3ac09..70e350053 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/community-list.container.graphql +++ b/apps/ui-community/src/components/layouts/accounts/components/community-list.container.graphql @@ -1,34 +1,34 @@ query AccountsCommunityListContainerCommunitiesForCurrentEndUser { - communitiesForCurrentEndUser { - ...AccountsCommunityListContainerCommunityFields - } + communitiesForCurrentEndUser { + ...AccountsCommunityListContainerCommunityFields + } } query AccountsCommunityListContainerMembersForCurrentEndUser { - membersForCurrentEndUser { - ...AccountsCommunityListContainerMemberFields - } + membersForCurrentEndUser { + ...AccountsCommunityListContainerMemberFields + } } fragment AccountsCommunityListContainerMemberFields on Member { - memberName - community { - id - } - isAdmin + memberName + community { + id + } + isAdmin - id + id } fragment AccountsCommunityListContainerCommunityFields on Community { - name - domain - whiteLabelDomain - handle - publicContentBlobUrl - - schemaVersion - createdAt - updatedAt - id -} \ No newline at end of file + name + domain + whiteLabelDomain + handle + publicContentBlobUrl + + schemaVersion + createdAt + updatedAt + id +} diff --git a/apps/ui-community/src/components/layouts/accounts/components/community-list.container.tsx b/apps/ui-community/src/components/layouts/accounts/components/community-list.container.tsx index 51a46b582..f6a41745b 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/community-list.container.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/community-list.container.tsx @@ -1,61 +1,69 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; import { - AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, - type AccountsCommunityListContainerCommunityFieldsFragment, - type AccountsCommunityListContainerMemberFieldsFragment, - AccountsCommunityListContainerMembersForCurrentEndUserDocument, + AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + type AccountsCommunityListContainerCommunityFieldsFragment, + type AccountsCommunityListContainerMemberFieldsFragment, + AccountsCommunityListContainerMembersForCurrentEndUserDocument, } from '../../../../generated.tsx'; import { CommunityList } from './community-list.tsx'; export const CommunityListContainer: React.FC = () => { - const { - loading: communityLoading, - error: communityError, - data: communityData - } = useQuery(AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument); + const { + loading: communityLoading, + error: communityError, + data: communityData, + } = useQuery( + AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + ); - const { - loading: membersLoading, - error: membersError, - data: membersData - } = useQuery(AccountsCommunityListContainerMembersForCurrentEndUserDocument, { - fetchPolicy: 'network-only' - }); + const { + loading: membersLoading, + error: membersError, + data: membersData, + } = useQuery(AccountsCommunityListContainerMembersForCurrentEndUserDocument, { + fetchPolicy: 'network-only', + }); - const members: AccountsCommunityListContainerMemberFieldsFragment[][] = []; - if ( - membersData?.membersForCurrentEndUser && - membersData?.membersForCurrentEndUser.length > 0 && - communityData?.communitiesForCurrentEndUser - ) { - for (const community of communityData.communitiesForCurrentEndUser) { - members.push( - membersData.membersForCurrentEndUser.filter((member: AccountsCommunityListContainerMemberFieldsFragment) => member?.community?.id === community?.id) - ); - } - } + const members: AccountsCommunityListContainerMemberFieldsFragment[][] = []; + if ( + membersData?.membersForCurrentEndUser && + membersData?.membersForCurrentEndUser.length > 0 && + communityData?.communitiesForCurrentEndUser + ) { + for (const community of communityData.communitiesForCurrentEndUser) { + members.push( + membersData.membersForCurrentEndUser.filter( + (member: AccountsCommunityListContainerMemberFieldsFragment) => + member?.community?.id === community?.id, + ), + ); + } + } - return ( - } - noDataComponent={
No Data...
} - error={communityError || membersError} - errorComponent={ - communityError ? ( -
Error :( {JSON.stringify(communityError)}
- ) : ( -
Error :( {JSON.stringify(membersError)}
- ) - } - /> - ); + return ( + + } + noDataComponent={
No Data...
} + error={communityError || membersError} + errorComponent={ + communityError ? ( +
Error :( {JSON.stringify(communityError)}
+ ) : ( +
Error :( {JSON.stringify(membersError)}
+ ) + } + /> + ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/components/community-list.stories.tsx b/apps/ui-community/src/components/layouts/accounts/components/community-list.stories.tsx index 92bd5c993..4930c0c3b 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/community-list.stories.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/community-list.stories.tsx @@ -4,185 +4,193 @@ import { expect, userEvent, within } from 'storybook/test'; import { CommunityList, type CommunityListProps } from './community-list.tsx'; const meta = { - title: 'Components/Accounts/CommunityList', - component: CommunityList, - parameters: { - layout: 'padded', - }, - decorators: [ - (Story) => ( - - - - ), - ], + title: 'Components/Accounts/CommunityList', + component: CommunityList, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( + + + + ), + ], } satisfies Meta; export default meta; type Story = StoryObj; const mockData = { - communities: [ - { - id: 'community-1', - name: 'Test Community 1', - domain: null, - whiteLabelDomain: null, - handle: null, - publicContentBlobUrl: null, - schemaVersion: '1.0', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - __typename: 'Community' as const, - }, - { - id: 'community-2', - name: 'Test Community 2', - domain: null, - whiteLabelDomain: null, - handle: null, - publicContentBlobUrl: null, - schemaVersion: '1.0', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - __typename: 'Community' as const, - }, - ], - members: [ - [ - { - id: 'member-1', - memberName: 'John Doe', - isAdmin: true, - community: { id: 'community-1', __typename: 'Community' as const }, - __typename: 'Member' as const, - }, - { - id: 'member-2', - memberName: 'Jane Smith', - isAdmin: false, - community: { id: 'community-1', __typename: 'Community' as const }, - __typename: 'Member' as const, - }, - ], - [ - { - id: 'member-3', - memberName: 'Bob Johnson', - isAdmin: true, - community: { id: 'community-2', __typename: 'Community' as const }, - __typename: 'Member' as const, - }, - ], - ], + communities: [ + { + id: 'community-1', + name: 'Test Community 1', + domain: null, + whiteLabelDomain: null, + handle: null, + publicContentBlobUrl: null, + schemaVersion: '1.0', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + __typename: 'Community' as const, + }, + { + id: 'community-2', + name: 'Test Community 2', + domain: null, + whiteLabelDomain: null, + handle: null, + publicContentBlobUrl: null, + schemaVersion: '1.0', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + __typename: 'Community' as const, + }, + ], + members: [ + [ + { + id: 'member-1', + memberName: 'John Doe', + isAdmin: true, + community: { id: 'community-1', __typename: 'Community' as const }, + __typename: 'Member' as const, + }, + { + id: 'member-2', + memberName: 'Jane Smith', + isAdmin: false, + community: { id: 'community-1', __typename: 'Community' as const }, + __typename: 'Member' as const, + }, + ], + [ + { + id: 'member-3', + memberName: 'Bob Johnson', + isAdmin: true, + community: { id: 'community-2', __typename: 'Community' as const }, + __typename: 'Member' as const, + }, + ], + ], }; export const Default: Story = { - args: { - data: mockData, - } satisfies CommunityListProps, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Verify the title is present - const title = await canvas.findByRole('heading', { name: /navigate to a community/i }); - expect(title).toBeInTheDocument(); - - // Verify the create community button is present - const createButton = await canvas.findByRole('button', { name: /create a community/i }); - expect(createButton).toBeInTheDocument(); - - // Verify the search input is present - const searchInput = canvas.getByPlaceholderText('Search for a community'); - expect(searchInput).toBeInTheDocument(); - - // Verify community names are displayed in the table - const community1 = await canvas.findByText('Test Community 1'); - const community2 = await canvas.findByText('Test Community 2'); - expect(community1).toBeInTheDocument(); - expect(community2).toBeInTheDocument(); - }, + args: { + data: mockData, + } satisfies CommunityListProps, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify the title is present + const title = await canvas.findByRole('heading', { + name: /navigate to a community/i, + }); + expect(title).toBeInTheDocument(); + + // Verify the create community button is present + const createButton = await canvas.findByRole('button', { + name: /create a community/i, + }); + expect(createButton).toBeInTheDocument(); + + // Verify the search input is present + const searchInput = canvas.getByPlaceholderText('Search for a community'); + expect(searchInput).toBeInTheDocument(); + + // Verify community names are displayed in the table + const community1 = await canvas.findByText('Test Community 1'); + const community2 = await canvas.findByText('Test Community 2'); + expect(community1).toBeInTheDocument(); + expect(community2).toBeInTheDocument(); + }, }; export const SearchFunctionality: Story = { - args: { - data: mockData, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Test search functionality - const searchInput = canvas.getByPlaceholderText('Search for a community'); - await userEvent.type(searchInput, 'Community 1'); - - // Verify only the matching community is shown - const community1 = await canvas.findByText('Test Community 1'); - expect(community1).toBeInTheDocument(); - - // Verify the other community is not shown - expect(canvas.queryByText('Test Community 2')).not.toBeInTheDocument(); - }, + args: { + data: mockData, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test search functionality + const searchInput = canvas.getByPlaceholderText('Search for a community'); + await userEvent.type(searchInput, 'Community 1'); + + // Verify only the matching community is shown + const community1 = await canvas.findByText('Test Community 1'); + expect(community1).toBeInTheDocument(); + + // Verify the other community is not shown + expect(canvas.queryByText('Test Community 2')).not.toBeInTheDocument(); + }, }; export const EmptyState: Story = { - args: { - data: { - communities: [], - members: [], - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Verify the empty state message is shown - const emptyMessage = await canvas.findByText('No communities found.'); - expect(emptyMessage).toBeInTheDocument(); - }, + args: { + data: { + communities: [], + members: [], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify the empty state message is shown + const emptyMessage = await canvas.findByText('No communities found.'); + expect(emptyMessage).toBeInTheDocument(); + }, }; export const SingleCommunity: Story = { - args: { - data: { - communities: [ - { - id: 'community-1', - name: 'Single Community', - domain: null, - whiteLabelDomain: null, - handle: null, - publicContentBlobUrl: null, - schemaVersion: '1.0', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - __typename: 'Community' as const, - }, - ], - members: [ - [ - { - id: 'member-1', - memberName: 'Admin User', - isAdmin: true, - community: { id: 'community-1', __typename: 'Community' as const }, - __typename: 'Member' as const, - }, - ], - ], - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Verify the single community is displayed - const community = await canvas.findByText('Single Community'); - expect(community).toBeInTheDocument(); - - // Verify member portal dropdown is present - const memberPortalButton = await canvas.findByRole('button', { name: /member portals/i }); - expect(memberPortalButton).toBeInTheDocument(); - - // Verify admin portal dropdown is present - const adminPortalButton = await canvas.findByRole('button', { name: /admin portals/i }); - expect(adminPortalButton).toBeInTheDocument(); - }, -}; \ No newline at end of file + args: { + data: { + communities: [ + { + id: 'community-1', + name: 'Single Community', + domain: null, + whiteLabelDomain: null, + handle: null, + publicContentBlobUrl: null, + schemaVersion: '1.0', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + __typename: 'Community' as const, + }, + ], + members: [ + [ + { + id: 'member-1', + memberName: 'Admin User', + isAdmin: true, + community: { id: 'community-1', __typename: 'Community' as const }, + __typename: 'Member' as const, + }, + ], + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify the single community is displayed + const community = await canvas.findByText('Single Community'); + expect(community).toBeInTheDocument(); + + // Verify member portal dropdown is present + const memberPortalButton = await canvas.findByRole('button', { + name: /member portals/i, + }); + expect(memberPortalButton).toBeInTheDocument(); + + // Verify admin portal dropdown is present + const adminPortalButton = await canvas.findByRole('button', { + name: /admin portals/i, + }); + expect(adminPortalButton).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/accounts/components/community-list.tsx b/apps/ui-community/src/components/layouts/accounts/components/community-list.tsx index 97e415528..9509ddfad 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/community-list.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/community-list.tsx @@ -1,135 +1,156 @@ import { DownOutlined } from '@ant-design/icons'; -import { Button, Dropdown, Input as Search, Space, Table, Typography } from 'antd'; +import { + Button, + Dropdown, + Input as Search, + Space, + Table, + Typography, +} from 'antd'; import { type ChangeEvent, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { AccountsCommunityListContainerCommunityFieldsFragment, AccountsCommunityListContainerMemberFieldsFragment } from '../../../../generated.tsx'; +import type { + AccountsCommunityListContainerCommunityFieldsFragment, + AccountsCommunityListContainerMemberFieldsFragment, +} from '../../../../generated.tsx'; const { Title } = Typography; export interface CommunityListProps { - data: { - communities: AccountsCommunityListContainerCommunityFieldsFragment[]; - members: AccountsCommunityListContainerMemberFieldsFragment[][]; - }; + data: { + communities: AccountsCommunityListContainerCommunityFieldsFragment[]; + members: AccountsCommunityListContainerMemberFieldsFragment[][]; + }; } export const CommunityList: React.FC = (props) => { - const [communityList, setCommunityList] = useState(props.data.communities); - const navigate = useNavigate(); + const [communityList, setCommunityList] = useState(props.data.communities); + const navigate = useNavigate(); - const onChange = (event: ChangeEvent) => { - const searchValue = event.target.value; - if (searchValue === '') { - setCommunityList(props.data.communities); - return; - } - const filteredCommunities = props.data.communities.filter((community) => { - return community?.name?.toLocaleLowerCase().includes(searchValue.toLocaleLowerCase()); - }); - setCommunityList(filteredCommunities); - }; + const onChange = (event: ChangeEvent) => { + const searchValue = event.target.value; + if (searchValue === '') { + setCommunityList(props.data.communities); + return; + } + const filteredCommunities = props.data.communities.filter((community) => { + return community?.name + ?.toLocaleLowerCase() + .includes(searchValue.toLocaleLowerCase()); + }); + setCommunityList(filteredCommunities); + }; - const columns = [ - { - title: 'Community Name', - dataIndex: 'community', - key: 'community', - width: '30%' - }, - { - title: 'Member Portal', - dataIndex: 'memberPortal', - key: 'memberPortal' - }, - { - title: 'Admin Portal', - dataIndex: 'adminPortal', - key: 'adminPortal' - } - ]; - const items = communityList.map((community, i) => ({ - key: community.id, - community: community.name, - memberPortal: ( - ({ - key: member.id as string, - label: ( - - ) - })) - }} - > - - - ), - adminPortal: ( - member.isAdmin) - .map((member) => ({ - key: member.id as string, - label: ( - - ) - })) - }} - > - - - ) - })); + const columns = [ + { + title: 'Community Name', + dataIndex: 'community', + key: 'community', + width: '30%', + }, + { + title: 'Member Portal', + dataIndex: 'memberPortal', + key: 'memberPortal', + }, + { + title: 'Admin Portal', + dataIndex: 'adminPortal', + key: 'adminPortal', + }, + ]; + const items = communityList.map((community, i) => ({ + key: community.id, + community: community.name, + memberPortal: ( + ({ + key: member.id as string, + label: ( + + ), + })), + }} + > + + + ), + adminPortal: ( + member.isAdmin) + .map((member) => ({ + key: member.id as string, + label: ( + + ), + })), + }} + > + + + ), + })); - return ( -
-
-

Navigate to a Community

- -
- -
- {items.length > 0 ? ( - - ) : ( - - No communities found. - - )} - - - ); + return ( +
+
+

Navigate to a Community

+ +
+ +
+ {items.length > 0 ? ( +
+ ) : ( + + No communities found. + + )} + + + ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/components/user-info.container.graphql b/apps/ui-community/src/components/layouts/accounts/components/user-info.container.graphql index 5bb3f5980..eb75683aa 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/user-info.container.graphql +++ b/apps/ui-community/src/components/layouts/accounts/components/user-info.container.graphql @@ -1,17 +1,17 @@ query AccountsUserInfoContainerCurrentEndUserAndCreateIfNotExists { - currentEndUserAndCreateIfNotExists { - ...AccountsUserInfoContainerEndUserFields - } + currentEndUserAndCreateIfNotExists { + ...AccountsUserInfoContainerEndUserFields + } } fragment AccountsUserInfoContainerEndUserFields on EndUser { - externalId - personalInformation { - identityDetails { - lastName - restOfName - } - } + externalId + personalInformation { + identityDetails { + lastName + restOfName + } + } - id -} \ No newline at end of file + id +} diff --git a/apps/ui-community/src/components/layouts/accounts/components/user-info.container.tsx b/apps/ui-community/src/components/layouts/accounts/components/user-info.container.tsx index 1231d64f4..4edbbc7f5 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/user-info.container.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/user-info.container.tsx @@ -1,18 +1,29 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; -import { AccountsUserInfoContainerCurrentEndUserAndCreateIfNotExistsDocument, type AccountsUserInfoContainerEndUserFieldsFragment } from '../../../../generated.tsx'; +import { + AccountsUserInfoContainerCurrentEndUserAndCreateIfNotExistsDocument, + type AccountsUserInfoContainerEndUserFieldsFragment, +} from '../../../../generated.tsx'; import { UserInfo } from './user-info.tsx'; export const UserInfoContainer: React.FC = () => { - const { loading, error, data } = useQuery(AccountsUserInfoContainerCurrentEndUserAndCreateIfNotExistsDocument); + const { loading, error, data } = useQuery( + AccountsUserInfoContainerCurrentEndUserAndCreateIfNotExistsDocument, + ); - return ( - } - noDataComponent={
No User Data
} - /> - ) -} \ No newline at end of file + return ( + + } + noDataComponent={
No User Data
} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/accounts/components/user-info.stories.tsx b/apps/ui-community/src/components/layouts/accounts/components/user-info.stories.tsx index 5194e6496..c5220b697 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/user-info.stories.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/user-info.stories.tsx @@ -1,48 +1,48 @@ import type { Meta, StoryObj } from '@storybook/react'; import { expect, within } from 'storybook/test'; -import { UserInfo, type UserInfoProps} from './user-info.tsx'; +import { UserInfo, type UserInfoProps } from './user-info.tsx'; const meta = { - title: 'Components/Accounts/UserInfo', - component: UserInfo, - parameters: { - layout: 'padded', - }, + title: 'Components/Accounts/UserInfo', + component: UserInfo, + parameters: { + layout: 'padded', + }, } satisfies Meta; export default meta; type Story = StoryObj; const mockUserData = { - id: 'user-123', - __typename: 'EndUser' as const, + id: 'user-123', + __typename: 'EndUser' as const, }; export const Default: Story = { - args: { - userData: mockUserData, - } satisfies UserInfoProps, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + args: { + userData: mockUserData, + } satisfies UserInfoProps, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // Verify the user ID is displayed - const userIdText = await canvas.findByTestId('user-id'); - expect(userIdText).toHaveTextContent('User ID: user-123'); - }, + // Verify the user ID is displayed + const userIdText = await canvas.findByTestId('user-id'); + expect(userIdText).toHaveTextContent('User ID: user-123'); + }, }; export const DifferentUser: Story = { - args: { - userData: { - id: 'user-456', - __typename: 'EndUser' as const, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + args: { + userData: { + id: 'user-456', + __typename: 'EndUser' as const, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // Verify the different user ID is displayed - const userIdText = await canvas.findByTestId('user-id'); - expect(userIdText).toHaveTextContent('User ID: user-456'); - }, -}; \ No newline at end of file + // Verify the different user ID is displayed + const userIdText = await canvas.findByTestId('user-id'); + expect(userIdText).toHaveTextContent('User ID: user-456'); + }, +}; diff --git a/apps/ui-community/src/components/layouts/accounts/components/user-info.tsx b/apps/ui-community/src/components/layouts/accounts/components/user-info.tsx index fc1b7e968..80dfed132 100644 --- a/apps/ui-community/src/components/layouts/accounts/components/user-info.tsx +++ b/apps/ui-community/src/components/layouts/accounts/components/user-info.tsx @@ -2,13 +2,13 @@ import { Typography } from 'antd'; import type { AccountsUserInfoContainerEndUserFieldsFragment } from '../../../../generated.tsx'; export interface UserInfoProps { - userData: AccountsUserInfoContainerEndUserFieldsFragment; + userData: AccountsUserInfoContainerEndUserFieldsFragment; } export const UserInfo: React.FC = (props) => { - return ( - - User ID: {props.userData.id}
-
- ); + return ( + + User ID: {props.userData.id}
+
+ ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/index.tsx b/apps/ui-community/src/components/layouts/accounts/index.tsx index e5c1f9d98..364f0310b 100644 --- a/apps/ui-community/src/components/layouts/accounts/index.tsx +++ b/apps/ui-community/src/components/layouts/accounts/index.tsx @@ -4,12 +4,12 @@ import { Home } from './pages/home.tsx'; import { SectionLayout } from './section-layout.tsx'; export const Accounts: React.FC = () => { - return ( - - }> - } /> - } /> - - - ); + return ( + + }> + } /> + } /> + + + ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/pages/create-community.stories.tsx b/apps/ui-community/src/components/layouts/accounts/pages/create-community.stories.tsx index 3b83ba71d..4b2ad7028 100644 --- a/apps/ui-community/src/components/layouts/accounts/pages/create-community.stories.tsx +++ b/apps/ui-community/src/components/layouts/accounts/pages/create-community.stories.tsx @@ -1,33 +1,33 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect, within } from 'storybook/test'; import { MemoryRouter } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; import { Accounts } from '../index.tsx'; const meta = { - title: 'Pages/Accounts/Create Community', - component: Accounts, - parameters: { - layout: 'fullscreen', - }, + title: 'Pages/Accounts/Create Community', + component: Accounts, + parameters: { + layout: 'fullscreen', + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, - decorators: [ - (Story) => ( - - - - ), - ], - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + args: {}, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // Verify the page header title is present - const pageTitle = await canvas.findByText('Create a Community'); - expect(pageTitle).toBeInTheDocument(); - }, -}; \ No newline at end of file + // Verify the page header title is present + const pageTitle = await canvas.findByText('Create a Community'); + expect(pageTitle).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/accounts/pages/create-community.tsx b/apps/ui-community/src/components/layouts/accounts/pages/create-community.tsx index 767cea264..890f37bc5 100644 --- a/apps/ui-community/src/components/layouts/accounts/pages/create-community.tsx +++ b/apps/ui-community/src/components/layouts/accounts/pages/create-community.tsx @@ -4,13 +4,15 @@ import { CommunityCreateContainer } from '../components/community-create.contain import { SubPageLayout } from '../sub-page-layout.tsx'; export const CreateCommunity: React.FC = () => { - const navigate = useNavigate(); - return ( - navigate('../')} />} - > - - - ); + const navigate = useNavigate(); + return ( + navigate('../')} /> + } + > + + + ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/pages/home.stories.tsx b/apps/ui-community/src/components/layouts/accounts/pages/home.stories.tsx index 6e6fb9a1d..fab0a1152 100644 --- a/apps/ui-community/src/components/layouts/accounts/pages/home.stories.tsx +++ b/apps/ui-community/src/components/layouts/accounts/pages/home.stories.tsx @@ -1,37 +1,39 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect, within } from 'storybook/test'; import { MemoryRouter } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; import { Accounts } from '../index.tsx'; const meta = { - title: 'Pages/Accounts/Home', - component: Accounts, - parameters: { - layout: 'fullscreen', - }, + title: 'Pages/Accounts/Home', + component: Accounts, + parameters: { + layout: 'fullscreen', + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, - decorators: [ - (Story) => ( - - - - ), - ], - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + args: {}, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // Verify the welcome title is present - const welcomeTitle = await canvas.findByText('Welcome to Owner Community'); - expect(welcomeTitle).toBeInTheDocument(); + // Verify the welcome title is present + const welcomeTitle = await canvas.findByText('Welcome to Owner Community'); + expect(welcomeTitle).toBeInTheDocument(); - // Verify the description text is present - const descriptionText = await canvas.findByText(/To join a community, you must provide/); - expect(descriptionText).toBeInTheDocument(); - }, -}; \ No newline at end of file + // Verify the description text is present + const descriptionText = await canvas.findByText( + /To join a community, you must provide/, + ); + expect(descriptionText).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/accounts/pages/home.tsx b/apps/ui-community/src/components/layouts/accounts/pages/home.tsx index ef1ce22ac..1be9bb3e0 100644 --- a/apps/ui-community/src/components/layouts/accounts/pages/home.tsx +++ b/apps/ui-community/src/components/layouts/accounts/pages/home.tsx @@ -8,26 +8,21 @@ import { SubPageLayout } from '../sub-page-layout.tsx'; const { Title } = Typography; export const Home: React.FC = () => { - return ( - - } - > - - Owner Community Home - - Welcome to Owner Community - To join a community, you must provide the community manager with the following: -
-
- - {/* */} -
-
- - -
- ); + return ( + }> + + Owner Community Home + + Welcome to Owner Community + To join a community, you must provide the community manager with the + following: +
+
+ + {/* */} +
+
+ +
+ ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/section-layout.tsx b/apps/ui-community/src/components/layouts/accounts/section-layout.tsx index ce2f7417a..c9443dc75 100644 --- a/apps/ui-community/src/components/layouts/accounts/section-layout.tsx +++ b/apps/ui-community/src/components/layouts/accounts/section-layout.tsx @@ -5,41 +5,41 @@ import { Outlet } from 'react-router-dom'; const { Header } = Layout; export const SectionLayout: React.FC = () => { - const { - token: { colorBgContainer } - } = theme.useToken(); - return ( - -
-
- -
-
+ const { + token: { colorBgContainer }, + } = theme.useToken(); + return ( + +
+
+ +
+
- - - - - -
- ); + + + + + +
+ ); }; diff --git a/apps/ui-community/src/components/layouts/accounts/sub-page-layout.tsx b/apps/ui-community/src/components/layouts/accounts/sub-page-layout.tsx index 7a999d2cd..c9adfa55d 100644 --- a/apps/ui-community/src/components/layouts/accounts/sub-page-layout.tsx +++ b/apps/ui-community/src/components/layouts/accounts/sub-page-layout.tsx @@ -1,36 +1,53 @@ - import { Layout, theme } from 'antd'; -import type React from "react"; +import type React from 'react'; const { Header, Content, Footer } = Layout; interface SubPageLayoutProps { - header: React.JSX.Element; - fixedHeader?: boolean; - children?: React.ReactNode; + header: React.JSX.Element; + fixedHeader?: boolean; + children?: React.ReactNode; } export const SubPageLayout: React.FC = (props) => { - const overFlow = props.fixedHeader ? 'scroll' : 'unset'; - const { - token: {colorTextBase, colorBgContainer } - }=theme.useToken(); - return ( - <> -
- - {props.header} -
-
- -
- {props.children} -
-
-
- Owner Community -
-
- - ); -}; \ No newline at end of file + const overFlow = props.fixedHeader ? 'scroll' : 'unset'; + const { + token: { colorTextBase, colorBgContainer }, + } = theme.useToken(); + return ( + <> +
+ {props.header} +
+
+ +
+ {props.children} +
+
+
+ Owner Community +
+
+ + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx index 3ab7e65e6..f5e9f10eb 100644 --- a/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx @@ -1,18 +1,16 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; import type { AdminCommunityDetailContainerCommunityFieldsFragment } from '../../../../generated.tsx'; -import { - AdminCommunityDetailContainerCommunityByIdDocument, -} from '../../../../generated.tsx'; +import { AdminCommunityDetailContainerCommunityByIdDocument } from '../../../../generated.tsx'; import { CommunityDetail } from './community-detail.tsx'; export interface CommunityDetailContainerProps { data: { id?: string }; } -export const CommunityDetailContainer: React.FC = ( - props, -) => { +export const CommunityDetailContainer: React.FC< + CommunityDetailContainerProps +> = (props) => { const { data: communityData, loading: communityLoading, diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx new file mode 100644 index 000000000..1b8b74ae5 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; +import type { AdminCommunityDetailContainerCommunityFieldsFragment } from '../../../../generated.tsx'; +import { CommunityDetail } from './community-detail.tsx'; + +const mockData: AdminCommunityDetailContainerCommunityFieldsFragment = { + __typename: 'Community', + id: '507f1f77bcf86cd799439011', + name: 'Test Community', + domain: 'test.com', + whiteLabelDomain: 'wl.test.com', + handle: 'testcommunity', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', +}; + +const meta = { + title: 'Components/Layouts/Admin/CommunityDetail', + component: CommunityDetail, + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockData, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify community details are displayed + expect(canvas.getByTestId('community-id')).toHaveTextContent(mockData.id); + expect(canvas.getByTestId('community-name')).toHaveTextContent( + mockData.name, + ); + expect(canvas.getByText('White Label Name')).toBeInTheDocument(); + expect(canvas.getByText('Domain Name')).toBeInTheDocument(); + expect(canvas.getByText('Handle Name')).toBeInTheDocument(); + }, +}; + +export const WithMinimalData: Story = { + args: { + data: { + __typename: 'Community', + id: '507f1f77bcf86cd799439011', + name: 'Minimal Community', + domain: null, + whiteLabelDomain: null, + handle: null, + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify only required fields are displayed + expect(canvas.getByTestId('community-id')).toHaveTextContent( + '507f1f77bcf86cd799439011', + ); + expect(canvas.getByTestId('community-name')).toHaveTextContent( + 'Minimal Community', + ); + + // Verify optional fields are not displayed + expect(canvas.queryByText('White Label Name')).not.toBeInTheDocument(); + expect(canvas.queryByText('Domain Name')).not.toBeInTheDocument(); + expect(canvas.queryByText('Handle Name')).not.toBeInTheDocument(); + }, +}; + +export const WithAllFields: Story = { + args: { + data: { + __typename: 'Community', + id: '507f1f77bcf86cd799439011', + name: 'Complete Community', + domain: 'completecommunity.com', + whiteLabelDomain: 'custom.completecommunity.com', + handle: 'complete-community', + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-15T12:00:00.000Z', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify all fields are displayed + expect(canvas.getByTestId('community-id')).toBeInTheDocument(); + expect(canvas.getByTestId('community-name')).toBeInTheDocument(); + expect(canvas.getByText('White Label Name')).toBeInTheDocument(); + expect(canvas.getByText('Domain Name')).toBeInTheDocument(); + expect(canvas.getByText('Handle Name')).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx index 9d27ad09c..25012e58f 100644 --- a/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx @@ -48,7 +48,9 @@ export const CommunityDetail: React.FC = (props) => { return (
{ - const { message } = App.useApp(); + const { message } = App.useApp(); - const [communityUpdate, { loading: mutationLoading, error: mutationError }] = useMutation( - AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, - ); + const [communityUpdate, { loading: mutationLoading, error: mutationError }] = + useMutation(AdminSettingsGeneralContainerCommunityUpdateSettingsDocument); const { data: communityData, loading: communityLoading, @@ -40,11 +41,14 @@ export const SettingsGeneralContainer: React.FC = () => { message.success('Saved'); } else { message.error( - result.data?.communityUpdateSettings?.status?.errorMessage ?? 'Unknown error', + result.data?.communityUpdateSettings?.status?.errorMessage ?? + 'Unknown error', ); } } catch (saveError) { - message.error(`Error updating community: ${saveError instanceof Error ? saveError.message : JSON.stringify(saveError)}`); + message.error( + `Error updating community: ${saveError instanceof Error ? saveError.message : JSON.stringify(saveError)}`, + ); } }; diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx index c720a2981..fcb84fb88 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from 'storybook/test'; -import { SettingsGeneral } from './settings-general.tsx'; import type { AdminSettingsGeneralContainerCommunityFieldsFragment } from '../../../../generated.tsx'; +import { SettingsGeneral } from './settings-general.tsx'; const mockData: AdminSettingsGeneralContainerCommunityFieldsFragment = { __typename: 'Community', @@ -37,11 +37,11 @@ export const Default: Story = { }, play: ({ canvasElement }) => { const canvas = within(canvasElement); - + // Verify community info is displayed expect(canvas.getByText(mockData.id)).toBeInTheDocument(); expect(canvas.getByText(/1\/1\/2024/)).toBeInTheDocument(); // Created date - + // Verify form fields have correct values const nameInput = canvas.getByPlaceholderText('Name') as HTMLInputElement; expect(nameInput.value).toBe(mockData.name); @@ -56,7 +56,7 @@ export const Loading: Story = { }, play: ({ canvasElement }) => { const canvas = within(canvasElement); - + // Verify save button is in loading state const saveButton = canvas.getByRole('button', { name: /save/i }); expect(saveButton).toHaveClass('ant-btn-loading'); @@ -88,16 +88,16 @@ export const FormSubmission: Story = { }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); - + // Update the name field const nameInput = canvas.getByPlaceholderText('Name'); await userEvent.clear(nameInput); await userEvent.type(nameInput, 'Updated Community Name'); - + // Submit the form const saveButton = canvas.getByRole('button', { name: /save/i }); await userEvent.click(saveButton); - + // Verify onSave was called (action will be logged in Storybook) expect(args.onSave).toHaveBeenCalled(); }, diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx index 8dd6776b0..7fd382324 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -55,15 +55,15 @@ export const SettingsGeneral: React.FC = (props) => { />
- The white domain is used to allow users to access your public community - website. + The white domain is used to allow users to access your public + community website.
They will be able access it at: https:// {data.whiteLabelDomain}.owner.community
- This is necessary to allow users to access - your community website unless you have a custom domain you own. (see - below) + This is necessary to allow users to + access your community website unless you have a custom domain you own. + (see below)
@@ -83,7 +83,8 @@ export const SettingsGeneral: React.FC = (props) => { Assign the CNAME of "www" to "cname.vercel-dns.com" in your DNS settings.
- Once added, you can use the domain name in the white label field above. + Once added, you can use the domain name in the white label field + above.
@@ -94,7 +95,12 @@ export const SettingsGeneral: React.FC = (props) => { defaultValue={data.handle ?? undefined} /> - diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx index 4496724e0..94de2280e 100644 --- a/apps/ui-community/src/components/layouts/admin/index.tsx +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -16,7 +16,12 @@ export interface PageLayoutProps { export const Admin: React.FC = () => { const pageLayouts: PageLayoutProps[] = [ - { path: '/:communityId/admin/:memberId', title: 'Home', icon: , id: 'ROOT' }, + { + path: '/:communityId/admin/:memberId', + title: 'Home', + icon: , + id: 'ROOT', + }, { path: '/:communityId/admin/:memberId/settings/*', title: 'Settings', @@ -31,7 +36,10 @@ export const Admin: React.FC = () => { return ( - }> + } + > } /> } /> diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx index fcb4db069..d4e374d14 100644 --- a/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx @@ -1,11 +1,11 @@ +import type { ApolloError } from '@apollo/client'; import { useLazyQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; -import type { ApolloError } from '@apollo/client'; import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { - AdminSectionLayoutContainerMembersForCurrentEndUserDocument, type AdminSectionLayoutContainerMemberFieldsFragment, + AdminSectionLayoutContainerMembersForCurrentEndUserDocument, type Member, } from '../../../generated.tsx'; import type { PageLayoutProps } from './index.tsx'; @@ -28,7 +28,9 @@ export const SectionLayoutContainer: React.FC = ( AdminSectionLayoutContainerMembersForCurrentEndUserDocument, ); const [memberData, setMemberData] = useState(null); - const [memberError, setMemberError] = useState(undefined); + const [memberError, setMemberError] = useState( + undefined, + ); const [memberLoading, setMemberLoading] = useState(false); useEffect(() => { @@ -42,7 +44,8 @@ export const SectionLayoutContainer: React.FC = ( // Filter for the current member by memberId const currentMember = membersDataTemp?.membersForCurrentEndUser?.find( - (m: AdminSectionLayoutContainerMemberFieldsFragment) => m.id === params['memberId'], + (m: AdminSectionLayoutContainerMemberFieldsFragment) => + m.id === params['memberId'], ); setMemberData(currentMember ? { member: currentMember } : null); diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.tsx index 1743f23fd..e928e39a5 100644 --- a/apps/ui-community/src/components/layouts/admin/section-layout.tsx +++ b/apps/ui-community/src/components/layouts/admin/section-layout.tsx @@ -14,7 +14,10 @@ const LocalSettingsKeys = { SidebarCollapsed: 'SidebarCollapsed', } as const; -const handleToggler = (isExpanded: boolean, setIsExpanded: (value: boolean) => void) => { +const handleToggler = ( + isExpanded: boolean, + setIsExpanded: (value: boolean) => void, +) => { const newValue = !isExpanded; setIsExpanded(newValue); if (newValue) { @@ -31,7 +34,9 @@ interface AdminSectionLayoutProps { export const SectionLayout: React.FC = (props) => { const params = useParams(); - const sidebarCollapsed = localStorage.getItem(LocalSettingsKeys.SidebarCollapsed); + const sidebarCollapsed = localStorage.getItem( + LocalSettingsKeys.SidebarCollapsed, + ); const [isExpanded, setIsExpanded] = useState(!sidebarCollapsed); const { token: { colorBgContainer }, @@ -53,7 +58,9 @@ export const SectionLayout: React.FC = (props) => { }} >
- +
= (props) => {
{props.children}
-
+
Owner Community
diff --git a/apps/ui-community/src/components/layouts/root/components/header.module.css b/apps/ui-community/src/components/layouts/root/components/header.module.css index 467f1c19a..99fa409f4 100644 --- a/apps/ui-community/src/components/layouts/root/components/header.module.css +++ b/apps/ui-community/src/components/layouts/root/components/header.module.css @@ -1,11 +1,11 @@ .top-bar { - border-bottom: 1px solid #e5e5e5; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); - padding: 0.5rem 0; - /*position: fixed; */ - top: 0; - min-width: 100%; - z-index: 1000; - height: 50px; - color: #000; + border-bottom: 1px solid #e5e5e5; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + padding: 0.5rem 0; + /*position: fixed; */ + top: 0; + min-width: 100%; + z-index: 1000; + height: 50px; + color: #000; } diff --git a/apps/ui-community/src/components/layouts/root/components/header.stories.tsx b/apps/ui-community/src/components/layouts/root/components/header.stories.tsx index c23077bc6..54e47cc9d 100644 --- a/apps/ui-community/src/components/layouts/root/components/header.stories.tsx +++ b/apps/ui-community/src/components/layouts/root/components/header.stories.tsx @@ -3,26 +3,26 @@ import { expect, within } from 'storybook/test'; import { Header } from './header.tsx'; const meta = { - title: 'Components/Root/Header', - component: Header, - parameters: { - layout: 'fullscreen', - }, + title: 'Components/Root/Header', + component: Header, + parameters: { + layout: 'fullscreen', + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // Verify the login button is present - const loginButton = await canvas.findByRole('button', { name: /log in/i }); - expect(loginButton).toBeInTheDocument(); + // Verify the login button is present + const loginButton = await canvas.findByRole('button', { name: /log in/i }); + expect(loginButton).toBeInTheDocument(); - // Verify the button text - expect(loginButton).toHaveTextContent('Log In v6'); - }, -}; \ No newline at end of file + // Verify the button text + expect(loginButton).toHaveTextContent('Log In v6'); + }, +}; diff --git a/apps/ui-community/src/components/layouts/root/components/header.tsx b/apps/ui-community/src/components/layouts/root/components/header.tsx index ce93c443c..11f211a89 100644 --- a/apps/ui-community/src/components/layouts/root/components/header.tsx +++ b/apps/ui-community/src/components/layouts/root/components/header.tsx @@ -13,7 +13,10 @@ export const Header: React.FC = () => { return ( <> -
+
diff --git a/apps/ui-community/src/components/layouts/root/index.tsx b/apps/ui-community/src/components/layouts/root/index.tsx index aacfceedd..cfb258222 100644 --- a/apps/ui-community/src/components/layouts/root/index.tsx +++ b/apps/ui-community/src/components/layouts/root/index.tsx @@ -1,7 +1,5 @@ import { SectionLayout } from './section-layout.tsx'; export const Root: React.FC = () => { - return ( - - ) -} \ No newline at end of file + return ; +}; diff --git a/apps/ui-community/src/components/layouts/root/pages/cms-page.stories.tsx b/apps/ui-community/src/components/layouts/root/pages/cms-page.stories.tsx index 80b1721b9..57bc8ef53 100644 --- a/apps/ui-community/src/components/layouts/root/pages/cms-page.stories.tsx +++ b/apps/ui-community/src/components/layouts/root/pages/cms-page.stories.tsx @@ -3,23 +3,23 @@ import { expect, within } from 'storybook/test'; import { Root } from '../index.tsx'; const meta = { - title: 'Pages/Root/Cms Page', - component: Root, - parameters: { - layout: 'fullscreen', - }, + title: 'Pages/Root/Cms Page', + component: Root, + parameters: { + layout: 'fullscreen', + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - // Verify the CMS page text is present - const cmsText = await canvas.findByText('Pretend this is a CMS page'); - expect(cmsText).toBeInTheDocument(); - }, -}; \ No newline at end of file + // Verify the CMS page text is present + const cmsText = await canvas.findByText('Pretend this is a CMS page'); + expect(cmsText).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/root/pages/cms-page.tsx b/apps/ui-community/src/components/layouts/root/pages/cms-page.tsx index 7d936c201..c171746dc 100644 --- a/apps/ui-community/src/components/layouts/root/pages/cms-page.tsx +++ b/apps/ui-community/src/components/layouts/root/pages/cms-page.tsx @@ -3,12 +3,11 @@ import { Typography } from 'antd'; const { Text } = Typography; export const CmsPage: React.FC = () => { - - return ( -
- Pretend this is a CMS page -
- With some additional content -
- ); -} \ No newline at end of file + return ( +
+ Pretend this is a CMS page +
+ With some additional content +
+ ); +}; diff --git a/apps/ui-community/src/components/layouts/root/section-layout.tsx b/apps/ui-community/src/components/layouts/root/section-layout.tsx index 17d421bf5..e9429e9b4 100644 --- a/apps/ui-community/src/components/layouts/root/section-layout.tsx +++ b/apps/ui-community/src/components/layouts/root/section-layout.tsx @@ -1,12 +1,11 @@ - import { Header } from './components/header.tsx'; import { CmsPage } from './pages/cms-page.tsx'; export const SectionLayout: React.FC = () => { - return ( -
-
- -
- ); + return ( +
+
+ +
+ ); }; diff --git a/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx b/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx new file mode 100644 index 000000000..e4bd9653c --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx @@ -0,0 +1,181 @@ +import { HomeOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from 'react-router-dom'; +import { expect, within } from 'storybook/test'; +import type { Member } from '../../../../generated.tsx'; +import { MenuComponent, type PageLayoutProps } from './menu-component.tsx'; + +const mockPageLayouts: PageLayoutProps[] = [ + { + path: '', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: 'settings/*', + title: 'Settings', + icon: , + id: 'settings', + parent: 'ROOT', + }, + { + path: 'members/*', + title: 'Members', + icon: , + id: 'members', + parent: 'ROOT', + }, +]; + +const mockMember: Member = { + __typename: 'Member', + id: 'member1', + memberName: 'Test Member', + isAdmin: true, + community: { + __typename: 'Community', + id: 'community1', + name: 'Test Community', + } as Member['community'], +} as Member; + +const meta = { + title: 'Components/Layouts/Shared/MenuComponent', + component: MenuComponent, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + pageLayouts: mockPageLayouts, + theme: 'light', + mode: 'inline', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify menu items are rendered + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Settings')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + }, +}; + +export const DarkTheme: Story = { + args: { + pageLayouts: mockPageLayouts, + theme: 'dark', + mode: 'inline', + }, +}; + +export const HorizontalMode: Story = { + args: { + pageLayouts: mockPageLayouts, + theme: 'light', + mode: 'horizontal', + }, +}; + +export const WithMemberData: Story = { + args: { + pageLayouts: mockPageLayouts, + theme: 'light', + mode: 'inline', + memberData: mockMember, + }, +}; + +export const WithPermissions: Story = { + args: { + pageLayouts: [ + { + path: '', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: 'settings/*', + title: 'Settings', + icon: , + id: 'settings', + parent: 'ROOT', + hasPermissions: (member: Member) => member.isAdmin ?? false, + }, + { + path: 'members/*', + title: 'Members', + icon: , + id: 'members', + parent: 'ROOT', + }, + ], + theme: 'light', + mode: 'inline', + memberData: mockMember, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify admin-only menu item is visible for admin member + expect(canvas.getByText('Settings')).toBeInTheDocument(); + }, +}; + +export const NoPermissions: Story = { + args: { + pageLayouts: [ + { + path: '', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: 'settings/*', + title: 'Settings', + icon: , + id: 'settings', + parent: 'ROOT', + hasPermissions: (member: Member) => member.isAdmin ?? false, + }, + { + path: 'members/*', + title: 'Members', + icon: , + id: 'members', + parent: 'ROOT', + }, + ], + theme: 'light', + mode: 'inline', + memberData: { + ...mockMember, + isAdmin: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify admin-only menu item is NOT visible for non-admin member + expect(canvas.queryByText('Settings')).not.toBeInTheDocument(); + expect(canvas.getByText('Home')).toBeInTheDocument(); + expect(canvas.getByText('Members')).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx b/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx index 13147c7f4..d11baf11f 100644 --- a/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx +++ b/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx @@ -1,6 +1,12 @@ import { Menu, type MenuTheme } from 'antd'; import type { RouteObject } from 'react-router-dom'; -import { Link, generatePath, matchRoutes, useLocation, useParams } from 'react-router-dom'; +import { + generatePath, + Link, + matchRoutes, + useLocation, + useParams, +} from 'react-router-dom'; import type { Member } from '../../../../generated.tsx'; const { SubMenu } = Menu; @@ -14,7 +20,7 @@ export interface PageLayoutProps { hasPermissions?: (member: Member) => boolean; } -interface MenuComponentProps { +export interface MenuComponentProps { pageLayouts: PageLayoutProps[]; theme: MenuTheme | undefined; mode: 'vertical' | 'horizontal' | 'inline' | undefined; @@ -33,34 +39,44 @@ export const MenuComponent: React.FC = ({ return generatePath(path.replace('*', ''), params); }; - const buildMenu = (parentId: string | number): React.ReactNode[] | undefined => { + const buildMenu = ( + parentId: string | number, + ): React.ReactNode[] | undefined => { const children = pageLayouts.filter((x) => x.parent === parentId); if (!children || children.length === 0) { return; } - return children.map((x) => { - const child = pageLayouts.find((y) => y.id === x.id); - if (!child) return null; + return children + .map((x) => { + const child = pageLayouts.find((y) => y.id === x.id); + if (!child) return null; - const grandChildren = pageLayouts.filter((gc) => gc.parent === child.id); - - if (memberData && child.hasPermissions && !child.hasPermissions(memberData)) { - return null; - } + const grandChildren = pageLayouts.filter( + (gc) => gc.parent === child.id, + ); - return grandChildren && grandChildren.length > 0 ? ( - - + if ( + memberData && + child.hasPermissions && + !child.hasPermissions(memberData) + ) { + return null; + } + + return grandChildren && grandChildren.length > 0 ? ( + + + {child.title} + + {buildMenu(child.id)} + + ) : ( + {child.title} - {buildMenu(child.id)} - - ) : ( - - {child.title} - - ); - }).filter(Boolean); + ); + }) + .filter(Boolean); }; const topMenu = () => { @@ -68,7 +84,9 @@ export const MenuComponent: React.FC = ({ if (!root) return null; const matchedPages = matchRoutes(pageLayouts as RouteObject[], location); - const matchedIds = matchedPages ? matchedPages.map((x) => x.route.id?.toString() ?? '') : []; + const matchedIds = matchedPages + ? matchedPages.map((x) => x.route.id?.toString() ?? '') + : []; return ( ( - - - - ), - ], + title: 'Components/UI/Molecules/AuthLanding', + component: AuthLanding, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + + ), + ], } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, - play: ({ canvasElement }) => { - // The AuthLanding component renders a Navigate component which doesn't render visible content - // We can only verify that the component doesn't throw an error during rendering - expect(canvasElement).toBeTruthy(); - }, -}; \ No newline at end of file + args: {}, + play: ({ canvasElement }) => { + // The AuthLanding component renders a Navigate component which doesn't render visible content + // We can only verify that the component doesn't throw an error during rendering + expect(canvasElement).toBeTruthy(); + }, +}; diff --git a/apps/ui-community/src/components/ui/molecules/auth-landing/index.tsx b/apps/ui-community/src/components/ui/molecules/auth-landing/index.tsx index 89c690ccd..94aac3a69 100644 --- a/apps/ui-community/src/components/ui/molecules/auth-landing/index.tsx +++ b/apps/ui-community/src/components/ui/molecules/auth-landing/index.tsx @@ -1,7 +1,5 @@ -import { Navigate } from "react-router-dom"; +import { Navigate } from 'react-router-dom'; export const AuthLanding: React.FC = () => { - return ( - - ); -} \ No newline at end of file + return ; +}; diff --git a/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.stories.tsx b/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.stories.tsx index efdd5b103..24f133e6c 100644 --- a/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.stories.tsx +++ b/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.stories.tsx @@ -1,82 +1,87 @@ +import { ApolloProvider, gql, useApolloClient } from '@apollo/client'; import type { Meta, StoryObj } from '@storybook/react'; -import { expect, within } from 'storybook/test'; -import { ApolloProvider, useApolloClient, gql } from '@apollo/client'; +import { useRef, useState } from 'react'; import { AuthProvider } from 'react-oidc-context'; import { MemoryRouter } from 'react-router-dom'; -import { useState, useRef } from 'react'; +import { expect, within } from 'storybook/test'; +import { client } from './apollo-client-links.tsx'; import { ApolloConnection } from './index.tsx'; -import { - client -} from './apollo-client-links.tsx'; // Mock environment variables const mockEnv = { - VITE_FUNCTION_ENDPOINT: 'https://mock-functions.example.com', - VITE_AAD_B2C_ACCOUNT_AUTHORITY: 'https://mock-authority.example.com', - VITE_AAD_B2C_ACCOUNT_CLIENTID: 'mock-client-id', - NODE_ENV: 'development', - PROD: false, + VITE_FUNCTION_ENDPOINT: 'https://mock-functions.example.com', + VITE_AAD_B2C_ACCOUNT_AUTHORITY: 'https://mock-authority.example.com', + VITE_AAD_B2C_ACCOUNT_CLIENTID: 'mock-client-id', + NODE_ENV: 'development', + PROD: false, }; // Mock window.sessionStorage and window.localStorage const mockStorage = { - getItem: (key: string) => { - if (key.includes('oidc.user')) { - return JSON.stringify({ - access_token: '', - profile: { sub: 'fallback-user' }, - }); - } - return null; - }, - setItem: (_key: string, _value: string) => Promise.resolve(), - removeItem: (_key: string) => Promise.resolve(), - clear: () => Promise.resolve(), - key: () => null, - length: 0, - set: (_key: string, _value: string) => Promise.resolve(), - get: (key: string) => Promise.resolve(mockStorage.getItem(key)), - remove: (key: string) => Promise.resolve(key), - getAllKeys: () => Promise.resolve([]), + getItem: (key: string) => { + if (key.includes('oidc.user')) { + return JSON.stringify({ + access_token: '', + profile: { sub: 'fallback-user' }, + }); + } + return null; + }, + setItem: (_key: string, _value: string) => Promise.resolve(), + removeItem: (_key: string) => Promise.resolve(), + clear: () => Promise.resolve(), + key: () => null, + length: 0, + set: (_key: string, _value: string) => Promise.resolve(), + get: (key: string) => Promise.resolve(mockStorage.getItem(key)), + remove: (key: string) => Promise.resolve(key), + getAllKeys: () => Promise.resolve([]), }; // Setup global mocks -Object.defineProperty(window, 'sessionStorage', { value: mockStorage, writable: true }); -Object.defineProperty(window, 'localStorage', { value: mockStorage, writable: true }); +Object.defineProperty(window, 'sessionStorage', { + value: mockStorage, + writable: true, +}); +Object.defineProperty(window, 'localStorage', { + value: mockStorage, + writable: true, +}); // Mock import.meta.env Object.defineProperty(import.meta, 'env', { - value: mockEnv, - writable: true, + value: mockEnv, + writable: true, }); const meta = { - title: 'Components/UI/Organisms/ApolloConnection/Apollo Client Links', - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: 'Utility functions for creating Apollo Client links with authentication, custom headers, and GraphQL server configuration. These stories demonstrate how the link functions work within the ApolloConnection component.', - }, - }, - }, - decorators: [ - (Story) => ( - - - - - - - - ), - ], + title: 'Components/UI/Organisms/ApolloConnection/Apollo Client Links', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Utility functions for creating Apollo Client links with authentication, custom headers, and GraphQL server configuration. These stories demonstrate how the link functions work within the ApolloConnection component.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + + + + + ), + ], } satisfies Meta; export default meta; @@ -84,191 +89,192 @@ type Story = StoryObj; // Test component that verifies Apollo link functionality const ApolloLinkTester = () => { - const apolloClient = useApolloClient(); - const [authResult, setAuthResult] = useState(null); - const [headersResult, setHeadersResult] = useState(null); - const authButtonRef = useRef(null); - const headersButtonRef = useRef(null); - - const testAuthHeader = async () => { - try { - // This will test if the auth header link is working - const result = await apolloClient.query({ - query: gql` + const apolloClient = useApolloClient(); + const [authResult, setAuthResult] = useState(null); + const [headersResult, setHeadersResult] = useState(null); + const authButtonRef = useRef(null); + const headersButtonRef = useRef(null); + + const testAuthHeader = async () => { + try { + // This will test if the auth header link is working + const result = await apolloClient.query({ + query: gql` query TestQuery { __typename } `, - fetchPolicy: 'network-only' - }); - const resultData = { success: true, data: result.data }; - setAuthResult(JSON.stringify(resultData)); - return resultData; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const resultData = { success: false, error: errorMessage }; - setAuthResult(JSON.stringify(resultData)); - return resultData; - } - }; - - const testCustomHeaders = () => { - // Test that custom headers are being set - const { link } = apolloClient; - const resultData = { linkType: link.constructor.name }; - setHeadersResult(JSON.stringify(resultData)); - return resultData; - }; - - return ( -
- - -
- Client Link Chain: {apolloClient.link.constructor.name} -
-
- ); + fetchPolicy: 'network-only', + }); + const resultData = { success: true, data: result.data }; + setAuthResult(JSON.stringify(resultData)); + return resultData; + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + const resultData = { success: false, error: errorMessage }; + setAuthResult(JSON.stringify(resultData)); + return resultData; + } + }; + + const testCustomHeaders = () => { + // Test that custom headers are being set + const { link } = apolloClient; + const resultData = { linkType: link.constructor.name }; + setHeadersResult(JSON.stringify(resultData)); + return resultData; + }; + + return ( +
+ + +
+ Client Link Chain: {apolloClient.link.constructor.name} +
+
+ ); }; // Story demonstrating Auth Header Link export const AuthHeaderLinkDemo: Story = { - name: 'Authentication Header Link', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const authButton = await canvas.findByTestId('test-auth-button'); - - // Click the test button to verify auth header functionality - await authButton.click(); - - // Wait for the result to be set - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify the button received a result (this tests the auth link chain) - const result = authButton.getAttribute('data-result'); - expect(result).toBeTruthy(); - - const parsedResult = JSON.parse(result as string); - // The test should either succeed or fail with a network error (both indicate the link is working) - expect(typeof parsedResult.success).toBe('boolean'); - }, + name: 'Authentication Header Link', + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + const authButton = await canvas.findByTestId('test-auth-button'); + + // Click the test button to verify auth header functionality + await authButton.click(); + + // Wait for the result to be set + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify the button received a result (this tests the auth link chain) + const result = authButton.getAttribute('data-result'); + expect(result).toBeTruthy(); + + const parsedResult = JSON.parse(result as string); + // The test should either succeed or fail with a network error (both indicate the link is working) + expect(typeof parsedResult.success).toBe('boolean'); + }, }; // Story demonstrating Custom Header Link export const CustomHeaderLinkDemo: Story = { - name: 'Custom Header Link', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const headersButton = await canvas.findByTestId('test-headers-button'); + name: 'Custom Header Link', + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + const headersButton = await canvas.findByTestId('test-headers-button'); - // Click the test button to verify custom headers functionality - await headersButton.click(); + // Click the test button to verify custom headers functionality + await headersButton.click(); - // Wait for the result to be set - await new Promise(resolve => setTimeout(resolve, 100)); + // Wait for the result to be set + await new Promise((resolve) => setTimeout(resolve, 100)); - // Verify the button received a result - const result = headersButton.getAttribute('data-result'); - expect(result).toBeTruthy(); + // Verify the button received a result + const result = headersButton.getAttribute('data-result'); + expect(result).toBeTruthy(); - const parsedResult = JSON.parse(result as string); - expect(parsedResult).toHaveProperty('linkType'); - }, + const parsedResult = JSON.parse(result as string); + expect(parsedResult).toHaveProperty('linkType'); + }, }; // Story demonstrating GraphQL Server Link export const GraphqlServerLinkDemo: Story = { - name: 'GraphQL Server Link', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const clientInfo = await canvas.findByTestId('client-info'); - - // Verify the client has the terminating link configured - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); - }, + name: 'GraphQL Server Link', + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + const clientInfo = await canvas.findByTestId('client-info'); + + // Verify the client has the terminating link configured + expect(clientInfo).toHaveTextContent('Client Link Chain'); + expect(clientInfo.textContent).toMatch(/Client Link Chain:/); + }, }; // Story demonstrating Apollo Client Instance export const ApolloClientDemo: Story = { - name: 'Apollo Client Instance', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const clientInfo = await canvas.findByTestId('client-info'); - - // Verify the client is properly configured with links - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); - - // Verify we can access the Apollo client - const authButton = await canvas.findByTestId('test-auth-button'); - expect(authButton).toBeInTheDocument(); - }, + name: 'Apollo Client Instance', + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + const clientInfo = await canvas.findByTestId('client-info'); + + // Verify the client is properly configured with links + expect(clientInfo).toHaveTextContent('Client Link Chain'); + expect(clientInfo.textContent).toMatch(/Client Link Chain:/); + + // Verify we can access the Apollo client + const authButton = await canvas.findByTestId('test-auth-button'); + expect(authButton).toBeInTheDocument(); + }, }; // Story demonstrating Link Chaining export const LinkChainingDemo: Story = { - name: 'Link Chaining', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const clientInfo = await canvas.findByTestId('client-info'); - - // Verify the link chain is properly configured - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); - - // Verify all test buttons are present (representing different link types) - const authButton = await canvas.findByTestId('test-auth-button'); - const headersButton = await canvas.findByTestId('test-headers-button'); - expect(authButton).toBeInTheDocument(); - expect(headersButton).toBeInTheDocument(); - }, + name: 'Link Chaining', + render: () => , + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + const clientInfo = await canvas.findByTestId('client-info'); + + // Verify the link chain is properly configured + expect(clientInfo).toHaveTextContent('Client Link Chain'); + expect(clientInfo.textContent).toMatch(/Client Link Chain:/); + + // Verify all test buttons are present (representing different link types) + const authButton = await canvas.findByTestId('test-auth-button'); + const headersButton = await canvas.findByTestId('test-headers-button'); + expect(authButton).toBeInTheDocument(); + expect(headersButton).toBeInTheDocument(); + }, }; // Story showing the complete ApolloConnection usage export const ApolloConnectionIntegration: Story = { - name: 'Apollo Connection Integration', - render: () => ( - - - - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const tester = await canvas.findByTestId('apollo-link-tester'); - - // Verify the ApolloConnection component renders with the tester - expect(tester).toBeInTheDocument(); - - // Verify all test buttons are present within the connection context - const authButton = await canvas.findByTestId('test-auth-button'); - const headersButton = await canvas.findByTestId('test-headers-button'); - expect(authButton).toBeInTheDocument(); - expect(headersButton).toBeInTheDocument(); - - // Verify client info is displayed - const clientInfo = await canvas.findByTestId('client-info'); - expect(clientInfo).toHaveTextContent('Client Link Chain'); - }, -}; \ No newline at end of file + name: 'Apollo Connection Integration', + render: () => ( + + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + const tester = await canvas.findByTestId('apollo-link-tester'); + + // Verify the ApolloConnection component renders with the tester + expect(tester).toBeInTheDocument(); + + // Verify all test buttons are present within the connection context + const authButton = await canvas.findByTestId('test-auth-button'); + const headersButton = await canvas.findByTestId('test-headers-button'); + expect(authButton).toBeInTheDocument(); + expect(headersButton).toBeInTheDocument(); + + // Verify client info is displayed + const clientInfo = await canvas.findByTestId('client-info'); + expect(clientInfo).toHaveTextContent('Client Link Chain'); + }, +}; diff --git a/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx b/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx index 97f73295b..63e7449f3 100644 --- a/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx +++ b/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx @@ -1,4 +1,10 @@ -import { ApolloClient, ApolloLink, type DefaultContext, from, InMemoryCache } from '@apollo/client'; +import { + ApolloClient, + ApolloLink, + type DefaultContext, + from, + InMemoryCache, +} from '@apollo/client'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { setContext } from '@apollo/client/link/context'; import type { UriFunction } from '@apollo/client/link/http'; @@ -7,101 +13,124 @@ import type { AuthContextProps } from 'react-oidc-context'; // apollo client instance export const client = new ApolloClient({ - cache: new InMemoryCache(), - // biome-ignore lint:useLiteralKeys - connectToDevTools: import.meta.env['NODE_ENV'] !== 'production' + cache: new InMemoryCache(), + // biome-ignore lint:useLiteralKeys + connectToDevTools: import.meta.env['NODE_ENV'] !== 'production', }); - // base apollo link with no customizations // could be used as a base for the link chain -export const BaseApolloLink = (): ApolloLink => setContext((_, { headers }) => { - return { - headers: { - ...headers - } - }; -}); - +export const BaseApolloLink = (): ApolloLink => + setContext((_, { headers }) => { + return { + headers: { + ...headers, + }, + }; + }); // apollo link to add auth header -export const ApolloLinkToAddAuthHeader = (auth: AuthContextProps): ApolloLink => - setContext((_, { headers }) => { - // Prefer token from react-oidc-context if available; otherwise, fall back to storage. - let access_token: string | undefined = auth.user?.access_token; - // In development, fall back to storage to avoid a brief race on refresh. - // In production, rely solely on react-oidc-context to provide the user/token. - if (!access_token && typeof window !== 'undefined' && !import.meta.env.PROD) { - try { - // biome-ignore lint:useLiteralKeys - const authority = import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? ''; - // biome-ignore lint:useLiteralKeys - const client_id = import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? ''; - const storageKey = `oidc.user:${authority}:${client_id}`; - const raw = window.sessionStorage.getItem(storageKey) ?? window.localStorage.getItem(storageKey); - if (raw) { - const parsed = JSON.parse(raw); - access_token = typeof parsed?.access_token === 'string' ? parsed.access_token : undefined; - } - } catch { - // ignore parse/storage errors and proceed without auth header - } - } - return { - headers: { - ...headers, - ...(access_token && { Authorization: `Bearer ${access_token}` }) - } - }; -}); +export const ApolloLinkToAddAuthHeader = (auth: AuthContextProps): ApolloLink => + setContext((_, { headers }) => { + // Prefer token from react-oidc-context if available; otherwise, fall back to storage. + let access_token: string | undefined = auth.user?.access_token; + // In development, fall back to storage to avoid a brief race on refresh. + // In production, rely solely on react-oidc-context to provide the user/token. + if ( + !access_token && + typeof window !== 'undefined' && + !import.meta.env.PROD + ) { + try { + // biome-ignore lint:useLiteralKeys + const authority = + import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? ''; + // biome-ignore lint:useLiteralKeys + const client_id = + import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? ''; + const storageKey = `oidc.user:${authority}:${client_id}`; + const raw = + window.sessionStorage.getItem(storageKey) ?? + window.localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw); + access_token = + typeof parsed?.access_token === 'string' + ? parsed.access_token + : undefined; + } + } catch { + // ignore parse/storage errors and proceed without auth header + } + } + return { + headers: { + ...headers, + ...(access_token && { Authorization: `Bearer ${access_token}` }), + }, + }; + }); // alternate way to add auth header -export const ApolloLinkToAddAuthHeader1 = (auth: AuthContextProps): ApolloLink => new ApolloLink((operation, forward) => {; - const access_token = (auth.isAuthenticated) ? auth.user?.access_token : undefined; - if(!access_token) { - return forward(operation); - } - operation.setContext((prevContext: DefaultContext) => { - // biome-ignore lint:useLiteralKeys - prevContext['headers']["Authorization"] = `Bearer ${access_token}`; - return prevContext; - }); - return forward(operation); -}); +export const ApolloLinkToAddAuthHeader1 = ( + auth: AuthContextProps, +): ApolloLink => + new ApolloLink((operation, forward) => { + const access_token = auth.isAuthenticated + ? auth.user?.access_token + : undefined; + if (!access_token) { + return forward(operation); + } + operation.setContext((prevContext: DefaultContext) => { + // biome-ignore lint:useLiteralKeys + prevContext['headers']['Authorization'] = `Bearer ${access_token}`; + return prevContext; + }); + return forward(operation); + }); // alternate way to add auth header -export const ApolloLinkToAddAuthHeader2 = (auth: AuthContextProps): ApolloLink => { - return setContext((_, { headers }) => { - const returnHeaders = { ...headers }; - const access_token = (auth.isAuthenticated === true) ? auth.user?.access_token : undefined; - if (access_token) { - // biome-ignore lint:useLiteralKeys - returnHeaders['Authorization'] = `Bearer ${access_token}`; - } - return { headers: returnHeaders }; - }); +export const ApolloLinkToAddAuthHeader2 = ( + auth: AuthContextProps, +): ApolloLink => { + return setContext((_, { headers }) => { + const returnHeaders = { ...headers }; + const access_token = + auth.isAuthenticated === true ? auth.user?.access_token : undefined; + if (access_token) { + // biome-ignore lint:useLiteralKeys + returnHeaders['Authorization'] = `Bearer ${access_token}`; + } + return { headers: returnHeaders }; + }); }; - // apollo link to add custom header -export const ApolloLinkToAddCustomHeader = (headerName: string, headerValue: string | null | undefined, ifTrue?: boolean): ApolloLink => new ApolloLink((operation, forward) => { - if(!headerValue || (ifTrue !== undefined && ifTrue === false)) { - return forward(operation); - } - operation.setContext((prevContext: DefaultContext) => { - // biome-ignore lint:useLiteralKeys - prevContext['headers'][headerName] = headerValue; - return prevContext; - }); - return forward(operation); -}); - +export const ApolloLinkToAddCustomHeader = ( + headerName: string, + headerValue: string | null | undefined, + ifTrue?: boolean, +): ApolloLink => + new ApolloLink((operation, forward) => { + if (!headerValue || (ifTrue !== undefined && ifTrue === false)) { + return forward(operation); + } + operation.setContext((prevContext: DefaultContext) => { + // biome-ignore lint:useLiteralKeys + prevContext['headers'][headerName] = headerValue; + return prevContext; + }); + return forward(operation); + }); // apollo link to batch graphql requests // includes removeTypenameFromVariables link -export const TerminatingApolloLinkForGraphqlServer= (config: BatchHttpLink.Options) => { - const batchHttpLink = new BatchHttpLink({ - uri: config.uri as string | UriFunction, - batchMax: Number(config.batchMax), // No more than 15 operations per batch - batchInterval: Number(config.batchInterval) // Wait no more than 50ms after first batched operation - }); - return from([removeTypenameFromVariables(), batchHttpLink]); -}; \ No newline at end of file +export const TerminatingApolloLinkForGraphqlServer = ( + config: BatchHttpLink.Options, +) => { + const batchHttpLink = new BatchHttpLink({ + uri: config.uri as string | UriFunction, + batchMax: Number(config.batchMax), // No more than 15 operations per batch + batchInterval: Number(config.batchInterval), // Wait no more than 50ms after first batched operation + }); + return from([removeTypenameFromVariables(), batchHttpLink]); +}; diff --git a/apps/ui-community/src/components/ui/organisms/apollo-connection/index.stories.tsx b/apps/ui-community/src/components/ui/organisms/apollo-connection/index.stories.tsx index 301cef0b5..064cb6541 100644 --- a/apps/ui-community/src/components/ui/organisms/apollo-connection/index.stories.tsx +++ b/apps/ui-community/src/components/ui/organisms/apollo-connection/index.stories.tsx @@ -8,206 +8,212 @@ import { ApolloConnection, type ApolloConnectionProps } from './index.tsx'; // Mock environment variables const mockEnv = { - VITE_FUNCTION_ENDPOINT: 'https://mock-functions.example.com', - VITE_AAD_B2C_ACCOUNT_AUTHORITY: 'https://mock-authority.example.com', - VITE_AAD_B2C_ACCOUNT_CLIENTID: 'mock-client-id', - NODE_ENV: 'development', + VITE_FUNCTION_ENDPOINT: 'https://mock-functions.example.com', + VITE_AAD_B2C_ACCOUNT_AUTHORITY: 'https://mock-authority.example.com', + VITE_AAD_B2C_ACCOUNT_CLIENTID: 'mock-client-id', + NODE_ENV: 'development', }; // Mock window.sessionStorage and window.localStorage const mockStorage = { - getItem: (key: string) => { - if (key.includes('oidc.user')) { - return JSON.stringify({ - access_token: '', - profile: { sub: 'fallback-user' }, - }); - } - return null; - }, - setItem: (_key: string, _value: string) => Promise.resolve(), - removeItem: (_key: string) => Promise.resolve(), - clear: () => Promise.resolve(), - key: () => null, - length: 0, - set: (_key: string, _value: string) => Promise.resolve(), - get: (key: string) => Promise.resolve(mockStorage.getItem(key)), - remove: (key: string) => Promise.resolve(key), - getAllKeys: () => Promise.resolve([]), + getItem: (key: string) => { + if (key.includes('oidc.user')) { + return JSON.stringify({ + access_token: '', + profile: { sub: 'fallback-user' }, + }); + } + return null; + }, + setItem: (_key: string, _value: string) => Promise.resolve(), + removeItem: (_key: string) => Promise.resolve(), + clear: () => Promise.resolve(), + key: () => null, + length: 0, + set: (_key: string, _value: string) => Promise.resolve(), + get: (key: string) => Promise.resolve(mockStorage.getItem(key)), + remove: (key: string) => Promise.resolve(key), + getAllKeys: () => Promise.resolve([]), }; // Setup global mocks -Object.defineProperty(window, 'sessionStorage', { value: mockStorage, writable: true }); -Object.defineProperty(window, 'localStorage', { value: mockStorage, writable: true }); +Object.defineProperty(window, 'sessionStorage', { + value: mockStorage, + writable: true, +}); +Object.defineProperty(window, 'localStorage', { + value: mockStorage, + writable: true, +}); // Mock import.meta.env Object.defineProperty(import.meta, 'env', { - value: mockEnv, - writable: true, + value: mockEnv, + writable: true, }); const meta = { - title: 'Components/UI/Organisms/ApolloConnection', - component: ApolloConnection, - parameters: { - layout: 'fullscreen', - }, - decorators: [ - (Story) => ( - - - - - - ), - ], - argTypes: { - children: { - control: { type: 'text' }, - description: 'Child components to be wrapped by ApolloConnection', - }, - }, + title: 'Components/UI/Organisms/ApolloConnection', + component: ApolloConnection, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + + + + ), + ], + argTypes: { + children: { + control: { type: 'text' }, + description: 'Child components to be wrapped by ApolloConnection', + }, + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - children:
Test Child Component
, - } satisfies ApolloConnectionProps, - decorators: [ - (Story) => ( - - - - ), - ], - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - - // Verify that the component renders without errors - expect(canvasElement).toBeTruthy(); - - // Verify that the child component is rendered - const childElement = await canvas.findByTestId('test-child'); - expect(childElement).toBeInTheDocument(); - expect(childElement).toHaveTextContent('Test Child Component'); - }, + args: { + children:
Test Child Component
, + } satisfies ApolloConnectionProps, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + // Verify that the component renders without errors + expect(canvasElement).toBeTruthy(); + + // Verify that the child component is rendered + const childElement = await canvas.findByTestId('test-child'); + expect(childElement).toBeInTheDocument(); + expect(childElement).toHaveTextContent('Test Child Component'); + }, }; export const WithCommunityRoute: Story = { - args: { - children:
Community Page Content
, - }, - decorators: [ - (Story) => ( - - - - ), - ], - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - - // Verify that the component renders with community context - expect(canvasElement).toBeTruthy(); - - // Verify that the child component is rendered - const childElement = await canvas.findByTestId('community-child'); - expect(childElement).toBeInTheDocument(); - expect(childElement).toHaveTextContent('Community Page Content'); - }, + args: { + children:
Community Page Content
, + }, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + // Verify that the component renders with community context + expect(canvasElement).toBeTruthy(); + + // Verify that the child component is rendered + const childElement = await canvas.findByTestId('community-child'); + expect(childElement).toBeInTheDocument(); + expect(childElement).toHaveTextContent('Community Page Content'); + }, }; export const WithAccountsRoute: Story = { - args: { - children:
Accounts Page Content
, - }, - decorators: [ - (Story) => ( - - - - ), - ], - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - - // Verify that the component renders with accounts context - expect(canvasElement).toBeTruthy(); - - // Verify that the child component is rendered - const childElement = await canvas.findByTestId('accounts-child'); - expect(childElement).toBeInTheDocument(); - expect(childElement).toHaveTextContent('Accounts Page Content'); - }, + args: { + children:
Accounts Page Content
, + }, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + // Verify that the component renders with accounts context + expect(canvasElement).toBeTruthy(); + + // Verify that the child component is rendered + const childElement = await canvas.findByTestId('accounts-child'); + expect(childElement).toBeInTheDocument(); + expect(childElement).toHaveTextContent('Accounts Page Content'); + }, }; export const Unauthenticated: Story = { - args: { - children:
Unauthenticated Content
, - }, - decorators: [ - (Story) => ( - { - // Mock unauthenticated state - return Promise.resolve(); - }} - > - - - - - - - ), - ], - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - - // Verify that the component renders even when unauthenticated - expect(canvasElement).toBeTruthy(); - - // Verify that the child component is rendered - const childElement = await canvas.findByTestId('unauth-child'); - expect(childElement).toBeInTheDocument(); - expect(childElement).toHaveTextContent('Unauthenticated Content'); - }, + args: { + children:
Unauthenticated Content
, + }, + decorators: [ + (Story) => ( + { + // Mock unauthenticated state + return Promise.resolve(); + }} + > + + + + + + + ), + ], + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + // Verify that the component renders even when unauthenticated + expect(canvasElement).toBeTruthy(); + + // Verify that the child component is rendered + const childElement = await canvas.findByTestId('unauth-child'); + expect(childElement).toBeInTheDocument(); + expect(childElement).toHaveTextContent('Unauthenticated Content'); + }, }; export const WithAdminRoute: Story = { - args: { - children:
Admin Page Content
, - }, - decorators: [ - (Story) => ( - - - - ), - ], - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - - // Verify that the component renders with admin context - expect(canvasElement).toBeTruthy(); - - // Verify that the child component is rendered - const childElement = await canvas.findByTestId('admin-child'); - expect(childElement).toBeInTheDocument(); - expect(childElement).toHaveTextContent('Admin Page Content'); - }, -}; \ No newline at end of file + args: { + children:
Admin Page Content
, + }, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + // Verify that the component renders with admin context + expect(canvasElement).toBeTruthy(); + + // Verify that the child component is rendered + const childElement = await canvas.findByTestId('admin-child'); + expect(childElement).toBeInTheDocument(); + expect(childElement).toHaveTextContent('Admin Page Content'); + }, +}; diff --git a/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx b/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx index fdd3c6275..d0b283538 100644 --- a/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx +++ b/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx @@ -1,64 +1,79 @@ -import { ApolloLink, ApolloProvider, from } from "@apollo/client"; +import { ApolloLink, ApolloProvider, from } from '@apollo/client'; import { RestLink } from 'apollo-link-rest'; -import { type FC, useEffect } from "react"; -import { useAuth } from "react-oidc-context"; -import { useLocation } from "react-router-dom"; -import { ApolloLinkToAddAuthHeader, ApolloLinkToAddCustomHeader, BaseApolloLink, client, TerminatingApolloLinkForGraphqlServer } from "./apollo-client-links.js"; +import { type FC, useEffect } from 'react'; +import { useAuth } from 'react-oidc-context'; +import { useLocation } from 'react-router-dom'; +import { + ApolloLinkToAddAuthHeader, + ApolloLinkToAddCustomHeader, + BaseApolloLink, + client, + TerminatingApolloLinkForGraphqlServer, +} from './apollo-client-links.js'; export interface ApolloConnectionProps { - children: React.ReactNode; + children: React.ReactNode; } -export const ApolloConnection: FC = (props: ApolloConnectionProps) => { - const auth = useAuth(); - const location = useLocation(); - - const communityId = location.pathname.match(/\/community\/([a-f\d]{24})/i)?.[1] ?? null; - const memberId = location.pathname.match(/\/(member|admin)\/([a-f\d]{24})/i)?.[2] ?? null; +export const ApolloConnection: FC = ( + props: ApolloConnectionProps, +) => { + const auth = useAuth(); + const location = useLocation(); + const communityId = + location.pathname.match(/\/community\/([a-f\d]{24})/i)?.[1] ?? null; + const memberId = + location.pathname.match(/\/(member|admin)\/([a-f\d]{24})/i)?.[2] ?? null; - const apolloLinkChainForGraphqlDataSource = from([ - BaseApolloLink(), - ApolloLinkToAddAuthHeader(auth), - ApolloLinkToAddCustomHeader('x-community-id', communityId, (communityId !== 'accounts')), - ApolloLinkToAddCustomHeader('x-member-id', memberId), - TerminatingApolloLinkForGraphqlServer({ - // biome-ignore lint:useLiteralKeys - uri: `${import.meta.env['VITE_FUNCTION_ENDPOINT']}`, - batchMax: 15, - batchInterval: 50 - }) - ]); + const apolloLinkChainForGraphqlDataSource = from([ + BaseApolloLink(), + ApolloLinkToAddAuthHeader(auth), + ApolloLinkToAddCustomHeader( + 'x-community-id', + communityId, + communityId !== 'accounts', + ), + ApolloLinkToAddCustomHeader('x-member-id', memberId), + TerminatingApolloLinkForGraphqlServer({ + // biome-ignore lint:useLiteralKeys + uri: `${import.meta.env['VITE_FUNCTION_ENDPOINT']}`, + batchMax: 15, + batchInterval: 50, + }), + ]); - const apolloLinkChainForCountryDataSource = from([ - new RestLink({ - uri: 'https://countries.trevorblades.com/' - }) - ]); + const apolloLinkChainForCountryDataSource = from([ + new RestLink({ + uri: 'https://countries.trevorblades.com/', + }), + ]); - const linkMap = { - CountryDetails: apolloLinkChainForCountryDataSource, - default: apolloLinkChainForGraphqlDataSource - }; + const linkMap = { + CountryDetails: apolloLinkChainForCountryDataSource, + default: apolloLinkChainForGraphqlDataSource, + }; - const updateLink = () => { - return ApolloLink.from([ - ApolloLink.split( - // various options to split: - // 1. use a custom property in context: (operation) => operation.getContext().dataSource === some DataSourceEnum, - // 2. check for string name of the query if it is named: (operation) => operation.operationName === "CountryDetails", - (operation) => operation.operationName in linkMap, - new ApolloLink((operation, forward) => { - const link = linkMap[operation.operationName as keyof typeof linkMap] || linkMap.default; - return link.request(operation, forward); - }), - apolloLinkChainForGraphqlDataSource - ) - ]); - }; + const updateLink = () => { + return ApolloLink.from([ + ApolloLink.split( + // various options to split: + // 1. use a custom property in context: (operation) => operation.getContext().dataSource === some DataSourceEnum, + // 2. check for string name of the query if it is named: (operation) => operation.operationName === "CountryDetails", + (operation) => operation.operationName in linkMap, + new ApolloLink((operation, forward) => { + const link = + linkMap[operation.operationName as keyof typeof linkMap] || + linkMap.default; + return link.request(operation, forward); + }), + apolloLinkChainForGraphqlDataSource, + ), + ]); + }; - useEffect(() => { - client.setLink(updateLink()); - }, [auth, communityId, memberId]); + useEffect(() => { + client.setLink(updateLink()); + }, [auth, communityId, memberId]); - return {props.children}; + return {props.children}; }; diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx index 595d9f976..0108a5f13 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx @@ -1,7 +1,10 @@ -import { ComponentQueryLoader } from '@ocom/ui-components'; import { useLazyQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@ocom/ui-components'; import { useEffect, useState } from 'react'; -import type { Member, SharedCommunitiesDropdownContainerMembersQuery } from '../../../../generated.tsx'; +import type { + Member, + SharedCommunitiesDropdownContainerMembersQuery, +} from '../../../../generated.tsx'; import { SharedCommunitiesDropdownContainerMembersDocument } from '../../../../generated.tsx'; import { CommunitiesDropdown } from './communities-dropdown.tsx'; @@ -11,16 +14,25 @@ interface CommunitiesDropdownContainerProps { }; } -export const CommunitiesDropdownContainer: React.FC = (_props) => { - const [memberQuery] = useLazyQuery(SharedCommunitiesDropdownContainerMembersDocument); - const [membersData, setMemberData] = useState(null); +export const CommunitiesDropdownContainer: React.FC< + CommunitiesDropdownContainerProps +> = (_props) => { + const [memberQuery] = useLazyQuery( + SharedCommunitiesDropdownContainerMembersDocument, + ); + const [membersData, setMemberData] = + useState(null); const [membersError, setMemberError] = useState(null); const [membersLoading, setMemberLoading] = useState(true); useEffect(() => { const getData = async () => { try { - const { data: membersDataTemp, loading: membersLoadingTemp, error: membersErrorTemp } = await memberQuery(); + const { + data: membersDataTemp, + loading: membersLoadingTemp, + error: membersErrorTemp, + } = await memberQuery(); setMemberData(membersDataTemp ?? null); setMemberError(membersErrorTemp ?? null); setMemberLoading(membersLoadingTemp); diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx new file mode 100644 index 000000000..75b60cd2d --- /dev/null +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx @@ -0,0 +1,135 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter } from 'react-router-dom'; +import { expect, userEvent, within } from 'storybook/test'; +import type { Member } from '../../../../generated.tsx'; +import { CommunitiesDropdown } from './communities-dropdown.tsx'; + +const mockMembers: Member[] = [ + { + __typename: 'Member', + id: 'member1', + memberName: 'John Doe', + isAdmin: true, + community: { + __typename: 'Community', + id: 'community1', + name: 'Community One', + } as Member['community'], + } as Member, + { + __typename: 'Member', + id: 'member2', + memberName: 'Jane Smith', + isAdmin: false, + community: { + __typename: 'Community', + id: 'community1', + name: 'Community One', + } as Member['community'], + } as Member, + { + __typename: 'Member', + id: 'member3', + memberName: 'Bob Johnson', + isAdmin: true, + community: { + __typename: 'Community', + id: 'community2', + name: 'Community Two', + } as Member['community'], + } as Member, +]; + +const meta = { + title: 'Components/UI/Organisms/DropdownMenu/CommunitiesDropdown', + component: CommunitiesDropdown, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: { + members: mockMembers, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify dropdown trigger is rendered + const dropdownTrigger = canvas.getByText(/Community/i); + expect(dropdownTrigger).toBeInTheDocument(); + }, +}; + +export const SingleCommunity: Story = { + args: { + data: { + members: [mockMembers[0] as Member], + }, + }, +}; + +export const MultipleCommunities: Story = { + args: { + data: { + members: mockMembers, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Click dropdown to open + const dropdownTrigger = canvas.getByRole('link'); + await userEvent.click(dropdownTrigger); + + // Wait for dropdown menu to appear + // Note: In actual storybook, the dropdown menu appears in a portal + // so this might not work perfectly in the test, but it demonstrates intent + }, +}; + +export const NoMembers: Story = { + args: { + data: { + members: [], + }, + }, +}; + +export const AdminMember: Story = { + args: { + data: { + members: [ + { + __typename: 'Member', + id: 'admin1', + memberName: 'Admin User', + isAdmin: true, + community: { + __typename: 'Community', + id: 'comm1', + name: 'Admin Community', + } as Member['community'], + } as Member, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify admin member is shown in dropdown + expect(canvas.getByText(/Admin Community/i)).toBeInTheDocument(); + }, +}; diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.tsx index c453db78c..998a7091a 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.tsx @@ -10,14 +10,27 @@ export interface CommunitiesDropdownProps { }; } -export const CommunitiesDropdown: React.FC = (props) => { +export const CommunitiesDropdown: React.FC = ( + props, +) => { const [dropdownVisible, setDropdownVisible] = useState(false); const params = useParams(); const navigate = useNavigate(); - const currentMember = props.data.members?.find((member) => member.id === params['memberId']); + const currentMember = props.data.members?.find( + (member) => member.id === params['memberId'], + ); - const populateItems = (member: Member, itemsMap: { [key: string]: { key: string; label: string | undefined; children: { key: string; label: string; onClick: () => void }[] } }) => { + const populateItems = ( + member: Member, + itemsMap: { + [key: string]: { + key: string; + label: string | undefined; + children: { key: string; label: string; onClick: () => void }[]; + }; + }, + ) => { const communityId = member?.community?.id; if (!communityId) return; @@ -56,8 +69,16 @@ export const CommunitiesDropdown: React.FC = (props) = } }; - const itemsMap: { [key: string]: { key: string; label: string | undefined; children: { key: string; label: string; onClick: () => void }[] } } = {}; - props.data.members?.forEach((member: Member) => populateItems(member, itemsMap)); + const itemsMap: { + [key: string]: { + key: string; + label: string | undefined; + children: { key: string; label: string; onClick: () => void }[]; + }; + } = {}; + props.data.members?.forEach((member: Member) => + populateItems(member, itemsMap), + ); const items: MenuProps['items'] = Object.values(itemsMap); @@ -71,8 +92,13 @@ export const CommunitiesDropdown: React.FC = (props) = open={dropdownVisible} onOpenChange={(visible) => setDropdownVisible(visible)} > - e.preventDefault()} className="ant-dropdown-link" style={{ minHeight: '50px' }}> - {currentMember?.community?.name} | {currentMember?.memberName} + e.preventDefault()} + className="ant-dropdown-link" + style={{ minHeight: '50px' }} + > + {currentMember?.community?.name} | {currentMember?.memberName}{' '} + ); diff --git a/apps/ui-community/src/config/oidc-config.tsx b/apps/ui-community/src/config/oidc-config.tsx index 7b49a3be6..7eb809350 100644 --- a/apps/ui-community/src/config/oidc-config.tsx +++ b/apps/ui-community/src/config/oidc-config.tsx @@ -1,38 +1,37 @@ type OIDCConfig = { - authority: string - client_id: string - redirect_uri: string - code_verifier: boolean - noonce: boolean - response_type: string - scope: string - onSigninCallback: () => void -} - + authority: string; + client_id: string; + redirect_uri: string; + code_verifier: boolean; + noonce: boolean; + response_type: string; + scope: string; + onSigninCallback: () => void; +}; export const oidcConfig: OIDCConfig = { - // biome-ignore lint:useLiteralKeys - authority: import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? "http://localhost:4000", - // biome-ignore lint:useLiteralKeys - client_id: import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? "mock-client", - // biome-ignore lint:useLiteralKeys - redirect_uri: import.meta.env['VITE_AAD_B2C_REDIRECT_URI'] ?? "http://localhost:3000/auth-redirect", - code_verifier: true, - noonce: true, - response_type: 'code', - // biome-ignore lint:useLiteralKeys - scope: import.meta.env['VITE_AAD_B2C_ACCOUNT_SCOPES'], - onSigninCallback: (): void => { - console.log('onSigninCallback'); - window.history.replaceState( - {}, - document.title, - window.location.pathname - ); - const redirectToPath = window.sessionStorage.getItem('redirectTo'); - if (redirectToPath){ - window.location.pathname = redirectToPath; - window.sessionStorage.removeItem('redirectTo'); - } - } -} + // biome-ignore lint:useLiteralKeys + authority: + import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? + 'http://localhost:4000', + // biome-ignore lint:useLiteralKeys + client_id: import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? 'mock-client', + // biome-ignore lint:useLiteralKeys + redirect_uri: + import.meta.env['VITE_AAD_B2C_REDIRECT_URI'] ?? + 'http://localhost:3000/auth-redirect', + code_verifier: true, + noonce: true, + response_type: 'code', + // biome-ignore lint:useLiteralKeys + scope: import.meta.env['VITE_AAD_B2C_ACCOUNT_SCOPES'], + onSigninCallback: (): void => { + console.log('onSigninCallback'); + window.history.replaceState({}, document.title, window.location.pathname); + const redirectToPath = window.sessionStorage.getItem('redirectTo'); + if (redirectToPath) { + window.location.pathname = redirectToPath; + window.sessionStorage.removeItem('redirectTo'); + } + }, +}; diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index c8be3be8e..ad6734968 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,180 +1,192 @@ import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; -import { createContext, type ReactNode, useEffect, useState, } from 'react'; +import { createContext, type ReactNode, useEffect, useState } from 'react'; + // import ModalPopUp from './components/modal-popup.tsx'; // import MaintenanceMessage from '../components/shared/maintenance-message/maintenance-message'; // import ImpendingMessage from '../components/shared/maintenance-message/impending-message'; // import { useMaintenanceMessage } from '../components/shared/maintenance-message'; interface ThemeContextType { - currentTokens: { - token: Partial; - hardCodedTokens: { - textColor: string | undefined; - backgroundColor: string | undefined; - }; - type: string; - } | undefined; - setTheme: (tokens: Partial, types: string) => void; + currentTokens: + | { + token: Partial; + hardCodedTokens: { + textColor: string | undefined; + backgroundColor: string | undefined; + }; + type: string; + } + | undefined; + setTheme: (tokens: Partial, types: string) => void; } export const ThemeContext = createContext({ - currentTokens: { - token: theme.defaultSeed, - hardCodedTokens: { - textColor: '#000000', - backgroundColor: '#ffffff' - }, - type: 'light' - }, - setTheme: () => { /* no-op */ } + currentTokens: { + token: theme.defaultSeed, + hardCodedTokens: { + textColor: '#000000', + backgroundColor: '#ffffff', + }, + type: 'light', + }, + setTheme: () => { + /* no-op */ + }, }); export const ThemeProvider = ({ children }: { children: ReactNode }) => { -// const { -// isImpending, -// isMaintenance, -// impendingMessage, -// maintenanceMessage, -// impendingStartTimestamp, -// maintenanceStartTimestamp, -// maintenanceEndTimestamp -// } = useMaintenanceMessage(); - const [currentTokens, setCurrentTokens] = useState({ - token: theme.defaultSeed, - hardCodedTokens: { - textColor: '#000000', - backgroundColor: '#ffffff' - }, - type: 'light' - }); - const [isHidden, setIsHidden] = useState(false); + // const { + // isImpending, + // isMaintenance, + // impendingMessage, + // maintenanceMessage, + // impendingStartTimestamp, + // maintenanceStartTimestamp, + // maintenanceEndTimestamp + // } = useMaintenanceMessage(); + const [currentTokens, setCurrentTokens] = useState< + ThemeContextType['currentTokens'] | undefined + >({ + token: theme.defaultSeed, + hardCodedTokens: { + textColor: '#000000', + backgroundColor: '#ffffff', + }, + type: 'light', + }); + const [isHidden, setIsHidden] = useState(false); - const toggleHidden = () => setIsHidden((prevHidden) => !prevHidden); + const toggleHidden = () => setIsHidden((prevHidden) => !prevHidden); - // setTheme functions that take tokens as argument - const setTheme = (tokens: Partial, type: string) => { - let valueToSet: ThemeContextType['currentTokens'] | undefined; - if (type === 'light') { - valueToSet = { - token: tokens, - hardCodedTokens: { - textColor: '#000000', - backgroundColor: '#ffffff' - }, - type: 'light' - }; - } else if (type === 'dark') { - valueToSet = { - token: tokens, - hardCodedTokens: { - textColor: '#ffffff', - backgroundColor: '#000000' - }, - type: 'dark' - }; - } else if (type === 'custom') { - valueToSet = { - token: { - ...currentTokens?.token - }, - hardCodedTokens: { - textColor: tokens?.colorTextBase, - backgroundColor: tokens?.colorBgBase - }, - type: 'custom' - }; - } - setCurrentTokens(valueToSet); + // setTheme functions that take tokens as argument + const setTheme = (tokens: Partial, type: string) => { + let valueToSet: ThemeContextType['currentTokens'] | undefined; + if (type === 'light') { + valueToSet = { + token: tokens, + hardCodedTokens: { + textColor: '#000000', + backgroundColor: '#ffffff', + }, + type: 'light', + }; + } else if (type === 'dark') { + valueToSet = { + token: tokens, + hardCodedTokens: { + textColor: '#ffffff', + backgroundColor: '#000000', + }, + type: 'dark', + }; + } else if (type === 'custom') { + valueToSet = { + token: { + ...currentTokens?.token, + }, + hardCodedTokens: { + textColor: tokens?.colorTextBase, + backgroundColor: tokens?.colorBgBase, + }, + type: 'custom', + }; + } + setCurrentTokens(valueToSet); - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); - }; + localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + }; - useEffect(() => { - const extractFromLocal = JSON.parse(localStorage.getItem('themeProp') || '{}'); - if (extractFromLocal && extractFromLocal.type === 'dark') { - setTheme( - { - colorTextBase: '#ffffff', - colorBgBase: '#000000' - }, - 'dark' - ); - return; - } else if (extractFromLocal && extractFromLocal.type === 'light') { - setTheme( - { - colorTextBase: '#000000', - colorBgBase: '#ffffff' - }, - 'light' - ); - return; - } else if (extractFromLocal && extractFromLocal.type === 'custom') { - setTheme( - { - colorTextBase: extractFromLocal.hardCodedTokens.textColor, - colorBgBase: extractFromLocal.hardCodedTokens.backgroundColor - }, - 'custom' - ); - return; - } else { - const valueToSet = { - type: 'light', - tokens: theme.defaultSeed, - hardCodedTokens: { - textColor: '#000000', - backgroundColor: '#ffffff' - } - }; - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); - setTheme(theme.defaultSeed, 'light'); - return; - } - }, []); + useEffect(() => { + const extractFromLocal = JSON.parse( + localStorage.getItem('themeProp') || '{}', + ); + if (extractFromLocal && extractFromLocal.type === 'dark') { + setTheme( + { + colorTextBase: '#ffffff', + colorBgBase: '#000000', + }, + 'dark', + ); + return; + } else if (extractFromLocal && extractFromLocal.type === 'light') { + setTheme( + { + colorTextBase: '#000000', + colorBgBase: '#ffffff', + }, + 'light', + ); + return; + } else if (extractFromLocal && extractFromLocal.type === 'custom') { + setTheme( + { + colorTextBase: extractFromLocal.hardCodedTokens.textColor, + colorBgBase: extractFromLocal.hardCodedTokens.backgroundColor, + }, + 'custom', + ); + return; + } else { + const valueToSet = { + type: 'light', + tokens: theme.defaultSeed, + hardCodedTokens: { + textColor: '#000000', + backgroundColor: '#ffffff', + }, + }; + localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + setTheme(theme.defaultSeed, 'light'); + return; + } + }, []); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.metaKey && event.shiftKey && event.key === 'k') { - toggleHidden(); - } - }; + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey && event.shiftKey && event.key === 'k') { + toggleHidden(); + } + }; - window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, []); -// console.log('isImpending', isImpending); -// console.log('isMaintenance', isMaintenance); - return ( - -
-
-
- + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + // console.log('isImpending', isImpending); + // console.log('isMaintenance', isMaintenance); + return ( + +
+
+
+ - {/* */} -
-

- Hit Cmd+Shift+K to hide -

-
- {children} -
-
- ); + {/* */} +
+

+ Hit Cmd+Shift+K to hide +

+
+ {children} +
+
+ ); }; diff --git a/apps/ui-community/src/main.tsx b/apps/ui-community/src/main.tsx index c5b52d38b..ea263e882 100644 --- a/apps/ui-community/src/main.tsx +++ b/apps/ui-community/src/main.tsx @@ -27,15 +27,15 @@ const ConfigProviderWrapper = () => { }, }} > - - - - - - - - - + + + + + + + + + ); }; diff --git a/apps/ui-community/tailwind.config.ts b/apps/ui-community/tailwind.config.ts index 465d475cd..c83de9ba9 100644 --- a/apps/ui-community/tailwind.config.ts +++ b/apps/ui-community/tailwind.config.ts @@ -1,12 +1,12 @@ -import type { Config } from 'tailwindcss' +import type { Config } from 'tailwindcss'; export default { - content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], - theme: { - extend: {}, - }, - plugins: [], - corePlugins: { - preflight: false, - } -} satisfies Config \ No newline at end of file + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], + theme: { + extend: {}, + }, + plugins: [], + corePlugins: { + preflight: false, + }, +} satisfies Config; diff --git a/apps/ui-community/tsconfig.app.json b/apps/ui-community/tsconfig.app.json index 8d94175fd..8618881b4 100644 --- a/apps/ui-community/tsconfig.app.json +++ b/apps/ui-community/tsconfig.app.json @@ -1,31 +1,31 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", // Overrides @cellix/typescript-config/base.json - "skipLibCheck": true, // Overrides @cellix/typescript-config/base.json + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", // Overrides @cellix/typescript-config/base.json + "skipLibCheck": true, // Overrides @cellix/typescript-config/base.json - /* Bundler mode */ - "moduleResolution": "bundler", // Overrides @cellix/typescript-config/base.json - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", // New setting - "noEmit": true, // New setting - "jsx": "react-jsx", // New setting + /* Bundler mode */ + "moduleResolution": "bundler", // Overrides @cellix/typescript-config/base.json + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", // New setting + "noEmit": true, // New setting + "jsx": "react-jsx", // New setting - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"], - "references": [ - { "path": "../../packages/cellix/ui-core" }, - { "path": "../../packages/ocom/ui-components" } - ] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"], + "references": [ + { "path": "../../packages/cellix/ui-core" }, + { "path": "../../packages/ocom/ui-components" } + ] } diff --git a/apps/ui-community/tsconfig.json b/apps/ui-community/tsconfig.json index e57e63eb8..ddacde5cf 100644 --- a/apps/ui-community/tsconfig.json +++ b/apps/ui-community/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "@cellix/typescript-config/base", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "jsx": "react-jsx", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "exactOptionalPropertyTypes": false, - "skipLibCheck": true - }, - "include": ["src"] + "extends": "@cellix/typescript-config/base", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "exactOptionalPropertyTypes": false, + "skipLibCheck": true + }, + "include": ["src"] } diff --git a/apps/ui-community/tsconfig.node.json b/apps/ui-community/tsconfig.node.json index 8b0e6ea7f..73a4c8f73 100644 --- a/apps/ui-community/tsconfig.node.json +++ b/apps/ui-community/tsconfig.node.json @@ -1,25 +1,25 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", // Overrides @cellix/typescript-config/base.json - "skipLibCheck": true, // Overrides @cellix/typescript-config/base.json + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", // Overrides @cellix/typescript-config/base.json + "skipLibCheck": true, // Overrides @cellix/typescript-config/base.json - /* Bundler mode */ - "moduleResolution": "bundler", // Overrides @cellix/typescript-config/base.json - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", // New setting - "noEmit": true, // New setting + /* Bundler mode */ + "moduleResolution": "bundler", // Overrides @cellix/typescript-config/base.json + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", // New setting + "noEmit": true, // New setting - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] } diff --git a/apps/ui-community/turbo.json b/apps/ui-community/turbo.json index 81ddd828d..42a7fb97b 100644 --- a/apps/ui-community/turbo.json +++ b/apps/ui-community/turbo.json @@ -1,4 +1,4 @@ { - "extends": ["//"], - "tags": ["frontend"] -} \ No newline at end of file + "extends": ["//"], + "tags": ["frontend"] +} diff --git a/apps/ui-community/vite.config.ts b/apps/ui-community/vite.config.ts index aaa52c314..b695c39f2 100644 --- a/apps/ui-community/vite.config.ts +++ b/apps/ui-community/vite.config.ts @@ -1,64 +1,65 @@ -import react from '@vitejs/plugin-react' -import { visualizer } from 'rollup-plugin-visualizer' -import { defineConfig, type PluginOption } from 'vite' +import react from '@vitejs/plugin-react'; +import { visualizer } from 'rollup-plugin-visualizer'; +import { defineConfig, type PluginOption } from 'vite'; const { NODE_ENV } = process.env; const isDev = NODE_ENV === 'development'; // Define groups for advancedChunks const dependencyChunkGroups = [ - { - name: 'vendor-react', - test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/ - }, - { - name: 'vendor-antd-icons', - test: /[\\/]node_modules[\\/]@ant-design[\\/]icons[\\/]/ - }, - { - name: 'vendor-antd-pro', - test: /[\\/]node_modules[\\/]@ant-design[\\/]pro-.*[\\/]/ - }, - { - name: 'vendor-antd', - // Matches @ant-design ONLY if it is NOT followed by /icons or /pro- - test: /[\\/]node_modules[\\/](antd|@ant-design(?![\\/](icons|pro-))|rc-.*)[\\/]/ - }, - { - name: 'vendor-apollo', - test: /[\\/]node_modules[\\/](@apollo|apollo-.*|graphql)[\\/]/ - }, - { - name: 'vendor-cellix', - test: /[\\/](@cellix|@ocom)[\\/]/ - }, - { - name: 'vendor-utils', - test: /[\\/]node_modules[\\/](lodash|dayjs|date-fns|axios)[\\/]/ - }, - { - name: 'vendor', - test: /[\\/]node_modules[\\/]/ - }, + { + name: 'vendor-react', + test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/, + }, + { + name: 'vendor-antd-icons', + test: /[\\/]node_modules[\\/]@ant-design[\\/]icons[\\/]/, + }, + { + name: 'vendor-antd-pro', + test: /[\\/]node_modules[\\/]@ant-design[\\/]pro-.*[\\/]/, + }, + { + name: 'vendor-antd', + // Matches @ant-design ONLY if it is NOT followed by /icons or /pro- + test: /[\\/]node_modules[\\/](antd|@ant-design(?![\\/](icons|pro-))|rc-.*)[\\/]/, + }, + { + name: 'vendor-apollo', + test: /[\\/]node_modules[\\/](@apollo|apollo-.*|graphql)[\\/]/, + }, + { + name: 'vendor-cellix', + test: /[\\/](@cellix|@ocom)[\\/]/, + }, + { + name: 'vendor-utils', + test: /[\\/]node_modules[\\/](lodash|dayjs|date-fns|axios)[\\/]/, + }, + { + name: 'vendor', + test: /[\\/]node_modules[\\/]/, + }, ]; // https://vite.dev/config/ export default defineConfig({ - plugins: [ - react() as PluginOption, - ...(isDev ? [visualizer() as PluginOption] : []), - ], - server: { - port: 3000, - }, - build: { - chunkSizeWarningLimit: 500, - rolldownOptions: { // Still used for compatibility, but Rolldown interprets it - output: { - advancedChunks: { - groups: dependencyChunkGroups, - }, - }, - }, - }, -}) \ No newline at end of file + plugins: [ + react() as PluginOption, + ...(isDev ? [visualizer() as PluginOption] : []), + ], + server: { + port: 3000, + }, + build: { + chunkSizeWarningLimit: 500, + rolldownOptions: { + // Still used for compatibility, but Rolldown interprets it + output: { + advancedChunks: { + groups: dependencyChunkGroups, + }, + }, + }, + }, +}); diff --git a/apps/ui-community/vitest.config.ts b/apps/ui-community/vitest.config.ts index 78eaed369..8b18441b9 100644 --- a/apps/ui-community/vitest.config.ts +++ b/apps/ui-community/vitest.config.ts @@ -3,12 +3,13 @@ import { fileURLToPath } from 'node:url'; import { createStorybookVitestConfig } from '@cellix/vitest-config'; import { defineConfig } from 'vitest/config'; -const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); +const dirname = + typeof __dirname !== 'undefined' + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); export default defineConfig( - createStorybookVitestConfig(dirname, { - additionalCoverageExclude: [ - 'eslint.config.js', - ], - }) -); \ No newline at end of file + createStorybookVitestConfig(dirname, { + additionalCoverageExclude: ['eslint.config.js'], + }), +); From 98beac2ebb56302f70173bf2e18c4d26c4bbdb40 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Tue, 23 Dec 2025 14:32:57 -0500 Subject: [PATCH 21/32] Enhance community settings and dropdown components; update routing paths, fix date format in stories, and improve error handling in dropdown container --- .github/workflows/copilot-setup-steps.yml | 3 ++ .../components/settings-general.stories.tsx | 2 +- .../src/components/layouts/admin/index.tsx | 4 +-- .../communities-dropdown.container.tsx | 4 +-- .../communities-dropdown.stories.tsx | 31 ++++++++++++++----- .../mongo-domain-adapter.test.ts | 6 ++-- .../mongoose-seedwork/mongo-domain-adapter.ts | 9 +++--- .../member/contexts/member.community.visa.ts | 21 ++++++++----- 8 files changed, 52 insertions(+), 28 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index e3c2685f7..d735e9798 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -39,6 +39,9 @@ jobs: with: version: 10.18.2 + - name: Configure pnpm for cloud agent + run: pnpm config set strict-ssl false + - name: Cache pnpm store uses: actions/cache@v4 with: diff --git a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx index fcb84fb88..740dd6814 100644 --- a/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx @@ -40,7 +40,7 @@ export const Default: Story = { // Verify community info is displayed expect(canvas.getByText(mockData.id)).toBeInTheDocument(); - expect(canvas.getByText(/1\/1\/2024/)).toBeInTheDocument(); // Created date + expect(canvas.getByText(/01\/01\/2024/)).toBeInTheDocument(); // Created date // Verify form fields have correct values const nameInput = canvas.getByPlaceholderText('Name') as HTMLInputElement; diff --git a/apps/ui-community/src/components/layouts/admin/index.tsx b/apps/ui-community/src/components/layouts/admin/index.tsx index 94de2280e..6a1b73b92 100644 --- a/apps/ui-community/src/components/layouts/admin/index.tsx +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -17,13 +17,13 @@ export interface PageLayoutProps { export const Admin: React.FC = () => { const pageLayouts: PageLayoutProps[] = [ { - path: '/:communityId/admin/:memberId', + path: '/community/:communityId/admin/:memberId', title: 'Home', icon: , id: 'ROOT', }, { - path: '/:communityId/admin/:memberId/settings/*', + path: '/community/:communityId/admin/:memberId/settings/*', title: 'Settings', icon: , id: 2, diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx index 0108a5f13..c1436d003 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx @@ -1,5 +1,5 @@ import { useLazyQuery } from '@apollo/client'; -import { ComponentQueryLoader } from '@ocom/ui-components'; +import { ComponentQueryLoader } from '@cellix/ui-core'; import { useEffect, useState } from 'react'; import type { Member, @@ -56,7 +56,7 @@ export const CommunitiesDropdownContainer: React.FC< }} /> } - error={membersError} + error={membersError ?? undefined} /> ); }; diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx index 75b60cd2d..6f34f1b99 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { BrowserRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { expect, userEvent, within } from 'storybook/test'; import type { Member } from '../../../../generated.tsx'; import { CommunitiesDropdown } from './communities-dropdown.tsx'; @@ -47,10 +47,22 @@ const meta = { layout: 'centered', }, decorators: [ - (Story) => ( - - - + (Story, context) => ( + + + } + /> + } /> + + ), ], } satisfies Meta; @@ -64,7 +76,7 @@ export const Default: Story = { members: mockMembers, }, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify dropdown trigger is rendered @@ -91,7 +103,7 @@ export const MultipleCommunities: Story = { const canvas = within(canvasElement); // Click dropdown to open - const dropdownTrigger = canvas.getByRole('link'); + const dropdownTrigger = canvas.getByText(/Community One/i); await userEvent.click(dropdownTrigger); // Wait for dropdown menu to appear @@ -109,6 +121,9 @@ export const NoMembers: Story = { }; export const AdminMember: Story = { + parameters: { + initialEntries: ['/community/comm1/member/admin1'], + }, args: { data: { members: [ @@ -126,7 +141,7 @@ export const AdminMember: Story = { ], }, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify admin member is shown in dropdown diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.test.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.test.ts index 63e494984..641683804 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.test.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.test.ts @@ -25,7 +25,7 @@ test.for(feature, ({ Scenario }) => { Scenario('Constructing a MongooseDomainAdapter', ({ Given, When, Then }) => { Given('a Mongoose document with id, createdAt, updatedAt, and schemaVersion', () => { doc = vi.mocked({ - id: { toString: () => 'abc123' }, + _id: { toString: () => 'abc123' }, createdAt: new Date('2023-01-01T00:00:00Z'), updatedAt: new Date('2023-01-02T00:00:00Z'), schemaVersion: 'v1', @@ -45,7 +45,7 @@ test.for(feature, ({ Scenario }) => { Given('a domain adapter constructed with a document with an ObjectId', () => { toStringMock = vi.fn(() => 'objectid123'); doc = vi.mocked({ - id: { toString: toStringMock }, + _id: { toString: toStringMock }, createdAt: new Date(), updatedAt: new Date(), schemaVersion: 'v2', @@ -67,7 +67,7 @@ test.for(feature, ({ Scenario }) => { const updated = new Date('2022-01-02T00:00:00Z'); Given('a domain adapter constructed with a document with createdAt, updatedAt, and schemaVersion', () => { doc = vi.mocked({ - id: { toString: () => 'id' }, + _id: { toString: () => 'id' }, createdAt: created, updatedAt: updated, schemaVersion: 'v3', diff --git a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.ts b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.ts index 1c27453be..2e58f7c21 100644 --- a/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.ts +++ b/packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-domain-adapter.ts @@ -14,10 +14,11 @@ export abstract class MongooseDomainAdapter this.doc = doc; } get id() { - if (!this.doc._id) { - throw new Error(`${this.constructor.name} document is missing _id`); - } - return this.doc._id.toString(); + const id = this.doc._id || this.doc.id; + if (!id) { + throw new Error(`${this.constructor.name} document is missing _id`); + } + return id.toString(); } get createdAt() { return this.doc.createdAt; diff --git a/packages/ocom/domain/src/domain/iam/member/contexts/member.community.visa.ts b/packages/ocom/domain/src/domain/iam/member/contexts/member.community.visa.ts index 1b376a1d0..195a3fe0a 100644 --- a/packages/ocom/domain/src/domain/iam/member/contexts/member.community.visa.ts +++ b/packages/ocom/domain/src/domain/iam/member/contexts/member.community.visa.ts @@ -33,14 +33,19 @@ export class MemberCommunityVisa // console.log("Member Visa: no community permissions"); // return false; // } - - const updatedPermissions: CommunityDomainPermissions = { - ...communityPermissions, //using spread here to ensure that we get type safety and we don't need to deep copy - isEditingOwnMemberAccount: false, - canCreateCommunities: true, //TODO: add a more complext rule here like can only create one community for free, otherwise need a paid plan - canManageVendorUserRolesAndPermissions: false, // end user roles cannot manage vendor user roles - isSystemAccount: false, - }; + const updatedPermissions: CommunityDomainPermissions = { + canManageCommunitySettings: communityPermissions.canManageCommunitySettings, + canManageMembers: communityPermissions.canManageMembers, + canEditOwnMemberProfile: communityPermissions.canEditOwnMemberProfile, + canEditOwnMemberAccounts: communityPermissions.canEditOwnMemberAccounts, + canManageEndUserRolesAndPermissions: + communityPermissions.canManageEndUserRolesAndPermissions, + canManageSiteContent: communityPermissions.canManageSiteContent, + isEditingOwnMemberAccount: false, + canCreateCommunities: true, //TODO: add a more complext rule here like can only create one community for free, otherwise need a paid plan + canManageVendorUserRolesAndPermissions: false, // end user roles cannot manage vendor user roles + isSystemAccount: false, + }; return func(updatedPermissions); } From 3bd3b166d1d8fde1d558acfe49b1d99a85d0dc58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:43:19 +0000 Subject: [PATCH 22/32] Changes before error encountered Co-authored-by: nnoce14 <22334223+nnoce14@users.noreply.github.com> --- .../components/layouts/accounts/pages/home.tsx | 2 +- .../components/community-detail.stories.tsx | 6 +++--- .../src/components/layouts/admin/pages/home.tsx | 2 +- .../components/layouts/admin/section-layout.tsx | 4 ++-- .../shared/components/menu-component.stories.tsx | 6 +++--- .../ui/organisms/apollo-connection/index.tsx | 16 ++++++++-------- .../communities-dropdown.stories.tsx | 2 +- .../dropdown-menu/communities-dropdown.tsx | 11 ++++++----- apps/ui-community/src/contexts/theme-context.tsx | 12 ++++++------ 9 files changed, 31 insertions(+), 30 deletions(-) diff --git a/apps/ui-community/src/components/layouts/accounts/pages/home.tsx b/apps/ui-community/src/components/layouts/accounts/pages/home.tsx index 1be9bb3e0..a6e505506 100644 --- a/apps/ui-community/src/components/layouts/accounts/pages/home.tsx +++ b/apps/ui-community/src/components/layouts/accounts/pages/home.tsx @@ -9,7 +9,7 @@ const { Title } = Typography; export const Home: React.FC = () => { return ( - }> + Owner Community Home diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx index 1b8b74ae5..d42fd6a97 100644 --- a/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx @@ -29,7 +29,7 @@ export const Default: Story = { args: { data: mockData, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify community details are displayed @@ -56,7 +56,7 @@ export const WithMinimalData: Story = { updatedAt: '2024-01-01T12:00:00.000Z', }, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify only required fields are displayed @@ -87,7 +87,7 @@ export const WithAllFields: Story = { updatedAt: '2024-01-15T12:00:00.000Z', }, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify all fields are displayed diff --git a/apps/ui-community/src/components/layouts/admin/pages/home.tsx b/apps/ui-community/src/components/layouts/admin/pages/home.tsx index c01cf7386..9a067944a 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/home.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/home.tsx @@ -19,7 +19,7 @@ export const Home: React.FC = () => { /> } > - + ); }; diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.tsx index e928e39a5..5dde7e883 100644 --- a/apps/ui-community/src/components/layouts/admin/section-layout.tsx +++ b/apps/ui-community/src/components/layouts/admin/section-layout.tsx @@ -59,12 +59,12 @@ export const SectionLayout: React.FC = (props) => { >
View Member Site diff --git a/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx b/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx index e4bd9653c..3a0e2193d 100644 --- a/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx +++ b/apps/ui-community/src/components/layouts/shared/components/menu-component.stories.tsx @@ -66,7 +66,7 @@ export const Default: Story = { theme: 'light', mode: 'inline', }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify menu items are rendered @@ -130,7 +130,7 @@ export const WithPermissions: Story = { mode: 'inline', memberData: mockMember, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify admin-only menu item is visible for admin member @@ -170,7 +170,7 @@ export const NoPermissions: Story = { isAdmin: false, }, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); // Verify admin-only menu item is NOT visible for non-admin member diff --git a/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx b/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx index d0b283538..83025309f 100644 --- a/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx +++ b/apps/ui-community/src/components/ui/organisms/apollo-connection/index.tsx @@ -1,6 +1,6 @@ import { ApolloLink, ApolloProvider, from } from '@apollo/client'; import { RestLink } from 'apollo-link-rest'; -import { type FC, useEffect } from 'react'; +import { type FC, useCallback, useEffect } from 'react'; import { useAuth } from 'react-oidc-context'; import { useLocation } from 'react-router-dom'; import { @@ -48,12 +48,12 @@ export const ApolloConnection: FC = ( }), ]); - const linkMap = { - CountryDetails: apolloLinkChainForCountryDataSource, - default: apolloLinkChainForGraphqlDataSource, - }; + const updateLink = useCallback(() => { + const linkMap = { + CountryDetails: apolloLinkChainForCountryDataSource, + default: apolloLinkChainForGraphqlDataSource, + }; - const updateLink = () => { return ApolloLink.from([ ApolloLink.split( // various options to split: @@ -69,11 +69,11 @@ export const ApolloConnection: FC = ( apolloLinkChainForGraphqlDataSource, ), ]); - }; + }, [apolloLinkChainForGraphqlDataSource, apolloLinkChainForCountryDataSource]); useEffect(() => { client.setLink(updateLink()); - }, [auth, communityId, memberId]); + }, [updateLink]); return {props.children}; }; diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx index 6f34f1b99..993d13d57 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx @@ -50,7 +50,7 @@ const meta = { (Story, context) => ( = ( const navigate = useNavigate(); const currentMember = props.data.members?.find( - (member) => member.id === params['memberId'], + (member) => member.id === params.memberId, ); const populateItems = ( @@ -87,19 +87,20 @@ export const CommunitiesDropdown: React.FC = ( menu={{ items, selectable: true, - defaultSelectedKeys: [params['memberId'] ?? ''], + defaultSelectedKeys: [params.memberId ?? ''], }} open={dropdownVisible} onOpenChange={(visible) => setDropdownVisible(visible)} > - e.preventDefault()} className="ant-dropdown-link" - style={{ minHeight: '50px' }} + style={{ minHeight: '50px', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} > {currentMember?.community?.name} | {currentMember?.memberName}{' '} - + ); }; diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index ad6734968..193334b98 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,6 +1,6 @@ import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; -import { createContext, type ReactNode, useEffect, useState } from 'react'; +import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; // import ModalPopUp from './components/modal-popup.tsx'; // import MaintenanceMessage from '../components/shared/maintenance-message/maintenance-message'; @@ -57,10 +57,10 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { }); const [isHidden, setIsHidden] = useState(false); - const toggleHidden = () => setIsHidden((prevHidden) => !prevHidden); + const toggleHidden = useCallback(() => setIsHidden((prevHidden) => !prevHidden), []); // setTheme functions that take tokens as argument - const setTheme = (tokens: Partial, type: string) => { + const setTheme = useCallback((tokens: Partial, type: string) => { let valueToSet: ThemeContextType['currentTokens'] | undefined; if (type === 'light') { valueToSet = { @@ -95,7 +95,7 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { setCurrentTokens(valueToSet); localStorage.setItem('themeProp', JSON.stringify(valueToSet)); - }; + }, [currentTokens]); useEffect(() => { const extractFromLocal = JSON.parse( @@ -141,7 +141,7 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { setTheme(theme.defaultSeed, 'light'); return; } - }, []); + }, [setTheme]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -155,7 +155,7 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, []); + }, [toggleHidden]); // console.log('isImpending', isImpending); // console.log('isMaintenance', isMaintenance); return ( From d76ce44420a2206cff6d85e2989c00c842d62261 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Mon, 5 Jan 2026 14:04:46 -0500 Subject: [PATCH 23/32] Address linting issues and fix ui-community package vitest issue; boost code coverage in apollo-client-links --- .snyk | 7 +- apps/ui-community/package.json | 5 +- .../community-create.container.stories.tsx | 200 +++++++++ .../layouts/accounts/pages/home.tsx | 4 +- .../components/community-detail.container.tsx | 12 +- .../components/layouts/admin/pages/home.tsx | 9 +- .../admin/section-layout.container.tsx | 50 +-- .../layouts/admin/section-layout.tsx | 22 +- .../apollo-client-links.stories.tsx | 422 ++++++++++-------- .../apollo-connection/apollo-client-links.tsx | 18 +- .../communities-dropdown.container.tsx | 46 +- .../communities-dropdown.stories.tsx | 3 +- .../dropdown-menu/communities-dropdown.tsx | 6 +- apps/ui-community/src/config/oidc-config.tsx | 13 +- .../src/contexts/theme-context.tsx | 77 ++-- package.json | 5 + pnpm-lock.yaml | 14 +- 17 files changed, 565 insertions(+), 348 deletions(-) create mode 100644 apps/ui-community/src/components/layouts/accounts/components/community-create.container.stories.tsx diff --git a/.snyk b/.snyk index aaedea816..2dc77933a 100644 --- a/.snyk +++ b/.snyk @@ -3,7 +3,7 @@ ignore: 'SNYK-JS-SIRV-12558119': - '* > sirv@2.0.4': reason: 'Transitive dependency in Docusaurus; not exploitable in static site serving context (dev-only asset handler)' - expires: '2025-12-31T00:00:00.000Z' + expires: '2026-01-19T00:00:00.000Z' created: '2025-11-06T15:57:00.000Z' 'SNYK-JS-JSYAML-13961110': - '* > js-yaml': @@ -27,4 +27,9 @@ ignore: - '@docusaurus/preset-classic@3.9.2 > * > express': reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.' expires: '2025-12-31T00:00:00.000Z' + created: '2025-12-02T09:39:00.000Z' + 'SNYK-JS-QS-14724253': + - '* > qs': + reason: 'Transitive dependency in express, @docusaurus/core, @apollo/server, apollo-link-rest; not exploitable in current usage.' + expires: '2026-01-19T00:00:00.000Z' created: '2025-12-02T09:39:00.000Z' \ No newline at end of file diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index 349d0693e..1171f239a 100644 --- a/apps/ui-community/package.json +++ b/apps/ui-community/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "tsc --build && vite build", - "start": "vite", + "prebuild": "biome lint", + "build": "tsc --build && vite build", + "start": "vite", "lint": "biome lint", "preview": "vite preview", "test": "vitest run --silent --reporter=dot", diff --git a/apps/ui-community/src/components/layouts/accounts/components/community-create.container.stories.tsx b/apps/ui-community/src/components/layouts/accounts/components/community-create.container.stories.tsx new file mode 100644 index 000000000..f0ad28775 --- /dev/null +++ b/apps/ui-community/src/components/layouts/accounts/components/community-create.container.stories.tsx @@ -0,0 +1,200 @@ +import { App as AntdApp } from 'antd'; +import type { Meta, StoryObj } from '@storybook/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { expect, userEvent, within } from 'storybook/test'; +import { + AccountsCommunityCreateContainerCommunityCreateDocument, + AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, +} from '../../../../generated.tsx'; +import { CommunityCreateContainer } from './community-create.container.tsx'; + +const meta = { + title: 'Components/Accounts/CommunityCreateContainer', + component: CommunityCreateContainer, + decorators: [ + (Story) => ( + + + + Accounts List Page
} /> + } /> + Root Page
} /> + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + }, + result: { + data: { + communitiesForCurrentEndUser: [], + }, + }, + }, + ], + }, + }, +}; + +export const Success: Story = { + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + }, + result: { + data: { + communitiesForCurrentEndUser: [ + { id: '1', name: 'Existing Community', __typename: 'Community' }, + ], + }, + }, + }, + { + request: { + query: AccountsCommunityCreateContainerCommunityCreateDocument, + variables: { + input: { name: 'New Community' }, + }, + }, + result: { + data: { + communityCreate: { + community: { + id: '2', + name: 'New Community', + __typename: 'Community', + }, + __typename: 'CommunityCreatePayload', + }, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill out the form + const nameInput = await canvas.findByPlaceholderText('Name'); + await userEvent.type(nameInput, 'New Community'); + + // Submit the form + const submitButton = await canvas.findByRole('button', { + name: /create community/i, + }); + await userEvent.click(submitButton); + +// Verify navigation happened (Root Page should be visible because navigate('../') from /accounts/create goes to /) + const rootPage = await canvas.findByText('Root Page'); + expect(rootPage).toBeInTheDocument(); + }, +}; + +export const ErrorState: Story = { + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + }, + result: { + data: { + communitiesForCurrentEndUser: [], + }, + }, + }, + { + request: { + query: AccountsCommunityCreateContainerCommunityCreateDocument, + variables: { + input: { name: 'Error Community' }, + }, + }, + error: new Error('Failed to create community'), + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill out the form + const nameInput = await canvas.findByPlaceholderText('Name'); + await userEvent.type(nameInput, 'Error Community'); + + // Submit the form + const submitButton = await canvas.findByRole('button', { + name: /create community/i, + }); + await userEvent.click(submitButton); + + // Verify error message appears (Antd message) + // Note: Antd messages are rendered outside the canvasElement usually, + // but in Storybook they might be in the body. + const body = within(canvasElement.ownerDocument.body); + const errorMessage = await body.findByText(/Error creating community/); + expect(errorMessage).toBeInTheDocument(); + }, +}; + +export const LoadingState: Story = { + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + }, + delay: 1000000, // Infinite delay to stay in loading state + result: { + data: { + communitiesForCurrentEndUser: [], + }, + }, + }, + ], + }, + }, +}; + +export const MutationError: Story = { + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: AccountsCommunityListContainerCommunitiesForCurrentEndUserDocument, + }, + result: { + data: { + communitiesForCurrentEndUser: [], + }, + }, + }, + { + request: { + query: AccountsCommunityCreateContainerCommunityCreateDocument, + }, + error: new Error('Initial load error'), + }, + ], + }, + }, +}; diff --git a/apps/ui-community/src/components/layouts/accounts/pages/home.tsx b/apps/ui-community/src/components/layouts/accounts/pages/home.tsx index a6e505506..cc8a6a540 100644 --- a/apps/ui-community/src/components/layouts/accounts/pages/home.tsx +++ b/apps/ui-community/src/components/layouts/accounts/pages/home.tsx @@ -1,6 +1,5 @@ import { Helmet } from '@dr.pogodin/react-helmet'; import { Typography } from 'antd'; -// import { useNavigate } from 'react-router-dom'; import { CommunityListContainer } from '../components/community-list.container.tsx'; import { UserInfoContainer } from '../components/user-info.container.tsx'; import { SubPageLayout } from '../sub-page-layout.tsx'; @@ -9,7 +8,8 @@ const { Title } = Typography; export const Home: React.FC = () => { return ( - + // biome-ignore lint:noUselessFragments + }> Owner Community Home diff --git a/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx index f5e9f10eb..55ad0734f 100644 --- a/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; import type { AdminCommunityDetailContainerCommunityFieldsFragment } from '../../../../generated.tsx'; import { AdminCommunityDetailContainerCommunityByIdDocument } from '../../../../generated.tsx'; -import { CommunityDetail } from './community-detail.tsx'; +import { CommunityDetail, type CommunityDetailProps } from './community-detail.tsx'; export interface CommunityDetailContainerProps { data: { id?: string }; @@ -19,16 +19,16 @@ export const CommunityDetailContainer: React.FC< variables: { id: props.data.id ?? '' }, }); + const communityDetailProps: CommunityDetailProps = { + data: communityData?.communityById as AdminCommunityDetailContainerCommunityFieldsFragment + }; + return ( + } error={communityError} /> diff --git a/apps/ui-community/src/components/layouts/admin/pages/home.tsx b/apps/ui-community/src/components/layouts/admin/pages/home.tsx index 9a067944a..41bd59802 100644 --- a/apps/ui-community/src/components/layouts/admin/pages/home.tsx +++ b/apps/ui-community/src/components/layouts/admin/pages/home.tsx @@ -1,7 +1,7 @@ import { PageHeader } from '@ant-design/pro-layout'; import { theme } from 'antd'; import { useParams } from 'react-router-dom'; -import { CommunityDetailContainer } from '../components/community-detail.container.tsx'; +import { CommunityDetailContainer, type CommunityDetailContainerProps } from '../components/community-detail.container.tsx'; import { SubPageLayout } from '../sub-page-layout.tsx'; export const Home: React.FC = () => { @@ -10,6 +10,11 @@ export const Home: React.FC = () => { } = theme.useToken(); const params = useParams(); + const communityDetailContainerProps: CommunityDetailContainerProps = { + // biome-ignore lint:useLiteralKeys + data: { id: params['communityId'] } + } + return ( { /> } > - + ); }; diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx index d4e374d14..563ba73da 100644 --- a/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx +++ b/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx @@ -1,10 +1,7 @@ -import type { ApolloError } from '@apollo/client'; -import { useLazyQuery } from '@apollo/client'; +import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; -import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { - type AdminSectionLayoutContainerMemberFieldsFragment, AdminSectionLayoutContainerMembersForCurrentEndUserDocument, type Member, } from '../../../generated.tsx'; @@ -15,60 +12,27 @@ interface SectionLayoutContainerProps { pageLayouts: PageLayoutProps[]; } -interface MemberDataState { - member: AdminSectionLayoutContainerMemberFieldsFragment; -} - export const SectionLayoutContainer: React.FC = ( props, ) => { const params = useParams(); - const [memberQuery] = useLazyQuery( + const { data: membersData, loading: membersLoading, error: membersError } = useQuery( AdminSectionLayoutContainerMembersForCurrentEndUserDocument, ); - const [memberData, setMemberData] = useState(null); - const [memberError, setMemberError] = useState( - undefined, - ); - const [memberLoading, setMemberLoading] = useState(false); - - useEffect(() => { - const getData = async () => { - try { - const { - data: membersDataTemp, - loading: memberLoadingTemp, - error: memberErrorTemp, - } = await memberQuery(); - - // Filter for the current member by memberId - const currentMember = membersDataTemp?.membersForCurrentEndUser?.find( - (m: AdminSectionLayoutContainerMemberFieldsFragment) => - m.id === params['memberId'], - ); - - setMemberData(currentMember ? { member: currentMember } : null); - setMemberError(memberErrorTemp); - setMemberLoading(memberLoadingTemp); - } catch (e) { - console.error('Error fetching data in section layout: ', e); - } - }; - getData(); - }, [params, memberQuery]); return ( member.id === params['memberId']) as Member} /> } - error={memberError} + error={membersError} /> ); }; diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.tsx index 5dde7e883..decc1f88b 100644 --- a/apps/ui-community/src/components/layouts/admin/section-layout.tsx +++ b/apps/ui-community/src/components/layouts/admin/section-layout.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { Link, Outlet, useParams } from 'react-router-dom'; import type { Member } from '../../../generated.tsx'; import { CommunitiesDropdownContainer } from '../../ui/organisms/dropdown-menu/communities-dropdown.container.tsx'; -import { MenuComponent } from '../shared/components/menu-component.tsx'; +import { MenuComponent, type MenuComponentProps } from '../shared/components/menu-component.tsx'; import type { PageLayoutProps } from './index.tsx'; import './section-layout.css'; @@ -42,6 +42,13 @@ export const SectionLayout: React.FC = (props) => { token: { colorBgContainer }, } = theme.useToken(); + const menuComponentProps: MenuComponentProps = { + pageLayouts: props.pageLayouts, + memberData: props.memberData, + theme: "light", + mode: "inline", + } + return (
= (props) => { >
View Member Site @@ -92,12 +101,7 @@ export const SectionLayout: React.FC = (props) => { >
- + { - if (key.includes('oidc.user')) { - return JSON.stringify({ - access_token: '', - profile: { sub: 'fallback-user' }, - }); - } - return null; + store: {} as Record, + getItem(key: string) { + return this.store[key] || null; + }, + setItem(key: string, value: string) { + this.store[key] = value; + }, + removeItem(key: string) { + delete this.store[key]; + }, + clear() { + this.store = {}; + }, + key: (index: number) => Object.keys(mockStorage.store)[index] || null, + get length() { + return Object.keys(this.store).length; }, - setItem: (_key: string, _value: string) => Promise.resolve(), - removeItem: (_key: string) => Promise.resolve(), - clear: () => Promise.resolve(), - key: () => null, - length: 0, - set: (_key: string, _value: string) => Promise.resolve(), - get: (key: string) => Promise.resolve(mockStorage.getItem(key)), - remove: (key: string) => Promise.resolve(key), - getAllKeys: () => Promise.resolve([]), }; // Setup global mocks -Object.defineProperty(window, 'sessionStorage', { +Object.defineProperty(globalThis, 'sessionStorage', { value: mockStorage, writable: true, }); -Object.defineProperty(window, 'localStorage', { +Object.defineProperty(globalThis, 'localStorage', { value: mockStorage, writable: true, }); // Mock import.meta.env -Object.defineProperty(import.meta, 'env', { - value: mockEnv, - writable: true, -}); +try { + Object.defineProperty(import.meta, 'env', { + value: mockEnv, + writable: true, + }); +} catch (e) { + console.warn('Could not mock import.meta.env', e); +} const meta = { title: 'Components/UI/Organisms/ApolloConnection/Apollo Client Links', parameters: { layout: 'fullscreen', - docs: { - description: { - component: - 'Utility functions for creating Apollo Client links with authentication, custom headers, and GraphQL server configuration. These stories demonstrate how the link functions work within the ApolloConnection component.', - }, - }, }, - decorators: [ - (Story) => ( - - - - - - - - ), - ], -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; + +// Terminating link for testing +const mockTerminatingLink = new ApolloLink((_operation) => { + return new Observable((observer) => { + observer.next({ + data: { + __typename: 'Query', + test: 'success', + }, + }); + observer.complete(); + }); +}); // Test component that verifies Apollo link functionality -const ApolloLinkTester = () => { - const apolloClient = useApolloClient(); +const ApolloLinkTester = ({ customClient }: { customClient?: ApolloClient }) => { + const defaultClient = useApolloClient(); + const activeClient = customClient || defaultClient; const [authResult, setAuthResult] = useState(null); const [headersResult, setHeadersResult] = useState(null); - const authButtonRef = useRef(null); - const headersButtonRef = useRef(null); const testAuthHeader = async () => { try { - // This will test if the auth header link is working - const result = await apolloClient.query({ + // We use a query that will trigger the link chain + // We don't care about the actual result, just that it doesn't throw before the link runs + const result = await activeClient.query({ query: gql` query TestQuery { __typename } `, - fetchPolicy: 'network-only', + fetchPolicy: 'no-cache', }); - const resultData = { success: true, data: result.data }; - setAuthResult(JSON.stringify(resultData)); - return resultData; + setAuthResult(JSON.stringify({ success: true, data: result.data })); } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - const resultData = { success: false, error: errorMessage }; - setAuthResult(JSON.stringify(resultData)); - return resultData; + const message = error instanceof Error ? error.message : String(error); + setAuthResult(JSON.stringify({ success: false, error: message })); } }; const testCustomHeaders = () => { - // Test that custom headers are being set - const { link } = apolloClient; - const resultData = { linkType: link.constructor.name }; - setHeadersResult(JSON.stringify(resultData)); - return resultData; + const { link } = activeClient; + setHeadersResult(JSON.stringify({ linkType: link.constructor.name })); }; return (
- - +
{authResult}
+
{headersResult}
- Client Link Chain: {apolloClient.link.constructor.name} + Client Link Chain: {activeClient.link?.constructor.name || 'None'}
); }; -// Story demonstrating Auth Header Link -export const AuthHeaderLinkDemo: Story = { - name: 'Authentication Header Link', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const canvas = within(canvasElement); - const authButton = await canvas.findByTestId('test-auth-button'); - - // Click the test button to verify auth header functionality - await authButton.click(); - - // Wait for the result to be set - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify the button received a result (this tests the auth link chain) - const result = authButton.getAttribute('data-result'); - expect(result).toBeTruthy(); - - const parsedResult = JSON.parse(result as string); - // The test should either succeed or fail with a network error (both indicate the link is working) - expect(typeof parsedResult.success).toBe('boolean'); - }, +const CustomClientTester = ({ link }: { link: ApolloLink }) => { + const testClient = useMemo(() => new ApolloClient({ + link: ApolloLink.from([link, mockTerminatingLink]), + cache: new InMemoryCache(), + }), [link]); + return ; }; -// Story demonstrating Custom Header Link -export const CustomHeaderLinkDemo: Story = { - name: 'Custom Header Link', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { +export const BaseLink: StoryObj = { + render: () => , + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const headersButton = await canvas.findByTestId('test-headers-button'); - - // Click the test button to verify custom headers functionality - await headersButton.click(); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } +}; - // Wait for the result to be set - await new Promise((resolve) => setTimeout(resolve, 100)); +export const AuthHeaderFallbackStorage: StoryObj = { + render: () => { + const auth: MockAuth = { user: null, isAuthenticated: false }; + const authority = mockEnv.VITE_AAD_B2C_ACCOUNT_AUTHORITY; + const client_id = mockEnv.VITE_AAD_B2C_ACCOUNT_CLIENTID; + const storageKey = `oidc.user:${authority}:${client_id}`; + + const mockToken = ['mock', 'token'].join('-'); + mockStorage.setItem(storageKey, JSON.stringify({ access_token: mockToken })); + + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } +}; - // Verify the button received a result - const result = headersButton.getAttribute('data-result'); - expect(result).toBeTruthy(); +export const AuthHeaderStorageParseError: StoryObj = { + render: () => { + const auth: MockAuth = { user: null, isAuthenticated: false }; + const authority = mockEnv.VITE_AAD_B2C_ACCOUNT_AUTHORITY; + const client_id = mockEnv.VITE_AAD_B2C_ACCOUNT_CLIENTID; + const storageKey = `oidc.user:${authority}:${client_id}`; + + mockStorage.setItem(storageKey, 'invalid-json{'); - const parsedResult = JSON.parse(result as string); - expect(parsedResult).toHaveProperty('linkType'); + return ; }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } }; -// Story demonstrating GraphQL Server Link -export const GraphqlServerLinkDemo: Story = { - name: 'GraphQL Server Link', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { +export const AuthHeaderLink1: StoryObj = { + render: () => { + const mockToken = ['mock', 'token', '1'].join('-'); + const auth: MockAuth = { user: { access_token: mockToken }, isAuthenticated: true }; + return ; + }, + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const clientInfo = await canvas.findByTestId('client-info'); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } +}; - // Verify the client has the terminating link configured - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); +export const AuthHeaderLink1NotAuth: StoryObj = { + render: () => { + const auth: MockAuth = { user: null, isAuthenticated: false }; + return ; }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } }; -// Story demonstrating Apollo Client Instance -export const ApolloClientDemo: Story = { - name: 'Apollo Client Instance', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { +export const AuthHeaderLink2: StoryObj = { + render: () => { + const mockToken = ['mock', 'token', '2'].join('-'); + const auth: MockAuth = { user: { access_token: mockToken }, isAuthenticated: true }; + return ; + }, + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const clientInfo = await canvas.findByTestId('client-info'); - - // Verify the client is properly configured with links - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } +}; - // Verify we can access the Apollo client - const authButton = await canvas.findByTestId('test-auth-button'); - expect(authButton).toBeInTheDocument(); - }, +export const CustomHeaderIfTrueFalse: StoryObj = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } }; -// Story demonstrating Link Chaining -export const LinkChainingDemo: Story = { - name: 'Link Chaining', - render: () => , - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { +export const CustomHeaderNoValue: StoryObj = { + render: () => , + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const clientInfo = await canvas.findByTestId('client-info'); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } +}; - // Verify the link chain is properly configured - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); +export const AuthHeaderNoToken: StoryObj = { + render: () => { + const auth: MockAuth = { user: null, isAuthenticated: false }; + mockStorage.clear(); + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByTestId('test-auth-button').then(b => b.click()); + const result = await canvas.findByTestId('auth-result'); + await waitFor(() => { + expect(result.textContent).toContain('success'); + }); + } +}; - // Verify all test buttons are present (representing different link types) - const authButton = await canvas.findByTestId('test-auth-button'); - const headersButton = await canvas.findByTestId('test-headers-button'); - expect(authButton).toBeInTheDocument(); - expect(headersButton).toBeInTheDocument(); +export const TerminatingLink: StoryObj = { + render: () => { + const testClient = new ApolloClient({ + link: TerminatingApolloLinkForGraphqlServer({ + uri: 'http://localhost/graphql', + batchMax: 5, + batchInterval: 10, + }), + cache: new InMemoryCache(), + }); + return } />; }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByTestId('test-headers-button').then(b => b.click()); + const result = await canvas.findByTestId('headers-result'); + expect(result.textContent).toContain('Link'); + } }; -// Story showing the complete ApolloConnection usage -export const ApolloConnectionIntegration: Story = { - name: 'Apollo Connection Integration', +export const ApolloConnectionIntegration: StoryObj = { render: () => ( - - - + + + + + + + ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const tester = await canvas.findByTestId('apollo-link-tester'); - - // Verify the ApolloConnection component renders with the tester - expect(tester).toBeInTheDocument(); - - // Verify all test buttons are present within the connection context - const authButton = await canvas.findByTestId('test-auth-button'); - const headersButton = await canvas.findByTestId('test-headers-button'); - expect(authButton).toBeInTheDocument(); - expect(headersButton).toBeInTheDocument(); - - // Verify client info is displayed - const clientInfo = await canvas.findByTestId('client-info'); - expect(clientInfo).toHaveTextContent('Client Link Chain'); - }, + expect(await canvas.findByTestId('apollo-link-tester')).toBeInTheDocument(); + } }; + diff --git a/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx b/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx index 63e7449f3..87f9d4d1c 100644 --- a/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx +++ b/apps/ui-community/src/components/ui/organisms/apollo-connection/apollo-client-links.tsx @@ -14,8 +14,10 @@ import type { AuthContextProps } from 'react-oidc-context'; // apollo client instance export const client = new ApolloClient({ cache: new InMemoryCache(), - // biome-ignore lint:useLiteralKeys - connectToDevTools: import.meta.env['NODE_ENV'] !== 'production', + devtools: { + // biome-ignore lint:useLiteralKeys + enabled: import.meta.env['NODE_ENV'] !== 'production', + }, }); // base apollo link with no customizations @@ -38,20 +40,18 @@ export const ApolloLinkToAddAuthHeader = (auth: AuthContextProps): ApolloLink => // In production, rely solely on react-oidc-context to provide the user/token. if ( !access_token && - typeof window !== 'undefined' && + typeof globalThis !== 'undefined' && !import.meta.env.PROD ) { try { // biome-ignore lint:useLiteralKeys - const authority = - import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? ''; + const authority = import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? ''; // biome-ignore lint:useLiteralKeys - const client_id = - import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? ''; + const client_id = import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? ''; const storageKey = `oidc.user:${authority}:${client_id}`; const raw = - window.sessionStorage.getItem(storageKey) ?? - window.localStorage.getItem(storageKey); + globalThis.sessionStorage.getItem(storageKey) ?? + globalThis.localStorage.getItem(storageKey); if (raw) { const parsed = JSON.parse(raw); access_token = diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx index c1436d003..616e3b0b9 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx @@ -1,12 +1,10 @@ -import { useLazyQuery } from '@apollo/client'; +import { useQuery } from '@apollo/client'; import { ComponentQueryLoader } from '@cellix/ui-core'; -import { useEffect, useState } from 'react'; import type { Member, - SharedCommunitiesDropdownContainerMembersQuery, } from '../../../../generated.tsx'; import { SharedCommunitiesDropdownContainerMembersDocument } from '../../../../generated.tsx'; -import { CommunitiesDropdown } from './communities-dropdown.tsx'; +import { CommunitiesDropdown, type CommunitiesDropdownProps } from './communities-dropdown.tsx'; interface CommunitiesDropdownContainerProps { data: { @@ -17,46 +15,26 @@ interface CommunitiesDropdownContainerProps { export const CommunitiesDropdownContainer: React.FC< CommunitiesDropdownContainerProps > = (_props) => { - const [memberQuery] = useLazyQuery( + const { data, loading, error } = useQuery( SharedCommunitiesDropdownContainerMembersDocument, ); - const [membersData, setMemberData] = - useState(null); - const [membersError, setMemberError] = useState(null); - const [membersLoading, setMemberLoading] = useState(true); - useEffect(() => { - const getData = async () => { - try { - const { - data: membersDataTemp, - loading: membersLoadingTemp, - error: membersErrorTemp, - } = await memberQuery(); - setMemberData(membersDataTemp ?? null); - setMemberError(membersErrorTemp ?? null); - setMemberLoading(membersLoadingTemp); - } catch (e) { - console.error('Error getting data in community dropdown: ', e); - setMemberError(e instanceof Error ? e : new Error('Unknown error')); - setMemberLoading(false); - } - }; - getData(); - }, [memberQuery]); + const communitiesDropdownProps: CommunitiesDropdownProps = { + data: { + members: (data?.membersForCurrentEndUser as Member[]) ?? [], + }, + }; return ( } - error={membersError ?? undefined} + error={error ?? undefined} /> ); }; diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx index 993d13d57..e089cc204 100644 --- a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx @@ -50,7 +50,8 @@ const meta = { (Story, context) => ( = ( const navigate = useNavigate(); const currentMember = props.data.members?.find( - (member) => member.id === params.memberId, + // biome-ignore lint:useLiteralKeys + (member) => member.id === params['memberId'], ); const populateItems = ( @@ -87,7 +88,8 @@ export const CommunitiesDropdown: React.FC = ( menu={{ items, selectable: true, - defaultSelectedKeys: [params.memberId ?? ''], + // biome-ignore lint:useLiteralKeys + defaultSelectedKeys: [params['memberId'] ?? ''], }} open={dropdownVisible} onOpenChange={(visible) => setDropdownVisible(visible)} diff --git a/apps/ui-community/src/config/oidc-config.tsx b/apps/ui-community/src/config/oidc-config.tsx index 7eb809350..aa6cbca34 100644 --- a/apps/ui-community/src/config/oidc-config.tsx +++ b/apps/ui-community/src/config/oidc-config.tsx @@ -10,14 +10,15 @@ type OIDCConfig = { }; export const oidcConfig: OIDCConfig = { - // biome-ignore lint:useLiteralKeys authority: + // biome-ignore lint:useLiteralKeys import.meta.env['VITE_AAD_B2C_ACCOUNT_AUTHORITY'] ?? 'http://localhost:4000', // biome-ignore lint:useLiteralKeys client_id: import.meta.env['VITE_AAD_B2C_ACCOUNT_CLIENTID'] ?? 'mock-client', - // biome-ignore lint:useLiteralKeys + redirect_uri: + // biome-ignore lint:useLiteralKeys import.meta.env['VITE_AAD_B2C_REDIRECT_URI'] ?? 'http://localhost:3000/auth-redirect', code_verifier: true, @@ -27,11 +28,11 @@ export const oidcConfig: OIDCConfig = { scope: import.meta.env['VITE_AAD_B2C_ACCOUNT_SCOPES'], onSigninCallback: (): void => { console.log('onSigninCallback'); - window.history.replaceState({}, document.title, window.location.pathname); - const redirectToPath = window.sessionStorage.getItem('redirectTo'); + globalThis.history.replaceState({}, document.title, globalThis.location.pathname); + const redirectToPath = globalThis.sessionStorage.getItem('redirectTo'); if (redirectToPath) { - window.location.pathname = redirectToPath; - window.sessionStorage.removeItem('redirectTo'); + globalThis.location.pathname = redirectToPath; + globalThis.sessionStorage.removeItem('redirectTo'); } }, }; diff --git a/apps/ui-community/src/contexts/theme-context.tsx b/apps/ui-community/src/contexts/theme-context.tsx index 193334b98..eb5aebc16 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -57,45 +57,46 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { }); const [isHidden, setIsHidden] = useState(false); - const toggleHidden = useCallback(() => setIsHidden((prevHidden) => !prevHidden), []); + const toggleHidden = useCallback(() => setIsHidden((prevHidden) => !prevHidden), []); - // setTheme functions that take tokens as argument - const setTheme = useCallback((tokens: Partial, type: string) => { - let valueToSet: ThemeContextType['currentTokens'] | undefined; - if (type === 'light') { - valueToSet = { - token: tokens, - hardCodedTokens: { - textColor: '#000000', - backgroundColor: '#ffffff', - }, - type: 'light', - }; - } else if (type === 'dark') { - valueToSet = { - token: tokens, - hardCodedTokens: { - textColor: '#ffffff', - backgroundColor: '#000000', - }, - type: 'dark', - }; - } else if (type === 'custom') { - valueToSet = { - token: { - ...currentTokens?.token, - }, - hardCodedTokens: { - textColor: tokens?.colorTextBase, - backgroundColor: tokens?.colorBgBase, - }, - type: 'custom', - }; - } - setCurrentTokens(valueToSet); - - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); - }, [currentTokens]); + // setTheme functions that take tokens as argument + const setTheme = useCallback((tokens: Partial, type: string) => { + setCurrentTokens((prevTokens) => { + let valueToSet: ThemeContextType['currentTokens'] | undefined; + if (type === 'light') { + valueToSet = { + token: tokens, + hardCodedTokens: { + textColor: '#000000', + backgroundColor: '#ffffff', + }, + type: 'light', + }; + } else if (type === 'dark') { + valueToSet = { + token: tokens, + hardCodedTokens: { + textColor: '#ffffff', + backgroundColor: '#000000', + }, + type: 'dark', + }; + } else if (type === 'custom') { + valueToSet = { + token: { + ...prevTokens?.token, + }, + hardCodedTokens: { + textColor: tokens?.colorTextBase, + backgroundColor: tokens?.colorBgBase, + }, + type: 'custom', + }; + } + localStorage.setItem('themeProp', JSON.stringify(valueToSet)); + return valueToSet; + }); + }, []); useEffect(() => { const extractFromLocal = JSON.parse( diff --git a/package.json b/package.json index 489aee8f4..d69dfef50 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,11 @@ "vitest": "catalog:" }, "pnpm": { + "auditConfig": { + "ignoreGhsas": [ + "GHSA-6rw7-vpxm-498p" + ] + }, "overrides": { "vite": "catalog:", "jiti": "2.6.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bc89737..8fd7b334e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,8 +496,8 @@ importers: specifier: ^16.4.5 version: 16.6.1 express: - specifier: ^4.22.0 - version: 4.22.0 + specifier: ^4.22.1 + version: 4.22.1 jose: specifier: ^5.9.6 version: 5.10.0 @@ -6502,8 +6502,8 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - express@4.22.0: - resolution: {integrity: sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} extend-shallow@2.0.1: @@ -17026,7 +17026,7 @@ snapshots: args: 5.0.3 axios: 0.27.2 etag: 1.8.1 - express: 4.22.0 + express: 4.22.1 fs-extra: 11.3.2 glob-to-regexp: 0.4.1 jsonwebtoken: 9.0.2 @@ -18366,7 +18366,7 @@ snapshots: expect-type@1.2.2: {} - express@4.22.0: + express@4.22.1: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 @@ -23860,7 +23860,7 @@ snapshots: colorette: 2.0.20 compression: 1.8.1 connect-history-api-fallback: 2.0.0 - express: 4.22.0 + express: 4.22.1 graceful-fs: 4.2.11 http-proxy-middleware: 2.0.9(@types/express@4.17.25) ipaddr.js: 2.3.0 From 6352694fe4386132c2598efe6894aaa7286fedd3 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Mon, 5 Jan 2026 14:16:23 -0500 Subject: [PATCH 24/32] Update express dependency specifier to ^4.22.0 in pnpm-lock.yaml --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fd7b334e..9f01c5734 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,7 +496,7 @@ importers: specifier: ^16.4.5 version: 16.6.1 express: - specifier: ^4.22.1 + specifier: ^4.22.0 version: 4.22.1 jose: specifier: ^5.9.6 From 3cd571a4948b1182555c17929471197cfadd126e Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Mon, 5 Jan 2026 15:55:45 -0500 Subject: [PATCH 25/32] Refactor path handling to use replaceAll for consistency in MenuComponent and merge-coverage scripts --- .../components/layouts/shared/components/menu-component.tsx | 6 +++--- build-pipeline/scripts/merge-coverage.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx b/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx index d11baf11f..12ffe8646 100644 --- a/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx +++ b/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx @@ -35,9 +35,9 @@ export const MenuComponent: React.FC = ({ const params = useParams(); const location = useLocation(); - const createPath = (path: string): string => { - return generatePath(path.replace('*', ''), params); - }; + const createPath = (path: string): string => { + return generatePath(path.replaceAll('*', ''), params); + }; const buildMenu = ( parentId: string | number, diff --git a/build-pipeline/scripts/merge-coverage.js b/build-pipeline/scripts/merge-coverage.js index 566acdd29..349172dd8 100755 --- a/build-pipeline/scripts/merge-coverage.js +++ b/build-pipeline/scripts/merge-coverage.js @@ -19,7 +19,7 @@ function processLcovContent(content, packagePath) { // Extract the file path after 'SF:' const filePath = line.substring(3); // Prefix with package path, ensuring no double slashes - const prefixedPath = path.join(packagePath, filePath).replace(/\\/g, '/'); + const prefixedPath = path.join(packagePath, filePath).replaceAll('\\', '/'); processedLines.push(`SF:${prefixedPath}`); } else { processedLines.push(line); From 1991382b4828029a2936d2a0f8f957201db549ae Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:23:50 -0500 Subject: [PATCH 26/32] Remove pnpm strict-ssl configuration step from Copilot setup workflow --- .github/workflows/copilot-setup-steps.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index d735e9798..e3c2685f7 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -39,9 +39,6 @@ jobs: with: version: 10.18.2 - - name: Configure pnpm for cloud agent - run: pnpm config set strict-ssl false - - name: Cache pnpm store uses: actions/cache@v4 with: From e6d2af6a1f1b179b640b82e491468f9b0044de05 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:39:31 -0500 Subject: [PATCH 27/32] Add step to display proxy and CA related environment variables in Copilot setup --- .github/workflows/copilot-setup-steps.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index e3c2685f7..36ae1c214 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -51,6 +51,10 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm- + - name: Show proxy / CA related env + run: | + env | sort | egrep -i 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' + - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile From 181c05a75e71ef168c6cf451ee3124cd05d5f69b Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:43:37 -0500 Subject: [PATCH 28/32] Refactor 'Show proxy / CA related env' step to remove unnecessary line breaks for improved readability --- .github/workflows/copilot-setup-steps.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 36ae1c214..6359be703 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -52,8 +52,7 @@ jobs: ${{ runner.os }}-pnpm- - name: Show proxy / CA related env - run: | - env | sort | egrep -i 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' + run: env | sort | egrep -i 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile From 98556d5b2aeaae20ac8b7fa96706be5604d47133 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:45:35 -0500 Subject: [PATCH 29/32] Fix grep command in 'Show proxy / CA related env' step for improved compatibility --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 6359be703..391ffcdb1 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -52,7 +52,7 @@ jobs: ${{ runner.os }}-pnpm- - name: Show proxy / CA related env - run: env | sort | egrep -i 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' + run: env | sort | grep -i 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile From ac578fb0a3c68d6e01d28754c26d9292ab69d0dc Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:47:06 -0500 Subject: [PATCH 30/32] Improve proxy environment variable display step by adding error handling and using extended regex --- .github/workflows/copilot-setup-steps.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 391ffcdb1..09006f8cc 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -52,7 +52,9 @@ jobs: ${{ runner.os }}-pnpm- - name: Show proxy / CA related env - run: env | sort | grep -i 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' + run: | + set -e + env | sort | grep -E 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' || true - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile From 109a0ded75a9507ced2bc236294107b99034ed10 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:48:00 -0500 Subject: [PATCH 31/32] Fix grep command in 'Show proxy / CA related env' step to ensure proper output without suppressing errors --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 09006f8cc..652ca2ecd 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -54,7 +54,7 @@ jobs: - name: Show proxy / CA related env run: | set -e - env | sort | grep -E 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' || true + env | sort | grep -E 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile From 35e9f84d40fcd2c198e45188c30186549ef73ae5 Mon Sep 17 00:00:00 2001 From: Nick Noce Date: Thu, 8 Jan 2026 10:54:59 -0500 Subject: [PATCH 32/32] Update CA certificates installation step to ensure proper environment setup --- .github/workflows/copilot-setup-steps.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 652ca2ecd..71ee9a28c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -51,10 +51,11 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm- - - name: Show proxy / CA related env + - name: Refresh CA certificates run: | - set -e - env | sort | grep -E 'HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|NPM_CONFIG_CA|NPM_CONFIG_CAFILE|SSL_CERT|REQUESTS_CA_BUNDLE|GIT_SSL_CAINFO|npm_config_(ca|cafile|registry)' + sudo apt-get update + sudo apt-get install -y ca-certificates + sudo update-ca-certificates - name: Install JavaScript dependencies run: pnpm install --frozen-lockfile