From f86b2a38cb14d134ff022eeeede1679d6f95fd0f Mon Sep 17 00:00:00 2001 From: Takeshi Kimata <117462761+kimatata@users.noreply.github.com> Date: Sat, 22 Jun 2024 16:54:08 +0900 Subject: [PATCH] feat: implement bulk step update --- backend/index.js | 6 +- backend/routes/steps/delete.js | 60 ------------- backend/routes/steps/edit.js | 90 +++++++++++++++++++ backend/routes/steps/new.js | 65 -------------- .../[folderId]/cases/[caseId]/CaseEditor.tsx | 89 +++++++++++------- .../cases/[caseId]/CaseStepsEditor.tsx | 5 +- .../[folderId]/cases/[caseId]/stepControl.ts | 31 ++----- frontend/types/case.ts | 18 ++-- 8 files changed, 167 insertions(+), 197 deletions(-) delete mode 100644 backend/routes/steps/delete.js create mode 100644 backend/routes/steps/edit.js delete mode 100644 backend/routes/steps/new.js diff --git a/backend/index.js b/backend/index.js index 6b53c44..0c70640 100644 --- a/backend/index.js +++ b/backend/index.js @@ -73,10 +73,8 @@ app.use('/cases', casesEditRoute); app.use('/cases', casesDeleteRoute); // "/steps" -const stepsNewRoute = require('./routes/steps/new')(sequelize); -const stepsDeleteRoute = require('./routes/steps/delete')(sequelize); -app.use('/steps', stepsNewRoute); -app.use('/steps', stepsDeleteRoute); +const stepsEditRoute = require('./routes/steps/edit')(sequelize); +app.use('/steps', stepsEditRoute); // "/attachments" const attachmentsNewRoute = require('./routes/attachments/new')(sequelize); diff --git a/backend/routes/steps/delete.js b/backend/routes/steps/delete.js deleted file mode 100644 index 95360be..0000000 --- a/backend/routes/steps/delete.js +++ /dev/null @@ -1,60 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const defineStep = require('../../models/steps'); -const defineCaseStep = require('../../models/caseSteps'); -const { DataTypes, Op } = require('sequelize'); - -module.exports = function (sequelize) { - const Step = defineStep(sequelize, DataTypes); - const CaseStep = defineCaseStep(sequelize, DataTypes); - const { verifySignedIn } = require('../../middleware/auth')(sequelize); - const { verifyProjectDeveloperFromCaseId } = require('../../middleware/verifyEditable')(sequelize); - - router.delete('/:stepId', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => { - const stepId = req.params.stepId; - const caseId = req.query.caseId; - - const t = await sequelize.transaction(); - - try { - const step = await Step.findByPk(stepId); - if (!step) { - await t.rollback(); - return res.status(404).send('Step not found'); - } - - // Get caseStep to be deleted. - const deletingCaseStep = await CaseStep.findOne({ - where: { - StepId: stepId, - }, - transaction: t, - }); - - // Decrease stepNo for all caseSteps with greater than the caseStep to be deleted. - await CaseStep.update( - { stepNo: sequelize.literal('stepNo - 1') }, - { - where: { - CaseId: caseId, - stepNo: { - [Op.gt]: deletingCaseStep.stepNo, - }, - }, - transaction: t, - } - ); - - await step.destroy({ transaction: t }); - - await t.commit(); - res.status(204).send(); - } catch (error) { - console.error(error); - await t.rollback(); - res.status(500).send('Internal Server Error'); - } - }); - - return router; -}; diff --git a/backend/routes/steps/edit.js b/backend/routes/steps/edit.js new file mode 100644 index 0000000..aa5a7a7 --- /dev/null +++ b/backend/routes/steps/edit.js @@ -0,0 +1,90 @@ +const express = require('express'); +const router = express.Router(); +const defineStep = require('../../models/steps'); +const defineCaseStep = require('../../models/caseSteps'); +const { DataTypes } = require('sequelize'); + +module.exports = function (sequelize) { + const Step = defineStep(sequelize, DataTypes); + const CaseStep = defineCaseStep(sequelize, DataTypes); + const { verifySignedIn } = require('../../middleware/auth')(sequelize); + const { verifyProjectDeveloperFromCaseId } = require('../../middleware/verifyEditable')(sequelize); + + router.post('/update', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => { + const caseId = req.query.caseId; + const steps = req.body; + const t = await sequelize.transaction(); + + const createStep = async (step) => { + const newStep = await Step.create( + { + step: step.step, + result: step.result, + }, + { transaction: t } + ); + await CaseStep.create( + { + caseId: caseId, + stepId: newStep.id, + stepNo: step.caseSteps.stepNo, + }, + { transaction: t } + ); + return newStep; + }; + + const deleteStep = async (step) => { + await CaseStep.destroy({ + where: { stepId: step.id }, + transaction: t, + }); + await Step.destroy({ + where: { id: step.id }, + transaction: t, + }); + return null; + }; + + const updateStep = async (step) => { + await Step.update(step, { + where: { id: step.id }, + transaction: t, + }); + await CaseStep.update( + { + stepNo: step.caseSteps.stepNo, + }, + { + where: { stepId: step.id }, + transaction: t, + } + ); + return step; + }; + try { + const results = await Promise.all( + steps.map(async (step) => { + if (step.editState === 'new') { + return createStep(step); + } else if (step.editState === 'deleted') { + return deleteStep(step); + } else if (step.editState === 'changed') { + return updateStep(step); + } else if (step.editState === 'notChanged') { + return step; + } + }) + ); + + await t.commit(); + res.json(results.filter((result) => result !== null)); + } catch (error) { + console.error(error); + await t.rollback(); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/backend/routes/steps/new.js b/backend/routes/steps/new.js deleted file mode 100644 index 0089553..0000000 --- a/backend/routes/steps/new.js +++ /dev/null @@ -1,65 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const defineStep = require('../../models/steps'); -const defineCaseStep = require('../../models/caseSteps'); -const { DataTypes, Op } = require('sequelize'); - -module.exports = function (sequelize) { - const Step = defineStep(sequelize, DataTypes); - const CaseStep = defineCaseStep(sequelize, DataTypes); - const { verifySignedIn } = require('../../middleware/auth')(sequelize); - const { verifyProjectDeveloperFromCaseId } = require('../../middleware/verifyEditable')(sequelize); - - router.post('/', verifySignedIn, verifyProjectDeveloperFromCaseId, async (req, res) => { - const newStepNo = req.query.newStepNo; - const caseId = req.query.caseId; - - const t = await sequelize.transaction(); - - try { - // Update existing stepNo for steps with stepNo greater than or equal to newStepNo - const maxStepNo = await CaseStep.max('stepNo', { - where: { caseId: caseId }, - transaction: t, - }); - if (maxStepNo >= newStepNo) { - await CaseStep.update( - { stepNo: sequelize.literal('stepNo + 1') }, - { - where: { - caseId: caseId, - stepNo: { [Op.gte]: newStepNo }, - }, - transaction: t, - } - ); - } - - const newStep = await Step.create( - { - step: '', - result: '', - }, - { transaction: t } - ); - - await CaseStep.create( - { - caseId: caseId, - stepId: newStep.id, - stepNo: newStepNo, - }, - { transaction: t } - ); - - await t.commit(); - res.json(newStep); - } catch (error) { - console.error(error); - await t.rollback(); - res.status(500).send('Internal Server Error'); - } - }); - - return router; -}; diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx index b5cba96..0122131 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseEditor.tsx @@ -6,9 +6,9 @@ import { Save, Plus, ArrowLeft, Circle } from 'lucide-react'; import { priorities, testTypes, templates } from '@/config/selection'; import CaseStepsEditor from './CaseStepsEditor'; import CaseAttachmentsEditor from './CaseAttachmentsEditor'; -import { CaseType, AttachmentType, CaseMessages } from '@/types/case'; -import { fetchCase, updateCase } from '../../../../../../../../../utils/caseControl'; -import { fetchCreateStep, fetchDeleteStep } from './stepControl'; +import { CaseType, AttachmentType, CaseMessages, StepType } from '@/types/case'; +import { fetchCase, updateCase } from '@/utils/caseControl'; +import { updateSteps } from './stepControl'; import { fetchCreateAttachments, fetchDownloadAttachment, fetchDeleteAttachment } from './attachmentControl'; import { TokenContext } from '@/utils/TokenProvider'; @@ -43,16 +43,30 @@ export default function CaseEditor({ projectId, folderId, caseId, messages, loca const [testCase, setTestCase] = useState(defaultTestCase); const [isTitleInvalid, setIsTitleInvalid] = useState(false); const [isUpdating, setIsUpdating] = useState(false); + const [plusCount, setPlusCount] = useState(0); const router = useRouter(); const onPlusClick = async (newStepNo: number) => { - const newStep = await fetchCreateStep(context.token.access_token, newStepNo, Number(caseId)); - if (newStep) { - newStep.caseSteps = { stepNo: newStepNo }; + const newStep: StepType = { + id: plusCount, + step: '', + result: '', + createdAt: new Date(), + updatedAt: new Date(), + caseSteps: { + stepNo: newStepNo, + }, + uid: `uid${plusCount}`, + editState: 'new', + }; + setPlusCount(plusCount + 1); + + if (testCase.Steps) { const updatedSteps = testCase.Steps.map((step) => { if (step.caseSteps.stepNo >= newStepNo) { return { ...step, + editState: step.editState === 'notChanged' ? 'changed' : step.editState, caseSteps: { ...step.caseSteps, stepNo: step.caseSteps.stepNo + 1, @@ -73,41 +87,42 @@ export default function CaseEditor({ projectId, folderId, caseId, messages, loca const onDeleteClick = async (stepId: number) => { // find deletedStep's stepNo - const deletedStep = testCase.Steps.find((step) => step.id === stepId); - if (!deletedStep) { - return; - } - const deletedStepNo = deletedStep.caseSteps.stepNo; - - // delete request - await fetchDeleteStep(context.token.access_token, stepId, Number(caseId)); - - const updatedSteps = testCase.Steps.map((step) => { - if (step.caseSteps.stepNo > deletedStepNo) { - return { - ...step, - caseSteps: { - ...step.caseSteps, - stepNo: step.caseSteps.stepNo - 1, - }, - }; + if (testCase.Steps) { + const deletedStep = testCase.Steps.find((step) => step.id === stepId); + if (!deletedStep) { + return; } - return step; - }).filter((step) => step.id !== stepId); + const deletedStepNo = deletedStep.caseSteps.stepNo; + deletedStep.editState = 'deleted'; - setTestCase({ - ...testCase, - Steps: updatedSteps, - }); + const updatedSteps = testCase.Steps.map((step) => { + if (step.caseSteps.stepNo > deletedStepNo) { + return { + ...step, + editState: step.editState === 'notChanged' ? 'changed' : step.editState, + caseSteps: { + ...step.caseSteps, + stepNo: step.caseSteps.stepNo - 1, + }, + }; + } + return step; + }); + + setTestCase({ + ...testCase, + Steps: updatedSteps, + }); + } }; - const handleDrop = async (event) => { + const handleDrop = async (event: DragEvent) => { event.preventDefault(); - handleFetchCreateAttachments(caseId, event.dataTransfer.files); + handleFetchCreateAttachments(Number(caseId), event.dataTransfer.files); }; - const handleInput = (event) => { - handleFetchCreateAttachments(caseId, event.target.files); + const handleInput = (event: DragEvent) => { + handleFetchCreateAttachments(Number(caseId), event.target.files); }; const handleFetchCreateAttachments = async (caseId: number, files: File[]) => { @@ -147,6 +162,9 @@ export default function CaseEditor({ projectId, folderId, caseId, messages, loca } try { const data = await fetchCase(context.token.access_token, Number(caseId)); + data.Steps.forEach((step: StepType) => { + step.editState = 'notChanged'; + }); setTestCase(data); } catch (error: any) { console.error('Error in effect:', error.message); @@ -181,6 +199,9 @@ export default function CaseEditor({ projectId, folderId, caseId, messages, loca onPress={async () => { setIsUpdating(true); await updateCase(context.token.access_token, testCase); + if (testCase.Steps) { + await updateSteps(context.token.access_token, Number(caseId), testCase.Steps); + } setIsUpdating(false); }} > diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseStepsEditor.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseStepsEditor.tsx index 77cf918..5d403cf 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseStepsEditor.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/CaseStepsEditor.tsx @@ -19,9 +19,12 @@ export default function StepsEditor({ isDisabled, steps, onStepUpdate, onStepPlu return stepNoA - stepNoB; }); + // filter steps + const filteredSteps = sortedSteps.filter((entry) => entry.editState !== 'deleted'); + return ( <> - {sortedSteps.map((step, index) => ( + {filteredSteps.map((step, index) => (
{step.caseSteps.stepNo}
diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/stepControl.ts b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/stepControl.ts index 7058a48..f1bedd8 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/stepControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/[caseId]/stepControl.ts @@ -1,16 +1,19 @@ import Config from '@/config/config'; +import { StepType } from '@/types/case'; const apiServer = Config.apiServer; -async function fetchCreateStep(jwt: string, newStepNo: number, parentCaseId: number) { +async function updateSteps(jwt: string, caseId: number, steps: StepType[]) { + console.log(steps); const fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, + body: JSON.stringify(steps), }; - const url = `${apiServer}/steps?newStepNo=${newStepNo}&caseId=${parentCaseId}`; + const url = `${apiServer}/steps/update?caseId=${caseId}`; try { const response = await fetch(url, fetchOptions); @@ -24,26 +27,4 @@ async function fetchCreateStep(jwt: string, newStepNo: number, parentCaseId: num } } -async function fetchDeleteStep(jwt: string, stepId: number, parentCaseId: number) { - const fetchOptions = { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - }; - - const url = `${apiServer}/steps/${stepId}?caseId=${parentCaseId}`; - - try { - const response = await fetch(url, fetchOptions); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - } catch (error: any) { - console.error('Error deleting project:', error); - throw error; - } -} - -export { fetchCreateStep, fetchDeleteStep }; +export { updateSteps }; diff --git a/frontend/types/case.ts b/frontend/types/case.ts index 5b53466..ebecad1 100644 --- a/frontend/types/case.ts +++ b/frontend/types/case.ts @@ -10,17 +10,17 @@ type CaseType = { preConditions: string; expectedResults: string; folderId: number; - Steps: StepType[]; // additional property - Attachments: AttachmentType[]; // additional property - isIncluded: boolean; // additional property - runStatus: number; // additional property + Steps?: StepType[]; + Attachments?: AttachmentType[]; + isIncluded?: boolean; + runStatus?: number; }; type CaseStepType = { - createdAt: Date; - updatedAt: Date; - CaseId: number; - StepId: number; + createdAt?: Date; + updatedAt?: Date; + CaseId?: number; + StepId?: number; stepNo: number; }; @@ -31,6 +31,8 @@ type StepType = { createdAt: Date; updatedAt: Date; caseSteps: CaseStepType; + uid: string; + editState: 'notChanged' | 'changed' | 'new' | 'deleted'; }; type CaseAttachmentType = {