3a703 admin edit submission (#305)

* wip: admin submission edit feature

* wip: refresh form submission after update

* wip: connect submissions page data to store

* Fixed the submission loading issue

* test: admin edit submission feature test

* Fix pending submission, editabe submission & more (#306)

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Favour Olayinka 2024-02-03 12:50:57 +01:00 committed by GitHub
parent a426f091c1
commit ef83ffcf77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 245 additions and 94 deletions

View File

@ -6,7 +6,11 @@ use App\Http\Controllers\Controller;
use App\Http\Resources\FormSubmissionResource;
use App\Models\Forms\Form;
use App\Exports\FormSubmissionExport;
use App\Http\Requests\AnswerFormRequest;
use App\Jobs\Form\StoreFormSubmissionJob;
use App\Models\Forms\FormSubmission;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
@ -27,6 +31,20 @@ class FormSubmissionController extends Controller
return FormSubmissionResource::collection($form->submissions()->paginate(100));
}
public function update(AnswerFormRequest $request, $id, $submissionId)
{
$form = $request->form;
$this->authorize('update', $form);
$job = new StoreFormSubmissionJob($request->form, $request->validated());
$job->setSubmissionId($submissionId)->handle();
$data = new FormSubmissionResource(FormSubmission::findOrFail($submissionId));
return $this->success([
'message' => 'Record successfully updated.',
'data' => $data
]);
}
public function export(string $id)
{
$form = Form::findOrFail((int) $id);

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware\Form;
use App\Models\Forms\Form;
use Closure;
use Illuminate\Http\Request;
class ResolveFormMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next, string $routeParamName = "id")
{
$form = Form::where($routeParamName,$request->route($routeParamName))->firstOrFail();
$request->merge([
'form' => $form,
]);
return $next($request);
}
}

View File

@ -56,6 +56,12 @@ class StoreFormSubmissionJob implements ShouldQueue
return $this->submissionId;
}
public function setSubmissionId(int $id)
{
$this->submissionId = $id;
return $this;
}
private function storeSubmission(array $formData)
{
// Create or update record
@ -76,6 +82,9 @@ class StoreFormSubmissionJob implements ShouldQueue
*/
private function submissionToUpdate(): ?FormSubmission
{
if($this->submissionId){
return $this->form->submissions()->findOrFail($this->submissionId);
}
if ($this->form->editable_submissions && isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) {
$submissionId = $this->submissionData['submission_id'] ? Hashids::decode($this->submissionData['submission_id']) : false;
$submissionId = $submissionId[0] ?? null;

View File

@ -0,0 +1,42 @@
<template>
<modal :show="show" max-width="lg" @close="emit('close')">
<open-form :theme="theme" :loading="false" :show-hidden="true" :form="form" :fields="form.properties" @submit="updateForm" :default-data-form="submission">
<template #submit-btn="{submitForm}">
<v-button :loading="loading" class="mt-2 px-8 mx-1" @click.prevent="submitForm">
Update Submission
</v-button>
</template>
</open-form>
</modal>
</template>
<script setup>
import {ref, defineProps, defineEmits, onMounted } from 'vue'
import OpenForm from '../forms/OpenForm.vue';
import { themes } from '~/lib/forms/form-themes.js'
const props = defineProps({
show: { type: Boolean, required: true },
form: { type: Object, required: true },
theme:{type:Object, default:themes.default},
submission:{type:Object}
})
let loading = ref(false)
const emit = defineEmits(['close', 'updated'])
const updateForm = (form, onFailure) =>{
loading.value = true
form.put('/open/forms/' + props.form.id + '/submissions/'+props.submission.id).then((res) => {
useAlert().success(res.message)
loading.value = false
emit('close')
emit('updated', res.data.data)
}).catch((error) => {
console.error(error)
loading.value = false
onFailure()
})
}
</script>

View File

@ -1,5 +1,13 @@
<template>
<div class="flex items-center justify-center space-x-1">
<button v-track.delete_record_click
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-blue-700"
@click="showEditSubmissionModal=true"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
</button>
<button v-track.delete_record_click
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-red-700"
@click="onDeleteClick"
@ -13,12 +21,15 @@
</svg>
</button>
</div>
<EditSubmissionModal :show="showEditSubmissionModal" :form="form" :submission="submission" @close="showEditSubmissionModal=false" @updated="(submission)=>$emit('updated', submission)"/>
</template>
<script>
import EditSubmissionModal from './EditSubmissionModal.vue'
export default {
components: { },
components: { EditSubmissionModal },
emits: ["updated", "deleted"],
props: {
form: {
type: Object,
@ -28,8 +39,8 @@ export default {
type: Array,
default: () => []
},
rowid: {
type: Number,
submission: {
type: Object,
default: () => {}
}
},
@ -40,6 +51,7 @@ export default {
},
data () {
return {
showEditSubmissionModal:false,
}
},
computed: {
@ -51,9 +63,9 @@ export default {
this.useAlert.confirm('Do you really want to delete this record?', this.deleteRecord)
},
async deleteRecord () {
opnFetch('/open/forms/' + this.form.id + '/records/' + this.rowid + '/delete', {method:'DELETE'}).then(async (data) => {
opnFetch('/open/forms/' + this.form.id + '/records/' + this.submission.id + '/delete', {method:'DELETE'}).then(async (data) => {
if (data.type === 'success') {
this.$emit('deleted')
this.$emit('deleted',this.submission)
this.useAlert.success(data.message)
} else {
this.useAlert.error('Something went wrong!')

View File

@ -195,10 +195,7 @@ export default {
window.parent.postMessage(payload, '*')
}
window.postMessage(payload, '*')
try {
this.pendingSubmission.remove()
} catch (e) {}
this.pendingSubmission.remove()
if (data.redirect && data.redirect_url) {
window.location.href = data.redirect_url

View File

@ -93,6 +93,7 @@ export default {
type: Array,
required: true
},
defaultDataForm:{},
adminPreview: { type: Boolean, default: false } // If used in FormEditorPreview
},
@ -297,12 +298,17 @@ export default {
}
await this.recordsStore.loadRecord(
opnFetch('/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((data) => {
return { submission_id: this.form.submission_id, ...data.data }
return { submission_id: this.form.submission_id, id: this.form.submission_id,...data.data }
})
)
return this.recordsStore.getById(this.form.submission_id)
return this.recordsStore.getByKey(this.form.submission_id)
},
async initForm () {
if(this.defaultDataForm){
this.dataForm = useForm(this.defaultDataForm)
return;
}
if (this.isPublicFormPage && this.form.editable_submissions) {
const urlParam = new URLSearchParams(window.location.search)
if (urlParam && urlParam.get('submission_id')) {

View File

@ -56,7 +56,7 @@
</div>
</modal>
<Loader v-if="!form || !formInitDone" class="h-6 w-6 text-nt-blue mx-auto"/>
<Loader v-if="!form" class="h-6 w-6 text-nt-blue mx-auto"/>
<div v-else>
<div v-if="form && tableData.length > 0" class="flex flex-wrap items-end">
<div class="flex-grow">
@ -89,7 +89,8 @@
:data="filteredData"
:loading="isLoading"
@resize="dataChanged()"
@deleted="onDeleteRecord()"
@deleted="onDeleteRecord"
@updated="(submission)=>onUpdateRecord(submission)"
@update-columns="onColumnUpdated"
/>
</scroll-shadow>
@ -102,7 +103,6 @@ import Fuse from 'fuse.js'
import clonedeep from 'clone-deep'
import VSwitch from '../../../forms/components/VSwitch.vue'
import OpenTable from '../../tables/OpenTable.vue'
import {now} from "@vueuse/core";
export default {
name: 'FormSubmissions',
@ -111,17 +111,19 @@ export default {
setup() {
const workingFormStore = useWorkingFormStore()
const recordStore = useRecordsStore()
return {
workingFormStore,
runtimeConfig: useRuntimeConfig()
recordStore,
form: storeToRefs(workingFormStore).content,
tableData:storeToRefs(recordStore).getAll,
runtimeConfig: useRuntimeConfig(),
slug: useRoute().params.slug
}
},
data() {
return {
formInitDone: false,
isLoading: false,
tableData: [],
currentPage: 1,
fullyLoaded: false,
showColumnsModal: false,
@ -134,20 +136,15 @@ export default {
}
},
computed: {
form: {
get() {
return this.workingFormStore.content
},
set(value) {
this.workingFormStore.set(value)
}
},
exportUrl() {
if (!this.form) {
return ''
}
return this.runtimeConfig.public.apiBase + '/open/forms/' + this.form.id + '/submissions/export'
},
isLoading(){
return this.recordStore.loading
},
filteredData() {
if (!this.tableData) return []
@ -169,23 +166,22 @@ export default {
},
watch: {
'form.id'() {
if (this.form === null) {
return
}
this.initFormStructure()
this.getSubmissionsData()
this.onFormChange()
}
},
mounted() {
this.initFormStructure()
this.getSubmissionsData()
this.onFormChange()
},
methods: {
initFormStructure() {
if (!this.form || !this.form.properties || this.formInitDone) {
onFormChange() {
if (this.form === null || this.form.slug !== this.slug) {
return
}
this.fullyLoaded = false
this.initFormStructure()
this.getSubmissionsData()
},
initFormStructure() {
// check if form properties already has a created_at column
this.properties = clonedeep(this.form.properties)
if (!this.properties.find((property) => {
@ -201,7 +197,6 @@ export default {
width: 140
})
}
this.formInitDone = true
this.removed_properties = (this.form.removed_properties) ? clonedeep(this.form.removed_properties) : []
// Get display columns from local storage
@ -216,24 +211,22 @@ export default {
}
},
getSubmissionsData() {
if (!this.form || this.fullyLoaded) {
if (this.fullyLoaded) {
return
}
this.isLoading = true
this.recordStore.startLoading()
opnFetch('/open/forms/' + this.form.id + '/submissions?page=' + this.currentPage).then((resData) => {
this.tableData = this.tableData.concat(resData.data.map((record) => record.data))
this.recordStore.save(resData.data.map((record) => record.data))
this.dataChanged()
if (this.currentPage < resData.meta.last_page) {
this.currentPage += 1
this.getSubmissionsData()
} else {
this.isLoading = false
this.recordStore.stopLoading()
this.fullyLoaded = true
}
}).catch((error) => {
console.error(error)
this.isLoading = false
this.recordStore.startLoading()
})
},
dataChanged() {
@ -252,10 +245,13 @@ export default {
return this.displayColumns[field.id] === true
})
},
onDeleteRecord() {
this.fullyLoaded = false
this.tableData = []
this.getSubmissionsData()
onUpdateRecord(submission){
this.recordStore.save(submission);
this.dataChanged()
},
onDeleteRecord(submission) {
this.recordStore.remove(submission);
this.dataChanged()
},
downloadAsCsv() {
opnFetch(this.exportUrl, {responseType: "blob"})

View File

@ -53,7 +53,9 @@
<td v-if="hasActions" class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
style="width: 100px"
>
<record-operations :form="form" :structure="columns" :rowid="row.id" @deleted="$emit('deleted')"/>
<record-operations :form="form" :structure="columns" :submission="row"
@deleted="(submission)=>$emit('deleted',submission)"
@updated="(submission)=>$emit('updated', submission)"/>
</td>
</tr>
<tr v-if="loading" class="n-table-row border-t bg-gray-50 dark:bg-gray-900">
@ -89,6 +91,7 @@ import {hash} from "~/lib/utils.js";
export default {
components: {ResizableTh, RecordOperations},
emits: ["updated", "deleted"],
props: {
columns: {
type: Array,

View File

@ -13,7 +13,7 @@ export const pendingSubmission = (form) => {
const set = (value) => {
if (process.server || !enabled.value) return
useStorage(formPendingSubmissionKey.value).value = JSON.stringify(value)
useStorage(formPendingSubmissionKey.value).value = value === null ? value : JSON.stringify(value)
}
const remove = () => {
@ -29,6 +29,7 @@ export const pendingSubmission = (form) => {
return {
enabled,
set,
get
get,
remove
}
}

View File

@ -162,6 +162,7 @@ const workspacesStore = useWorkspacesStore()
const slug = useRoute().params.slug
formsStore.startLoading()
const user = computed(() => authStore.user)
const form = computed(() => formsStore.getByKey(slug))
const workspace = computed(() => workspacesStore.getByKey(form?.value?.workspace_id))

View File

@ -18,4 +18,9 @@ useOpnSeoMeta({
title: (props.form) ? 'Form Submissions - ' + props.form.title : 'Form Submissions'
})
onBeforeRouteLeave(()=>{
console.log('Clearing store state')
useRecordsStore().resetState()
})
</script>

View File

@ -1,49 +1,21 @@
import { defineStore } from 'pinia'
export const namespaced = true
import { useContentStore } from '~/composables/stores/useContentStore'
/**
* Loads records from database
*/
export const useRecordsStore = defineStore('records', {
state: () => ({
content: [],
loading: false
}),
getters: {
getById: (state) => (id) => {
if (state.content.length === 0) return null
return state.content.find(item => item.submission_id === id)
}
},
actions: {
set (items) {
this.content = items
},
addOrUpdate (item) {
this.content = this.content.filter((val) => val.id !== item.id)
this.content.push(item)
},
remove (itemId) {
this.content = this.content.filter((val) => val.id !== itemId)
},
startLoading () {
this.loading = true
},
stopLoading () {
this.loading = false
},
resetState () {
this.set([])
this.stopLoading()
},
loadRecord (request) {
this.set([])
this.startLoading()
return request.then((data) => {
this.addOrUpdate(data)
this.stopLoading()
})
}
export const useRecordsStore = defineStore('records', ()=>{
const contentStore = useContentStore()
const loadRecord = (request)=> {
contentStore.resetState()
contentStore.startLoading()
return request.then((data) => {
contentStore.save(data)
contentStore.stopLoading()
})
}
})
return {...contentStore, loadRecord}
})

View File

@ -18,6 +18,7 @@ use App\Http\Controllers\Forms\RecordController;
use App\Http\Controllers\WorkspaceController;
use App\Http\Controllers\TemplateController;
use App\Http\Controllers\Forms\Integration\FormZapierWebhookController;
use App\Http\Middleware\Form\ResolveFormMiddleware;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
@ -85,6 +86,7 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::delete('/{id}', [FormController::class, 'destroy'])->name('destroy');
Route::get('/{id}/submissions', [FormSubmissionController::class, 'submissions'])->name('submissions');
Route::put('/{id}/submissions/{submission_id}', [FormSubmissionController::class, 'update'])->name('submissions.update')->middleware([ResolveFormMiddleware::class]);
Route::get('/{id}/submissions/export', [FormSubmissionController::class, 'export'])->name('submissions.export');
Route::get('/{id}/submissions/file/{filename}', [FormSubmissionController::class, 'submissionFile'])
->middleware('signed')

View File

@ -0,0 +1,61 @@
<?php
use Tests\Helpers\FormSubmissionDataFactory;
it('can update form submission', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->makeForm($user, $workspace);
$form = $this->createForm($user, $workspace, [
'closes_at' => \Carbon\Carbon::now()->addDays(1)->toDateTimeString(),
]);
$formData = FormSubmissionDataFactory::generateSubmissionData($form, ['text' => 'John']);
$textFieldId = array_keys($formData)[0];
$updatedFormData = $formData;
$updatedFormTextValue = "Updated text";
$updatedFormData[$textFieldId] = $updatedFormTextValue;
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.'
]);
$submission = $form->submissions()->first();
$updateResponse = $this->putJson(route('open.forms.submissions.update', ['id'=>$form->id, 'submission_id' => $submission->id]), $updatedFormData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Record successfully updated.'
]);
$expectedTextString = $updateResponse->json('data')['data'][$textFieldId];
expect($expectedTextString)->toBe($updatedFormTextValue);
$updatedSubmission = $form->submissions()->first();
expect($updatedSubmission->data[$textFieldId])->toBe($updatedFormTextValue);
});
it('cannot update form submission as non admin', function () {
$secondUser =$this->createUser();
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->makeForm($user, $workspace);
$form = $this->createForm($user, $workspace, [
'closes_at' => \Carbon\Carbon::now()->addDays(1)->toDateTimeString(),
]);
$formData = FormSubmissionDataFactory::generateSubmissionData($form, ['text' => 'John']);
$textFieldId = array_keys($formData)[0];
$updatedFormData = $formData;
$updatedFormTextValue = "Updated text";
$updatedFormData[$textFieldId] = $updatedFormTextValue;
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.'
]);
$submission = $form->submissions()->first();
$this->actingAs($secondUser);
$updateResponse = $this->putJson(route('open.forms.submissions.update', ['id'=>$form->id, 'submission_id' => $submission->id]), $updatedFormData)
->assertStatus(403);
});