diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eeea307..f5588f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Template for next version - [API] fix change user role (https://github.com/ohmyform/api/pull/49) - [API] update sqlite to 5.1.6 - [API] delete visitors on form delete (https://github.com/ohmyform/ohmyform/issues/181) +- [API] add mutation to delete submissions as form admin (https://github.com/ohmyform/ohmyform/issues/186) +- [UI] add interface to delete submissions as form admin (https://github.com/ohmyform/ohmyform/issues/186) ## [1.0.3] - 2022-03-27 diff --git a/api/src/entity/submission.entity.ts b/api/src/entity/submission.entity.ts index 1a222fee..dbf53e51 100644 --- a/api/src/entity/submission.entity.ts +++ b/api/src/entity/submission.entity.ts @@ -20,7 +20,7 @@ export class SubmissionEntity { @PrimaryGeneratedColumn() public id: number - @OneToMany(() => SubmissionFieldEntity, field => field.submission, { eager: true }) + @OneToMany(() => SubmissionFieldEntity, field => field.submission, { eager: true, cascade: true }) public fields: SubmissionFieldEntity[] @ManyToOne(() => FormEntity, form => form.submissions, { eager: true }) @@ -29,7 +29,7 @@ export class SubmissionEntity { @RelationId('form') readonly formId: number - @ManyToOne(() => VisitorEntity, visitor => visitor.submissions, { eager: true }) + @ManyToOne(() => VisitorEntity, visitor => visitor.submissions, { eager: true, cascade: true }) public visitor: VisitorEntity @Column() diff --git a/api/src/resolver/submission/index.ts b/api/src/resolver/submission/index.ts index 483fe0d3..7c843271 100644 --- a/api/src/resolver/submission/index.ts +++ b/api/src/resolver/submission/index.ts @@ -1,3 +1,4 @@ +import { SubmissionDeleteMutation } from './submission.delete.mutation' import { SubmissionFieldResolver } from './submission.field.resolver' import { SubmissionListQuery } from './submission.list.query' import { SubmissionProgressResolver } from './submission.progress.resolver' @@ -10,6 +11,7 @@ import { SubmissionStatisticResolver } from './submission.statistic.resolver' import { SubmissionFinishMutation } from './submission.finish.mutation' export const submissionResolvers = [ + SubmissionDeleteMutation, SubmissionFieldResolver, SubmissionListQuery, SubmissionProgressResolver, diff --git a/api/src/resolver/submission/submission.delete.mutation.ts b/api/src/resolver/submission/submission.delete.mutation.ts new file mode 100644 index 00000000..8dcd7e37 --- /dev/null +++ b/api/src/resolver/submission/submission.delete.mutation.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common' +import { Args, ID, Mutation } from '@nestjs/graphql' +import { User } from '../../decorator/user.decorator' +import { DeletedModel } from '../../dto/deleted.model' +import { SubmissionEntity } from '../../entity/submission.entity' +import { UserEntity } from '../../entity/user.entity' +import { SubmissionByIdPipe } from '../../pipe/submission/submission.by.id.pipe' +import { FormService } from '../../service/form/form.service' +import { IdService } from '../../service/id.service' +import { SubmissionDeleteService } from '../../service/submission/submission.delete.service' + +@Injectable() +export class SubmissionDeleteMutation { + constructor( + private readonly formService: FormService, + private readonly deleteService: SubmissionDeleteService, + private readonly idService: IdService, + ) { + } + + @Mutation(() => DeletedModel) + async submissionDelete( + @User() user: UserEntity, + @Args({ name: 'id', type: () => ID }, SubmissionByIdPipe) submission: SubmissionEntity, + ): Promise { + if (!this.formService.isAdmin(submission.form, user)) { + throw new Error('invalid form') + } + + await this.deleteService.delete(submission.id) + + return new DeletedModel(this.idService.encode(submission.id)) + } +} diff --git a/api/src/service/form/form.delete.service.ts b/api/src/service/form/form.delete.service.ts index 4c155b0d..d827e529 100644 --- a/api/src/service/form/form.delete.service.ts +++ b/api/src/service/form/form.delete.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm' import { FormEntity } from '../../entity/form.entity' import { SubmissionEntity } from '../../entity/submission.entity' import { VisitorEntity } from '../../entity/visitor.entity' +import { SubmissionDeleteService } from '../submission/submission.delete.service' @Injectable() export class FormDeleteService { @@ -12,17 +13,22 @@ export class FormDeleteService { private readonly formRepository: Repository, @InjectRepository(SubmissionEntity) private readonly submissionRepository: Repository, + private readonly submissionDelete: SubmissionDeleteService, @InjectRepository(VisitorEntity) private readonly visitorRepository: Repository, ) { } async delete(id: number): Promise { - await this.submissionRepository.delete({ + const submissions = await this.submissionRepository.find({ form: { id, }, }) + + await Promise.all( + submissions.map(submission => this.submissionDelete.delete(submission.id)), + ) await this.visitorRepository.delete({ form: { id, diff --git a/api/src/service/submission/index.ts b/api/src/service/submission/index.ts index ca460216..30f0e2af 100644 --- a/api/src/service/submission/index.ts +++ b/api/src/service/submission/index.ts @@ -1,3 +1,4 @@ +import { SubmissionDeleteService } from './submission.delete.service' import { SubmissionHookService } from './submission.hook.service' import { SubmissionNotificationService } from './submission.notification.service' import { SubmissionService } from './submission.service' @@ -7,6 +8,7 @@ import { SubmissionStatisticService } from './submission.statistic.service' import { SubmissionTokenService } from './submission.token.service' export const submissionServices = [ + SubmissionDeleteService, SubmissionHookService, SubmissionNotificationService, SubmissionService, diff --git a/api/src/service/submission/submission.delete.service.ts b/api/src/service/submission/submission.delete.service.ts new file mode 100644 index 00000000..46deab5a --- /dev/null +++ b/api/src/service/submission/submission.delete.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' +import { SubmissionEntity } from '../../entity/submission.entity' +import { SubmissionFieldEntity } from '../../entity/submission.field.entity' + +@Injectable() +export class SubmissionDeleteService { + constructor( + @InjectRepository(SubmissionEntity) + private readonly submissionRepository: Repository, + @InjectRepository(SubmissionFieldEntity) + private readonly fieldRepository: Repository, + ) { + } + + async delete(id: number): Promise { + await this.fieldRepository.delete({ + submission: { + id, + }, + }) + await this.submissionRepository.delete(id) + } +} diff --git a/ui/graphql/mutation/form.delete.mutation.ts b/ui/graphql/mutation/form.delete.mutation.ts index a8699f8b..79c92cda 100644 --- a/ui/graphql/mutation/form.delete.mutation.ts +++ b/ui/graphql/mutation/form.delete.mutation.ts @@ -20,5 +20,20 @@ const MUTATION = gql` ` export const useFormDeleteMutation = ( - data?: MutationHookOptions -): MutationTuple => useMutation(MUTATION, data) + options: MutationHookOptions = {} +): MutationTuple => { + const oldUpdate = options.update + + options.update = (cache, result, options) => { + cache.evict({ + fieldName: 'listForms', + }) + cache.gc() + + if (oldUpdate) { + oldUpdate(cache, result, options) + } + } + + return useMutation(MUTATION, options) +} diff --git a/ui/graphql/mutation/submission.delete.mutation.ts b/ui/graphql/mutation/submission.delete.mutation.ts new file mode 100644 index 00000000..d31a3bdd --- /dev/null +++ b/ui/graphql/mutation/submission.delete.mutation.ts @@ -0,0 +1,39 @@ +import { MutationHookOptions, MutationTuple, useMutation } from '@apollo/client' +import { gql } from '@apollo/client/core' + +interface Data { + submission: { + id + } +} + +interface Variables { + id: string +} + +const MUTATION = gql` + mutation submissionDelete($id: ID!) { + submission: submissionDelete(id: $id) { + id + } + } +` + +export const useSubmissionDeleteMutation = ( + options: MutationHookOptions = {} +): MutationTuple => { + const oldUpdate = options.update + + options.update = (cache, result, options) => { + cache.evict({ + fieldName: 'listSubmissions', + }) + cache.gc() + + if (oldUpdate) { + oldUpdate(cache, result, options) + } + } + + return useMutation(MUTATION, options) +} diff --git a/ui/graphql/mutation/user.delete.mutation.ts b/ui/graphql/mutation/user.delete.mutation.ts index 48e59dae..13ea48d7 100644 --- a/ui/graphql/mutation/user.delete.mutation.ts +++ b/ui/graphql/mutation/user.delete.mutation.ts @@ -20,5 +20,20 @@ const MUTATION = gql` ` export const useUserDeleteMutation = ( - data?: MutationHookOptions -): MutationTuple => useMutation(MUTATION, data) + options: MutationHookOptions = {} +): MutationTuple => { + const oldUpdate = options.update + + options.update = (cache, result, options) => { + cache.evict({ + fieldName: 'listUsers', + }) + cache.gc() + + if (oldUpdate) { + oldUpdate(cache, result, options) + } + } + + return useMutation(MUTATION, options) +} diff --git a/ui/locales/en/submission.json b/ui/locales/en/submission.json index 75a25c80..11cd8145 100644 --- a/ui/locales/en/submission.json +++ b/ui/locales/en/submission.json @@ -1,8 +1,13 @@ { "add": "Add Submission", "city": "City", + "confirmDelete": "Really delete Submission?", "country": "Country", "created": "Created", + "delete.button": "Delete", + "deleteError": "Could not delete Submission", + "deleteNow": "Delete", + "deleted": "Submission deleted", "device": { "name": "Device Name", "type": "Device Type" diff --git a/ui/pages/admin/forms/[id]/submissions.tsx b/ui/pages/admin/forms/[id]/submissions.tsx index 7bd53c7a..43829b15 100644 --- a/ui/pages/admin/forms/[id]/submissions.tsx +++ b/ui/pages/admin/forms/[id]/submissions.tsx @@ -1,4 +1,4 @@ -import { Button, Progress, Table } from 'antd' +import { Button, message, Popconfirm, Progress, Table } from 'antd' import { PaginationProps } from 'antd/es/pagination' import { ProgressProps } from 'antd/lib/progress' import { ColumnsType } from 'antd/lib/table/interface' @@ -10,12 +10,15 @@ import dayjs from 'dayjs' import { NextPage } from 'next' import Link from 'next/link' import { useRouter } from 'next/router' -import React, { useState } from 'react' +import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ExportSubmissionAction } from '../../../../components/form/admin/export.submission.action' import { SubmissionValues } from '../../../../components/form/admin/submission.values' import { FormPagerFragment } from '../../../../graphql/fragment/form.pager.fragment' import { SubmissionFragment } from '../../../../graphql/fragment/submission.fragment' +import { + useSubmissionDeleteMutation, +} from '../../../../graphql/mutation/submission.delete.mutation' import { useSubmissionPagerQuery } from '../../../../graphql/query/submission.pager.query' const Submissions: NextPage = () => { @@ -44,6 +47,21 @@ const Submissions: NextPage = () => { setEntries(pager.entries) }, }) + const [deleteMutation] = useSubmissionDeleteMutation() + + const doDelete = useCallback(async (id) => { + try { + await deleteMutation({ + variables: { + id, + }, + }) + + await message.success(t('submission:deleted')) + } catch (e) { + await message.error(t('submission:deleteError')) + } + }, []) const columns: ColumnsType = [ { @@ -79,6 +97,22 @@ const Submissions: NextPage = () => { }, responsive: ['lg'], }, + { + title: ' ', + render(_, submission) { + return ( + doDelete(submission.id)} + > + + + ) + }, + width: 100, + }, ] return ( diff --git a/ui/pages/index.tsx b/ui/pages/index.tsx index 325c150b..b95d1d03 100644 --- a/ui/pages/index.tsx +++ b/ui/pages/index.tsx @@ -1,4 +1,4 @@ -import { Alert, Layout } from 'antd' +import { Alert, Layout, Space } from 'antd' import { AuthFooter } from 'components/auth/footer' import { GetStaticProps, NextPage } from 'next' import getConfig from 'next/config' @@ -78,8 +78,26 @@ const Index: NextPage = () => { {status.error && ( +
There is an error with your API connection:
+ {status.error.message} +
+ We need to be able to access the server graphql endpoint at /graphql,{' '} + if you only stared the{' '} + ohmyform/ui{' '} + container you are missing the{' '} + ohmyform/api{' '} + container. As an alternative you can also start the{' '} + ohmyform/ohmyform{' '} + container which includes both the ui and the api. +
+ + } + style={{marginBottom: 40, marginLeft: 16, marginRight: 16 }} /> )}