Skip to content

Commit 0443ff3

Browse files
committed
feat: tags are autopopulated when attempting to create questions
1 parent e7be695 commit 0443ff3

6 files changed

Lines changed: 171 additions & 49 deletions

File tree

plugins/stack-overflow-teams-backend/src/router.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@ export async function createRouter({
283283
.status(401)
284284
.json({ error: 'Missing Stack Overflow Teams Access Token' });
285285
}
286-
const tags = await stackOverflowService.getTags(authToken);
286+
const search = req.query.search as string | undefined;
287+
const tags = await stackOverflowService.getTags(authToken, search);
287288
return res.send(tags);
288289
} catch (error: any) {
289290
logger.error('Error fetching tags', { error });

plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ export async function createStackOverflowService({
3636
// GET
3737
getQuestions: authToken =>
3838
api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName, { sort: 'creation', order: 'desc' }),
39-
getTags: authToken =>
40-
api.GET<PaginatedResponse<Tag>>('/tags', authToken, teamName, { sort: 'postCount', order: 'desc'}),
39+
getTags: (authToken, search?: string) => {
40+
const params: Record<string, string> = { sort: 'postCount', order: 'desc' };
41+
if (search) {
42+
params.partialName = search;
43+
}
44+
return api.GET<PaginatedResponse<Tag>>('/tags', authToken, teamName, params);
45+
},
4146
getUsers: authToken =>
4247
api.GET<PaginatedResponse<User>>('/users', authToken, teamName),
4348
getMe: authToken => api.GET<User>('/users/me', authToken, teamName),
@@ -63,4 +68,4 @@ export async function createStackOverflowService({
6368
teamName,
6469
),
6570
};
66-
}
71+
}

plugins/stack-overflow-teams-backend/src/services/StackOverflowService/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export type StackOverflowConfig = {
6262

6363
export interface StackOverflowAPI {
6464
getQuestions(authToken: string): Promise<PaginatedResponse<Question>>;
65-
getTags(authToken: string): Promise<PaginatedResponse<Tag>>;
65+
getTags(authToken: string, search?: string): Promise<PaginatedResponse<Tag>>;
6666
getUsers(authToken: string): Promise<PaginatedResponse<User>>;
6767
getMe(authToken: string): Promise<User>;
6868
postQuestions(

plugins/stack-overflow-teams/src/api/StackOverflowAPI.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface BaseUrlResponse {
1919
export interface StackOverflowAPI {
2020
search(query: string): Promise<any>;
2121
getQuestions(): Promise<ApiResponse<Question>>;
22-
getTags(): Promise<ApiResponse<Tag>>;
22+
getTags(search?: string): Promise<ApiResponse<Tag>>;
2323
getUsers(): Promise<ApiResponse<User>>;
2424
getMe(): Promise<User>;
2525
getBaseUrl(): Promise<string>;
@@ -66,7 +66,10 @@ export const createStackOverflowApi = (
6666
return {
6767
search: (query: string) => requestAPI<any>('search', 'POST', { query }),
6868
getQuestions: () => requestAPI<ApiResponse<Question>>('questions'),
69-
getTags: () => requestAPI<ApiResponse<Tag>>('tags'),
69+
getTags: (search?: string) => {
70+
const params = search ? [`search=${encodeURIComponent(search)}`] : undefined;
71+
return requestAPI<ApiResponse<Tag>>('tags', 'GET', undefined, params);
72+
},
7073
getUsers: () => requestAPI<ApiResponse<User>>('users'),
7174
getMe: () => requestAPI<User>('me'),
7275
getBaseUrl: async () => {

plugins/stack-overflow-teams/src/components/StackOverflow/StackOverflowPostQuestionModal.tsx

Lines changed: 154 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useCallback } from 'react';
1+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
22
import { useApi } from '@backstage/core-plugin-api';
33
import Chip from '@material-ui/core/Chip'
44
import { stackoverflowteamsApiRef } from '../../api';
@@ -30,7 +30,8 @@ import PersonIcon from '@mui/icons-material/Person';
3030
import { useStackOverflowStyles } from './hooks';
3131
import { TiptapEditor } from './TiptapEditor';
3232
import type { Tag } from '../../types'
33-
import { CircularProgress } from '@mui/material';
33+
import CircularProgress from '@mui/material/CircularProgress';
34+
import { debounce } from '@material-ui/core';
3435

3536
// Utility function to detect Mac
3637
const isMac = () => {
@@ -74,7 +75,12 @@ export const StackOverflowPostQuestionModal = () => {
7475
const [loadingTags, setLoadingTags] = useState(false)
7576
const [tagError, setTagError] = useState<string | null>(null)
7677

77-
const fetchPopularTags = useCallback(async function () {
78+
// Autopopulate tags
79+
const [tagSearchResults, setTagSearchResults] = useState<Tag[]>([]);
80+
const [searchingTags, setSearchingTags] = useState(false);
81+
const [showCreateTagOption, setShowCreateTagOption] = useState(false);
82+
83+
const fetchPopularTags = useCallback(async function fetchPopularTags() {
7884
if (!isAuthenticated) return;
7985

8086
setLoadingTags(true);
@@ -91,6 +97,29 @@ export const StackOverflowPostQuestionModal = () => {
9197
setLoadingTags(false);
9298
}
9399
}, [stackOverflowApi, isAuthenticated])
100+
101+
const searchTags = useMemo(
102+
() => debounce(async (searchTerm: string) => {
103+
if (!searchTerm.trim() || !isAuthenticated) {
104+
setTagSearchResults([]);
105+
setShowCreateTagOption(false);
106+
return;
107+
}
108+
setSearchingTags(true);
109+
try {
110+
const response = await stackOverflowApi.getTags(searchTerm.trim());
111+
const results = response.items || [];
112+
setTagSearchResults(results);
113+
setShowCreateTagOption(results.length === 0); // Show create option only when no results found!
114+
} catch (err) {
115+
setTagSearchResults([]);
116+
setShowCreateTagOption(true); // Show create option if search fails, will keep this for now, since is highly unlikely that this fails without all other things being broken
117+
} finally {
118+
setSearchingTags(false);
119+
}
120+
}, 500),
121+
[stackOverflowApi, isAuthenticated]
122+
);
94123

95124
// Get modifier key info
96125
const modifierKey = getModifierKey();
@@ -198,6 +227,7 @@ export const StackOverflowPostQuestionModal = () => {
198227
if (!tagsStarted) setTagsStarted(true);
199228
}
200229
setTagInput('');
230+
setTagSearchResults([]);
201231
};
202232

203233
// This can be uncommented in future once mentioning users over API v3 is supported
@@ -551,43 +581,126 @@ export const StackOverflowPostQuestionModal = () => {
551581
</Typography>
552582
)}
553583
</Box>
554-
<Box sx={{ mb: 3 }}>
555-
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
556-
Tags
557-
</Typography>
558-
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
559-
Add a minimum of one tag
560-
</Typography>
561-
<TextField
562-
fullWidth
563-
variant="outlined"
564-
value={tagInput}
565-
onChange={e => {
566-
setTagInput(e.target.value);
567-
if (e.target.value.includes(',') || e.target.value.includes(' ')) {
568-
handleTagAdd();
569-
}
570-
}}
571-
onFocus={() => setFocusedField('tags')}
572-
onKeyDown={e => e.key === 'Enter' && handleTagAdd()}
573-
placeholder="e.g., react, javascript, authentication"
574-
error={!!tagsValidation}
575-
/>
576-
577-
{tags.length > 0 && (
578-
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
579-
{tags.map((tag, index) => (
580-
<Chip
581-
key={index}
582-
label={tag}
583-
onDelete={() => setTags(tags.filter(t => t !== tag))}
584-
size="medium"
585-
variant="outlined"
586-
color="primary"
584+
<Box sx={{ mb: 3, position: 'relative' }}>
585+
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
586+
Tags
587+
</Typography>
588+
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
589+
Add a minimum of one tag
590+
</Typography>
591+
<TextField
592+
fullWidth
593+
variant="outlined"
594+
value={tagInput}
595+
onChange={e => {
596+
const value = e.target.value;
597+
setTagInput(value);
598+
setShowCreateTagOption(false);
599+
600+
// Search for tags as user types
601+
const lastTag = value.split(/[\s,]/).pop()?.trim() || '';
602+
if (lastTag.length >= 2) {
603+
searchTags(lastTag);
604+
} else {
605+
setTagSearchResults([]);
606+
}
607+
608+
if (value.includes(',') || value.includes(' ')) {
609+
handleTagAdd();
610+
}
611+
}}
612+
onFocus={() => setFocusedField('tags')}
613+
onKeyDown={e => e.key === 'Enter' && handleTagAdd()}
614+
placeholder="e.g., react, javascript, authentication"
615+
error={!!tagsValidation}
616+
/>
617+
{(tagSearchResults.length > 0 || searchingTags || (tagInput.trim() && tagSearchResults.length === 0 && !searchingTags)) && (
618+
<Paper
619+
elevation={3}
620+
sx={{
621+
position: 'absolute',
622+
zIndex: 1000,
623+
width: '100%',
624+
maxHeight: 200,
625+
overflow: 'auto',
626+
mt: 1
627+
}}
628+
>
629+
{searchingTags && (
630+
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
631+
<CircularProgress size={16} />
632+
<Typography variant="body2">Searching tags...</Typography>
633+
</Box>
634+
)}
635+
{tagSearchResults.map(tag => (
636+
<ListItem
637+
key={tag.name}
638+
onClick={() => {
639+
if (!tags.includes(tag.name) && tags.length < 5) {
640+
setTags([...tags, tag.name]);
641+
if (!tagsStarted) setTagsStarted(true);
642+
}
643+
setTagInput('');
644+
setTagSearchResults([]);
645+
}}
646+
sx={{
647+
cursor: 'pointer',
648+
'&:hover': { backgroundColor: 'action.hover' }
649+
}}
650+
>
651+
<ListItemText
652+
primary={tag.name}
653+
// secondary={`${tag.count} questions`}
587654
/>
588-
))}
589-
</Box>
590-
)}
655+
</ListItem>
656+
))}
657+
{tagInput.trim() && showCreateTagOption && (
658+
<ListItem
659+
onClick={() => {
660+
const trimmedTag = tagInput.trim();
661+
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < 5) {
662+
setTags([...tags, trimmedTag]);
663+
if (!tagsStarted) setTagsStarted(true);
664+
setShowCreateTagOption(false);
665+
}
666+
setTagInput('');
667+
setTagSearchResults([]);
668+
}}
669+
sx={{
670+
cursor: 'pointer',
671+
'&:hover': { backgroundColor: 'action.hover' },
672+
borderTop: tagSearchResults.length > 0 ? '1px solid' : 'none',
673+
borderColor: 'divider'
674+
}}
675+
>
676+
<ListItemText
677+
primary={
678+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
679+
<Typography>Create "{tagInput.trim()}"</Typography>
680+
<Chip size="small" label="New" color="primary" variant="outlined" />
681+
</Box>
682+
}
683+
secondary="This will create a new tag"
684+
/>
685+
</ListItem>
686+
)}
687+
</Paper>
688+
)}
689+
690+
{tags.length > 0 && (
691+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
692+
{tags.map((tag, index) => (
693+
<Chip
694+
key={index}
695+
label={tag}
696+
onDelete={() => setTags(tags.filter(t => t !== tag))}
697+
size="medium"
698+
variant="outlined"
699+
color="primary"
700+
/>
701+
))}
702+
</Box>
703+
)}
591704
</Box>
592705
{/* This is the UI for mentioning users (not yet supported on v3) */}
593706
{/* <Box sx={{ mb: 3 }}>
@@ -679,7 +792,7 @@ export const StackOverflowPostQuestionModal = () => {
679792
);
680793
}
681794
return (
682-
<Grid container spacing={4} sx={{ height: '100%' }}>
795+
<Grid container spacing={4} sx={{ height: '80vh' }}>
683796
<Grid item xs={12} md={8}>
684797
{renderQuestionForm()}
685798
</Grid>
@@ -698,7 +811,7 @@ export const StackOverflowPostQuestionModal = () => {
698811
top: '50%',
699812
left: '50%',
700813
transform: 'translate(-50%, -50%)',
701-
width: { xs: '95vw', sm: '90vw', md: '80vw', lg: '65vw' },
814+
width: { xs: '95vw', sm: '90vw', md: '80vw', lg: '70vw' },
702815
maxHeight: '90vh',
703816
bgcolor: 'background.paper',
704817
boxShadow: 24,

plugins/stack-overflow-teams/src/components/StackOverflow/TiptapEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const TiptapEditor: React.FC<TiptapEditorProps> = ({
106106
overflow: 'hidden',
107107
'&:focus-within': {
108108
borderColor: theme.palette.primary.main,
109-
borderWidth: '1px',
109+
borderWidth: '2px',
110110
},
111111
transition: 'border-color 0.2s ease-in-out',
112112
}}

0 commit comments

Comments
 (0)