From 26c535c419c7ce9a5ee86225d9a7104ab069894f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 5 Apr 2025 11:34:42 +0000 Subject: [PATCH] adding tags entity --- backend/src/db/api/organizations.js | 4 + backend/src/db/api/tags.js | 302 +++++++++++ backend/src/db/api/tasks.js | 50 ++ backend/src/db/migrations/1743852715214.js | 90 ++++ backend/src/db/migrations/1743852754061.js | 47 ++ backend/src/db/migrations/1743852778642.js | 36 ++ backend/src/db/models/organizations.js | 8 + backend/src/db/models/tags.js | 57 +++ backend/src/db/models/tasks.js | 18 + .../db/seeders/20200430130760-user-roles.js | 76 +++ .../db/seeders/20231127130745-sample-data.js | 399 +++++++++------ backend/src/db/seeders/20250405113155.js | 87 ++++ backend/src/index.js | 8 + backend/src/routes/tags.js | 447 ++++++++++++++++ backend/src/services/search.js | 2 + backend/src/services/tags.js | 114 +++++ frontend/src/components/Tags/CardTags.tsx | 105 ++++ frontend/src/components/Tags/ListTags.tsx | 90 ++++ frontend/src/components/Tags/TableTags.tsx | 481 ++++++++++++++++++ .../src/components/Tags/configureTagsCols.tsx | 73 +++ frontend/src/components/Tasks/CardTasks.tsx | 11 + frontend/src/components/Tasks/ListTasks.tsx | 9 + .../components/Tasks/configureTasksCols.tsx | 19 + frontend/src/helpers/dataFormatter.js | 19 + frontend/src/menuAside.ts | 13 +- frontend/src/pages/dashboard.tsx | 35 ++ .../organizations/organizations-view.tsx | 35 ++ frontend/src/pages/tags/[tagsId].tsx | 139 +++++ frontend/src/pages/tags/tags-edit.tsx | 137 +++++ frontend/src/pages/tags/tags-list.tsx | 160 ++++++ frontend/src/pages/tags/tags-new.tsx | 108 ++++ frontend/src/pages/tags/tags-table.tsx | 159 ++++++ frontend/src/pages/tags/tags-view.tsx | 91 ++++ frontend/src/pages/tasks/[tasksId].tsx | 13 + frontend/src/pages/tasks/tasks-edit.tsx | 13 + frontend/src/pages/tasks/tasks-list.tsx | 1 + frontend/src/pages/tasks/tasks-new.tsx | 12 + frontend/src/pages/tasks/tasks-table.tsx | 1 + frontend/src/pages/tasks/tasks-view.tsx | 35 ++ frontend/src/stores/store.ts | 2 + frontend/src/stores/tags/tagsSlice.ts | 236 +++++++++ 41 files changed, 3595 insertions(+), 147 deletions(-) create mode 100644 backend/src/db/api/tags.js create mode 100644 backend/src/db/migrations/1743852715214.js create mode 100644 backend/src/db/migrations/1743852754061.js create mode 100644 backend/src/db/migrations/1743852778642.js create mode 100644 backend/src/db/models/tags.js create mode 100644 backend/src/db/seeders/20250405113155.js create mode 100644 backend/src/routes/tags.js create mode 100644 backend/src/services/tags.js create mode 100644 frontend/src/components/Tags/CardTags.tsx create mode 100644 frontend/src/components/Tags/ListTags.tsx create mode 100644 frontend/src/components/Tags/TableTags.tsx create mode 100644 frontend/src/components/Tags/configureTagsCols.tsx create mode 100644 frontend/src/pages/tags/[tagsId].tsx create mode 100644 frontend/src/pages/tags/tags-edit.tsx create mode 100644 frontend/src/pages/tags/tags-list.tsx create mode 100644 frontend/src/pages/tags/tags-new.tsx create mode 100644 frontend/src/pages/tags/tags-table.tsx create mode 100644 frontend/src/pages/tags/tags-view.tsx create mode 100644 frontend/src/stores/tags/tagsSlice.ts diff --git a/backend/src/db/api/organizations.js b/backend/src/db/api/organizations.js index 900c01e..f705ad6 100644 --- a/backend/src/db/api/organizations.js +++ b/backend/src/db/api/organizations.js @@ -152,6 +152,10 @@ module.exports = class OrganizationsDBApi { transaction, }); + output.tags_organizations = await organizations.getTags_organizations({ + transaction, + }); + return output; } diff --git a/backend/src/db/api/tags.js b/backend/src/db/api/tags.js new file mode 100644 index 0000000..1bd47f1 --- /dev/null +++ b/backend/src/db/api/tags.js @@ -0,0 +1,302 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class TagsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tags = await db.tags.create( + { + id: data.id || undefined, + + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await tags.setOrganizations(data.organizations || null, { + transaction, + }); + + return tags; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const tagsData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const tags = await db.tags.bulkCreate(tagsData, { transaction }); + + // For each item created, replace relation files + + return tags; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + const globalAccess = currentUser.app_role?.globalAccess; + + const tags = await db.tags.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await tags.update(updatePayload, { transaction }); + + if (data.organizations !== undefined) { + await tags.setOrganizations( + data.organizations, + + { transaction }, + ); + } + + return tags; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tags = await db.tags.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of tags) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of tags) { + await record.destroy({ transaction }); + } + }); + + return tags; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tags = await db.tags.findByPk(id, options); + + await tags.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await tags.destroy({ + transaction, + }); + + return tags; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const tags = await db.tags.findOne({ where }, { transaction }); + + if (!tags) { + return tags; + } + + const output = tags.get({ plain: true }); + + output.organizations = await tags.getOrganizations({ + transaction, + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userOrganizations = (user && user.organizations?.id) || null; + + if (userOrganizations) { + if (options?.currentUser?.organizationsId) { + where.organizationsId = options.currentUser.organizationsId; + } + } + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.organizations, + as: 'organizations', + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('tags', 'name', filter.name), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.organizations) { + const listItems = filter.organizations.split('|').map((item) => { + return Utils.uuid(item); + }); + + where = { + ...where, + organizationsId: { [Op.or]: listItems }, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + if (globalAccess) { + delete where.organizationsId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.tags.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ) { + let where = {}; + + if (!globalAccess && organizationId) { + where.organizationId = organizationId; + } + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('tags', 'name', query), + ], + }; + } + + const records = await db.tags.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/api/tasks.js b/backend/src/db/api/tasks.js index 0baa09d..f06ef5c 100644 --- a/backend/src/db/api/tasks.js +++ b/backend/src/db/api/tasks.js @@ -43,6 +43,10 @@ module.exports = class TasksDBApi { transaction, }); + await tasks.setTags(data.tags || [], { + transaction, + }); + return tasks; } @@ -130,6 +134,10 @@ module.exports = class TasksDBApi { ); } + if (data.tags !== undefined) { + await tasks.setTags(data.tags, { transaction }); + } + return tasks; } @@ -207,6 +215,10 @@ module.exports = class TasksDBApi { transaction, }); + output.tags = await tasks.getTags({ + transaction, + }); + return output; } @@ -314,6 +326,12 @@ module.exports = class TasksDBApi { model: db.organizations, as: 'organizations', }, + + { + model: db.tags, + as: 'tags', + required: false, + }, ]; if (filter) { @@ -411,6 +429,38 @@ module.exports = class TasksDBApi { }; } + if (filter.tags) { + const searchTerms = filter.tags.split('|'); + + include = [ + { + model: db.tags, + as: 'tags_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { + id: { + [Op.in]: searchTerms.map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + if (filter.createdAtRange) { const [start, end] = filter.createdAtRange; diff --git a/backend/src/db/migrations/1743852715214.js b/backend/src/db/migrations/1743852715214.js new file mode 100644 index 0000000..ad2fe29 --- /dev/null +++ b/backend/src/db/migrations/1743852715214.js @@ -0,0 +1,90 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'tags', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tags', + 'organizationsId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'organizations', + key: 'id', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('tags', 'organizationsId', { + transaction, + }); + + await queryInterface.dropTable('tags', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1743852754061.js b/backend/src/db/migrations/1743852754061.js new file mode 100644 index 0000000..ae579fd --- /dev/null +++ b/backend/src/db/migrations/1743852754061.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'tags', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('tags', 'name', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1743852778642.js b/backend/src/db/migrations/1743852778642.js new file mode 100644 index 0000000..e6bfba3 --- /dev/null +++ b/backend/src/db/migrations/1743852778642.js @@ -0,0 +1,36 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/organizations.js b/backend/src/db/models/organizations.js index ccbb7ef..e144a52 100644 --- a/backend/src/db/models/organizations.js +++ b/backend/src/db/models/organizations.js @@ -66,6 +66,14 @@ module.exports = function (sequelize, DataTypes) { constraints: false, }); + db.organizations.hasMany(db.tags, { + as: 'tags_organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + //end loop db.organizations.belongsTo(db.users, { diff --git a/backend/src/db/models/tags.js b/backend/src/db/models/tags.js new file mode 100644 index 0000000..840e7db --- /dev/null +++ b/backend/src/db/models/tags.js @@ -0,0 +1,57 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const tags = sequelize.define( + 'tags', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + tags.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.tags.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + + db.tags.belongsTo(db.users, { + as: 'createdBy', + }); + + db.tags.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return tags; +}; diff --git a/backend/src/db/models/tasks.js b/backend/src/db/models/tasks.js index c93ff22..1112a8e 100644 --- a/backend/src/db/models/tasks.js +++ b/backend/src/db/models/tasks.js @@ -50,6 +50,24 @@ module.exports = function (sequelize, DataTypes) { ); tasks.associate = (db) => { + db.tasks.belongsToMany(db.tags, { + as: 'tags', + foreignKey: { + name: 'tasks_tagsId', + }, + constraints: false, + through: 'tasksTagsTags', + }); + + db.tasks.belongsToMany(db.tags, { + as: 'tags_filter', + foreignKey: { + name: 'tasks_tagsId', + }, + constraints: false, + through: 'tasksTagsTags', + }); + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity //end loop diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 499729a..e2337e1 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -83,6 +83,7 @@ module.exports = { 'roles', 'permissions', 'organizations', + 'tags', , ]; await queryInterface.bulkInsert( @@ -223,6 +224,31 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_TASKS'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('User'), + permissionId: getId('CREATE_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('User'), + permissionId: getId('READ_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('User'), + permissionId: getId('UPDATE_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('User'), + permissionId: getId('DELETE_TAGS'), + }, + { createdAt, updatedAt, @@ -330,6 +356,31 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_TASKS'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_TAGS'), + }, + { createdAt, updatedAt, @@ -505,6 +556,31 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_ORGANIZATIONS'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('CREATE_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('READ_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('UPDATE_TAGS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('SuperAdmin'), + permissionId: getId('DELETE_TAGS'), + }, + { createdAt, updatedAt, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index a9cea91..6f5633f 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -9,7 +9,41 @@ const Tasks = db.tasks; const Organizations = db.organizations; +const Tags = db.tags; + const CategoriesData = [ + { + name: 'Jonas Salk', + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + + { + name: 'Francis Crick', + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + + { + name: 'Pierre Simon de Laplace', + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + + { + name: 'Andreas Vesalius', + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + { name: 'Justus Liebig', @@ -17,44 +51,63 @@ const CategoriesData = [ // type code here for "relation_one" field }, - - { - name: 'John Bardeen', - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, - - { - name: 'Jean Piaget', - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, - - { - name: 'Sheldon Glashow', - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, - - { - name: 'Linus Pauling', - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, ]; const ProjectsData = [ + { + name: 'Archimedes', + + description: + 'Like fire across the galaxy the Clone Wars spread. In league with the wicked Count Dooku, more and more planets slip. Against this threat, upon the Jedi Knights falls the duty to lead the newly formed army of the Republic. And as the heat of war grows, so, to, grows the prowess of one most gifted student of the Force.', + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + { name: 'Claude Levi-Strauss', + description: + 'Strong is Vader. Mind what you have learned. Save you it can.', + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + + { + name: 'Francis Galton', + + description: + 'Soon will I rest, yes, forever sleep. Earned it I have. Twilight is upon me, soon night must fall.', + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + + { + name: 'Charles Sherrington', + + description: 'To answer power with power, the Jedi way this is', + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "relation_one" field + }, + + { + name: 'Ludwig Boltzmann', + description: 'Death is a natural part of life. Rejoice for those around you who transform into the Force. Mourn them do not. Miss them do not. Attachment leads to jealously. The shadow of greed, that is.', @@ -64,81 +117,14 @@ const ProjectsData = [ // type code here for "relation_one" field }, - - { - name: 'Max von Laue', - - description: 'You will find only what you bring in.', - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, - - { - name: 'Gertrude Belle Elion', - - description: 'Already know you that which you need.', - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, - - { - name: 'Emil Kraepelin', - - description: 'Good relations with the Wookiees, I have.', - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, - - { - name: 'Ernst Haeckel', - - description: 'Truly wonderful, the mind of a child is.', - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - // type code here for "relation_one" field - }, ]; const TasksData = [ { - title: 'I want my damn cart back', + title: 'No one tells me shit', - description: 'At an end your rule is, and not short enough it was!', - - status: 'InProgress', - - // type code here for "relation_one" field - - // type code here for "relation_one" field - - // type code here for "relation_one" field - - start_date: new Date('2024-05-30'), - - end_date: new Date('2024-12-14'), - - // type code here for "relation_one" field - }, - - { - title: 'Yup', - - description: 'Younglings, younglings gather ’round.', + description: + 'Through the Force, things you will see. Other places. The future - the past. Old friends long gone.', status: 'ToDo', @@ -148,20 +134,44 @@ const TasksData = [ // type code here for "relation_one" field - start_date: new Date('2024-09-14'), + start_date: new Date('2024-08-22'), - end_date: new Date('2024-11-06'), + end_date: new Date('2024-04-20'), // type code here for "relation_one" field + + // type code here for "relation_many" field }, { - title: 'Turd gone wrong', + title: 'No one tells me shit', + + description: 'That is why you fail.', + + status: 'InProgress', + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_date: new Date('2024-07-25'), + + end_date: new Date('2025-01-16'), + + // type code here for "relation_one" field + + // type code here for "relation_many" field + }, + + { + title: 'Let me tell ya', description: 'Much to learn you still have my old padawan. ... This is just the beginning!', - status: 'InProgress', + status: 'ToDo', // type code here for "relation_one" field @@ -169,37 +179,19 @@ const TasksData = [ // type code here for "relation_one" field - start_date: new Date('2024-11-17'), + start_date: new Date('2024-10-08'), - end_date: new Date('2024-06-18'), + end_date: new Date('2024-05-10'), // type code here for "relation_one" field + + // type code here for "relation_many" field }, { - title: 'I want my damn cart back', + title: "How 'bout them Cowboys", - description: 'Ow, ow, OW! On my ear you are!', - - status: 'InProgress', - - // type code here for "relation_one" field - - // type code here for "relation_one" field - - // type code here for "relation_one" field - - start_date: new Date('2025-02-28'), - - end_date: new Date('2024-04-05'), - - // type code here for "relation_one" field - }, - - { - title: 'My buddy Harlen', - - description: 'Around the survivors a perimeter create.', + description: 'Luminous beings are we - not this crude matter.', status: 'ToDo', @@ -209,34 +201,90 @@ const TasksData = [ // type code here for "relation_one" field - start_date: new Date('2024-10-19'), + start_date: new Date('2024-12-06'), - end_date: new Date('2024-06-01'), + end_date: new Date('2025-03-25'), // type code here for "relation_one" field + + // type code here for "relation_many" field + }, + + { + title: 'I want my 5$ back', + + description: 'Not if anything to say about it I have', + + status: 'ToDo', + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + // type code here for "relation_one" field + + start_date: new Date('2024-07-05'), + + end_date: new Date('2024-06-13'), + + // type code here for "relation_one" field + + // type code here for "relation_many" field }, ]; const OrganizationsData = [ { - name: 'Alfred Kinsey', + name: 'Pierre Simon de Laplace', }, { - name: 'Jean Piaget', - }, - - { - name: 'Rudolf Virchow', - }, - - { - name: 'Werner Heisenberg', + name: 'Isaac Newton', }, { name: 'Enrico Fermi', }, + + { + name: 'Isaac Newton', + }, + + { + name: 'Ernst Haeckel', + }, +]; + +const TagsData = [ + { + // type code here for "relation_one" field + + name: 'Enrico Fermi', + }, + + { + // type code here for "relation_one" field + + name: 'Francis Galton', + }, + + { + // type code here for "relation_one" field + + name: 'Anton van Leeuwenhoek', + }, + + { + // type code here for "relation_one" field + + name: 'John von Neumann', + }, + + { + // type code here for "relation_one" field + + name: 'Alfred Binet', + }, ]; // Similar logic for "relation_many" @@ -646,6 +694,65 @@ async function associateTaskWithOrganization() { } } +// Similar logic for "relation_many" + +async function associateTagWithOrganization() { + const relatedOrganization0 = await Organizations.findOne({ + offset: Math.floor(Math.random() * (await Organizations.count())), + }); + const Tag0 = await Tags.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Tag0?.setOrganization) { + await Tag0.setOrganization(relatedOrganization0); + } + + const relatedOrganization1 = await Organizations.findOne({ + offset: Math.floor(Math.random() * (await Organizations.count())), + }); + const Tag1 = await Tags.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Tag1?.setOrganization) { + await Tag1.setOrganization(relatedOrganization1); + } + + const relatedOrganization2 = await Organizations.findOne({ + offset: Math.floor(Math.random() * (await Organizations.count())), + }); + const Tag2 = await Tags.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Tag2?.setOrganization) { + await Tag2.setOrganization(relatedOrganization2); + } + + const relatedOrganization3 = await Organizations.findOne({ + offset: Math.floor(Math.random() * (await Organizations.count())), + }); + const Tag3 = await Tags.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Tag3?.setOrganization) { + await Tag3.setOrganization(relatedOrganization3); + } + + const relatedOrganization4 = await Organizations.findOne({ + offset: Math.floor(Math.random() * (await Organizations.count())), + }); + const Tag4 = await Tags.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (Tag4?.setOrganization) { + await Tag4.setOrganization(relatedOrganization4); + } +} + module.exports = { up: async (queryInterface, Sequelize) => { await Categories.bulkCreate(CategoriesData); @@ -656,6 +763,8 @@ module.exports = { await Organizations.bulkCreate(OrganizationsData); + await Tags.bulkCreate(TagsData); + await Promise.all([ // Similar logic for "relation_many" @@ -678,6 +787,10 @@ module.exports = { await associateTaskWithCategory(), await associateTaskWithOrganization(), + + // Similar logic for "relation_many" + + await associateTagWithOrganization(), ]); }, @@ -689,5 +802,7 @@ module.exports = { await queryInterface.bulkDelete('tasks', null, {}); await queryInterface.bulkDelete('organizations', null, {}); + + await queryInterface.bulkDelete('tags', null, {}); }, }; diff --git a/backend/src/db/seeders/20250405113155.js b/backend/src/db/seeders/20250405113155.js new file mode 100644 index 0000000..4d82ef2 --- /dev/null +++ b/backend/src/db/seeders/20250405113155.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['tags']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.super_admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 04dfe75..99e61ea 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -33,6 +33,8 @@ const permissionsRoutes = require('./routes/permissions'); const organizationsRoutes = require('./routes/organizations'); +const tagsRoutes = require('./routes/tags'); + const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; @@ -140,6 +142,12 @@ app.use( organizationsRoutes, ); +app.use( + '/api/tags', + passport.authenticate('jwt', { session: false }), + tagsRoutes, +); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/tags.js b/backend/src/routes/tags.js new file mode 100644 index 0000000..dc11e54 --- /dev/null +++ b/backend/src/routes/tags.js @@ -0,0 +1,447 @@ +const express = require('express'); + +const TagsService = require('../services/tags'); +const TagsDBApi = require('../db/api/tags'); +const wrapAsync = require('../helpers').wrapAsync; + +const config = require('../config'); + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('tags')); + +/** + * @swagger + * components: + * schemas: + * Tags: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Tags + * description: The Tags managing API + */ + +/** + * @swagger + * /api/tags: + * post: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Tags" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tags" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TagsService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Tags" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tags" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TagsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tags/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Tags" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tags" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await TagsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tags/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tags" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await TagsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tags/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tags" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await TagsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tags: + * get: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Get all tags + * description: Get all tags + * responses: + * 200: + * description: Tags list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tags" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await TagsDBApi.findAll(req.query, globalAccess, { + currentUser, + }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/tags/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Count all tags + * description: Count all tags + * responses: + * 200: + * description: Tags count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tags" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const currentUser = req.currentUser; + const payload = await TagsDBApi.findAll(req.query, globalAccess, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tags/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Find all tags that match search criteria + * description: Find all tags that match search criteria + * responses: + * 200: + * description: Tags list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tags" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + + const organizationId = req.currentUser.organization?.id; + + const payload = await TagsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/tags/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Tags] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tags" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await TagsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index ed59873..ad36dd8 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -50,6 +50,8 @@ module.exports = class SearchService { tasks: ['title', 'description'], organizations: ['name'], + + tags: ['name'], }; const columnsInt = {}; diff --git a/backend/src/services/tags.js b/backend/src/services/tags.js new file mode 100644 index 0000000..afe5189 --- /dev/null +++ b/backend/src/services/tags.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const TagsDBApi = require('../db/api/tags'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class TagsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await TagsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await TagsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let tags = await TagsDBApi.findBy({ id }, { transaction }); + + if (!tags) { + throw new ValidationError('tagsNotFound'); + } + + const updatedTags = await TagsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedTags; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await TagsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await TagsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/Tags/CardTags.tsx b/frontend/src/components/Tags/CardTags.tsx new file mode 100644 index 0000000..890e046 --- /dev/null +++ b/frontend/src/components/Tags/CardTags.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + tags: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardTags = ({ + tags, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TAGS'); + + return ( +
+ {loading && } +
    + {!loading && + tags.map((item, index) => ( +
  • +
    + + {item.name} + + +
    + +
    +
    +
    +
    +
    Name
    +
    +
    {item.name}
    +
    +
    +
    +
  • + ))} + {!loading && tags.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardTags; diff --git a/frontend/src/components/Tags/ListTags.tsx b/frontend/src/components/Tags/ListTags.tsx new file mode 100644 index 0000000..3bcc8c5 --- /dev/null +++ b/frontend/src/components/Tags/ListTags.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + tags: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListTags = ({ + tags, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TAGS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + tags.map((item) => ( + +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Name

+

{item.name}

+
+ + +
+
+ ))} + {!loading && tags.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListTags; diff --git a/frontend/src/components/Tags/TableTags.tsx b/frontend/src/components/Tags/TableTags.tsx new file mode 100644 index 0000000..62856f3 --- /dev/null +++ b/frontend/src/components/Tags/TableTags.tsx @@ -0,0 +1,481 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/tags/tagsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureTagsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleTags = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + tags, + loading, + count, + notify: tagsNotify, + refetch, + } = useAppSelector((state) => state.tags); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (tagsNotify.showNotification) { + notify(tagsNotify.typeNotification, tagsNotify.textNotification); + } + }, [tagsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `tags`, currentUser).then((newCols) => + setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={tags ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleTags; diff --git a/frontend/src/components/Tags/configureTagsCols.tsx b/frontend/src/components/Tags/configureTagsCols.tsx new file mode 100644 index 0000000..8d4181e --- /dev/null +++ b/frontend/src/components/Tags/configureTagsCols.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_TAGS'); + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Tasks/CardTasks.tsx b/frontend/src/components/Tasks/CardTasks.tsx index eb9f858..2262277 100644 --- a/frontend/src/components/Tasks/CardTasks.tsx +++ b/frontend/src/components/Tasks/CardTasks.tsx @@ -161,6 +161,17 @@ const CardTasks = ({ + +
+
Tags
+
+
+ {dataFormatter + .tagsManyListFormatter(item.tags) + .join(', ')} +
+
+
))} diff --git a/frontend/src/components/Tasks/ListTasks.tsx b/frontend/src/components/Tasks/ListTasks.tsx index 3fae7db..a769429 100644 --- a/frontend/src/components/Tasks/ListTasks.tsx +++ b/frontend/src/components/Tasks/ListTasks.tsx @@ -104,6 +104,15 @@ const ListTasks = ({ {dataFormatter.dateTimeFormatter(item.end_date)}

+ +
+

Tags

+

+ {dataFormatter + .tagsManyListFormatter(item.tags) + .join(', ')} +

+
+ dataFormatter.tagsManyListFormatter(value).join(', '), + renderEditCell: (params) => ( + + ), + }, + { field: 'actions', type: 'actions', diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js index 30ccc3f..04682d6 100644 --- a/frontend/src/helpers/dataFormatter.js +++ b/frontend/src/helpers/dataFormatter.js @@ -171,4 +171,23 @@ export default { if (!val) return ''; return { label: val.name, id: val.id }; }, + + tagsManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + tagsOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + tagsManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + tagsOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, }; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index bd2a1aa..ebed274 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -68,6 +68,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable, permissions: 'READ_ORGANIZATIONS', }, + { + href: '/tags/tags-list', + label: 'Tags', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable, + permissions: 'READ_TAGS', + }, { href: '/profile', label: 'Profile', @@ -87,11 +95,6 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiFileCode, permissions: 'READ_API_DOCS', }, - { - label: 'Alert Hello', - icon: icon.mdiBellOutline, - onClick: () => alert("Hello!"), - }, ]; export default menuAside; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 4b3d659..30596ff 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -29,6 +29,7 @@ const Dashboard = () => { const [roles, setRoles] = React.useState('Loading...'); const [permissions, setPermissions] = React.useState('Loading...'); const [organizations, setOrganizations] = React.useState('Loading...'); + const [tags, setTags] = React.useState('Loading...'); const [widgetsRole, setWidgetsRole] = React.useState({ role: { value: '', label: '' }, @@ -49,6 +50,7 @@ const Dashboard = () => { 'roles', 'permissions', 'organizations', + 'tags', ]; const fns = [ setUsers, @@ -58,6 +60,7 @@ const Dashboard = () => { setRoles, setPermissions, setOrganizations, + setTags, ]; const requests = entities.map((entity, index) => { @@ -389,6 +392,38 @@ const Dashboard = () => { )} + + {hasPermission(currentUser, 'READ_TAGS') && ( + +
+
+
+
+ Tags +
+
+ {tags} +
+
+
+ +
+
+
+ + )} diff --git a/frontend/src/pages/organizations/organizations-view.tsx b/frontend/src/pages/organizations/organizations-view.tsx index c90ba06..4f65121 100644 --- a/frontend/src/pages/organizations/organizations-view.tsx +++ b/frontend/src/pages/organizations/organizations-view.tsx @@ -247,6 +247,41 @@ const OrganizationsView = () => { + <> +

Tags organizations

+ +
+ + + + + + + + {organizations.tags_organizations && + Array.isArray(organizations.tags_organizations) && + organizations.tags_organizations.map((item: any) => ( + + router.push(`/tags/tags-view/?id=${item.id}`) + } + > + + + ))} + +
Name
{item.name}
+
+ {!organizations?.tags_organizations?.length && ( +
No data
+ )} +
+ + { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + organizations: null, + + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { tags } = useAppSelector((state) => state.tags); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { tagsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: tagsId })); + }, [tagsId]); + + useEffect(() => { + if (typeof tags === 'object') { + setInitialValues(tags); + } + }, [tags]); + + useEffect(() => { + if (typeof tags === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = tags[el])); + + setInitialValues(newInitialVal); + } + }, [tags]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: tagsId, data })); + await router.push('/tags/tags-list'); + }; + + return ( + <> + + {getPageTitle('Edit tags')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + router.push('/tags/tags-list')} + /> + + +
+
+
+ + ); +}; + +EditTags.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default EditTags; diff --git a/frontend/src/pages/tags/tags-edit.tsx b/frontend/src/pages/tags/tags-edit.tsx new file mode 100644 index 0000000..db593c4 --- /dev/null +++ b/frontend/src/pages/tags/tags-edit.tsx @@ -0,0 +1,137 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/tags/tagsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const EditTagsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + organizations: null, + + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { tags } = useAppSelector((state) => state.tags); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof tags === 'object') { + setInitialValues(tags); + } + }, [tags]); + + useEffect(() => { + if (typeof tags === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = tags[el])); + setInitialValues(newInitialVal); + } + }, [tags]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/tags/tags-list'); + }; + + return ( + <> + + {getPageTitle('Edit tags')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + router.push('/tags/tags-list')} + /> + + +
+
+
+ + ); +}; + +EditTagsPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default EditTagsPage; diff --git a/frontend/src/pages/tags/tags-list.tsx b/frontend/src/pages/tags/tags-list.tsx new file mode 100644 index 0000000..118a6af --- /dev/null +++ b/frontend/src/pages/tags/tags-list.tsx @@ -0,0 +1,160 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableTags from '../../components/Tags/TableTags'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/tags/tagsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TagsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_TAGS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getTagsCSV = async () => { + const response = await axios({ + url: '/tags?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'tagsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Tags')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + + +
+ + + + + ); +}; + +TagsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TagsTablesPage; diff --git a/frontend/src/pages/tags/tags-new.tsx b/frontend/src/pages/tags/tags-new.tsx new file mode 100644 index 0000000..1372c30 --- /dev/null +++ b/frontend/src/pages/tags/tags-new.tsx @@ -0,0 +1,108 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/tags/tagsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + organizations: '', + + name: '', +}; + +const TagsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/tags/tags-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + router.push('/tags/tags-list')} + /> + + +
+
+
+ + ); +}; + +TagsNew.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TagsNew; diff --git a/frontend/src/pages/tags/tags-table.tsx b/frontend/src/pages/tags/tags-table.tsx new file mode 100644 index 0000000..e9f02c6 --- /dev/null +++ b/frontend/src/pages/tags/tags-table.tsx @@ -0,0 +1,159 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableTags from '../../components/Tags/TableTags'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/tags/tagsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TagsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_TAGS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getTagsCSV = async () => { + const response = await axios({ + url: '/tags?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'tagsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Tags')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + +
+ + + + + ); +}; + +TagsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TagsTablesPage; diff --git a/frontend/src/pages/tags/tags-view.tsx b/frontend/src/pages/tags/tags-view.tsx new file mode 100644 index 0000000..143bd60 --- /dev/null +++ b/frontend/src/pages/tags/tags-view.tsx @@ -0,0 +1,91 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/tags/tagsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TagsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { tags } = useAppSelector((state) => state.tags); + + const { currentUser } = useAppSelector((state) => state.auth); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View tags')} + + + + + + +
+

organizations

+ +

{tags?.organizations?.name ?? 'No data'}

+
+ +
+

Name

+

{tags?.name}

+
+ + + + router.push('/tags/tags-list')} + /> +
+
+ + ); +}; + +TagsView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TagsView; diff --git a/frontend/src/pages/tasks/[tasksId].tsx b/frontend/src/pages/tasks/[tasksId].tsx index 3a181e4..004a0cd 100644 --- a/frontend/src/pages/tasks/[tasksId].tsx +++ b/frontend/src/pages/tasks/[tasksId].tsx @@ -55,6 +55,8 @@ const EditTasks = () => { end_date: new Date(), organizations: null, + + tags: [], }; const [initialValues, setInitialValues] = useState(initVals); @@ -213,6 +215,17 @@ const EditTasks = () => { > + + + + diff --git a/frontend/src/pages/tasks/tasks-edit.tsx b/frontend/src/pages/tasks/tasks-edit.tsx index cc8cc63..d3d2063 100644 --- a/frontend/src/pages/tasks/tasks-edit.tsx +++ b/frontend/src/pages/tasks/tasks-edit.tsx @@ -55,6 +55,8 @@ const EditTasksPage = () => { end_date: new Date(), organizations: null, + + tags: [], }; const [initialValues, setInitialValues] = useState(initVals); @@ -211,6 +213,17 @@ const EditTasksPage = () => { > + + + + diff --git a/frontend/src/pages/tasks/tasks-list.tsx b/frontend/src/pages/tasks/tasks-list.tsx index b740f2f..19777fe 100644 --- a/frontend/src/pages/tasks/tasks-list.tsx +++ b/frontend/src/pages/tasks/tasks-list.tsx @@ -41,6 +41,7 @@ const TasksTablesPage = () => { { label: 'Category', title: 'category' }, + { label: 'Tags', title: 'tags' }, { label: 'Status', title: 'status', diff --git a/frontend/src/pages/tasks/tasks-new.tsx b/frontend/src/pages/tasks/tasks-new.tsx index a527cb3..04a7eb4 100644 --- a/frontend/src/pages/tasks/tasks-new.tsx +++ b/frontend/src/pages/tasks/tasks-new.tsx @@ -50,6 +50,8 @@ const initialValues = { end_date: '', organizations: '', + + tags: [], }; const TasksNew = () => { @@ -157,6 +159,16 @@ const TasksNew = () => { > + + + + diff --git a/frontend/src/pages/tasks/tasks-table.tsx b/frontend/src/pages/tasks/tasks-table.tsx index 6ac9b93..17acb82 100644 --- a/frontend/src/pages/tasks/tasks-table.tsx +++ b/frontend/src/pages/tasks/tasks-table.tsx @@ -41,6 +41,7 @@ const TasksTablesPage = () => { { label: 'Category', title: 'category' }, + { label: 'Tags', title: 'tags' }, { label: 'Status', title: 'status', diff --git a/frontend/src/pages/tasks/tasks-view.tsx b/frontend/src/pages/tasks/tasks-view.tsx index a481de7..75a1c29 100644 --- a/frontend/src/pages/tasks/tasks-view.tsx +++ b/frontend/src/pages/tasks/tasks-view.tsx @@ -137,6 +137,41 @@ const TasksView = () => {

{tasks?.organizations?.name ?? 'No data'}

+ <> +

Tags

+ +
+ + + + + + + + {tasks.tags && + Array.isArray(tasks.tags) && + tasks.tags.map((item: any) => ( + + router.push(`/tags/tags-view/?id=${item.id}`) + } + > + + + ))} + +
Name
{item.name}
+
+ {!tasks?.tags?.length && ( +
No data
+ )} +
+ + { + const { id, query } = data; + const result = await axios.get(`tags${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'tags/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('tags/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'tags/deleteTags', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`tags/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'tags/createTags', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('tags', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'tags/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('tags/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'tags/updateTags', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`tags/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const tagsSlice = createSlice({ + name: 'tags', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.tags = action.payload.rows; + state.count = action.payload.count; + } else { + state.tags = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Tags has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Tags'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Tags'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Tags'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Tags has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = tagsSlice.actions; + +export default tagsSlice.reducer;