From bd7eaf3d1779c18abdfb1512c1ce6be6a5af9066 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 27 Sep 2021 12:12:19 +0200 Subject: [PATCH 1/5] Create/update snippet with tags. DB migration to support tags --- README.md | 2 +- .../src/components/Snippets/SnippetForm.tsx | 25 +++++++++++++-- client/src/typescript/interfaces/Snippet.ts | 1 + package.json | 1 + src/controllers/snippets.ts | 32 +++++++++++++++++-- src/db/migrations/02_tags.ts | 19 +++++++++++ src/models/Snippet.ts | 5 +++ src/typescript/interfaces/Snippet.ts | 1 + src/utils/index.ts | 1 + src/utils/tagsParser.ts | 8 +++++ 10 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/db/migrations/02_tags.ts create mode 100644 src/utils/tagsParser.ts diff --git a/README.md b/README.md index 9040a3e..add45c7 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ docker run -p 5000:5000 -v /path/to/data:/app/data snippet-box ![Snippet screenshot](./.github/img/snippet.png) -- Edditor +- Editor - Create and edit your snippets from simple and easy to use editor ![Editor screenshot](./.github/img/editor.png) diff --git a/client/src/components/Snippets/SnippetForm.tsx b/client/src/components/Snippets/SnippetForm.tsx index e42da75..d839b25 100644 --- a/client/src/components/Snippets/SnippetForm.tsx +++ b/client/src/components/Snippets/SnippetForm.tsx @@ -25,7 +25,8 @@ export const SnippetForm = (props: Props): JSX.Element => { language: '', code: '', docs: '', - isPinned: false + isPinned: false, + tags: '' }); useEffect(() => { @@ -109,11 +110,31 @@ export const SnippetForm = (props: Props): JSX.Element => { id='language' name='language' value={formData.language} - placeholder='bash' + placeholder='python' required onChange={e => inputHandler(e)} /> + + {/* TAGS */} +
+ + inputHandler(e)} + /> +
+ Language tag will be added automatically +
+

{/* CODE SECTION */} diff --git a/client/src/typescript/interfaces/Snippet.ts b/client/src/typescript/interfaces/Snippet.ts index 99e517d..8e1ced5 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/package.json b/package.json index ead2fb6..62db005 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..fe6a93e 100644 --- a/src/controllers/snippets.ts +++ b/src/controllers/snippets.ts @@ -3,7 +3,7 @@ import { QueryTypes } from 'sequelize'; import { sequelize } from '../db'; import { asyncWrapper } from '../middleware'; import { SnippetModel } from '../models'; -import { ErrorResponse } from '../utils'; +import { ErrorResponse, tagsParser } from '../utils'; /** * @description Create new snippet @@ -12,7 +12,17 @@ import { ErrorResponse } from '../utils'; */ export const createSnippet = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { - const snippet = await SnippetModel.create(req.body); + const { language, tags } = <{ language: string; tags: string }>req.body; + const parsedTags = tagsParser(tags); + + if (!parsedTags.includes(language.toLowerCase())) { + parsedTags.push(language.toLowerCase()); + } + + const snippet = await SnippetModel.create({ + ...req.body, + tags: parsedTags.join(',') + }); res.status(201).json({ data: snippet @@ -81,7 +91,23 @@ export const updateSnippet = asyncWrapper( ); } - snippet = await snippet.update(req.body); + // Check if language was changed. Edit tags if so + const { language: oldLanguage } = snippet; + const { language, tags } = <{ language: string; tags: string }>req.body; + let parsedTags = tagsParser(tags); + + if (oldLanguage != language) { + parsedTags = parsedTags.filter(tag => tag != oldLanguage); + + if (!parsedTags.includes(language)) { + parsedTags.push(language.toLowerCase()); + } + } + + snippet = await snippet.update({ + ...req.body, + tags: parsedTags.join(',') + }); res.status(200).json({ data: snippet diff --git a/src/db/migrations/02_tags.ts b/src/db/migrations/02_tags.ts new file mode 100644 index 0000000..8797d31 --- /dev/null +++ b/src/db/migrations/02_tags.ts @@ -0,0 +1,19 @@ +import { DataTypes, QueryInterface, QueryTypes } from 'sequelize'; +import { sequelize } from '../'; +const { STRING } = DataTypes; + +export const up = async (queryInterface: QueryInterface): Promise => { + await queryInterface.addColumn('snippets', 'tags', { + type: STRING, + allowNull: true, + defaultValue: '' + }); + + await sequelize.query(`UPDATE snippets SET tags = language`, { + type: QueryTypes.UPDATE + }); +}; + +export const down = async (queryInterface: QueryInterface): Promise => { + await queryInterface.removeColumn('snippets', 'tags'); +}; diff --git a/src/models/Snippet.ts b/src/models/Snippet.ts index a435188..c7bb43a 100644 --- a/src/models/Snippet.ts +++ b/src/models/Snippet.ts @@ -41,6 +41,11 @@ export const SnippetModel = sequelize.define('Snippet', { allowNull: true, defaultValue: 0 }, + tags: { + type: STRING, + allowNull: true, + defaultValue: '' + }, createdAt: { type: DATE }, diff --git a/src/typescript/interfaces/Snippet.ts b/src/typescript/interfaces/Snippet.ts index 785ee09..3d96a2b 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/utils/index.ts b/src/utils/index.ts index e59e2fd..c86e0f3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './Logger'; export * from './ErrorResponse'; +export * from './tagsParser'; diff --git a/src/utils/tagsParser.ts b/src/utils/tagsParser.ts new file mode 100644 index 0000000..14fa27e --- /dev/null +++ b/src/utils/tagsParser.ts @@ -0,0 +1,8 @@ +export const tagsParser = (tags: string): string[] => { + const parsedTags = tags + .split(',') + .map(tag => tag.trim().toLowerCase()) + .filter(String); + + return parsedTags; +}; From 5b0fc6976ad8f112306c16a30af5fa04a459f393 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 27 Sep 2021 14:38:18 +0200 Subject: [PATCH 2/5] Removed tags column. Added tags and snippets_tags tables --- src/db/migrations/02_tags.ts | 91 +++++++++++++++++++++--- src/models/Snippet.ts | 89 +++++++++++------------ src/models/Snippet_Tag.ts | 35 +++++++++ src/models/Tag.ts | 26 +++++++ src/models/index.ts | 2 + src/typescript/interfaces/Snippet.ts | 1 - src/typescript/interfaces/Snippet_Tag.ts | 10 +++ src/typescript/interfaces/Tag.ts | 8 +++ src/typescript/interfaces/index.ts | 2 + 9 files changed, 209 insertions(+), 55 deletions(-) create mode 100644 src/models/Snippet_Tag.ts create mode 100644 src/models/Tag.ts create mode 100644 src/typescript/interfaces/Snippet_Tag.ts create mode 100644 src/typescript/interfaces/Tag.ts diff --git a/src/db/migrations/02_tags.ts b/src/db/migrations/02_tags.ts index 8797d31..d15c3fd 100644 --- a/src/db/migrations/02_tags.ts +++ b/src/db/migrations/02_tags.ts @@ -1,19 +1,90 @@ -import { DataTypes, QueryInterface, QueryTypes } from 'sequelize'; -import { sequelize } from '../'; -const { STRING } = DataTypes; +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.addColumn('snippets', 'tags', { - type: STRING, - allowNull: true, - defaultValue: '' + await queryInterface.createTable('tags', { + id: { + type: INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + name: { + type: STRING, + allowNull: false + } }); - await sequelize.query(`UPDATE snippets SET tags = language`, { - type: QueryTypes.UPDATE + 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[] = []; + + 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'); + console.log(err); + } finally { + resolve(); + } + }); }); }; export const down = async (queryInterface: QueryInterface): Promise => { - await queryInterface.removeColumn('snippets', 'tags'); + await queryInterface.dropTable('tags'); + await queryInterface.dropTable('snippets_tags'); }; diff --git a/src/models/Snippet.ts b/src/models/Snippet.ts index c7bb43a..0b23780 100644 --- a/src/models/Snippet.ts +++ b/src/models/Snippet.ts @@ -4,52 +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 - }, - tags: { - type: STRING, - allowNull: true, - defaultValue: '' - }, - 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..9c2e2de --- /dev/null +++ b/src/models/Tag.ts @@ -0,0 +1,26 @@ +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 + } + }, + { + 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/typescript/interfaces/Snippet.ts b/src/typescript/interfaces/Snippet.ts index 3d96a2b..785ee09 100644 --- a/src/typescript/interfaces/Snippet.ts +++ b/src/typescript/interfaces/Snippet.ts @@ -8,7 +8,6 @@ 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..876ad66 100644 --- a/src/typescript/interfaces/index.ts +++ b/src/typescript/interfaces/index.ts @@ -1,2 +1,4 @@ export * from './Model'; export * from './Snippet'; +export * from './Tag'; +export * from './Snippet_Tag'; From 3cd268858920a1f504bbf3348be06b9627048594 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 27 Sep 2021 16:34:09 +0200 Subject: [PATCH 3/5] Include tags with snippets. Filter by tags. Display tags on single snippet view. --- .../components/Snippets/SnippetDetails.tsx | 13 +++++- client/src/containers/Snippets.tsx | 24 +++++----- client/src/store/SnippetsContext.tsx | 18 ++++---- client/src/typescript/interfaces/Context.ts | 6 +-- client/src/typescript/interfaces/Snippet.ts | 2 +- .../src/typescript/interfaces/Statistics.ts | 4 +- src/controllers/snippets.ts | 46 ++++++++++++++----- src/db/migrations/02_tags.ts | 1 - src/routes/snippets.ts | 4 +- src/typescript/interfaces/Snippet.ts | 1 + src/utils/getTags.ts | 18 ++++++++ src/utils/index.ts | 1 + 12 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 src/utils/getTags.ts diff --git a/client/src/components/Snippets/SnippetDetails.tsx b/client/src/components/Snippets/SnippetDetails.tsx index 9de585b..a3cb22e 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 => ( + + + + ))} +
+
+ {/* ACTIONS */}
-
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..5d3b305 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(); @@ -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 8e1ced5..1973787 100644 --- a/client/src/typescript/interfaces/Snippet.ts +++ b/client/src/typescript/interfaces/Snippet.ts @@ -7,7 +7,7 @@ export interface NewSnippet { code: string; docs?: string; isPinned: boolean; - tags: string; + 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/src/controllers/snippets.ts b/src/controllers/snippets.ts index fe6a93e..65fac50 100644 --- a/src/controllers/snippets.ts +++ b/src/controllers/snippets.ts @@ -2,8 +2,10 @@ import { Request, Response, NextFunction } from 'express'; import { QueryTypes } from 'sequelize'; import { sequelize } from '../db'; import { asyncWrapper } from '../middleware'; -import { SnippetModel } from '../models'; -import { ErrorResponse, tagsParser } from '../utils'; +import { SnippetInstance, SnippetModel } from '../models'; +import { ErrorResponse, getTags, tagsParser, Logger } from '../utils'; + +const logger = new Logger('snippets-controller'); /** * @description Create new snippet @@ -37,7 +39,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'); + } finally { + resolve(); + } + }); res.status(200).json({ 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 * @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) { @@ -65,6 +83,9 @@ export const getSnippet = asyncWrapper( ); } + const tags = await getTags(+req.params.id); + snippet.tags = tags; + res.status(200).json({ data: snippet }); @@ -144,19 +165,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 index d15c3fd..558a144 100644 --- a/src/db/migrations/02_tags.ts +++ b/src/db/migrations/02_tags.ts @@ -76,7 +76,6 @@ export const up = async (queryInterface: QueryInterface): Promise => { } } catch (err) { logger.log('Error while assigning tags to snippets'); - console.log(err); } finally { resolve(); } 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/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/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 c86e0f3..f05b365 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './Logger'; export * from './ErrorResponse'; export * from './tagsParser'; +export * from './getTags'; From 86ea246e9055457c68c28d9e92c3b54e3bbbc82e Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 28 Sep 2021 12:17:59 +0200 Subject: [PATCH 4/5] Create snippet with tags --- .../components/Snippets/SnippetDetails.tsx | 4 +- .../src/components/Snippets/SnippetForm.tsx | 18 ++-- src/controllers/snippets.ts | 89 +++++++++++++------ src/db/migrations/02_tags.ts | 67 +++++++------- src/models/Tag.ts | 3 +- src/utils/index.ts | 2 +- src/utils/tagParser.ts | 6 ++ src/utils/tagsParser.ts | 8 -- 8 files changed, 121 insertions(+), 76 deletions(-) create mode 100644 src/utils/tagParser.ts delete mode 100644 src/utils/tagsParser.ts diff --git a/client/src/components/Snippets/SnippetDetails.tsx b/client/src/components/Snippets/SnippetDetails.tsx index a3cb22e..514f343 100644 --- a/client/src/components/Snippets/SnippetDetails.tsx +++ b/client/src/components/Snippets/SnippetDetails.tsx @@ -64,8 +64,8 @@ export const SnippetDetails = (props: Props): JSX.Element => { {/* TAGS */}
- {tags.map(tag => ( - + {tags.map((tag, idx) => ( + ))} diff --git a/client/src/components/Snippets/SnippetForm.tsx b/client/src/components/Snippets/SnippetForm.tsx index d839b25..257e9fc 100644 --- a/client/src/components/Snippets/SnippetForm.tsx +++ b/client/src/components/Snippets/SnippetForm.tsx @@ -26,7 +26,7 @@ export const SnippetForm = (props: Props): JSX.Element => { code: '', docs: '', isPinned: false, - tags: '' + tags: [] }); useEffect(() => { @@ -46,6 +46,14 @@ export const SnippetForm = (props: Props): JSX.Element => { }); }; + const stringToTags = (e: ChangeEvent) => { + const tags = e.target.value.split(','); + setFormData({ + ...formData, + tags + }); + }; + const formHandler = (e: FormEvent) => { e.preventDefault(); @@ -126,13 +134,13 @@ export const SnippetForm = (props: Props): JSX.Element => { className='form-control' id='tags' name='tags' - value={formData.tags} + // value={formData.tags} placeholder='automation, files, loop' - required - onChange={e => inputHandler(e)} + onChange={e => stringToTags(e)} />
- Language tag will be added automatically + Tags should be separate with a comma. Language tag will be added + automatically

diff --git a/src/controllers/snippets.ts b/src/controllers/snippets.ts index 65fac50..01dce23 100644 --- a/src/controllers/snippets.ts +++ b/src/controllers/snippets.ts @@ -2,8 +2,13 @@ import { Request, Response, NextFunction } from 'express'; import { QueryTypes } from 'sequelize'; import { sequelize } from '../db'; import { asyncWrapper } from '../middleware'; -import { SnippetInstance, SnippetModel } from '../models'; -import { ErrorResponse, getTags, tagsParser, Logger } from '../utils'; +import { + SnippetInstance, + SnippetModel, + Snippet_TagModel, + TagModel +} from '../models'; +import { ErrorResponse, getTags, tagParser, Logger } from '../utils'; const logger = new Logger('snippets-controller'); @@ -14,20 +19,65 @@ const logger = new Logger('snippets-controller'); */ export const createSnippet = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { - const { language, tags } = <{ language: string; tags: string }>req.body; - const parsedTags = tagsParser(tags); - - if (!parsedTags.includes(language.toLowerCase())) { - parsedTags.push(language.toLowerCase()); + interface Body { + language: string; + tags: string[]; } + // 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: parsedTags.join(',') + tags: [...parsedRequestTags].join(',') }); + // 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 = [...parsedRequestTags].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 parsedRequestTags) { + const tagObj = rawAllTags.find(t => t.name == tag); + + if (tagObj) { + await Snippet_TagModel.create({ + snippet_id: snippet.id, + tag_id: tagObj.id + }); + } + } + + // Get raw snippet values + const rawSnippet = snippet.get({ plain: true }); + res.status(201).json({ - data: snippet + data: { + ...rawSnippet, + tags: [...parsedRequestTags] + } }); } ); @@ -50,7 +100,7 @@ export const getAllSnippets = asyncWrapper( snippet.tags = tags; } } catch (err) { - logger.log('Error while fetching tags'); + logger.log('Error while fetching tags', 'ERROR'); } finally { resolve(); } @@ -112,23 +162,7 @@ export const updateSnippet = asyncWrapper( ); } - // Check if language was changed. Edit tags if so - const { language: oldLanguage } = snippet; - const { language, tags } = <{ language: string; tags: string }>req.body; - let parsedTags = tagsParser(tags); - - if (oldLanguage != language) { - parsedTags = parsedTags.filter(tag => tag != oldLanguage); - - if (!parsedTags.includes(language)) { - parsedTags.push(language.toLowerCase()); - } - } - - snippet = await snippet.update({ - ...req.body, - tags: parsedTags.join(',') - }); + snippet = await snippet.update(req.body); res.status(200).json({ data: snippet @@ -156,6 +190,7 @@ export const deleteSnippet = asyncWrapper( ); } + await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } }); await snippet.destroy(); res.status(200).json({ diff --git a/src/db/migrations/02_tags.ts b/src/db/migrations/02_tags.ts index 558a144..02d09a9 100644 --- a/src/db/migrations/02_tags.ts +++ b/src/db/migrations/02_tags.ts @@ -20,7 +20,8 @@ export const up = async (queryInterface: QueryInterface): Promise => { }, name: { type: STRING, - allowNull: false + allowNull: false, + unique: true } }); @@ -47,40 +48,42 @@ export const up = async (queryInterface: QueryInterface): Promise => { const uniqueLanguages = [...new Set(languages)]; const tags: TagInstance[] = []; - 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) { + 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(); } - } + }); }); - }); - - // 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 => { diff --git a/src/models/Tag.ts b/src/models/Tag.ts index 9c2e2de..759bd06 100644 --- a/src/models/Tag.ts +++ b/src/models/Tag.ts @@ -16,7 +16,8 @@ export const TagModel = sequelize.define( }, name: { type: STRING, - allowNull: false + allowNull: false, + unique: true } }, { diff --git a/src/utils/index.ts b/src/utils/index.ts index f05b365..96fabc9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ export * from './Logger'; export * from './ErrorResponse'; -export * from './tagsParser'; +export * from './tagParser'; export * from './getTags'; 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; +}; diff --git a/src/utils/tagsParser.ts b/src/utils/tagsParser.ts deleted file mode 100644 index 14fa27e..0000000 --- a/src/utils/tagsParser.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const tagsParser = (tags: string): string[] => { - const parsedTags = tags - .split(',') - .map(tag => tag.trim().toLowerCase()) - .filter(String); - - return parsedTags; -}; From 714793a40722d6ec2d78bfdfdf1ad5c8d4b1a402 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 28 Sep 2021 13:26:48 +0200 Subject: [PATCH 5/5] Updating snippets with tags --- CHANGELOG.md | 3 + Dockerfile | 10 +-- Dockerfile.arm | 10 +-- .../src/components/Snippets/SnippetForm.tsx | 10 ++- client/src/store/SnippetsContext.tsx | 4 +- package.json | 2 +- src/controllers/snippets.ts | 75 ++++++++----------- src/typescript/interfaces/Body.ts | 9 +++ src/typescript/interfaces/index.ts | 1 + src/utils/createTags.ts | 39 ++++++++++ src/utils/index.ts | 1 + 11 files changed, 97 insertions(+), 67 deletions(-) create mode 100644 src/typescript/interfaces/Body.ts create mode 100644 src/utils/createTags.ts 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/SnippetForm.tsx b/client/src/components/Snippets/SnippetForm.tsx index 257e9fc..fffb372 100644 --- a/client/src/components/Snippets/SnippetForm.tsx +++ b/client/src/components/Snippets/SnippetForm.tsx @@ -54,6 +54,10 @@ export const SnippetForm = (props: Props): JSX.Element => { }); }; + const tagsToString = (): string => { + return formData.tags.join(','); + }; + const formHandler = (e: FormEvent) => { e.preventDefault(); @@ -134,13 +138,13 @@ export const SnippetForm = (props: Props): JSX.Element => { className='form-control' id='tags' name='tags' - // value={formData.tags} + value={tagsToString()} placeholder='automation, files, loop' onChange={e => stringToTags(e)} />
- Tags should be separate with a comma. Language tag will be added - automatically + Tags should be separated with a comma. Language tag will be + added automatically

diff --git a/client/src/store/SnippetsContext.tsx b/client/src/store/SnippetsContext.tsx index 5d3b305..b180f71 100644 --- a/client/src/store/SnippetsContext.tsx +++ b/client/src/store/SnippetsContext.tsx @@ -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) { diff --git a/package.json b/package.json index 62db005..0b2d4f3 100644 --- a/package.json +++ b/package.json @@ -10,7 +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: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 01dce23..f8c4543 100644 --- a/src/controllers/snippets.ts +++ b/src/controllers/snippets.ts @@ -2,13 +2,15 @@ import { Request, Response, NextFunction } from 'express'; import { QueryTypes } from 'sequelize'; import { sequelize } from '../db'; import { asyncWrapper } from '../middleware'; +import { SnippetModel, Snippet_TagModel } from '../models'; import { - SnippetInstance, - SnippetModel, - Snippet_TagModel, - TagModel -} from '../models'; -import { ErrorResponse, getTags, tagParser, Logger } from '../utils'; + ErrorResponse, + getTags, + tagParser, + Logger, + createTags +} from '../utils'; +import { Body } from '../typescript/interfaces'; const logger = new Logger('snippets-controller'); @@ -19,11 +21,6 @@ const logger = new Logger('snippets-controller'); */ export const createSnippet = asyncWrapper( async (req: Request, res: Response, next: NextFunction): Promise => { - interface Body { - language: string; - tags: string[]; - } - // Get tags from request body const { language, tags: requestTags } = req.body; const parsedRequestTags = tagParser([ @@ -37,38 +34,8 @@ export const createSnippet = asyncWrapper( tags: [...parsedRequestTags].join(',') }); - // 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 = [...parsedRequestTags].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 parsedRequestTags) { - const tagObj = rawAllTags.find(t => t.name == tag); - - if (tagObj) { - await Snippet_TagModel.create({ - snippet_id: snippet.id, - tag_id: tagObj.id - }); - } - } + // Create tags + await createTags(parsedRequestTags, snippet.id); // Get raw snippet values const rawSnippet = snippet.get({ plain: true }); @@ -162,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] + } }); } ); 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/index.ts b/src/typescript/interfaces/index.ts index 876ad66..b86dc4a 100644 --- a/src/typescript/interfaces/index.ts +++ b/src/typescript/interfaces/index.ts @@ -2,3 +2,4 @@ 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/index.ts b/src/utils/index.ts index 96fabc9..e9efd2a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './Logger'; export * from './ErrorResponse'; export * from './tagParser'; export * from './getTags'; +export * from './createTags';