diff --git a/.github/instructions/ui/container-components.instructions.md b/.github/instructions/ui/container-components.instructions.md index a35acf6ca..f24e443a3 100644 --- a/.github/instructions/ui/container-components.instructions.md +++ b/.github/instructions/ui/container-components.instructions.md @@ -21,8 +21,19 @@ 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. This is critical for mutations to ensure that buttons or actions triggered by the user show a loading state and are disabled during processing. 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`. +- **Mutation Response Handling**: Container components are responsible for processing mutation results and providing user feedback. + - **REQUIRED**: Use the `App.useApp()` hook from `antd` to access the `message`, `notification`, or `modal` instances. Do NOT use static imports like `import { message } from 'antd'`. + - Always check the response for a `status` object (e.g., `result.data?.mutationName?.status`). + - Use `message.success()` when `status.success` is true. + - Use `message.error()` when `status.success` is false, displaying the `status.errorMessage` if available. + - Wrap mutation calls in `try/catch` blocks to handle network or execution errors, displaying them via `message.error()`. - 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 @@ -31,7 +42,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/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..957936037 100644 --- a/.github/instructions/ui/presentational-components.instructions.md +++ b/.github/instructions/ui/presentational-components.instructions.md @@ -21,6 +21,11 @@ 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`. 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**: Any button or action that triggers a mutation MUST apply this `loading` prop directly to the UI component's `loading` property (e.g., ` - - - ); + + + + ); }; 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..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'; @@ -8,26 +7,22 @@ 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 ( + // biome-ignore lint:noUselessFragments + }> + + 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.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..55ad0734f --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.container.tsx @@ -0,0 +1,36 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { AdminCommunityDetailContainerCommunityFieldsFragment } from '../../../../generated.tsx'; +import { AdminCommunityDetailContainerCommunityByIdDocument } from '../../../../generated.tsx'; +import { CommunityDetail, type CommunityDetailProps } from './community-detail.tsx'; + +export interface CommunityDetailContainerProps { + data: { id?: string }; +} + +export const CommunityDetailContainer: React.FC< + CommunityDetailContainerProps +> = (props) => { + const { + data: communityData, + loading: communityLoading, + error: communityError, + } = useQuery(AdminCommunityDetailContainerCommunityByIdDocument, { + 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/components/community-detail.stories.tsx b/apps/ui-community/src/components/layouts/admin/components/community-detail.stories.tsx new file mode 100644 index 000000000..d42fd6a97 --- /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: ({ 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: ({ 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: ({ 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 new file mode 100644 index 000000000..25012e58f --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/community-detail.tsx @@ -0,0 +1,84 @@ +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.container.graphql b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.graphql new file mode 100644 index 000000000..ff728bcb7 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.graphql @@ -0,0 +1,29 @@ +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..0277aa807 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.container.tsx @@ -0,0 +1,69 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { App } from 'antd'; +import { + type AdminSettingsGeneralContainerCommunityFieldsFragment, + AdminSettingsGeneralContainerCommunityUpdateSettingsDocument, + AdminSettingsGeneralContainerCurrentCommunityDocument, + type CommunityUpdateSettingsInput, +} from '../../../../generated.tsx'; +import { + SettingsGeneral, + type SettingsGeneralProps, +} from './settings-general.tsx'; + +export const SettingsGeneralContainer: React.FC = () => { + const { message } = App.useApp(); + + const [communityUpdate, { loading: mutationLoading, 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 { + const result = await communityUpdate({ + variables: { + input: values, + }, + }); + + 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: ${saveError instanceof Error ? saveError.message : JSON.stringify(saveError)}`, + ); + } + }; + + const settingsProps: SettingsGeneralProps = { + onSave: handleSave, + data: communityData?.currentCommunity as AdminSettingsGeneralContainerCommunityFieldsFragment, + loading: mutationLoading, + }; + + return ( + } + 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 new file mode 100644 index 000000000..740dd6814 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import type { AdminSettingsGeneralContainerCommunityFieldsFragment } from '../../../../generated.tsx'; +import { SettingsGeneral } from './settings-general.tsx'; + +const mockData: AdminSettingsGeneralContainerCommunityFieldsFragment = { + __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/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: fn(), + loading: false, + }, + play: ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify community info is displayed + expect(canvas.getByText(mockData.id)).toBeInTheDocument(); + expect(canvas.getByText(/01\/01\/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: fn(), + 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-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', + }, + onSave: fn(), + loading: false, + }, +}; + +export const FormSubmission: Story = { + args: { + data: mockData, + onSave: fn(), + 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 new file mode 100644 index 000000000..7fd382324 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/components/settings-general.tsx @@ -0,0 +1,109 @@ +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; + loading?: boolean; +} + +export type { SettingsGeneralProps }; + +export const SettingsGeneral: React.FC = (props) => { + const [form] = Form.useForm(); + const data = props.data; + + return ( + <> + + {props.data.id} + + {dayjs(props.data.createdAt).format('MM/DD/YYYY')} + + + {dayjs(props.data.updatedAt).format('MM/DD/YYYY')} + + + +
{ + props.onSave(values); + }} + > + + + + + + +
+ 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. +
+ 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. +
+ Once added, you can use the domain name in the white label field + above. +
+
+ + + + + + + + ); +}; 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..6a1b73b92 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/index.tsx @@ -0,0 +1,48 @@ +import { HomeOutlined, SettingOutlined } from '@ant-design/icons'; +import { Route, Routes } from 'react-router-dom'; +import type { Member } from '../../../generated.tsx'; +import { Home } from './pages/home.tsx'; +import { Settings } from './pages/settings.tsx'; +import { SectionLayoutContainer } from './section-layout.container.tsx'; + +export interface PageLayoutProps { + path: string; + title: string; + icon: React.JSX.Element; + id: string | number; + parent?: string; + hasPermissions?: (member: Member) => boolean; +} + +export const Admin: React.FC = () => { + const pageLayouts: PageLayoutProps[] = [ + { + path: '/community/:communityId/admin/:memberId', + title: 'Home', + icon: , + id: 'ROOT', + }, + { + path: '/community/:communityId/admin/:memberId/settings/*', + title: 'Settings', + icon: , + id: 2, + parent: 'ROOT', + // Note: Permission check would be: + // hasPermissions: (member: Member) => member?.role?.permissions?.communityPermissions?.canManageCommunitySettings ?? false + // Currently schema doesn't include role/permissions, so we allow all admin users to access settings + }, + ]; + + 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..41bd59802 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/pages/home.tsx @@ -0,0 +1,30 @@ +import { PageHeader } from '@ant-design/pro-layout'; +import { theme } from 'antd'; +import { useParams } from 'react-router-dom'; +import { CommunityDetailContainer, type CommunityDetailContainerProps } from '../components/community-detail.container.tsx'; +import { SubPageLayout } from '../sub-page-layout.tsx'; + +export const Home: React.FC = () => { + const { + token: { colorTextBase }, + } = theme.useToken(); + const params = useParams(); + + const communityDetailContainerProps: CommunityDetailContainerProps = { + // biome-ignore lint:useLiteralKeys + data: { id: params['communityId'] } + } + + return ( + Home} + /> + } + > + + + ); +}; 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.container.tsx b/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx new file mode 100644 index 000000000..563ba73da --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/section-layout.container.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import { useParams } from 'react-router-dom'; +import { + AdminSectionLayoutContainerMembersForCurrentEndUserDocument, + type Member, +} from '../../../generated.tsx'; +import type { PageLayoutProps } from './index.tsx'; +import { SectionLayout } from './section-layout.tsx'; + +interface SectionLayoutContainerProps { + pageLayouts: PageLayoutProps[]; +} + +export const SectionLayoutContainer: React.FC = ( + props, +) => { + const params = useParams(); + + const { data: membersData, loading: membersLoading, error: membersError } = useQuery( + AdminSectionLayoutContainerMembersForCurrentEndUserDocument, + ); + + return ( + member.id === params['memberId']) as Member} + /> + } + error={membersError} + /> + ); +}; diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.css b/apps/ui-community/src/components/layouts/admin/section-layout.css new file mode 100644 index 000000000..e1a89f088 --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/section-layout.css @@ -0,0 +1,19 @@ +#components-layout-demo-fixed-sider .logo { + height: 32px; + margin: 16px; + background: rgba(255, 255, 255, 0.2); +} + +.site-layout .site-layout-background { + background: #fff; +} + +.ant-dropdown .ant-menu-root.ant-menu-vertical { + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); +} + +.allowBoxShadow { + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); + border-radius: 4px; + padding: 8px 12px; +} diff --git a/apps/ui-community/src/components/layouts/admin/section-layout.graphql b/apps/ui-community/src/components/layouts/admin/section-layout.graphql new file mode 100644 index 000000000..4dbdc6cac --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/section-layout.graphql @@ -0,0 +1,15 @@ +query AdminSectionLayoutContainerMembersForCurrentEndUser { + membersForCurrentEndUser { + ...AdminSectionLayoutContainerMemberFields + } +} + +fragment AdminSectionLayoutContainerMemberFields on Member { + id + memberName + isAdmin + community { + id + name + } +} 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..decc1f88b --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/section-layout.tsx @@ -0,0 +1,121 @@ +import { LoggedInUserContainer } from '@ocom/ui-components'; +import { Layout, theme } from 'antd'; +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, type MenuComponentProps } from '../shared/components/menu-component.tsx'; +import type { PageLayoutProps } from './index.tsx'; +import './section-layout.css'; + +const { Sider, Header } = Layout; + +const LocalSettingsKeys = { + SidebarCollapsed: 'SidebarCollapsed', +} as const; + +const handleToggler = ( + isExpanded: boolean, + setIsExpanded: (value: boolean) => void, +) => { + const newValue = !isExpanded; + setIsExpanded(newValue); + if (newValue) { + localStorage.removeItem(LocalSettingsKeys.SidebarCollapsed); + } else { + localStorage.setItem(LocalSettingsKeys.SidebarCollapsed, 'true'); + } +}; + +interface AdminSectionLayoutProps { + pageLayouts: PageLayoutProps[]; + memberData: Member; +} + +export const SectionLayout: React.FC = (props) => { + const params = useParams(); + const sidebarCollapsed = localStorage.getItem( + LocalSettingsKeys.SidebarCollapsed, + ); + const [isExpanded, setIsExpanded] = useState(!sidebarCollapsed); + const { + token: { colorBgContainer }, + } = theme.useToken(); + + const menuComponentProps: MenuComponentProps = { + pageLayouts: props.pageLayouts, + memberData: props.memberData, + theme: "light", + mode: "inline", + } + + return ( + +
+
+
+ +
+ + View Member Site + + + +
+
+ + + handleToggler(isExpanded, setIsExpanded)} + style={{ + overflow: 'auto', + height: 'calc(100vh - 64px)', + position: 'relative', + left: 0, + top: 0, + bottom: 0, + backgroundColor: 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..6ef63e93f --- /dev/null +++ b/apps/ui-community/src/components/layouts/admin/sub-page-layout.tsx @@ -0,0 +1,50 @@ +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 { + token: { colorText, colorBgContainer }, + } = theme.useToken(); + const overFlow = props.fixedHeader ? 'scroll' : 'unset'; + return ( + <> +
+ {props.header} +
+
+ +
{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..3a0e2193d --- /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: ({ 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: ({ 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: ({ 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 new file mode 100644 index 000000000..12ffe8646 --- /dev/null +++ b/apps/ui-community/src/components/layouts/shared/components/menu-component.tsx @@ -0,0 +1,107 @@ +import { Menu, type MenuTheme } from 'antd'; +import type { RouteObject } from 'react-router-dom'; +import { + generatePath, + Link, + matchRoutes, + useLocation, + useParams, +} from 'react-router-dom'; +import type { Member } from '../../../../generated.tsx'; + +const { SubMenu } = Menu; + +export interface PageLayoutProps { + path: string; + title: string; + icon: React.JSX.Element; + id: string | number; + parent?: string; + hasPermissions?: (member: Member) => boolean; +} + +export interface MenuComponentProps { + pageLayouts: PageLayoutProps[]; + theme: MenuTheme | undefined; + mode: 'vertical' | 'horizontal' | 'inline' | undefined; + memberData?: Member; +} + +export const MenuComponent: React.FC = ({ + pageLayouts, + memberData, + ...props +}) => { + const params = useParams(); + const location = useLocation(); + + const createPath = (path: string): string => { + return generatePath(path.replaceAll('*', ''), params); + }; + + 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; + + const grandChildren = pageLayouts.filter( + (gc) => gc.parent === child.id, + ); + + if ( + memberData && + child.hasPermissions && + !child.hasPermissions(memberData) + ) { + return null; + } + + return grandChildren && grandChildren.length > 0 ? ( + + + {child.title} + + {buildMenu(child.id)} + + ) : ( + + {child.title} + + ); + }) + .filter(Boolean); + }; + + const topMenu = () => { + const root = pageLayouts.find((x) => x.id === 'ROOT'); + if (!root) return null; + + const matchedPages = matchRoutes(pageLayouts as RouteObject[], location); + const matchedIds = matchedPages + ? matchedPages.map((x) => x.route.id?.toString() ?? '') + : []; + + return ( + + + {root.title} + + {buildMenu(root.id)} + + ); + }; + + return topMenu(); +}; diff --git a/apps/ui-community/src/components/ui/molecules/auth-landing/auth-landing.stories.tsx b/apps/ui-community/src/components/ui/molecules/auth-landing/auth-landing.stories.tsx index db393b742..5794ab0d1 100644 --- a/apps/ui-community/src/components/ui/molecules/auth-landing/auth-landing.stories.tsx +++ b/apps/ui-community/src/components/ui/molecules/auth-landing/auth-landing.stories.tsx @@ -1,31 +1,31 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect } from 'storybook/test'; import { MemoryRouter } from 'react-router-dom'; +import { expect } from 'storybook/test'; import { AuthLanding } from './index.tsx'; const meta = { - title: 'Components/UI/Molecules/AuthLanding', - component: AuthLanding, - parameters: { - layout: 'fullscreen', - }, - decorators: [ - (Story) => ( - - - - ), - ], + 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..af1a275f0 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,274 +1,330 @@ +import { ApolloClient, gql, InMemoryCache, useApolloClient, ApolloLink, Observable } from '@apollo/client'; import type { Meta, StoryObj } from '@storybook/react'; -import { expect, within } from 'storybook/test'; -import { ApolloProvider, useApolloClient, gql } from '@apollo/client'; -import { AuthProvider } from 'react-oidc-context'; +import { useState, useMemo } from 'react'; +import { AuthProvider, type AuthContextProps } from 'react-oidc-context'; import { MemoryRouter } from 'react-router-dom'; -import { useState, useRef } from 'react'; -import { ApolloConnection } from './index.tsx'; +import { expect, within, waitFor } from 'storybook/test'; import { - client + ApolloLinkToAddAuthHeader, + ApolloLinkToAddAuthHeader1, + ApolloLinkToAddAuthHeader2, + ApolloLinkToAddCustomHeader, + BaseApolloLink, + TerminatingApolloLinkForGraphqlServer, } from './apollo-client-links.tsx'; +import { ApolloConnection } from './index.tsx'; + +interface MockAuth { + user: { access_token: string } | null; + isAuthenticated: boolean; +} // 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-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([]), + 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; + }, }; // Setup global mocks -Object.defineProperty(window, 'sessionStorage', { value: mockStorage, writable: true }); -Object.defineProperty(window, 'localStorage', { value: mockStorage, writable: true }); +Object.defineProperty(globalThis, 'sessionStorage', { + value: mockStorage, + writable: true, +}); +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; + title: 'Components/UI/Organisms/ApolloConnection/Apollo Client Links', + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; export default meta; -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); +// Terminating link for testing +const mockTerminatingLink = new ApolloLink((_operation) => { + return new Observable((observer) => { + observer.next({ + data: { + __typename: 'Query', + test: 'success', + }, + }); + observer.complete(); + }); +}); - const testAuthHeader = async () => { - try { - // This will test if the auth header link is working - const result = await apolloClient.query({ - query: gql` +// Test component that verifies Apollo link functionality +const ApolloLinkTester = ({ customClient }: { customClient?: ApolloClient }) => { + const defaultClient = useApolloClient(); + const activeClient = customClient || defaultClient; + const [authResult, setAuthResult] = useState(null); + const [headersResult, setHeadersResult] = useState(null); + + const testAuthHeader = async () => { + try { + // 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' - }); - 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: 'no-cache', + }); + setAuthResult(JSON.stringify({ success: true, data: result.data })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setAuthResult(JSON.stringify({ success: false, error: message })); + } + }; + + const testCustomHeaders = () => { + const { link } = activeClient; + setHeadersResult(JSON.stringify({ linkType: link.constructor.name })); + }; + + return ( +
+ + +
{authResult}
+
{headersResult}
+
+ 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 }) => { - const canvas = within(canvasElement); - const headersButton = await canvas.findByTestId('test-headers-button'); - - // 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)); - - // 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'); - }, +export const BaseLink: 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 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:/); - }, +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'); + }); + } }; -// 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'); +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{'); + + 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 client is properly configured with links - expect(clientInfo).toHaveTextContent('Client Link Chain'); - expect(clientInfo.textContent).toMatch(/Client Link Chain:/); +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); + 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 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 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'); +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); + 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 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'); + }); + } +}; - // 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 CustomHeaderNoValue: 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 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'); +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 the ApolloConnection component renders with the tester - expect(tester).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'); + } +}; - // 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(); +export const ApolloConnectionIntegration: StoryObj = { + render: () => ( + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByTestId('apollo-link-tester')).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 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..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 @@ -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(), + devtools: { + // biome-ignore lint:useLiteralKeys + enabled: 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 globalThis !== '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 = + globalThis.sessionStorage.getItem(storageKey) ?? + globalThis.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 0d1087fcb..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,63 +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 { useParams } from "react-router-dom"; -import { ApolloLinkToAddAuthHeader, ApolloLinkToAddCustomHeader, BaseApolloLink, client, TerminatingApolloLinkForGraphqlServer } from "./apollo-client-links.js"; +import { type FC, useCallback, 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 params = useParams(); // useParams.memberId won't work here because ApolloConnection wraps the Routes, not inside a Route - const communityId = params['*']?.slice(0, 24) ?? null; - const memberId = params['*']?.match(/(member|admin)\/([\w\d]+)/)?.[2] ?? null; - - - const apolloLinkChainForGraphqlDataSource = from([ - BaseApolloLink(), - ApolloLinkToAddAuthHeader(auth), - ApolloLinkToAddCustomHeader('community', communityId, (communityId !== 'accounts')), - ApolloLinkToAddCustomHeader('member', 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 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 - ) - ]); - }; - - useEffect(() => { - client.setLink(updateLink()); - }, [auth]); - - return {props.children}; +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 apolloLinkChainForCountryDataSource = from([ + new RestLink({ + uri: 'https://countries.trevorblades.com/', + }), + ]); + + const updateLink = useCallback(() => { + const linkMap = { + CountryDetails: apolloLinkChainForCountryDataSource, + default: apolloLinkChainForGraphqlDataSource, + }; + + 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, + ), + ]); + }, [apolloLinkChainForGraphqlDataSource, apolloLinkChainForCountryDataSource]); + + useEffect(() => { + client.setLink(updateLink()); + }, [updateLink]); + + return {props.children}; }; diff --git a/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.graphql b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.graphql new file mode 100644 index 000000000..6e7a0efc2 --- /dev/null +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.graphql @@ -0,0 +1,15 @@ +query SharedCommunitiesDropdownContainerMembers { + membersForCurrentEndUser { + ...SharedCommunitiesDropdownContainerMembersFields + } +} + +fragment SharedCommunitiesDropdownContainerMembersFields on Member { + id + memberName + isAdmin + community { + id + name + } +} 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 new file mode 100644 index 000000000..616e3b0b9 --- /dev/null +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.container.tsx @@ -0,0 +1,40 @@ +import { useQuery } from '@apollo/client'; +import { ComponentQueryLoader } from '@cellix/ui-core'; +import type { + Member, +} from '../../../../generated.tsx'; +import { SharedCommunitiesDropdownContainerMembersDocument } from '../../../../generated.tsx'; +import { CommunitiesDropdown, type CommunitiesDropdownProps } from './communities-dropdown.tsx'; + +interface CommunitiesDropdownContainerProps { + data: { + id?: string; + }; +} + +export const CommunitiesDropdownContainer: React.FC< + CommunitiesDropdownContainerProps +> = (_props) => { + const { data, loading, error } = useQuery( + SharedCommunitiesDropdownContainerMembersDocument, + ); + + const communitiesDropdownProps: CommunitiesDropdownProps = { + data: { + members: (data?.membersForCurrentEndUser as Member[]) ?? [], + }, + }; + + return ( + + } + 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 new file mode 100644 index 000000000..e089cc204 --- /dev/null +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/react'; +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'; + +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, context) => ( + + + } + /> + } /> + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: { + members: mockMembers, + }, + }, + play: ({ 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.getByText(/Community One/i); + 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 = { + parameters: { + initialEntries: ['/community/comm1/member/admin1'], + }, + 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: ({ 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 new file mode 100644 index 000000000..ecdb16629 --- /dev/null +++ b/apps/ui-community/src/components/ui/organisms/dropdown-menu/communities-dropdown.tsx @@ -0,0 +1,108 @@ +import { DownOutlined } from '@ant-design/icons'; +import { Dropdown, type MenuProps } from 'antd'; +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import type { Member } from '../../../../generated.tsx'; + +export interface CommunitiesDropdownProps { + data: { + members: Member[]; + }; +} + +export const CommunitiesDropdown: React.FC = ( + props, +) => { + const [dropdownVisible, setDropdownVisible] = useState(false); + const params = useParams(); + const navigate = useNavigate(); + + const currentMember = props.data.members?.find( + // biome-ignore lint:useLiteralKeys + (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 communityId = member?.community?.id; + if (!communityId) return; + + // Initialize community in itemsMap if it doesn't exist + if (!itemsMap[communityId]) { + itemsMap[communityId] = { + key: communityId, + label: member?.community?.name, + children: [], + }; + } + + // Add member to the community's children + const memberPath = `/community/${communityId}/member/${member?.id}`; + const memberItem = { + key: member?.id ?? '', + label: member?.memberName ?? '', + onClick: () => { + setDropdownVisible(false); + navigate(memberPath); + }, + }; + itemsMap[communityId].children.push(memberItem); + + // Add admin variant if applicable + if (member?.isAdmin) { + const adminPath = `/community/${communityId}/admin/${member?.id}`; + itemsMap[communityId].children.push({ + key: `${member?.id}-admin`, + label: `${member?.memberName} (Admin)`, + onClick: () => { + setDropdownVisible(false); + navigate(adminPath); + }, + }); + } + }; + + 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); + + return ( + setDropdownVisible(visible)} + > + + + ); +}; diff --git a/apps/ui-community/src/config/oidc-config.tsx b/apps/ui-community/src/config/oidc-config.tsx index 7b49a3be6..aa6cbca34 100644 --- a/apps/ui-community/src/config/oidc-config.tsx +++ b/apps/ui-community/src/config/oidc-config.tsx @@ -1,38 +1,38 @@ 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'); - } - } -} + 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', + + redirect_uri: + // biome-ignore lint:useLiteralKeys + 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'); + globalThis.history.replaceState({}, document.title, globalThis.location.pathname); + const redirectToPath = globalThis.sessionStorage.getItem('redirectTo'); + if (redirectToPath) { + 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 c8be3be8e..eb5aebc16 100644 --- a/apps/ui-community/src/contexts/theme-context.tsx +++ b/apps/ui-community/src/contexts/theme-context.tsx @@ -1,180 +1,193 @@ 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'; // 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 toggleHidden = () => setIsHidden((prevHidden) => !prevHidden); + // 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); - // 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); + const toggleHidden = useCallback(() => setIsHidden((prevHidden) => !prevHidden), []); - localStorage.setItem('themeProp', JSON.stringify(valueToSet)); - }; + // 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(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; + } + }, [setTheme]); - 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); + }; + }, [toggleHidden]); + // 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 59d37192f..ea263e882 100644 --- a/apps/ui-community/src/main.tsx +++ b/apps/ui-community/src/main.tsx @@ -1,5 +1,5 @@ import { HelmetProvider } from '@dr.pogodin/react-helmet'; -import { ConfigProvider } from 'antd'; +import { App as AntdApp, ConfigProvider } from 'antd'; import React, { useContext } from 'react'; import { createRoot } from 'react-dom/client'; import { AuthProvider } from 'react-oidc-context'; @@ -27,13 +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'], + }), +); 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); 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( 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 e9afb6ffb..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,7 +14,11 @@ export abstract class MongooseDomainAdapter this.doc = doc; } get 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/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..d7e56c524 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; @@ -54,7 +56,7 @@ export const buildApplicationServicesFactory = (infrastructureServicesRegistry: if (openIdConfigKey === 'AccountPortal') { const endUser = await readonlyDataSource.User.EndUser.EndUserReadRepo.getByExternalId(verifiedJwt.sub); - const member = hints?.memberId ? await readonlyDataSource.Community.Member.MemberReadRepo.getByIdWithRole(hints?.memberId) : null; + const member = hints?.memberId ? await readonlyDataSource.Community.Member.MemberReadRepo.getByIdWithCommunityAndRoleAndUser(hints?.memberId) : null; const community = hints?.communityId ? await readonlyDataSource.Community.Community.CommunityReadRepo.getById(hints?.communityId) : null; if (endUser && member && community) { 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); } 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) + ); } } }; 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/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, 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..6ef45a75d 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 @@ -11,6 +11,7 @@ export interface MemberReadRepository { getByCommunityId: (communityId: string, options?: FindOptions) => Promise; getById: (id: string, options?: FindOneOptions) => Promise; getByIdWithRole: (id: string, options?: FindOneOptions) => Promise; + getByIdWithCommunityAndRoleAndUser: (id: string, options?: FindOneOptions) => Promise; /** * Retrieves all Member entities for a given end-user external ID. * Finds members whose accounts reference a user with the specified external ID. @@ -62,15 +63,25 @@ export class MemberReadRepositoryImpl implements MemberReadRepository { } /** - * Retrieves a Member entity by its ID, including the 'createdBy' field. + * Retrieves a Member entity by its ID, including the 'role' and 'accounts.user' field. * @param id - The ID of the Member entity. * @param options - Optional find options for querying. * @returns A promise that resolves to a MemberEntityReference object or null if not found. */ + async getByIdWithCommunityAndRoleAndUser(id: string, options?: FindOneOptions): Promise { + const finalOptions: FindOneOptions = { + ...options, + populateFields: ['community', 'role', 'role.community', 'accounts.user'] + }; + const result = await this.mongoDataSource.findById(id, finalOptions); + if (!result) { return null; } + return this.converter.toDomain(result, this.passport); + } + 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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5d823a6f..9f01c5734 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: apollo-link-rest: specifier: ^0.9.0 version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.18.3))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.14.0) + dayjs: + specifier: ^1.11.19 + version: 1.11.19 less: specifier: ^4.4.0 version: 4.4.2 @@ -494,7 +497,7 @@ importers: version: 16.6.1 express: specifier: ^4.22.0 - version: 4.22.0 + version: 4.22.1 jose: specifier: ^5.9.6 version: 5.10.0 @@ -6499,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: @@ -17023,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 @@ -18363,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 @@ -23857,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