diff --git a/CHANGELOG.md b/CHANGELOG.md index a172248..0188a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.2 (2021-09-28) +- Added support for tags ([#10](https://github.com/pawelmalak/snippet-box/issues/10)) + ### v1.1 (2021-09-24) - Added pin icon directly to snippet card ([#4](https://github.com/pawelmalak/snippet-box/issues/4)) - Fixed issue with copying snippets ([#6](https://github.com/pawelmalak/snippet-box/issues/6)) diff --git a/Dockerfile b/Dockerfile index d16175b..53f5176 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,14 +10,8 @@ COPY . . RUN mkdir -p ./public ./data -# Build server code -RUN npm run build - -# Build client code -RUN cd ./client \ - && npm install \ - && npm run build \ - && cd .. \ +# Build +RUN npm run build \ && mv ./client/build/* ./public # Clean up src files diff --git a/Dockerfile.arm b/Dockerfile.arm index 5b3067d..244b0dd 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -12,14 +12,8 @@ COPY . . RUN mkdir -p ./public ./data -# Build server code -RUN npm run build - -# Build client code -RUN cd ./client \ - && npm install \ - && npm run build \ - && cd .. \ +# Build +RUN npm run build \ && mv ./client/build/* ./public # Clean up src files diff --git a/client/src/components/Snippets/SnippetDetails.tsx b/client/src/components/Snippets/SnippetDetails.tsx index 9de585b..514f343 100644 --- a/client/src/components/Snippets/SnippetDetails.tsx +++ b/client/src/components/Snippets/SnippetDetails.tsx @@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom'; import { SnippetsContext } from '../../store'; import { Snippet } from '../../typescript/interfaces'; import { dateParser } from '../../utils'; -import { Button, Card } from '../UI'; +import { Badge, Button, Card } from '../UI'; import copy from 'clipboard-copy'; import { SnippetPin } from './SnippetPin'; @@ -15,6 +15,7 @@ export const SnippetDetails = (props: Props): JSX.Element => { const { title, language, + tags, createdAt, updatedAt, description, @@ -61,6 +62,16 @@ export const SnippetDetails = (props: Props): JSX.Element => {
+ {/* TAGS */} +
+ {tags.map((tag, idx) => ( + + + + ))} +
+
+ {/* ACTIONS */}
+ + {/* TAGS */} +
+ + stringToTags(e)} + /> +
+ Tags should be separated with a comma. Language tag will be + added automatically +
+

{/* CODE SECTION */} diff --git a/client/src/containers/Snippets.tsx b/client/src/containers/Snippets.tsx index ffe8e31..72d5299 100644 --- a/client/src/containers/Snippets.tsx +++ b/client/src/containers/Snippets.tsx @@ -5,7 +5,7 @@ import { Button, Card, EmptyState, Layout } from '../components/UI'; import { Snippet } from '../typescript/interfaces'; export const Snippets = (): JSX.Element => { - const { snippets, languageCount, getSnippets, countSnippets } = + const { snippets, tagCount, getSnippets, countTags } = useContext(SnippetsContext); const [filter, setFilter] = useState(null); @@ -13,16 +13,16 @@ export const Snippets = (): JSX.Element => { useEffect(() => { getSnippets(); - countSnippets(); + countTags(); }, []); useEffect(() => { setLocalSnippets([...snippets]); }, [snippets]); - const filterHandler = (language: string) => { - setFilter(language); - const filteredSnippets = snippets.filter(s => s.language === language); + const filterHandler = (tag: string) => { + setFilter(tag); + const filteredSnippets = snippets.filter(s => s.tags.includes(tag)); setLocalSnippets(filteredSnippets); }; @@ -44,21 +44,21 @@ export const Snippets = (): JSX.Element => { Total {snippets.length} -
Filter by language
+
Filter by tags
- {languageCount.map((el, idx) => { - const isActiveFilter = filter === el.language; + {tagCount.map((tag, idx) => { + const isActiveFilter = filter === tag.name; return (
filterHandler(el.language)} + onClick={() => filterHandler(tag.name)} > - {el.language} - {el.count} + {tag.name} + {tag.count}
); })} diff --git a/client/src/store/SnippetsContext.tsx b/client/src/store/SnippetsContext.tsx index f73d7ee..b180f71 100644 --- a/client/src/store/SnippetsContext.tsx +++ b/client/src/store/SnippetsContext.tsx @@ -5,14 +5,14 @@ import { Context, Snippet, Response, - LanguageCount, + TagCount, NewSnippet } from '../typescript/interfaces'; export const SnippetsContext = createContext({ snippets: [], currentSnippet: null, - languageCount: [], + tagCount: [], getSnippets: () => {}, getSnippetById: (id: number) => {}, setSnippet: (id: number) => {}, @@ -20,7 +20,7 @@ export const SnippetsContext = createContext({ updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => {}, deleteSnippet: (id: number) => {}, toggleSnippetPin: (id: number) => {}, - countSnippets: () => {} + countTags: () => {} }); interface Props { @@ -30,7 +30,7 @@ interface Props { export const SnippetsContextProvider = (props: Props): JSX.Element => { const [snippets, setSnippets] = useState([]); const [currentSnippet, setCurrentSnippet] = useState(null); - const [languageCount, setLanguageCount] = useState([]); + const [tagCount, setTagCount] = useState([]); const history = useHistory(); @@ -53,13 +53,13 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => { }; const setSnippet = (id: number): void => { - getSnippetById(id); - if (id < 0) { setCurrentSnippet(null); return; } + getSnippetById(id); + const snippet = snippets.find(s => s.id === id); if (snippet) { @@ -132,17 +132,17 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => { } }; - const countSnippets = (): void => { + const countTags = (): void => { axios - .get>('/api/snippets/statistics/count') - .then(res => setLanguageCount(res.data.data)) + .get>('/api/snippets/statistics/count') + .then(res => setTagCount(res.data.data)) .catch(err => redirectOnError()); }; const context = { snippets, currentSnippet, - languageCount, + tagCount, getSnippets, getSnippetById, setSnippet, @@ -150,7 +150,7 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => { updateSnippet, deleteSnippet, toggleSnippetPin, - countSnippets + countTags }; return ( diff --git a/client/src/typescript/interfaces/Context.ts b/client/src/typescript/interfaces/Context.ts index 215674e..6e33734 100644 --- a/client/src/typescript/interfaces/Context.ts +++ b/client/src/typescript/interfaces/Context.ts @@ -1,9 +1,9 @@ -import { LanguageCount, NewSnippet, Snippet } from '.'; +import { TagCount, NewSnippet, Snippet } from '.'; export interface Context { snippets: Snippet[]; currentSnippet: Snippet | null; - languageCount: LanguageCount[]; + tagCount: TagCount[]; getSnippets: () => void; getSnippetById: (id: number) => void; setSnippet: (id: number) => void; @@ -11,5 +11,5 @@ export interface Context { updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => void; deleteSnippet: (id: number) => void; toggleSnippetPin: (id: number) => void; - countSnippets: () => void; + countTags: () => void; } diff --git a/client/src/typescript/interfaces/Snippet.ts b/client/src/typescript/interfaces/Snippet.ts index 99e517d..1973787 100644 --- a/client/src/typescript/interfaces/Snippet.ts +++ b/client/src/typescript/interfaces/Snippet.ts @@ -7,6 +7,7 @@ export interface NewSnippet { code: string; docs?: string; isPinned: boolean; + tags: string[]; } export interface Snippet extends Model, NewSnippet {} diff --git a/client/src/typescript/interfaces/Statistics.ts b/client/src/typescript/interfaces/Statistics.ts index 2bf242b..80cedcd 100644 --- a/client/src/typescript/interfaces/Statistics.ts +++ b/client/src/typescript/interfaces/Statistics.ts @@ -1,4 +1,4 @@ -export interface LanguageCount { +export interface TagCount { count: number; - language: string; + name: string; } diff --git a/package.json b/package.json index ead2fb6..0b2d4f3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:client": "npm start --prefix=client", "dev:server": "nodemon", "dev": "npm-run-all -n --parallel dev:**", + "build:client": "npm run build --prefix=client", "build:clear": "rm -rf build", "build:tsc": "tsc", "build": "npm-run-all -n build:**" diff --git a/src/controllers/snippets.ts b/src/controllers/snippets.ts index 67a524b..f8c4543 100644 --- a/src/controllers/snippets.ts +++ b/src/controllers/snippets.ts @@ -2,8 +2,17 @@ import { Request, Response, NextFunction } from 'express'; import { QueryTypes } from 'sequelize'; import { sequelize } from '../db'; import { asyncWrapper } from '../middleware'; -import { SnippetModel } from '../models'; -import { ErrorResponse } from '../utils'; +import { SnippetModel, Snippet_TagModel } from '../models'; +import { + ErrorResponse, + getTags, + tagParser, + Logger, + createTags +} from '../utils'; +import { Body } from '../typescript/interfaces'; + +const logger = new Logger('snippets-controller'); /** * @description Create new snippet @@ -12,10 +21,30 @@ import { ErrorResponse } from '../utils'; */ export const createSnippet = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { - const snippet = await SnippetModel.create(req.body); + // Get tags from request body + const { language, tags: requestTags } = req.body; + const parsedRequestTags = tagParser([ + ...requestTags, + language.toLowerCase() + ]); + + // Create snippet + const snippet = await SnippetModel.create({ + ...req.body, + tags: [...parsedRequestTags].join(',') + }); + + // Create tags + await createTags(parsedRequestTags, snippet.id); + + // Get raw snippet values + const rawSnippet = snippet.get({ plain: true }); res.status(201).json({ - data: snippet + data: { + ...rawSnippet, + tags: [...parsedRequestTags] + } }); } ); @@ -27,7 +56,22 @@ export const createSnippet = asyncWrapper( */ export const getAllSnippets = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { - const snippets = await SnippetModel.findAll(); + const snippets = await SnippetModel.findAll({ + raw: true + }); + + await new Promise(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', 'ERROR'); + } finally { + resolve(); + } + }); res.status(200).json({ data: snippets @@ -36,14 +80,15 @@ export const getAllSnippets = asyncWrapper( ); /** - * @description Get single sinppet by id + * @description Get single snippet by id * @route /api/snippets/:id * @request GET */ export const getSnippet = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { const snippet = await SnippetModel.findOne({ - where: { id: req.params.id } + where: { id: req.params.id }, + raw: true }); if (!snippet) { @@ -55,6 +100,9 @@ export const getSnippet = asyncWrapper( ); } + const tags = await getTags(+req.params.id); + snippet.tags = tags; + res.status(200).json({ data: snippet }); @@ -81,10 +129,28 @@ export const updateSnippet = asyncWrapper( ); } - snippet = await snippet.update(req.body); + // Get tags from request body + const { language, tags: requestTags } = req.body; + let parsedRequestTags = tagParser([...requestTags, language.toLowerCase()]); + + // Update snippet + snippet = await snippet.update({ + ...req.body, + tags: [...parsedRequestTags].join(',') + }); + + // Delete old tags and create new ones + await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } }); + await createTags(parsedRequestTags, snippet.id); + + // Get raw snippet values + const rawSnippet = snippet.get({ plain: true }); res.status(200).json({ - data: snippet + data: { + ...rawSnippet, + tags: [...parsedRequestTags] + } }); } ); @@ -109,6 +175,7 @@ export const deleteSnippet = asyncWrapper( ); } + await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } }); await snippet.destroy(); res.status(200).json({ @@ -118,19 +185,20 @@ export const deleteSnippet = asyncWrapper( ); /** - * @description Count snippets by language + * @description Count tags * @route /api/snippets/statistics/count * @request GET */ -export const countSnippets = asyncWrapper( +export const countTags = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { const result = await sequelize.query( `SELECT - COUNT(language) AS count, - language - FROM snippets - GROUP BY language - ORDER BY language ASC`, + COUNT(tags.name) as count, + tags.name + FROM snippets_tags + INNER JOIN tags ON snippets_tags.tag_id = tags.id + GROUP BY tags.name + ORDER BY name ASC`, { type: QueryTypes.SELECT } diff --git a/src/db/migrations/02_tags.ts b/src/db/migrations/02_tags.ts new file mode 100644 index 0000000..02d09a9 --- /dev/null +++ b/src/db/migrations/02_tags.ts @@ -0,0 +1,92 @@ +import { Logger } from '../../utils'; +import { DataTypes, QueryInterface } from 'sequelize'; +import { + SnippetModel, + Snippet_TagModel, + TagInstance, + TagModel +} from '../../models'; + +const { STRING, INTEGER } = DataTypes; +const logger = new Logger('migration[02]'); + +export const up = async (queryInterface: QueryInterface): Promise => { + await queryInterface.createTable('tags', { + id: { + type: INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + name: { + type: STRING, + allowNull: false, + unique: true + } + }); + + await queryInterface.createTable('snippets_tags', { + id: { + type: INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + snippet_id: { + type: INTEGER, + allowNull: false + }, + tag_id: { + type: INTEGER, + allowNull: false + } + }); + + // Create new tags from language column + const snippets = await SnippetModel.findAll(); + const languages = snippets.map(snippet => snippet.language); + const uniqueLanguages = [...new Set(languages)]; + const tags: TagInstance[] = []; + + if (snippets.length > 0) { + await new Promise(resolve => { + uniqueLanguages.forEach(async language => { + try { + const tag = await TagModel.create({ name: language }); + tags.push(tag); + } catch (err) { + logger.log('Error while creating new tags'); + } finally { + if (uniqueLanguages.length == tags.length) { + resolve(); + } + } + }); + }); + + // Assign tag to snippet + await new Promise(resolve => { + snippets.forEach(async snippet => { + try { + const tag = tags.find(tag => tag.name == snippet.language); + + if (tag) { + await Snippet_TagModel.create({ + snippet_id: snippet.id, + tag_id: tag.id + }); + } + } catch (err) { + logger.log('Error while assigning tags to snippets'); + } finally { + resolve(); + } + }); + }); + } +}; + +export const down = async (queryInterface: QueryInterface): Promise => { + await queryInterface.dropTable('tags'); + await queryInterface.dropTable('snippets_tags'); +}; diff --git a/src/models/Snippet.ts b/src/models/Snippet.ts index a435188..0b23780 100644 --- a/src/models/Snippet.ts +++ b/src/models/Snippet.ts @@ -4,47 +4,53 @@ import { Snippet, SnippetCreationAttributes } from '../typescript/interfaces'; const { INTEGER, STRING, DATE, TEXT } = DataTypes; -interface SnippetInstance +export interface SnippetInstance extends Model, Snippet {} -export const SnippetModel = sequelize.define('Snippet', { - id: { - type: INTEGER, - primaryKey: true, - autoIncrement: true +export const SnippetModel = sequelize.define( + 'Snippet', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true + }, + title: { + type: STRING, + allowNull: false + }, + description: { + type: TEXT, + allowNull: true, + defaultValue: '' + }, + language: { + type: STRING, + allowNull: false + }, + code: { + type: TEXT, + allowNull: false + }, + docs: { + type: TEXT, + allowNull: true, + defaultValue: '' + }, + isPinned: { + type: INTEGER, + allowNull: true, + defaultValue: 0 + }, + createdAt: { + type: DATE + }, + updatedAt: { + type: DATE + } }, - title: { - type: STRING, - allowNull: false - }, - description: { - type: TEXT, - allowNull: true, - defaultValue: '' - }, - language: { - type: STRING, - allowNull: false - }, - code: { - type: TEXT, - allowNull: false - }, - docs: { - type: TEXT, - allowNull: true, - defaultValue: '' - }, - isPinned: { - type: INTEGER, - allowNull: true, - defaultValue: 0 - }, - createdAt: { - type: DATE - }, - updatedAt: { - type: DATE + { + tableName: 'snippets' } -}); +); diff --git a/src/models/Snippet_Tag.ts b/src/models/Snippet_Tag.ts new file mode 100644 index 0000000..0eaa25d --- /dev/null +++ b/src/models/Snippet_Tag.ts @@ -0,0 +1,35 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../db'; +import { + Snippet_Tag, + Snippet_TagCreationAttributes +} from '../typescript/interfaces'; + +const { INTEGER } = DataTypes; + +export interface Snippet_TagInstance + extends Model, + Snippet_Tag {} + +export const Snippet_TagModel = sequelize.define( + 'Snippet_Tag', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true + }, + snippet_id: { + type: INTEGER, + allowNull: false + }, + tag_id: { + type: INTEGER, + allowNull: false + } + }, + { + timestamps: false, + tableName: 'snippets_tags' + } +); diff --git a/src/models/Tag.ts b/src/models/Tag.ts new file mode 100644 index 0000000..759bd06 --- /dev/null +++ b/src/models/Tag.ts @@ -0,0 +1,27 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../db'; +import { Tag, TagCreationAttributes } from '../typescript/interfaces'; + +const { INTEGER, STRING } = DataTypes; + +export interface TagInstance extends Model, Tag {} + +export const TagModel = sequelize.define( + 'Tag', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: STRING, + allowNull: false, + unique: true + } + }, + { + timestamps: false, + tableName: 'tags' + } +); diff --git a/src/models/index.ts b/src/models/index.ts index 02fc3fa..44b6eec 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1 +1,3 @@ export * from './Snippet'; +export * from './Tag'; +export * from './Snippet_Tag'; diff --git a/src/routes/snippets.ts b/src/routes/snippets.ts index 4b3727c..19ca584 100644 --- a/src/routes/snippets.ts +++ b/src/routes/snippets.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { - countSnippets, + countTags, createSnippet, deleteSnippet, getAllSnippets, @@ -22,4 +22,4 @@ snippetRouter .put(updateSnippet) .delete(deleteSnippet); -snippetRouter.route('/statistics/count').get(countSnippets); +snippetRouter.route('/statistics/count').get(countTags); diff --git a/src/typescript/interfaces/Body.ts b/src/typescript/interfaces/Body.ts new file mode 100644 index 0000000..6dcf8f7 --- /dev/null +++ b/src/typescript/interfaces/Body.ts @@ -0,0 +1,9 @@ +export interface Body { + title: string; + description?: string; + language: string; + code: string; + docs?: string; + isPinned: boolean; + tags: string[]; +} diff --git a/src/typescript/interfaces/Snippet.ts b/src/typescript/interfaces/Snippet.ts index 785ee09..2a4c637 100644 --- a/src/typescript/interfaces/Snippet.ts +++ b/src/typescript/interfaces/Snippet.ts @@ -8,6 +8,7 @@ export interface Snippet extends Model { code: string; docs: string; isPinned: number; + tags?: string[]; } export interface SnippetCreationAttributes diff --git a/src/typescript/interfaces/Snippet_Tag.ts b/src/typescript/interfaces/Snippet_Tag.ts new file mode 100644 index 0000000..936a301 --- /dev/null +++ b/src/typescript/interfaces/Snippet_Tag.ts @@ -0,0 +1,10 @@ +import { Optional } from 'sequelize'; + +export interface Snippet_Tag { + id: number; + snippet_id: number; + tag_id: number; +} + +export interface Snippet_TagCreationAttributes + extends Optional {} diff --git a/src/typescript/interfaces/Tag.ts b/src/typescript/interfaces/Tag.ts new file mode 100644 index 0000000..923750f --- /dev/null +++ b/src/typescript/interfaces/Tag.ts @@ -0,0 +1,8 @@ +import { Optional } from 'sequelize'; + +export interface Tag { + id: number; + name: string; +} + +export interface TagCreationAttributes extends Optional {} diff --git a/src/typescript/interfaces/index.ts b/src/typescript/interfaces/index.ts index 063adcb..b86dc4a 100644 --- a/src/typescript/interfaces/index.ts +++ b/src/typescript/interfaces/index.ts @@ -1,2 +1,5 @@ export * from './Model'; export * from './Snippet'; +export * from './Tag'; +export * from './Snippet_Tag'; +export * from './Body'; diff --git a/src/utils/createTags.ts b/src/utils/createTags.ts new file mode 100644 index 0000000..1f53cc4 --- /dev/null +++ b/src/utils/createTags.ts @@ -0,0 +1,39 @@ +import { sequelize } from '../db'; +import { QueryTypes } from 'sequelize'; +import { TagModel, Snippet_TagModel } from '../models'; + +export const createTags = async ( + parsedTags: Set, + snippetId: number +): Promise => { + // Get all tags + const rawAllTags = await sequelize.query<{ id: number; name: string }>( + `SELECT * FROM tags`, + { type: QueryTypes.SELECT } + ); + + const parsedAllTags = rawAllTags.map(tag => tag.name); + + // Create array of new tags + const newTags = [...parsedTags].filter(tag => !parsedAllTags.includes(tag)); + + // Create new tags + if (newTags.length > 0) { + for (const tag of newTags) { + const { id, name } = await TagModel.create({ name: tag }); + rawAllTags.push({ id, name }); + } + } + + // Associate tags with snippet + for (const tag of parsedTags) { + const tagObj = rawAllTags.find(t => t.name == tag); + + if (tagObj) { + await Snippet_TagModel.create({ + snippet_id: snippetId, + tag_id: tagObj.id + }); + } + } +}; diff --git a/src/utils/getTags.ts b/src/utils/getTags.ts new file mode 100644 index 0000000..f44a1c1 --- /dev/null +++ b/src/utils/getTags.ts @@ -0,0 +1,18 @@ +import { sequelize } from '../db'; +import { QueryTypes } from 'sequelize'; + +export const getTags = async (snippetId: number): Promise => { + 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); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index e59e2fd..e9efd2a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,5 @@ export * from './Logger'; export * from './ErrorResponse'; +export * from './tagParser'; +export * from './getTags'; +export * from './createTags'; diff --git a/src/utils/tagParser.ts b/src/utils/tagParser.ts new file mode 100644 index 0000000..9e66bcc --- /dev/null +++ b/src/utils/tagParser.ts @@ -0,0 +1,6 @@ +export const tagParser = (tags: string[]): Set => { + const parsedTags = tags.map(tag => tag.trim().toLowerCase()).filter(String); + const uniqueTags = new Set([...parsedTags]); + + return uniqueTags; +};