diff --git a/backend/.gitignore b/backend/.gitignore index 3fa91ea..de5fee4 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,4 @@ node_modules/ .env database.sqlite +public/uploads/ diff --git a/backend/index.js b/backend/index.js index b1f14c8..b5d3a9d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,4 +1,5 @@ const express = require("express"); +const path = require('path'); const { Sequelize } = require("sequelize"); const app = express(); @@ -13,6 +14,9 @@ app.use(cors(corsOptions)); // enable json middleware app.use(express.json()); +// Specify the directory to serve static files +app.use(express.static(path.join(__dirname, "public"))); + // init sequalize const sequelize = new Sequelize({ dialect: "sqlite", diff --git a/backend/migrations/20240316200815-create-attachments.js b/backend/migrations/20240316200815-create-attachments.js new file mode 100644 index 0000000..489f59c --- /dev/null +++ b/backend/migrations/20240316200815-create-attachments.js @@ -0,0 +1,38 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.createTable('attachments', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: Sequelize.STRING, + allowNull: false, + }, + detail: { + type: Sequelize.STRING, + allowNull: true, + }, + path: { + type: Sequelize.STRING, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.dropTable('attachments'); + } +}; diff --git a/backend/migrations/20240316201158-create-cases-attachments.js b/backend/migrations/20240316201158-create-cases-attachments.js new file mode 100644 index 0000000..96b7c73 --- /dev/null +++ b/backend/migrations/20240316201158-create-cases-attachments.js @@ -0,0 +1,48 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("caseAttachments", { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + caseId: { + type: Sequelize.INTEGER, + references: { + model: "cases", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + attachmentId: { + type: Sequelize.INTEGER, + references: { + model: "attachments", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + await queryInterface.addIndex("caseAttachments", ["caseId", "attachmentId"], { + unique: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("caseAttachments"); + }, +}; diff --git a/backend/models/attachments.js b/backend/models/attachments.js new file mode 100644 index 0000000..92ca26d --- /dev/null +++ b/backend/models/attachments.js @@ -0,0 +1,26 @@ +function defineAttachment(sequelize, DataTypes) { + const Attachment = sequelize.define("Attachment", { + title: { + type: DataTypes.STRING, + allowNull: false, + }, + detail: { + type: DataTypes.STRING, + allowNull: true, + }, + path: { + type: DataTypes.STRING, + allowNull: false, + }, + }); + + Attachment.associate = (models) => { + Attachment.belongsToMany(models.Case, { + through: "caseAttachments" + }); + }; + + return Attachment; +} + +module.exports = defineAttachment; diff --git a/backend/models/caseAttachments.js b/backend/models/caseAttachments.js new file mode 100644 index 0000000..898565f --- /dev/null +++ b/backend/models/caseAttachments.js @@ -0,0 +1,27 @@ +function defineCaseAttachment(sequelize, DataTypes) { + const CaseAttachment = sequelize.define("CaseAttachment", { + caseId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + attachmentId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }); + + CaseAttachment.associate = (models) => { + CaseAttachment.belongsTo(models.Case, { + foreignKey: "caseId", + onDelete: "CASCADE", + }); + CaseAttachment.belongsTo(models.Attachment, { + foreignKey: "attachmentId", + onDelete: "CASCADE", + }); + }; + + return CaseAttachment; +} + +module.exports = defineCaseAttachment; diff --git a/backend/public/sample/861px-Selenium_Logo.png b/backend/public/sample/861px-Selenium_Logo.png new file mode 100644 index 0000000..9f4242c Binary files /dev/null and b/backend/public/sample/861px-Selenium_Logo.png differ diff --git a/backend/public/sample/logo-shadow.svg b/backend/public/sample/logo-shadow.svg new file mode 100644 index 0000000..e5b59bb --- /dev/null +++ b/backend/public/sample/logo-shadow.svg @@ -0,0 +1,24 @@ + diff --git a/backend/routes/cases/index.js b/backend/routes/cases/index.js index 64bdd40..9e1050b 100644 --- a/backend/routes/cases/index.js +++ b/backend/routes/cases/index.js @@ -2,13 +2,17 @@ const express = require("express"); const router = express.Router(); const defineCase = require("../../models/cases"); const defineStep = require("../../models/steps"); +const defineAttachment = require("../../models/attachments"); const { DataTypes } = require("sequelize"); module.exports = function (sequelize) { const Case = defineCase(sequelize, DataTypes); const Step = defineStep(sequelize, DataTypes); - Case.belongsToMany(Step, { through: 'caseSteps' }); - Step.belongsToMany(Case, { through: 'caseSteps' }); + const Attachment = defineAttachment(sequelize, DataTypes); + Case.belongsToMany(Step, { through: "caseSteps" }); + Step.belongsToMany(Case, { through: "caseSteps" }); + Case.belongsToMany(Attachment, { through: "caseAttachments" }); + Attachment.belongsToMany(Case, { through: "caseAttachments" }); router.get("/", async (req, res) => { const { caseId, folderId } = req.query; @@ -20,10 +24,15 @@ module.exports = function (sequelize) { if (caseId) { // Include steps if requested using caseId const testcase = await Case.findByPk(caseId, { - include: [{ - model: Step, - through: { attributes: ['stepNo'] }, - }], + include: [ + { + model: Step, + through: { attributes: ["stepNo"] }, + }, + { + model: Attachment, + }, + ], }); return res.json(testcase); } diff --git a/backend/seeders/seed.js b/backend/seeders/seed.js index 635f57d..dc50257 100644 --- a/backend/seeders/seed.js +++ b/backend/seeders/seed.js @@ -174,6 +174,38 @@ module.exports = { updatedAt: new Date(), }, ]); + + await queryInterface.bulkInsert("attachments", [ + { + title: "Selenium logo", + detail: "", + path: "http://localhost:3001/sample/861px-Selenium_Logo.png", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + title: "vitest logo", + detail: "", + path: "http://localhost:3001/sample/logo-shadow.svg", + createdAt: new Date(), + updatedAt: new Date(), + } + ]); + + await queryInterface.bulkInsert("caseAttachments", [ + { + caseId: 1, + attachmentId: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + caseId: 1, + attachmentId: 2, + createdAt: new Date(), + updatedAt: new Date(), + } + ]); }, down: async (queryInterface, Sequelize) => { diff --git a/frontend/app/projects/[projectId]/folders/[folderId]/cases/[caseId]/attachments-editor.tsx b/frontend/app/projects/[projectId]/folders/[folderId]/cases/[caseId]/attachments-editor.tsx new file mode 100644 index 0000000..8b3aa04 --- /dev/null +++ b/frontend/app/projects/[projectId]/folders/[folderId]/cases/[caseId]/attachments-editor.tsx @@ -0,0 +1,35 @@ +import { Image, Button, Tooltip } from "@nextui-org/react"; +import { AttachmentType } from "./page"; +import { Trash } from "lucide-react"; + +type Props = { + attachments: AttachmentType[]; + onAttachmentDelete: (attachmentId: number) => void; +}; + +export default function AttachmentsEditor({ + attachments = [], + onAttachmentDelete, +}: Props) { + return ( + <> + {attachments.map((attachment, index) => ( +