diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index dc2c23fc..c79f2673 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,5 +1,11 @@ # @baseapp-frontend/components +## 0.0.46 + +### Patch Changes + +- Create base activity log components for list page + ## 0.0.45 ### Patch Changes diff --git a/packages/components/__generated__/ActivityLogsFragment.graphql.ts b/packages/components/__generated__/ActivityLogsFragment.graphql.ts new file mode 100644 index 00000000..8e036f1f --- /dev/null +++ b/packages/components/__generated__/ActivityLogsFragment.graphql.ts @@ -0,0 +1,257 @@ +/** + * @generated SignedSource<<9b6fd566697e49370aeb48f13f3d18ef>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ + +/* eslint-disable */ +// @ts-nocheck +import { ReaderFragment, RefetchableFragment } from 'relay-runtime' +import { FragmentRefs } from 'relay-runtime' + +export type ActivityLogsFragment$data = { + readonly activityLogs: + | { + readonly edges: ReadonlyArray< + | { + readonly node: + | { + readonly createdAt: any + readonly id: string + readonly url: string | null | undefined + readonly user: + | { + readonly avatar: + | { + readonly url: string + } + | null + | undefined + readonly email: string | null | undefined + readonly fullName: string | null | undefined + readonly id: string + } + | null + | undefined + readonly verb: string | null | undefined + } + | null + | undefined + } + | null + | undefined + > + readonly pageInfo: { + readonly endCursor: string | null | undefined + readonly hasNextPage: boolean + } + } + | null + | undefined + readonly ' $fragmentType': 'ActivityLogsFragment' +} +export type ActivityLogsFragment$key = { + readonly ' $data'?: ActivityLogsFragment$data + readonly ' $fragmentSpreads': FragmentRefs<'ActivityLogsFragment'> +} + +const node: ReaderFragment = (function () { + var v0 = ['activityLogs'], + v1 = { + alias: null, + args: null, + kind: 'ScalarField', + name: 'id', + storageKey: null, + }, + v2 = { + alias: null, + args: null, + kind: 'ScalarField', + name: 'url', + storageKey: null, + } + return { + argumentDefinitions: [ + { + defaultValue: 10, + kind: 'LocalArgument', + name: 'count', + }, + { + defaultValue: null, + kind: 'LocalArgument', + name: 'cursor', + }, + ], + kind: 'Fragment', + metadata: { + connection: [ + { + count: 'count', + cursor: 'cursor', + direction: 'forward', + path: v0 /*: any*/, + }, + ], + refetch: { + connection: { + forward: { + count: 'count', + cursor: 'cursor', + }, + backward: null, + path: v0 /*: any*/, + }, + fragmentPathInResult: [], + operation: require('./ActivityLogsPaginationQuery.graphql'), + }, + }, + name: 'ActivityLogsFragment', + selections: [ + { + alias: 'activityLogs', + args: null, + concreteType: 'ActivityLogConnection', + kind: 'LinkedField', + name: '__ActivityLogs_activityLogs_connection', + plural: false, + selections: [ + { + alias: null, + args: null, + concreteType: 'ActivityLogEdge', + kind: 'LinkedField', + name: 'edges', + plural: true, + selections: [ + { + alias: null, + args: null, + concreteType: 'ActivityLog', + kind: 'LinkedField', + name: 'node', + plural: false, + selections: [ + v1 /*: any*/, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'createdAt', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'verb', + storageKey: null, + }, + v2 /*: any*/, + { + alias: null, + args: null, + concreteType: 'User', + kind: 'LinkedField', + name: 'user', + plural: false, + selections: [ + v1 /*: any*/, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'fullName', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'email', + storageKey: null, + }, + { + alias: null, + args: [ + { + kind: 'Literal', + name: 'height', + value: 48, + }, + { + kind: 'Literal', + name: 'width', + value: 48, + }, + ], + concreteType: 'File', + kind: 'LinkedField', + name: 'avatar', + plural: false, + selections: [v2 /*: any*/], + storageKey: 'avatar(height:48,width:48)', + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: '__typename', + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'cursor', + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + concreteType: 'PageInfo', + kind: 'LinkedField', + name: 'pageInfo', + plural: false, + selections: [ + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'endCursor', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'hasNextPage', + storageKey: null, + }, + ], + storageKey: null, + }, + ], + storageKey: null, + }, + ], + type: 'Query', + abstractKey: null, + } +})() + +;(node as any).hash = 'ba76e757af14e0dbe50c2ee788f01632' + +export default node diff --git a/packages/components/__generated__/ActivityLogsPaginationQuery.graphql.ts b/packages/components/__generated__/ActivityLogsPaginationQuery.graphql.ts new file mode 100644 index 00000000..34eed2fd --- /dev/null +++ b/packages/components/__generated__/ActivityLogsPaginationQuery.graphql.ts @@ -0,0 +1,257 @@ +/** + * @generated SignedSource<<7e37dbc54f4e804a1f8393e08030b101>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ + +/* eslint-disable */ +// @ts-nocheck +import { ConcreteRequest, Query } from 'relay-runtime' +import { FragmentRefs } from 'relay-runtime' + +export type ActivityLogsPaginationQuery$variables = { + count?: number | null | undefined + cursor?: string | null | undefined +} +export type ActivityLogsPaginationQuery$data = { + readonly ' $fragmentSpreads': FragmentRefs<'ActivityLogsFragment'> +} +export type ActivityLogsPaginationQuery = { + response: ActivityLogsPaginationQuery$data + variables: ActivityLogsPaginationQuery$variables +} + +const node: ConcreteRequest = (function () { + var v0 = [ + { + defaultValue: 10, + kind: 'LocalArgument', + name: 'count', + }, + { + defaultValue: null, + kind: 'LocalArgument', + name: 'cursor', + }, + ], + v1 = [ + { + kind: 'Variable', + name: 'after', + variableName: 'cursor', + }, + { + kind: 'Variable', + name: 'first', + variableName: 'count', + }, + ], + v2 = { + alias: null, + args: null, + kind: 'ScalarField', + name: 'id', + storageKey: null, + }, + v3 = { + alias: null, + args: null, + kind: 'ScalarField', + name: 'url', + storageKey: null, + } + return { + fragment: { + argumentDefinitions: v0 /*: any*/, + kind: 'Fragment', + metadata: null, + name: 'ActivityLogsPaginationQuery', + selections: [ + { + args: [ + { + kind: 'Variable', + name: 'count', + variableName: 'count', + }, + { + kind: 'Variable', + name: 'cursor', + variableName: 'cursor', + }, + ], + kind: 'FragmentSpread', + name: 'ActivityLogsFragment', + }, + ], + type: 'Query', + abstractKey: null, + }, + kind: 'Request', + operation: { + argumentDefinitions: v0 /*: any*/, + kind: 'Operation', + name: 'ActivityLogsPaginationQuery', + selections: [ + { + alias: null, + args: v1 /*: any*/, + concreteType: 'ActivityLogConnection', + kind: 'LinkedField', + name: 'activityLogs', + plural: false, + selections: [ + { + alias: null, + args: null, + concreteType: 'ActivityLogEdge', + kind: 'LinkedField', + name: 'edges', + plural: true, + selections: [ + { + alias: null, + args: null, + concreteType: 'ActivityLog', + kind: 'LinkedField', + name: 'node', + plural: false, + selections: [ + v2 /*: any*/, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'createdAt', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'verb', + storageKey: null, + }, + v3 /*: any*/, + { + alias: null, + args: null, + concreteType: 'User', + kind: 'LinkedField', + name: 'user', + plural: false, + selections: [ + v2 /*: any*/, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'fullName', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'email', + storageKey: null, + }, + { + alias: null, + args: [ + { + kind: 'Literal', + name: 'height', + value: 48, + }, + { + kind: 'Literal', + name: 'width', + value: 48, + }, + ], + concreteType: 'File', + kind: 'LinkedField', + name: 'avatar', + plural: false, + selections: [v3 /*: any*/], + storageKey: 'avatar(height:48,width:48)', + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: '__typename', + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'cursor', + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + concreteType: 'PageInfo', + kind: 'LinkedField', + name: 'pageInfo', + plural: false, + selections: [ + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'endCursor', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'hasNextPage', + storageKey: null, + }, + ], + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: v1 /*: any*/, + filters: null, + handle: 'connection', + key: 'ActivityLogs_activityLogs', + kind: 'LinkedHandle', + name: 'activityLogs', + }, + ], + }, + params: { + cacheID: '79c66245f518478d20204207fd2aa732', + id: null, + metadata: {}, + name: 'ActivityLogsPaginationQuery', + operationKind: 'query', + text: 'query ActivityLogsPaginationQuery(\n $count: Int = 10\n $cursor: String\n) {\n ...ActivityLogsFragment_1G22uz\n}\n\nfragment ActivityLogsFragment_1G22uz on Query {\n activityLogs(first: $count, after: $cursor) {\n edges {\n node {\n id\n createdAt\n verb\n url\n user {\n id\n fullName\n email\n avatar(width: 48, height: 48) {\n url\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n', + }, + } +})() + +;(node as any).hash = 'ba76e757af14e0dbe50c2ee788f01632' + +export default node diff --git a/packages/components/__generated__/ActivityLogsQuery.graphql.ts b/packages/components/__generated__/ActivityLogsQuery.graphql.ts new file mode 100644 index 00000000..da5e5402 --- /dev/null +++ b/packages/components/__generated__/ActivityLogsQuery.graphql.ts @@ -0,0 +1,255 @@ +/** + * @generated SignedSource<<19973e03f96279570ada8d066a149d0b>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ + +/* eslint-disable */ +// @ts-nocheck +import { ConcreteRequest, Query } from 'relay-runtime' +import { FragmentRefs } from 'relay-runtime' + +export type ActivityLogsQuery$variables = { + after?: string | null | undefined + first?: number | null | undefined +} +export type ActivityLogsQuery$data = { + readonly ' $fragmentSpreads': FragmentRefs<'ActivityLogsFragment'> +} +export type ActivityLogsQuery = { + response: ActivityLogsQuery$data + variables: ActivityLogsQuery$variables +} + +const node: ConcreteRequest = (function () { + var v0 = { + defaultValue: null, + kind: 'LocalArgument', + name: 'after', + }, + v1 = { + defaultValue: null, + kind: 'LocalArgument', + name: 'first', + }, + v2 = [ + { + kind: 'Variable', + name: 'after', + variableName: 'after', + }, + { + kind: 'Variable', + name: 'first', + variableName: 'first', + }, + ], + v3 = { + alias: null, + args: null, + kind: 'ScalarField', + name: 'id', + storageKey: null, + }, + v4 = { + alias: null, + args: null, + kind: 'ScalarField', + name: 'url', + storageKey: null, + } + return { + fragment: { + argumentDefinitions: [v0 /*: any*/, v1 /*: any*/], + kind: 'Fragment', + metadata: null, + name: 'ActivityLogsQuery', + selections: [ + { + args: [ + { + kind: 'Variable', + name: 'count', + variableName: 'first', + }, + { + kind: 'Variable', + name: 'cursor', + variableName: 'after', + }, + ], + kind: 'FragmentSpread', + name: 'ActivityLogsFragment', + }, + ], + type: 'Query', + abstractKey: null, + }, + kind: 'Request', + operation: { + argumentDefinitions: [v1 /*: any*/, v0 /*: any*/], + kind: 'Operation', + name: 'ActivityLogsQuery', + selections: [ + { + alias: null, + args: v2 /*: any*/, + concreteType: 'ActivityLogConnection', + kind: 'LinkedField', + name: 'activityLogs', + plural: false, + selections: [ + { + alias: null, + args: null, + concreteType: 'ActivityLogEdge', + kind: 'LinkedField', + name: 'edges', + plural: true, + selections: [ + { + alias: null, + args: null, + concreteType: 'ActivityLog', + kind: 'LinkedField', + name: 'node', + plural: false, + selections: [ + v3 /*: any*/, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'createdAt', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'verb', + storageKey: null, + }, + v4 /*: any*/, + { + alias: null, + args: null, + concreteType: 'User', + kind: 'LinkedField', + name: 'user', + plural: false, + selections: [ + v3 /*: any*/, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'fullName', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'email', + storageKey: null, + }, + { + alias: null, + args: [ + { + kind: 'Literal', + name: 'height', + value: 48, + }, + { + kind: 'Literal', + name: 'width', + value: 48, + }, + ], + concreteType: 'File', + kind: 'LinkedField', + name: 'avatar', + plural: false, + selections: [v4 /*: any*/], + storageKey: 'avatar(height:48,width:48)', + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: '__typename', + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'cursor', + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + concreteType: 'PageInfo', + kind: 'LinkedField', + name: 'pageInfo', + plural: false, + selections: [ + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'endCursor', + storageKey: null, + }, + { + alias: null, + args: null, + kind: 'ScalarField', + name: 'hasNextPage', + storageKey: null, + }, + ], + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: v2 /*: any*/, + filters: null, + handle: 'connection', + key: 'ActivityLogs_activityLogs', + kind: 'LinkedHandle', + name: 'activityLogs', + }, + ], + }, + params: { + cacheID: '23080349fdcee969cb4dfd8eb05d574d', + id: null, + metadata: {}, + name: 'ActivityLogsQuery', + operationKind: 'query', + text: 'query ActivityLogsQuery(\n $first: Int\n $after: String\n) {\n ...ActivityLogsFragment_3JmDlL\n}\n\nfragment ActivityLogsFragment_3JmDlL on Query {\n activityLogs(first: $first, after: $after) {\n edges {\n node {\n id\n createdAt\n verb\n url\n user {\n id\n fullName\n email\n avatar(width: 48, height: 48) {\n url\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n', + }, + } +})() + +;(node as any).hash = 'daa3b9b572d3c7596c5dc4d6185f9be9' + +export default node diff --git a/packages/components/modules/messages/SearchNotFoundState/index.tsx b/packages/components/modules/__shared__/SearchNotFoundState/index.tsx similarity index 100% rename from packages/components/modules/messages/SearchNotFoundState/index.tsx rename to packages/components/modules/__shared__/SearchNotFoundState/index.tsx diff --git a/packages/components/modules/activity-log/ActivityLogComponent/EventFilterChip/index.tsx b/packages/components/modules/activity-log/ActivityLogComponent/EventFilterChip/index.tsx new file mode 100644 index 00000000..568d9e6a --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/EventFilterChip/index.tsx @@ -0,0 +1,47 @@ +import { FC, MouseEvent, useState } from 'react' + +import { Checkbox, Chip, ListItemText, Menu, MenuItem } from '@mui/material' + +import { EventFilterOption } from '../types' +import { EventFilterChipProps } from './types' + +const EventFilterChip: FC = ({ options, selectedOptions, onChange }) => { + const [anchorEl, setAnchorEl] = useState(null) + + const handleClick = (event: MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const handleToggle = (option: EventFilterOption) => { + const currentIndex = selectedOptions.indexOf(option) + const newSelectedOptions = [...selectedOptions] + + if (currentIndex === -1) { + newSelectedOptions.push(option) + } else { + newSelectedOptions.splice(currentIndex, 1) + } + + onChange(newSelectedOptions) + } + + return ( + <> + + + {options.map((option) => ( + handleToggle(option)}> + -1} /> + + + ))} + + + ) +} + +export default EventFilterChip diff --git a/packages/components/modules/activity-log/ActivityLogComponent/EventFilterChip/types.ts b/packages/components/modules/activity-log/ActivityLogComponent/EventFilterChip/types.ts new file mode 100644 index 00000000..461305dc --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/EventFilterChip/types.ts @@ -0,0 +1,7 @@ +import { EventFilterOption } from '../types' + +export interface EventFilterChipProps { + options: EventFilterOption[] + selectedOptions: EventFilterOption[] + onChange: (selected: EventFilterOption[]) => void +} diff --git a/packages/components/modules/activity-log/ActivityLogComponent/LogGroups/index.tsx b/packages/components/modules/activity-log/ActivityLogComponent/LogGroups/index.tsx new file mode 100644 index 00000000..75c2a9e9 --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/LogGroups/index.tsx @@ -0,0 +1,87 @@ +import { FC } from 'react' + +import { Avatar, Box, CircularProgress, Typography } from '@mui/material' +import { Virtuoso } from 'react-virtuoso' + +import { Timestamp } from '../../../__shared__' +import { ActivityLogNode } from '../../types' +import LogItem from '../LogItem' +import { LogGroup, LogGroupsProps } from './types' + +const LogGroups: FC = ({ + logGroups, + LoadingState = CircularProgress, + LoadingStateProps, + VirtuosoProps, + loadNext, + hasNext, + isLoadingNext, +}) => { + const renderLogItem = (log: ActivityLogNode, isLast: boolean) => { + if (!log) return null + + return + } + + const renderLoadingState = () => { + if (!isLoadingNext) return + + return ( + + ) + } + + const renderItemContent = (group: LogGroup) => ( + + + + + {group.logs[0]?.user?.fullName} + + + {group.logs.map((log: ActivityLogNode, index: number) => + renderLogItem(log, index === group.logs.length - 1), + )} + + + ) + + return ( +
+ renderItemContent(group)} + components={{ + Footer: renderLoadingState, + }} + endReached={() => { + if (hasNext) { + loadNext(10) + } + }} + {...VirtuosoProps} + /> +
+ ) +} + +export default LogGroups diff --git a/packages/components/modules/activity-log/ActivityLogComponent/LogGroups/types.ts b/packages/components/modules/activity-log/ActivityLogComponent/LogGroups/types.ts new file mode 100644 index 00000000..42e45e20 --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/LogGroups/types.ts @@ -0,0 +1,22 @@ +import { FC } from 'react' + +import { LoadMoreFn } from 'react-relay' +import { VirtuosoProps } from 'react-virtuoso' +import { OperationType } from 'relay-runtime' + +import { ActivityLogNode } from '../../types' + +export interface LogGroup { + lastActivityTimestamp: string + logs: ActivityLogNode[] +} + +export interface LogGroupsProps { + logGroups: LogGroup[] + LoadingState?: FC + LoadingStateProps?: any + VirtuosoProps?: Partial> + loadNext: LoadMoreFn + hasNext: boolean + isLoadingNext: boolean +} diff --git a/packages/components/modules/activity-log/ActivityLogComponent/LogItem/index.tsx b/packages/components/modules/activity-log/ActivityLogComponent/LogItem/index.tsx new file mode 100644 index 00000000..220de473 --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/LogItem/index.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react' + +import { Box, Typography, useTheme } from '@mui/material' + +import { LogItemProps } from './types' + +const verbMapping: { [key: string]: string } = { + 'comments.add_comment': 'Created a comment', + 'comments.change_comment': 'Edited a comment', + 'comments.delete_comment': 'Deleted a comment', + 'comments.reply_comment': 'Replied to a comment', + 'comments.pin_comment': 'Pinned a comment', + 'profiles.update_profile': 'Updated their profile', + 'baseapp_reactions.add_reaction': 'Added a reaction', +} + +const getDisplayText = (verb: string) => verbMapping[verb] ?? verb + +const LogItem: FC = ({ log, sx }) => { + const theme = useTheme() + if (!log?.verb) { + return null + } + + return ( + + + {getDisplayText(log.verb)} + + + ) +} + +export default LogItem diff --git a/packages/components/modules/activity-log/ActivityLogComponent/LogItem/types.ts b/packages/components/modules/activity-log/ActivityLogComponent/LogItem/types.ts new file mode 100644 index 00000000..79e9360c --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/LogItem/types.ts @@ -0,0 +1,8 @@ +import { SxProps, Theme } from '@mui/system' + +import { ActivityLogNode } from '../../types' + +export interface LogItemProps { + log: ActivityLogNode + sx?: SxProps +} diff --git a/packages/components/modules/activity-log/ActivityLogComponent/constants.ts b/packages/components/modules/activity-log/ActivityLogComponent/constants.ts new file mode 100644 index 00000000..7d1c0004 --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/constants.ts @@ -0,0 +1 @@ +export const EVENT_FILTER_OPTIONS = ['All', 'Comments', 'Reactions', 'Posts'] as const diff --git a/packages/components/modules/activity-log/ActivityLogComponent/index.tsx b/packages/components/modules/activity-log/ActivityLogComponent/index.tsx new file mode 100644 index 00000000..5f7a3b81 --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/index.tsx @@ -0,0 +1,63 @@ +'use client' + +import { ChangeEventHandler, FC, useState, useTransition } from 'react' + +import { Searchbar } from '@baseapp-frontend/design-system' + +import { Box, Typography } from '@mui/material' +import { useForm } from 'react-hook-form' + +import SearchNotFoundState from '../../__shared__/SearchNotFoundState' +import { useActivityLogs } from '../graphql/queries/ActivityLogsFragment' +import EventFilterChip from './EventFilterChip' +import DefaultLogGroups from './LogGroups' +import { ActivityLogComponentProps, EventFilterOption } from './types' + +const ActivityLogComponent: FC = ({ + queryRef, + LogGroups = DefaultLogGroups, + LogGroupsProps, +}) => { + const [selectedFilters, setSelectedFilters] = useState(['All']) + const [isPending] = useTransition() + const { control, watch } = useForm({ defaultValues: { search: '' } }) + const searchValue = watch('search') + const { logGroups, loadNext, hasNext, isLoadingNext } = useActivityLogs(queryRef) + // TODO: use startTransition from useTransition when triggering a refetch + const handleSearchChange: ChangeEventHandler = () => {} + const handleSearchClear = () => {} + const emptyLogsList = logGroups.length === 0 + return ( + + + + Activity Log + + + + + + {!isPending && searchValue && emptyLogsList && } + + + + ) +} +export default ActivityLogComponent diff --git a/packages/components/modules/activity-log/ActivityLogComponent/types.ts b/packages/components/modules/activity-log/ActivityLogComponent/types.ts new file mode 100644 index 00000000..25a85e63 --- /dev/null +++ b/packages/components/modules/activity-log/ActivityLogComponent/types.ts @@ -0,0 +1,13 @@ +import { FC } from 'react' + +import { ActivityLogsPaginationQuery$data } from '../../../__generated__/ActivityLogsPaginationQuery.graphql' +import { LogGroupsProps } from './LogGroups/types' +import { EVENT_FILTER_OPTIONS } from './constants' + +export interface ActivityLogComponentProps { + queryRef: ActivityLogsPaginationQuery$data + LogGroups?: FC + LogGroupsProps?: Partial +} + +export type EventFilterOption = (typeof EVENT_FILTER_OPTIONS)[number] diff --git a/packages/components/modules/activity-log/graphql/queries/ActivityLogs.ts b/packages/components/modules/activity-log/graphql/queries/ActivityLogs.ts new file mode 100644 index 00000000..61f68e05 --- /dev/null +++ b/packages/components/modules/activity-log/graphql/queries/ActivityLogs.ts @@ -0,0 +1,7 @@ +import { graphql } from 'react-relay' + +export const ActivityLogsQuery = graphql` + query ActivityLogsQuery($first: Int, $after: String) { + ...ActivityLogsFragment @arguments(count: $first, cursor: $after) + } +` diff --git a/packages/components/modules/activity-log/graphql/queries/ActivityLogsFragment.ts b/packages/components/modules/activity-log/graphql/queries/ActivityLogsFragment.ts new file mode 100644 index 00000000..5a9bcbeb --- /dev/null +++ b/packages/components/modules/activity-log/graphql/queries/ActivityLogsFragment.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react' + +import { graphql, usePaginationFragment } from 'react-relay' + +import { ActivityLogsFragment$key } from '../../../../__generated__/ActivityLogsFragment.graphql' +import { ActivityLogsPaginationQuery } from '../../../../__generated__/ActivityLogsPaginationQuery.graphql' +import { LogGroup } from '../../ActivityLogComponent/LogGroups/types' + +export const ActivityLogsFragmentQuery = graphql` + fragment ActivityLogsFragment on Query + @refetchable(queryName: "ActivityLogsPaginationQuery") + @argumentDefinitions(count: { type: "Int", defaultValue: 10 }, cursor: { type: "String" }) { + activityLogs(first: $count, after: $cursor) @connection(key: "ActivityLogs_activityLogs") { + edges { + node { + id + createdAt + verb + url + user { + id + fullName + email + avatar(width: 48, height: 48) { + url + } + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +` + +export const useActivityLogs = (targetRef: ActivityLogsFragment$key) => { + const { data, loadNext, isLoadingNext, hasNext, refetch } = usePaginationFragment< + ActivityLogsPaginationQuery, + ActivityLogsFragment$key + >(ActivityLogsFragmentQuery, targetRef) + + const logGroups = useMemo(() => { + const groupedLogs: { [key: string]: LogGroup[] } = {} + + data?.activityLogs?.edges?.forEach((edge) => { + const log = edge?.node + if (!log) return + const userId = log.user?.id as string + const timestamp = new Date(log.createdAt).getTime() + + if (!groupedLogs[userId]) { + groupedLogs[userId] = [] + } + + const userLogGroups = groupedLogs[userId] + if (!userLogGroups) return + + const lastGroup = userLogGroups[userLogGroups.length - 1] + + if ( + !lastGroup || + timestamp - new Date(lastGroup.lastActivityTimestamp).getTime() > 15 * 60 * 1000 + ) { + userLogGroups.push({ + lastActivityTimestamp: log.createdAt, + logs: [log], + }) + } else { + lastGroup.logs.unshift(log) + lastGroup.lastActivityTimestamp = log.createdAt + } + }) + + const result: LogGroup[] = Object.values(groupedLogs).flat() + result.sort( + (a, b) => + new Date(b.lastActivityTimestamp).getTime() - new Date(a.lastActivityTimestamp).getTime(), + ) + + return result + }, [data]) + + return { logGroups, loadNext, isLoadingNext, hasNext, refetch } +} diff --git a/packages/components/modules/activity-log/index.ts b/packages/components/modules/activity-log/index.ts new file mode 100644 index 00000000..d053519b --- /dev/null +++ b/packages/components/modules/activity-log/index.ts @@ -0,0 +1,14 @@ +export { default as ActivityLogComponent } from './ActivityLogComponent' +export type * from './ActivityLogComponent/types' + +export { default as EventFilterChip } from './ActivityLogComponent/EventFilterChip' +export type * from './ActivityLogComponent/EventFilterChip/types' + +export { default as LogGroups } from './ActivityLogComponent/LogGroups' +export type * from './ActivityLogComponent/LogGroups/types' + +export { default as LogItem } from './ActivityLogComponent/LogItem' +export type * from './ActivityLogComponent/LogItem/types' + +export * from './graphql/queries/ActivityLogs' +export * from './graphql/queries/ActivityLogsFragment' diff --git a/packages/components/modules/activity-log/types.ts b/packages/components/modules/activity-log/types.ts new file mode 100644 index 00000000..d1af87a4 --- /dev/null +++ b/packages/components/modules/activity-log/types.ts @@ -0,0 +1,5 @@ +import { ActivityLogsFragment$data } from '../../__generated__/ActivityLogsFragment.graphql' + +export type ActivityLogs = NonNullable +export type ActivityLogEdges = ActivityLogs['edges'] +export type ActivityLogNode = NonNullable['node'] diff --git a/packages/components/modules/messages/ChatRoomsList/index.tsx b/packages/components/modules/messages/ChatRoomsList/index.tsx index 9af5c142..197ae5aa 100644 --- a/packages/components/modules/messages/ChatRoomsList/index.tsx +++ b/packages/components/modules/messages/ChatRoomsList/index.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form' import { Virtuoso } from 'react-virtuoso' import { RoomsListFragment$key } from '../../../__generated__/RoomsListFragment.graphql' -import SearchNotFoundState from '../SearchNotFoundState' +import SearchNotFoundState from '../../__shared__/SearchNotFoundState' import { useChatRoom } from '../context' import { useRoomsList } from '../graphql/queries/RoomsList' import useRoomListSubscription from '../graphql/subscriptions/useRoomListSubscription' diff --git a/packages/components/modules/messages/CreateChatRoomList/index.tsx b/packages/components/modules/messages/CreateChatRoomList/index.tsx index 6fae5a5a..3d820126 100644 --- a/packages/components/modules/messages/CreateChatRoomList/index.tsx +++ b/packages/components/modules/messages/CreateChatRoomList/index.tsx @@ -14,9 +14,9 @@ import Image from 'next/image' import { useForm } from 'react-hook-form' import { Virtuoso } from 'react-virtuoso' +import SearchNotFoundState from '../../__shared__/SearchNotFoundState' import { useAllProfilesList } from '../../profiles/graphql/queries/AllProfilesList' import EmptyProfilesListState from '../EmptyProfilesListState' -import SearchNotFoundState from '../SearchNotFoundState' import { ProfileEdge, ProfileNode } from '../types' import DefaultChatRoomListItem from './ChatRoomListItem' import { GroupChatContainer, MainContainer, SearchbarContainer } from './styled' diff --git a/packages/components/modules/messages/CreateGroup/ConnectionsList/index.tsx b/packages/components/modules/messages/CreateGroup/ConnectionsList/index.tsx index 0b6e1745..fdcd5926 100644 --- a/packages/components/modules/messages/CreateGroup/ConnectionsList/index.tsx +++ b/packages/components/modules/messages/CreateGroup/ConnectionsList/index.tsx @@ -7,8 +7,8 @@ import { LoadingState } from '@baseapp-frontend/design-system' import { Box } from '@mui/material' import { Virtuoso } from 'react-virtuoso' +import DefaultSearchNotFoundState from '../../../__shared__/SearchNotFoundState' import DefaultEmptyProfilesListState from '../../EmptyProfilesListState' -import DefaultSearchNotFoundState from '../../SearchNotFoundState' import { ConnectionsListProps } from './types' const ConnectionsList: FC = ({ diff --git a/packages/components/package.json b/packages/components/package.json index ad21fc38..c3814de1 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/components", "description": "BaseApp components modules such as comments, notifications, messages, and more.", - "version": "0.0.45", + "version": "0.0.46", "main": "./index.ts", "types": "dist/index.d.ts", "sideEffects": false,