stash
This commit is contained in:
parent
796fef81be
commit
7df585d774
11 changed files with 246 additions and 72 deletions
|
|
@ -125,12 +125,12 @@ class StorageLocation(models.Model):
|
||||||
class WorkflowInstance(models.Model):
|
class WorkflowInstance(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
state = models.CharField(max_length=255)
|
state = models.CharField(max_length=255)
|
||||||
payload = models.JSONField(default=dict, blank=True)
|
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
|
||||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.status})"
|
return f"{self.name} ({self.state})"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
|
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class CategoryTestMixin:
|
class CategoryTestMixin:
|
||||||
|
|
@ -58,28 +59,29 @@ class LocationTestMixin:
|
||||||
|
|
||||||
class WorkflowTestMixin:
|
class WorkflowTestMixin:
|
||||||
def prepare_workflows(self):
|
def prepare_workflows(self):
|
||||||
|
# `payload` is an opaque, frontend-serialized JSON string on the backend.
|
||||||
self.f['workflow1'] = WorkflowInstance.objects.create(
|
self.f['workflow1'] = WorkflowInstance.objects.create(
|
||||||
name='workflow1',
|
name='workflow1',
|
||||||
state='initial',
|
state='initial',
|
||||||
payload={},
|
payload=json.dumps({}),
|
||||||
owner=self.f['local_user1']
|
owner=self.f['local_user1']
|
||||||
)
|
)
|
||||||
self.f['workflow2'] = WorkflowInstance.objects.create(
|
self.f['workflow2'] = WorkflowInstance.objects.create(
|
||||||
name='workflow1',
|
name='workflow1',
|
||||||
state='upload',
|
state='upload',
|
||||||
payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']},
|
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}),
|
||||||
owner=self.f['local_user1']
|
owner=self.f['local_user1']
|
||||||
)
|
)
|
||||||
self.f['workflow3'] = WorkflowInstance.objects.create(
|
self.f['workflow3'] = WorkflowInstance.objects.create(
|
||||||
name='workflow1',
|
name='workflow1',
|
||||||
state='describe',
|
state='describe',
|
||||||
payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
|
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
|
||||||
'descriptions': ['file 1 description']},
|
'descriptions': ['file 1 description']}),
|
||||||
owner=self.f['local_user1']
|
owner=self.f['local_user1']
|
||||||
)
|
)
|
||||||
self.f['workflow_user2'] = WorkflowInstance.objects.create(
|
self.f['workflow_user2'] = WorkflowInstance.objects.create(
|
||||||
name='workflow2',
|
name='workflow2',
|
||||||
state='initial',
|
state='initial',
|
||||||
payload={},
|
payload=json.dumps({}),
|
||||||
owner=self.f['local_user2']
|
owner=self.f['local_user2']
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
|
||||||
self.assertEqual(reply.data[0]['state'], 'initial')
|
self.assertEqual(reply.data[0]['state'], 'initial')
|
||||||
self.assertEqual(reply.data[1]['state'], 'upload')
|
self.assertEqual(reply.data[1]['state'], 'upload')
|
||||||
self.assertEqual(reply.data[2]['state'], 'describe')
|
self.assertEqual(reply.data[2]['state'], 'describe')
|
||||||
self.assertEqual(reply.data[0]['payload'], {})
|
self.assertEqual(json.loads(reply.data[0]['payload']), {})
|
||||||
self.assertEqual(reply.data[1]['payload'], {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
|
self.assertEqual(json.loads(reply.data[1]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
|
||||||
self.assertEqual(reply.data[2]['payload'], {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
|
self.assertEqual(json.loads(reply.data[2]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
|
||||||
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||||
'b1c2d3e4f5a60718293b4c5d6e7f8090'],
|
'b1c2d3e4f5a60718293b4c5d6e7f8090'],
|
||||||
'descriptions': ['file 1 description']})
|
'descriptions': ['file 1 description']})
|
||||||
|
|
@ -44,5 +44,5 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
|
||||||
self.assertEqual(len(reply.data), 1)
|
self.assertEqual(len(reply.data), 1)
|
||||||
self.assertEqual(reply.data[0]['name'], 'workflow2')
|
self.assertEqual(reply.data[0]['name'], 'workflow2')
|
||||||
self.assertEqual(reply.data[0]['state'], 'initial')
|
self.assertEqual(reply.data[0]['state'], 'initial')
|
||||||
self.assertEqual(reply.data[0]['payload'], {})
|
self.assertEqual(json.loads(reply.data[0]['payload']), {})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import FotoFirstStep2 from './steps/FotoFirstStep2.vue';
|
||||||
import FotoFirstStep3 from './steps/FotoFirstStep3.vue';
|
import FotoFirstStep3 from './steps/FotoFirstStep3.vue';
|
||||||
import FotoFirstStep4 from './steps/FotoFirstStep4.vue';
|
import FotoFirstStep4 from './steps/FotoFirstStep4.vue';
|
||||||
import BulkImportStep1 from './steps/BulkImportStep1.vue';
|
import BulkImportStep1 from './steps/BulkImportStep1.vue';
|
||||||
|
import BulkImportStep6 from './steps/BulkImportStep6.vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component registry mapping workflow types and steps to components
|
* Component registry mapping workflow types and steps to components
|
||||||
|
|
@ -25,6 +26,7 @@ const componentRegistry = {
|
||||||
|
|
||||||
// Bulk Item Import Workflow components
|
// Bulk Item Import Workflow components
|
||||||
'import-items-1': BulkImportStep1,
|
'import-items-1': BulkImportStep1,
|
||||||
|
'import-items-6': BulkImportStep6,
|
||||||
// Additional steps can be added as needed
|
// Additional steps can be added as needed
|
||||||
// 'import-items-2': BulkImportStep2,
|
// 'import-items-2': BulkImportStep2,
|
||||||
// 'import-items-3': BulkImportStep3,
|
// 'import-items-3': BulkImportStep3,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ The `stepComponent` computed property now uses the registry:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
stepComponent() {
|
stepComponent() {
|
||||||
const workflowType = this.workflowInstance?.workflow_type;
|
// `name` on the WorkflowInstance doubles as the workflow type identifier
|
||||||
|
const workflowType = this.workflowInstance?.name;
|
||||||
if (workflowType && this.currentStep) {
|
if (workflowType && this.currentStep) {
|
||||||
return getStepComponent(workflowType, this.currentStep);
|
return getStepComponent(workflowType, this.currentStep);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,7 @@ export default {
|
||||||
isDragOver: false,
|
isDragOver: false,
|
||||||
fileAnalysis: null,
|
fileAnalysis: null,
|
||||||
detectedColumns: [],
|
detectedColumns: [],
|
||||||
|
parsedRows: [],
|
||||||
previewData: [],
|
previewData: [],
|
||||||
columnMapping: {},
|
columnMapping: {},
|
||||||
requiredFields: [
|
requiredFields: [
|
||||||
|
|
@ -239,11 +240,11 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// Load existing data if resuming
|
// Load existing data if resuming
|
||||||
if (this.payload.uploaded_file) {
|
if (this.payload.file_name) {
|
||||||
this.uploadedFile = this.payload.uploaded_file;
|
|
||||||
this.uploadTime = this.payload.upload_time;
|
this.uploadTime = this.payload.upload_time;
|
||||||
this.fileAnalysis = this.payload.file_analysis;
|
this.fileAnalysis = this.payload.file_analysis;
|
||||||
this.detectedColumns = this.payload.detected_columns || [];
|
this.detectedColumns = this.payload.detected_columns || [];
|
||||||
|
this.parsedRows = this.payload.parsed_rows || [];
|
||||||
this.previewData = this.payload.preview_data || [];
|
this.previewData = this.payload.preview_data || [];
|
||||||
this.columnMapping = this.payload.column_mapping || {};
|
this.columnMapping = this.payload.column_mapping || {};
|
||||||
}
|
}
|
||||||
|
|
@ -296,55 +297,66 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
async analyzeFile(file) {
|
async analyzeFile(file) {
|
||||||
// This is a simplified version - in reality, you'd use a library like Papa Parse for CSV
|
// Parsing happens entirely client-side. The backend never sees the
|
||||||
// or SheetJS for Excel files
|
// raw file - only the structured `items` list produced here, sent
|
||||||
|
// later as generic payload/request body.
|
||||||
if (file.name.endsWith('.csv')) {
|
if (file.name.endsWith('.csv')) {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const lines = text.split('\n').filter(line => line.trim());
|
this.parsedRows = this.parseCsv(text);
|
||||||
|
|
||||||
if (lines.length > 0) {
|
|
||||||
// Parse CSV header
|
|
||||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
|
||||||
this.detectedColumns = headers;
|
|
||||||
|
|
||||||
// Parse preview data
|
|
||||||
this.previewData = lines.slice(1, 6).map(line => {
|
|
||||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
|
||||||
const row = {};
|
|
||||||
headers.forEach((header, index) => {
|
|
||||||
row[header] = values[index] || '';
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fileAnalysis = {
|
|
||||||
totalRows: lines.length - 1, // Exclude header
|
|
||||||
totalColumns: headers.length,
|
|
||||||
validRows: lines.length - 1, // Simplified - assume all valid for demo
|
|
||||||
errorRows: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// For Excel files, you'd use a library like SheetJS
|
// For Excel files, you'd use a library like SheetJS to parse
|
||||||
// This is a mock implementation
|
// client-side. This is a mock implementation.
|
||||||
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
||||||
this.previewData = [
|
this.parsedRows = [
|
||||||
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
||||||
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
|
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
|
||||||
];
|
];
|
||||||
this.fileAnalysis = {
|
|
||||||
totalRows: 100,
|
|
||||||
totalColumns: 5,
|
|
||||||
validRows: 98,
|
|
||||||
errorRows: 2
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.previewData = this.parsedRows.slice(0, 5);
|
||||||
|
this.fileAnalysis = {
|
||||||
|
totalRows: this.parsedRows.length,
|
||||||
|
totalColumns: this.detectedColumns.length,
|
||||||
|
validRows: this.parsedRows.length,
|
||||||
|
errorRows: 0
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-map columns based on common names
|
// Auto-map columns based on common names
|
||||||
this.autoMapColumns();
|
this.autoMapColumns();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Simple CSV parser: no external dependency, entirely client-side.
|
||||||
|
// Returns an array of row objects keyed by the detected header columns.
|
||||||
|
parseCsv(text) {
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
if (lines.length === 0) {
|
||||||
|
this.detectedColumns = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||||
|
this.detectedColumns = headers;
|
||||||
|
return lines.slice(1).map(line => {
|
||||||
|
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||||
|
const row = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
row[header] = values[index] || '';
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convert the fully parsed rows + column mapping into the generic item
|
||||||
|
// dicts consumed by the "Import Items" step, which creates each one via
|
||||||
|
// the standard generic InventoryItem create endpoint.
|
||||||
|
buildItemsFromMapping() {
|
||||||
|
return this.parsedRows.map(row => ({
|
||||||
|
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
|
||||||
|
description: this.columnMapping.description ? row[this.columnMapping.description] : '',
|
||||||
|
category: this.columnMapping.category ? row[this.columnMapping.category] : '',
|
||||||
|
quantity: this.columnMapping.quantity ? row[this.columnMapping.quantity] : undefined,
|
||||||
|
})).filter(item => item.name);
|
||||||
|
},
|
||||||
|
|
||||||
autoMapColumns() {
|
autoMapColumns() {
|
||||||
const mapping = {};
|
const mapping = {};
|
||||||
|
|
||||||
|
|
@ -401,6 +413,7 @@ export default {
|
||||||
this.uploadTime = null;
|
this.uploadTime = null;
|
||||||
this.fileAnalysis = null;
|
this.fileAnalysis = null;
|
||||||
this.detectedColumns = [];
|
this.detectedColumns = [];
|
||||||
|
this.parsedRows = [];
|
||||||
this.previewData = [];
|
this.previewData = [];
|
||||||
this.columnMapping = {};
|
this.columnMapping = {};
|
||||||
this.updatePayload();
|
this.updatePayload();
|
||||||
|
|
@ -429,12 +442,14 @@ export default {
|
||||||
|
|
||||||
updatePayload() {
|
updatePayload() {
|
||||||
this.$emit('update', {
|
this.$emit('update', {
|
||||||
uploaded_file: this.uploadedFile,
|
file_name: this.uploadedFile?.name || null,
|
||||||
upload_time: this.uploadTime,
|
upload_time: this.uploadTime,
|
||||||
file_analysis: this.fileAnalysis,
|
file_analysis: this.fileAnalysis,
|
||||||
detected_columns: this.detectedColumns,
|
detected_columns: this.detectedColumns,
|
||||||
|
parsed_rows: this.parsedRows,
|
||||||
preview_data: this.previewData,
|
preview_data: this.previewData,
|
||||||
column_mapping: this.columnMapping
|
column_mapping: this.columnMapping,
|
||||||
|
items: this.buildItemsFromMapping()
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
124
frontend/src/components/workflow/steps/BulkImportStep6.vue
Normal file
124
frontend/src/components/workflow/steps/BulkImportStep6.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<div class="bulk-import-step-6">
|
||||||
|
<div class="step-header mb-4">
|
||||||
|
<h4 class="mb-2">Import Items</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
Create inventory items from the rows parsed in the File Upload step.
|
||||||
|
The file itself was never sent to the server - only these structured
|
||||||
|
item rows are.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!results" class="card mb-4">
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<p class="mb-3">
|
||||||
|
<strong>{{ items.length }}</strong> item(s) ready to import.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" :disabled="importing || items.length === 0" @click="runImport">
|
||||||
|
<span v-if="importing" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Import {{ items.length }} item(s)
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="mb-2">Import complete</h6>
|
||||||
|
<p class="mb-2">
|
||||||
|
<span class="badge bg-success me-2">{{ results.created_count }} created</span>
|
||||||
|
<span v-if="results.errors && results.errors.length" class="badge bg-warning text-dark">
|
||||||
|
{{ results.errors.length }} issue(s)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul v-if="results.errors && results.errors.length" class="small text-muted mb-0">
|
||||||
|
<li v-for="(err, index) in results.errors" :key="index">{{ err }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-navigation d-flex justify-content-between">
|
||||||
|
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||||
|
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" :disabled="!results" @click="$emit('next')">
|
||||||
|
Next: Generate Report
|
||||||
|
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import { mapActions } from 'vuex';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'BulkImportStep6',
|
||||||
|
components: {
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
workflowInstance: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
importing: false,
|
||||||
|
error: null,
|
||||||
|
results: this.payload.import_results || null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
items() {
|
||||||
|
return this.payload.items || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['createInventoryItem']),
|
||||||
|
async runImport() {
|
||||||
|
this.importing = true;
|
||||||
|
this.error = null;
|
||||||
|
const created_item_ids = [];
|
||||||
|
const errors = [];
|
||||||
|
// Items were fully parsed client-side (File Upload step). The backend
|
||||||
|
// never sees the source file or any bulk-import-specific endpoint -
|
||||||
|
// each row is created through the same generic
|
||||||
|
// POST /api/inventory_items/ endpoint any other client would use.
|
||||||
|
for (const [index, row] of this.items.entries()) {
|
||||||
|
if (!row.name) {
|
||||||
|
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await this.createInventoryItem({
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || '',
|
||||||
|
owned_quantity: parseInt(row.quantity) || 1,
|
||||||
|
...(row.category ? {category: row.category} : {})
|
||||||
|
});
|
||||||
|
created_item_ids.push(created.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create item:', row, err);
|
||||||
|
errors.push(`Row ${index + 1} ("${row.name}"): failed to create.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.results = {created_item_ids, created_count: created_item_ids.length, errors};
|
||||||
|
this.$emit('update', {import_results: this.results});
|
||||||
|
this.importing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ import FallBackResolver from "@/dns";
|
||||||
import NeighborsCache from "@/neigbors";
|
import NeighborsCache from "@/neigbors";
|
||||||
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
|
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
|
||||||
import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
|
import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
|
||||||
|
import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js";
|
||||||
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
||||||
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
||||||
|
|
||||||
|
|
@ -549,30 +550,33 @@ export default createStore({
|
||||||
}
|
}
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.get(getters.signAuth, '/api/workflows/')
|
const data = await servers.get(getters.signAuth, '/api/workflows/')
|
||||||
commit('setActiveWorkflows', data)
|
const workflows = data.map(deserializeWorkflowPayload)
|
||||||
|
commit('setActiveWorkflows', workflows)
|
||||||
state.last_load.active_workflows = Date.now()
|
state.last_load.active_workflows = Date.now()
|
||||||
return data
|
return workflows
|
||||||
},
|
},
|
||||||
async createWorkflow({state, commit, dispatch, getters}, workflowData) {
|
async createWorkflow({state, commit, dispatch, getters}, workflowData) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.post(getters.signAuth, '/api/workflows/', workflowData)
|
// The backend stores `payload` as an opaque string - the frontend is
|
||||||
|
// responsible for serializing/deserializing the JSON itself.
|
||||||
|
const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData))
|
||||||
state.last_load.active_workflows = 0 // Invalidate cache
|
state.last_load.active_workflows = 0 // Invalidate cache
|
||||||
return data
|
return deserializeWorkflowPayload(data)
|
||||||
},
|
},
|
||||||
async updateWorkflow({state, commit, dispatch, getters}, workflow) {
|
async updateWorkflow({state, commit, dispatch, getters}, workflow) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', workflow)
|
const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow))
|
||||||
state.last_load.active_workflows = 0 // Invalidate cache
|
state.last_load.active_workflows = 0 // Invalidate cache
|
||||||
return data
|
return deserializeWorkflowPayload(data)
|
||||||
},
|
},
|
||||||
async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) {
|
async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', {
|
const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', {
|
||||||
current_step: currentStep,
|
current_step: currentStep,
|
||||||
payload: payload
|
payload: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
state.last_load.active_workflows = 0 // Invalidate cache
|
state.last_load.active_workflows = 0 // Invalidate cache
|
||||||
return data
|
return deserializeWorkflowPayload(data)
|
||||||
},
|
},
|
||||||
async deleteWorkflow({state, commit, dispatch, getters}, workflowId) {
|
async deleteWorkflow({state, commit, dispatch, getters}, workflowId) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@
|
||||||
<li class="breadcrumb-item">
|
<li class="breadcrumb-item">
|
||||||
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
|
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="breadcrumb-item active" aria-current="page">{{ workflowInstance?.workflow_type || 'Loading...' }}</li>
|
<li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.name || workflowInstance?.name || 'Loading...' }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 class="h3 mb-0">{{ workflowInstance?.workflow_type || 'Workflow Detail' }}</h1>
|
<h1 class="h3 mb-0">{{ workflowDefinition?.name || workflowInstance?.name || 'Workflow Detail' }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<button class="btn btn-outline-secondary" @click="$router.go(-1)">
|
<button class="btn btn-outline-secondary" @click="$router.go(-1)">
|
||||||
|
|
@ -236,8 +236,9 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
workflowDefinition() {
|
workflowDefinition() {
|
||||||
if (!this.workflowInstance?.workflow_type) return null;
|
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
||||||
return workflowRegistry.get(this.workflowInstance.workflow_type);
|
if (!this.workflowInstance?.name) return null;
|
||||||
|
return workflowRegistry.get(this.workflowInstance.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
stepDefinitions() {
|
stepDefinitions() {
|
||||||
|
|
@ -258,8 +259,7 @@ export default {
|
||||||
|
|
||||||
stepComponent() {
|
stepComponent() {
|
||||||
// Return step-specific component if it exists using the component registry
|
// Return step-specific component if it exists using the component registry
|
||||||
const workflowType = this.workflowInstance?.workflow_type;
|
const workflowType = this.workflowInstance?.name;
|
||||||
console.log('Determining step component for workflow type:', this.workflowInstance, 'and step:', this.currentStep);
|
|
||||||
if (workflowType && this.currentStep) {
|
if (workflowType && this.currentStep) {
|
||||||
return getStepComponent(workflowType, this.currentStep);
|
return getStepComponent(workflowType, this.currentStep);
|
||||||
}
|
}
|
||||||
|
|
@ -467,7 +467,7 @@ export default {
|
||||||
|
|
||||||
hasStepComponent(stepNumber) {
|
hasStepComponent(stepNumber) {
|
||||||
// Check if a specific step has a custom component available
|
// Check if a specific step has a custom component available
|
||||||
const workflowType = this.workflowInstance?.workflow_type;
|
const workflowType = this.workflowInstance?.name;
|
||||||
return workflowType ? hasStepComponent(workflowType, stepNumber) : false;
|
return workflowType ? hasStepComponent(workflowType, stepNumber) : false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="workflow in activeWorkflows" :key="workflow.id">
|
<tr v-for="workflow in activeWorkflows" :key="workflow.id">
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ workflow.workflow_type }}</strong>
|
<strong>{{ getWorkflowDisplayName(workflow) }}</strong>
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">{{ workflow.payload?.workflow_config?.category || 'System' }}</small>
|
<small class="text-muted">{{ workflow.payload?.workflow_config?.category || 'System' }}</small>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -215,10 +215,14 @@ export default {
|
||||||
},
|
},
|
||||||
isWorkflowRunning(workflowId) {
|
isWorkflowRunning(workflowId) {
|
||||||
return this.activeWorkflows.some(active =>
|
return this.activeWorkflows.some(active =>
|
||||||
active.workflow_type === workflowId &&
|
active.name === workflowId &&
|
||||||
active.state === 'running'
|
active.state === 'running'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getWorkflowDisplayName(workflow) {
|
||||||
|
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
||||||
|
return workflowRegistry.get(workflow.name)?.name || workflow.name;
|
||||||
|
},
|
||||||
async startWorkflow(workflow) {
|
async startWorkflow(workflow) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
@ -266,16 +270,17 @@ export default {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async abortWorkflowInstance(workflow) {
|
async abortWorkflowInstance(workflow) {
|
||||||
if (confirm(`Are you sure you want to abort "${workflow.workflow_type}"?`)) {
|
const displayName = this.getWorkflowDisplayName(workflow);
|
||||||
|
if (confirm(`Are you sure you want to abort "${displayName}"?`)) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
await this.deleteWorkflow(workflow.id);
|
await this.deleteWorkflow(workflow.id);
|
||||||
await this.loadActiveWorkflows();
|
await this.loadActiveWorkflows();
|
||||||
console.log('Workflow aborted successfully:', workflow.workflow_type);
|
console.log('Workflow aborted successfully:', displayName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error aborting workflow:', error);
|
console.error('Error aborting workflow:', error);
|
||||||
this.error = `Failed to abort ${workflow.workflow_type}`;
|
this.error = `Failed to abort ${displayName}`;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,27 @@ class WorkflowRegistry {
|
||||||
// Create and export the default registry instance
|
// Create and export the default registry instance
|
||||||
export const workflowRegistry = new WorkflowRegistry();
|
export const workflowRegistry = new WorkflowRegistry();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend stores WorkflowInstance.payload as an opaque string - it never
|
||||||
|
* parses or understands it as JSON. The frontend is fully responsible for
|
||||||
|
* serializing it before sending and deserializing it after receiving.
|
||||||
|
*/
|
||||||
|
export function serializeWorkflowPayload(workflow) {
|
||||||
|
if (!workflow || !('payload' in workflow)) return workflow;
|
||||||
|
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeWorkflowPayload(workflow) {
|
||||||
|
if (!workflow) return workflow;
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = workflow.payload ? JSON.parse(workflow.payload) : {};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse workflow payload JSON:', e);
|
||||||
|
}
|
||||||
|
return {...workflow, payload};
|
||||||
|
}
|
||||||
|
|
||||||
// Export individual classes for direct use
|
// Export individual classes for direct use
|
||||||
export {
|
export {
|
||||||
BaseWorkflow,
|
BaseWorkflow,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue