mirror of
https://github.com/pawelmalak/snippet-box.git
synced 2025-12-21 21:33:10 +01:00
Include tags with snippets. Filter by tags. Display tags on single snippet view.
This commit is contained in:
@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
|
|||||||
import { SnippetsContext } from '../../store';
|
import { SnippetsContext } from '../../store';
|
||||||
import { Snippet } from '../../typescript/interfaces';
|
import { Snippet } from '../../typescript/interfaces';
|
||||||
import { dateParser } from '../../utils';
|
import { dateParser } from '../../utils';
|
||||||
import { Button, Card } from '../UI';
|
import { Badge, Button, Card } from '../UI';
|
||||||
import copy from 'clipboard-copy';
|
import copy from 'clipboard-copy';
|
||||||
import { SnippetPin } from './SnippetPin';
|
import { SnippetPin } from './SnippetPin';
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ export const SnippetDetails = (props: Props): JSX.Element => {
|
|||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
language,
|
language,
|
||||||
|
tags,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
description,
|
description,
|
||||||
@@ -61,6 +62,16 @@ export const SnippetDetails = (props: Props): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
{/* TAGS */}
|
||||||
|
<div>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span className='me-2'>
|
||||||
|
<Badge text={tag} color='dark' />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<div className='d-grid g-2' style={{ rowGap: '10px' }}>
|
<div className='d-grid g-2' style={{ rowGap: '10px' }}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button, Card, EmptyState, Layout } from '../components/UI';
|
|||||||
import { Snippet } from '../typescript/interfaces';
|
import { Snippet } from '../typescript/interfaces';
|
||||||
|
|
||||||
export const Snippets = (): JSX.Element => {
|
export const Snippets = (): JSX.Element => {
|
||||||
const { snippets, languageCount, getSnippets, countSnippets } =
|
const { snippets, tagCount, getSnippets, countTags } =
|
||||||
useContext(SnippetsContext);
|
useContext(SnippetsContext);
|
||||||
|
|
||||||
const [filter, setFilter] = useState<string | null>(null);
|
const [filter, setFilter] = useState<string | null>(null);
|
||||||
@@ -13,16 +13,16 @@ export const Snippets = (): JSX.Element => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSnippets();
|
getSnippets();
|
||||||
countSnippets();
|
countTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalSnippets([...snippets]);
|
setLocalSnippets([...snippets]);
|
||||||
}, [snippets]);
|
}, [snippets]);
|
||||||
|
|
||||||
const filterHandler = (language: string) => {
|
const filterHandler = (tag: string) => {
|
||||||
setFilter(language);
|
setFilter(tag);
|
||||||
const filteredSnippets = snippets.filter(s => s.language === language);
|
const filteredSnippets = snippets.filter(s => s.tags.includes(tag));
|
||||||
setLocalSnippets(filteredSnippets);
|
setLocalSnippets(filteredSnippets);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,21 +44,21 @@ export const Snippets = (): JSX.Element => {
|
|||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>{snippets.length}</span>
|
<span>{snippets.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<h5 className='card-title'>Filter by language</h5>
|
<h5 className='card-title'>Filter by tags</h5>
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{languageCount.map((el, idx) => {
|
{tagCount.map((tag, idx) => {
|
||||||
const isActiveFilter = filter === el.language;
|
const isActiveFilter = filter === tag.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={idx}
|
||||||
className={`d-flex justify-content-between cursor-pointer ${
|
className={`d-flex justify-content-between cursor-pointer ${
|
||||||
isActiveFilter && 'text-dark fw-bold'
|
isActiveFilter && 'text-dark fw-bold'
|
||||||
}`}
|
}`}
|
||||||
key={idx}
|
onClick={() => filterHandler(tag.name)}
|
||||||
onClick={() => filterHandler(el.language)}
|
|
||||||
>
|
>
|
||||||
<span>{el.language}</span>
|
<span>{tag.name}</span>
|
||||||
<span>{el.count}</span>
|
<span>{tag.count}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import {
|
|||||||
Context,
|
Context,
|
||||||
Snippet,
|
Snippet,
|
||||||
Response,
|
Response,
|
||||||
LanguageCount,
|
TagCount,
|
||||||
NewSnippet
|
NewSnippet
|
||||||
} from '../typescript/interfaces';
|
} from '../typescript/interfaces';
|
||||||
|
|
||||||
export const SnippetsContext = createContext<Context>({
|
export const SnippetsContext = createContext<Context>({
|
||||||
snippets: [],
|
snippets: [],
|
||||||
currentSnippet: null,
|
currentSnippet: null,
|
||||||
languageCount: [],
|
tagCount: [],
|
||||||
getSnippets: () => {},
|
getSnippets: () => {},
|
||||||
getSnippetById: (id: number) => {},
|
getSnippetById: (id: number) => {},
|
||||||
setSnippet: (id: number) => {},
|
setSnippet: (id: number) => {},
|
||||||
@@ -20,7 +20,7 @@ export const SnippetsContext = createContext<Context>({
|
|||||||
updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => {},
|
updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => {},
|
||||||
deleteSnippet: (id: number) => {},
|
deleteSnippet: (id: number) => {},
|
||||||
toggleSnippetPin: (id: number) => {},
|
toggleSnippetPin: (id: number) => {},
|
||||||
countSnippets: () => {}
|
countTags: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,7 +30,7 @@ interface Props {
|
|||||||
export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
const [currentSnippet, setCurrentSnippet] = useState<Snippet | null>(null);
|
const [currentSnippet, setCurrentSnippet] = useState<Snippet | null>(null);
|
||||||
const [languageCount, setLanguageCount] = useState<LanguageCount[]>([]);
|
const [tagCount, setTagCount] = useState<TagCount[]>([]);
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
@@ -132,17 +132,17 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const countSnippets = (): void => {
|
const countTags = (): void => {
|
||||||
axios
|
axios
|
||||||
.get<Response<LanguageCount[]>>('/api/snippets/statistics/count')
|
.get<Response<TagCount[]>>('/api/snippets/statistics/count')
|
||||||
.then(res => setLanguageCount(res.data.data))
|
.then(res => setTagCount(res.data.data))
|
||||||
.catch(err => redirectOnError());
|
.catch(err => redirectOnError());
|
||||||
};
|
};
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
snippets,
|
snippets,
|
||||||
currentSnippet,
|
currentSnippet,
|
||||||
languageCount,
|
tagCount,
|
||||||
getSnippets,
|
getSnippets,
|
||||||
getSnippetById,
|
getSnippetById,
|
||||||
setSnippet,
|
setSnippet,
|
||||||
@@ -150,7 +150,7 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
|||||||
updateSnippet,
|
updateSnippet,
|
||||||
deleteSnippet,
|
deleteSnippet,
|
||||||
toggleSnippetPin,
|
toggleSnippetPin,
|
||||||
countSnippets
|
countTags
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { LanguageCount, NewSnippet, Snippet } from '.';
|
import { TagCount, NewSnippet, Snippet } from '.';
|
||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
snippets: Snippet[];
|
snippets: Snippet[];
|
||||||
currentSnippet: Snippet | null;
|
currentSnippet: Snippet | null;
|
||||||
languageCount: LanguageCount[];
|
tagCount: TagCount[];
|
||||||
getSnippets: () => void;
|
getSnippets: () => void;
|
||||||
getSnippetById: (id: number) => void;
|
getSnippetById: (id: number) => void;
|
||||||
setSnippet: (id: number) => void;
|
setSnippet: (id: number) => void;
|
||||||
@@ -11,5 +11,5 @@ export interface Context {
|
|||||||
updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => void;
|
updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => void;
|
||||||
deleteSnippet: (id: number) => void;
|
deleteSnippet: (id: number) => void;
|
||||||
toggleSnippetPin: (id: number) => void;
|
toggleSnippetPin: (id: number) => void;
|
||||||
countSnippets: () => void;
|
countTags: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface NewSnippet {
|
|||||||
code: string;
|
code: string;
|
||||||
docs?: string;
|
docs?: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
tags: string;
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Snippet extends Model, NewSnippet {}
|
export interface Snippet extends Model, NewSnippet {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export interface LanguageCount {
|
export interface TagCount {
|
||||||
count: number;
|
count: number;
|
||||||
language: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
import { QueryTypes } from 'sequelize';
|
import { QueryTypes } from 'sequelize';
|
||||||
import { sequelize } from '../db';
|
import { sequelize } from '../db';
|
||||||
import { asyncWrapper } from '../middleware';
|
import { asyncWrapper } from '../middleware';
|
||||||
import { SnippetModel } from '../models';
|
import { SnippetInstance, SnippetModel } from '../models';
|
||||||
import { ErrorResponse, tagsParser } from '../utils';
|
import { ErrorResponse, getTags, tagsParser, Logger } from '../utils';
|
||||||
|
|
||||||
|
const logger = new Logger('snippets-controller');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Create new snippet
|
* @description Create new snippet
|
||||||
@@ -37,7 +39,22 @@ export const createSnippet = asyncWrapper(
|
|||||||
*/
|
*/
|
||||||
export const getAllSnippets = asyncWrapper(
|
export const getAllSnippets = asyncWrapper(
|
||||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
const snippets = await SnippetModel.findAll();
|
const snippets = await SnippetModel.findAll({
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>(async resolve => {
|
||||||
|
try {
|
||||||
|
for await (let snippet of snippets) {
|
||||||
|
const tags = await getTags(+snippet.id);
|
||||||
|
snippet.tags = tags;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('Error while fetching tags');
|
||||||
|
} finally {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
data: snippets
|
data: snippets
|
||||||
@@ -46,14 +63,15 @@ export const getAllSnippets = asyncWrapper(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Get single sinppet by id
|
* @description Get single snippet by id
|
||||||
* @route /api/snippets/:id
|
* @route /api/snippets/:id
|
||||||
* @request GET
|
* @request GET
|
||||||
*/
|
*/
|
||||||
export const getSnippet = asyncWrapper(
|
export const getSnippet = asyncWrapper(
|
||||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
const snippet = await SnippetModel.findOne({
|
const snippet = await SnippetModel.findOne({
|
||||||
where: { id: req.params.id }
|
where: { id: req.params.id },
|
||||||
|
raw: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snippet) {
|
if (!snippet) {
|
||||||
@@ -65,6 +83,9 @@ export const getSnippet = asyncWrapper(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = await getTags(+req.params.id);
|
||||||
|
snippet.tags = tags;
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
data: snippet
|
data: snippet
|
||||||
});
|
});
|
||||||
@@ -144,19 +165,20 @@ export const deleteSnippet = asyncWrapper(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Count snippets by language
|
* @description Count tags
|
||||||
* @route /api/snippets/statistics/count
|
* @route /api/snippets/statistics/count
|
||||||
* @request GET
|
* @request GET
|
||||||
*/
|
*/
|
||||||
export const countSnippets = asyncWrapper(
|
export const countTags = asyncWrapper(
|
||||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
const result = await sequelize.query(
|
const result = await sequelize.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
COUNT(language) AS count,
|
COUNT(tags.name) as count,
|
||||||
language
|
tags.name
|
||||||
FROM snippets
|
FROM snippets_tags
|
||||||
GROUP BY language
|
INNER JOIN tags ON snippets_tags.tag_id = tags.id
|
||||||
ORDER BY language ASC`,
|
GROUP BY tags.name
|
||||||
|
ORDER BY name ASC`,
|
||||||
{
|
{
|
||||||
type: QueryTypes.SELECT
|
type: QueryTypes.SELECT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export const up = async (queryInterface: QueryInterface): Promise<void> => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('Error while assigning tags to snippets');
|
logger.log('Error while assigning tags to snippets');
|
||||||
console.log(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import {
|
||||||
countSnippets,
|
countTags,
|
||||||
createSnippet,
|
createSnippet,
|
||||||
deleteSnippet,
|
deleteSnippet,
|
||||||
getAllSnippets,
|
getAllSnippets,
|
||||||
@@ -22,4 +22,4 @@ snippetRouter
|
|||||||
.put(updateSnippet)
|
.put(updateSnippet)
|
||||||
.delete(deleteSnippet);
|
.delete(deleteSnippet);
|
||||||
|
|
||||||
snippetRouter.route('/statistics/count').get(countSnippets);
|
snippetRouter.route('/statistics/count').get(countTags);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Snippet extends Model {
|
|||||||
code: string;
|
code: string;
|
||||||
docs: string;
|
docs: string;
|
||||||
isPinned: number;
|
isPinned: number;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnippetCreationAttributes
|
export interface SnippetCreationAttributes
|
||||||
|
|||||||
18
src/utils/getTags.ts
Normal file
18
src/utils/getTags.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { sequelize } from '../db';
|
||||||
|
import { QueryTypes } from 'sequelize';
|
||||||
|
|
||||||
|
export const getTags = async (snippetId: number): Promise<string[]> => {
|
||||||
|
const tags = await sequelize.query<{ name: string }>(
|
||||||
|
`SELECT tags.name
|
||||||
|
FROM tags
|
||||||
|
INNER JOIN
|
||||||
|
snippets_tags ON tags.id = snippets_tags.tag_id
|
||||||
|
INNER JOIN
|
||||||
|
snippets ON snippets.id = snippets_tags.snippet_id
|
||||||
|
WHERE
|
||||||
|
snippets_tags.snippet_id = ${snippetId};`,
|
||||||
|
{ type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
return tags.map(tag => tag.name);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './Logger';
|
export * from './Logger';
|
||||||
export * from './ErrorResponse';
|
export * from './ErrorResponse';
|
||||||
export * from './tagsParser';
|
export * from './tagsParser';
|
||||||
|
export * from './getTags';
|
||||||
|
|||||||
Reference in New Issue
Block a user