• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

alkem-io / client-web / #9048

11 Oct 2024 01:42PM UTC coverage: 5.943%. First build
#9048

Pull #7022

travis-ci

Pull Request #7022: [v0.74.0] Roles API + Unauthenticated Explore page

202 of 10241 branches covered (1.97%)

Branch coverage included in aggregate %.

63 of 431 new or added lines in 60 files covered. (14.62%)

1468 of 17861 relevant lines covered (8.22%)

0.19 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/domain/communication/room/Comments/CommentInputField.tsx
1
import { Box, IconButton, InputBaseComponentProps, Paper, Popper, PopperProps, styled, Tooltip } from '@mui/material';
2
import React, { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
3
import { useTranslation } from 'react-i18next';
4
import { Mention, MentionsInput, OnChangeHandlerFunc, SuggestionDataItem } from 'react-mentions';
5
import { useMentionableContributorsLazyQuery } from '@/core/apollo/generated/apollo-hooks';
6
import { gutters } from '@/core/ui/grid/utils';
7
import { Caption } from '@/core/ui/typography';
8
import { ProfileChipView } from '@/domain/community/contributor/ProfileChip/ProfileChipView';
9
import { useCombinedRefs } from '@/domain/shared/utils/useCombinedRefs';
10
import { HelpOutlineOutlined } from '@mui/icons-material';
11
import Gutters from '@/core/ui/grid/Gutters';
12
import { useSpace } from '@/domain/space/context/useSpace';
13
import { useSubSpace } from '@/domain/space/hooks/useSubSpace';
14

×
15
export const POPPER_Z_INDEX = 1400; // Dialogs are 1300
×
16
const MAX_USERS_LISTED = 30;
17

×
18
export const MENTION_SYMBOL = '@';
×
19
const MENTION_INVALID_CHARS_REGEXP = /[?]/; // skip mentions if any of these are used after MENTION_SYMBOL
×
20
const MAX_SPACES_IN_MENTION = 2;
×
21
const MAX_MENTION_LENGTH = 30;
22

23
interface EnrichedSuggestionDataItem extends SuggestionDataItem {
24
  // `id` and `display` are from SuggestionDataItem and used by react-mentions
25
  // `id` must contain the type of mentioned contributor (user/organization/vc)
26
  id: string;
27
  display: string;
28
  avatarUrl: string | undefined;
29
  city?: string;
30
  country?: string;
31
  virtualContributor?: boolean;
32
}
33

×
34
const SuggestionsVCDisclaimer = () => {
×
35
  const { t } = useTranslation();
×
36
  return (
37
    <Gutters
38
      row
39
      height={gutters(2)}
40
      alignItems="center"
41
      justifyContent="space-between"
42
      fontSize="small"
43
      fontStyle="italic"
44
      paddingX={gutters(0.5)}
45
    >
46
      {t('components.post-comment.vcInteractions.disclaimer')}
47
      <Tooltip title={<Caption>{t('components.post-comment.vcInteractions.help')}</Caption>} placement="top" arrow>
48
        <IconButton size="small" aria-label={t('components.post-comment.vcInteractions.help')}>
49
          <HelpOutlineOutlined fontSize="small" />
50
        </IconButton>
51
      </Tooltip>
52
    </Gutters>
53
  );
54
};
55

56
/**
57
 * Rounded paper that pops under the input field showing the mentions
58
 */
59
type SuggestionsContainerProps = {
60
  anchorElement: PopperProps['anchorEl'];
61
  disclaimer?: ReactNode;
62
};
63

×
64
const SuggestionsContainer = ({
65
  anchorElement,
66
  children,
×
67
  disclaimer = null,
68
}: PropsWithChildren<SuggestionsContainerProps>) => {
×
69
  return (
70
    <Popper open placement="bottom-start" anchorEl={anchorElement} sx={{ zIndex: POPPER_Z_INDEX }}>
71
      <Paper elevation={3}>
72
        <Box
×
73
          sx={theme => ({
74
            width: gutters(17)(theme),
75
            maxHeight: gutters(20)(theme),
76
            overflowY: 'auto',
77
            '& li': {
78
              listStyle: 'none',
79
              margin: 0,
80
              padding: 0,
81
            },
82
            '& li:hover': {
83
              background: theme.palette.highlight.light,
84
            },
85
          })}
86
        >
87
          {disclaimer}
88
          {children}
89
        </Box>
90
      </Paper>
91
    </Popper>
92
  );
93
};
94

95
/**
96
 * CommentInput
97
 * Wrapper around MentionsInput to style it properly and to query for users on mentions
98
 */
99
export interface CommentInputFieldProps {
100
  value: string;
101
  onValueChange?: (newValue: string) => void;
102
  onBlur?: () => void;
103
  inactive?: boolean;
104
  readOnly?: boolean;
105
  maxLength?: number;
106
  onReturnKey?: (event: React.KeyboardEvent<HTMLTextAreaElement> | React.KeyboardEvent<HTMLInputElement>) => void;
107
  popperAnchor: SuggestionsContainerProps['anchorElement'];
108
  vcInteractions?: { threadID: string }[];
109
  vcEnabled?: boolean;
110
  threadId?: string;
111
  'aria-label'?: string;
112
  placeholder?: string;
×
113
}
114

115
const StyledCommentInput = styled(Box)(({ theme }) => ({
116
  flex: 1,
117
  '& textarea': {
118
    // TODO: Maybe this should be somewhere else
119
    // Align the textarea contents and override default react-mentions styles
120
    lineHeight: '20px',
121
    top: '-1px !important',
122
    left: '-1px !important',
123
    border: 'none !important',
124
    outline: 'none',
125
  },
126
  '& textarea:focus': {
127
    outline: 'none',
128
  },
129
  '& strong': {
130
    color: theme.palette.common.black,
131
  },
×
132
}));
133

×
134
const hasExcessiveSpaces = (searchTerm: string) => searchTerm.trim().split(' ').length > MAX_SPACES_IN_MENTION + 1;
135

136
export const CommentInputField = ({ ref, ...props }: React.ComponentPropsWithRef<'div'> & InputBaseComponentProps) => {
137
  const {
138
    value,
139
    onValueChange,
140
    onBlur,
141
    inactive,
142
    readOnly,
143
    maxLength,
144
    onReturnKey,
145
    popperAnchor,
146
    vcInteractions = [],
147
    vcEnabled = true,
×
148
    threadId,
×
149
    'aria-label': ariaLabel,
150
    placeholder,
×
151
  } = props as CommentInputFieldProps;
152

×
153
  const { t } = useTranslation();
×
154
  const containerRef = useCombinedRefs(null, ref);
155

×
156
  const currentMentionedUsersRef = useRef<SuggestionDataItem[]>([]);
×
157
  const [tooltipOpen, setTooltipOpen] = useState(false);
×
158
  const emptyQueries = useRef<string[]>([]).current;
159

×
160
  const [queryUsers] = useMentionableContributorsLazyQuery();
NEW
161

×
162
  const { space } = useSpace();
163
  const spaceRoleSetId = space.about.membership?.roleSetID;
×
164
  const { subspace } = useSubSpace();
×
165
  const subspaceRoleSetId = subspace.about.membership?.roleSetID;
166
  const roleSetId = subspaceRoleSetId ? subspaceRoleSetId : spaceRoleSetId;
×
167

168
  const isAlreadyMentioned = ({ profile }: { profile: { url: string } }) =>
×
169
    currentMentionedUsersRef.current.some(mention => mention.id === profile.url);
×
170

×
171
  const hasVcInteraction = vcInteractions.some(interaction => interaction?.threadID === threadId);
×
172

173
  const getMentionableContributors = async (search: string): Promise<EnrichedSuggestionDataItem[]> => {
174
    if (
175
      !search ||
176
      emptyQueries.some(query => search.startsWith(query)) ||
×
177
      hasExcessiveSpaces(search) ||
178
      MENTION_INVALID_CHARS_REGEXP.test(search) ||
179
      search.length > MAX_MENTION_LENGTH
×
180
    ) {
181
      return [];
×
182
    }
183

184
    const filter = { email: search, displayName: search };
185

×
186
    const { data } = await queryUsers({
187
      variables: {
188
        filter,
189
        first: MAX_USERS_LISTED,
190
        roleSetId: roleSetId ? roleSetId : undefined,
×
191
        includeVirtualContributors: roleSetId !== '',
192
      },
×
NEW
193
    });
×
194

×
195
    const mentionableContributors: EnrichedSuggestionDataItem[] = [];
×
196

197
    if (!hasVcInteraction && vcEnabled) {
198
      data?.lookup?.roleSet?.virtualContributorsInRoleInHierarchy?.forEach(vc => {
199
        if (!isAlreadyMentioned(vc) && vc.profile.displayName.toLowerCase().includes(search.toLowerCase())) {
200
          mentionableContributors.push({
201
            id: vc.profile.url,
202
            display: vc.profile.displayName,
203
            avatarUrl: vc.profile.avatar?.uri,
204
            virtualContributor: true,
205
          });
×
206
        }
×
207
      });
×
208
    }
209

210
    data?.usersPaginated.users.forEach(user => {
211
      if (!isAlreadyMentioned(user)) {
212
        mentionableContributors.push({
213
          id: user.profile.url,
214
          display: user.profile.displayName,
215
          avatarUrl: user.profile.avatar?.uri,
216
          city: user.profile.location?.city,
217
          country: user.profile.location?.country,
×
218
        });
×
219
      }
220
    });
221

×
222
    if (!mentionableContributors.length) {
223
      emptyQueries.push(search);
224
    }
×
225

×
226
    return mentionableContributors;
×
227
  };
228

229
  const findMentionableContributors = async (
230
    search: string,
231
    callback: (users: EnrichedSuggestionDataItem[]) => void
×
232
  ) => {
×
233
    const users = await getMentionableContributors(search);
×
234
    callback(users);
235
  };
×
236

×
237
  // Open a tooltip (which is the same Popper that contains the matching users) but with a helper message
×
238
  // that says something like "Start typing to mention someone"
×
239
  useEffect(() => {
×
240
    const input = containerRef.current?.querySelector('textarea');
241
    if (!input) return;
×
242

243
    const cursorPosition = input.selectionEnd;
244
    let isMentionOpen = input.value === MENTION_SYMBOL;
×
245
    if (!isMentionOpen && cursorPosition >= 2) {
×
246
      const lastChars = input.value.slice(cursorPosition - 2, cursorPosition);
×
247
      isMentionOpen = lastChars === ` ${MENTION_SYMBOL}` || lastChars === `\n${MENTION_SYMBOL}`;
248
    }
249
    setTooltipOpen(isMentionOpen);
×
250
  }, [value]);
×
251

×
252
  const handleChange: OnChangeHandlerFunc = (_event, newValue, _newPlaintextValue, mentions) => {
×
253
    currentMentionedUsersRef.current = mentions;
254
    onValueChange?.(newValue);
×
255
  };
×
256

×
257
  const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement> | React.KeyboardEvent<HTMLInputElement>) => {
×
258
    if (inactive) {
259
      event.preventDefault();
260
      return;
261
    }
262
    if (event.key === 'Enter' && event.shiftKey === false) {
×
263
      if (onReturnKey) {
264
        event.preventDefault();
265
        onReturnKey(event);
×
266
      }
×
267
    }
268
  };
269

270
  return (
271
    <StyledCommentInput
272
      ref={containerRef}
273
      sx={theme => ({
274
        '& textarea': { color: inactive ? theme.palette.neutralMedium.main : theme.palette.common.black },
275
      })}
276
    >
277
      <MentionsInput
278
        value={value}
279
        onChange={handleChange}
×
280
        onKeyDown={onKeyDown}
281
        readOnly={readOnly}
×
282
        maxLength={maxLength}
283
        onBlur={onBlur}
284
        placeholder={placeholder}
285
        inputRef={(textarea: HTMLTextAreaElement) => {
286
          if (textarea && ariaLabel) {
287
            textarea.setAttribute('aria-label', ariaLabel);
288
          }
289
        }}
290
        forceSuggestionsAboveCursor
291
        allowSpaceInQuery
×
292
        customSuggestionsContainer={children => (
293
          <SuggestionsContainer
×
294
            anchorElement={popperAnchor}
×
295
            disclaimer={vcEnabled && hasVcInteraction && <SuggestionsVCDisclaimer />}
296
          >
297
            {children}
298
          </SuggestionsContainer>
299
        )}
300
      >
301
        <Mention
302
          trigger={MENTION_SYMBOL}
×
303
          data={findMentionableContributors}
304
          appendSpaceOnAdd
305
          displayTransform={(_, display) => `${MENTION_SYMBOL}${display}`}
306
          renderSuggestion={(suggestion, _, __, ___, focused) => {
307
            const user = suggestion as EnrichedSuggestionDataItem;
308
            return (
309
              <ProfileChipView
310
                key={user.id}
311
                displayName={user.display}
312
                avatarUrl={user.avatarUrl}
313
                city={user.city}
×
314
                country={user.country}
315
                virtualContributor={user.virtualContributor}
316
                padding={theme => `0 ${gutters(0.5)(theme)} 0 ${gutters(0.5)(theme)}`}
317
                selected={focused}
318
              />
319
            );
320
          }}
321
          // Markdown link generated:
322
          // __id__ and __display__ are replaced by react-mentions,
323
          // they'll be URL and displayName of the mentioned user
324
          markup={`[${MENTION_SYMBOL}__display__](__id__)`}
325
        />
326
      </MentionsInput>
327
      {tooltipOpen && (
328
        <SuggestionsContainer anchorElement={popperAnchor}>
329
          <Caption sx={{ padding: gutters() }}>{t('components.post-comment.tooltip.mentions')}</Caption>
330
        </SuggestionsContainer>
331
      )}
332
    </StyledCommentInput>
333
  );
334
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc