refactor workflow thumbnails to be separate flow/endpoints

This commit is contained in:
Mary Hipp
2025-02-24 16:12:43 -05:00
committed by psychedelicious
parent d4423aa16f
commit ab4433da2f
14 changed files with 323 additions and 272 deletions

View File

@@ -122,7 +122,7 @@ class ApiDependencies:
workflow_records = SqliteWorkflowRecordsStorage(db=db)
style_preset_records = SqliteStylePresetRecordsStorage(db=db)
style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images")
workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder / "thumbnails")
workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder)
services = InvocationServices(
board_image_records=board_image_records,

View File

@@ -1,29 +1,25 @@
from typing import Optional
import io
import traceback
from typing import Optional
import json
from fastapi import APIRouter, Body, File, HTTPException, Path, Query, UploadFile, Form
from fastapi import APIRouter, Body, HTTPException, Path, Query, File, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.workflow_records.workflow_records_common import (
WorkflowValidator,
Workflow,
WorkflowCategory,
WorkflowNotFoundError,
WorkflowRecordDTO,
WorkflowRecordListItemDTO,
WorkflowRecordListItemWithThumbnailDTO,
WorkflowRecordOrderBy,
WorkflowWithoutIDValidator,
)
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import (
WorkflowThumbnailFileNotFoundException,
WorkflowWithoutID,
WorkflowRecordWithThumbnailDTO,
)
IMAGE_MAX_AGE = 31536000
workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"])
@@ -31,18 +27,17 @@ workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"])
"/i/{workflow_id}",
operation_id="get_workflow",
responses={
200: {"model": WorkflowRecordDTO},
200: {"model": WorkflowRecordWithThumbnailDTO},
},
)
async def get_workflow(
workflow_id: str = Path(description="The workflow to get"),
) -> WorkflowRecordDTO:
) -> WorkflowRecordWithThumbnailDTO:
"""Gets a workflow"""
try:
thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
workflow.thumbnail_url = thumbnail_url
return workflow
return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -55,42 +50,10 @@ async def get_workflow(
},
)
async def update_workflow(
workflow: str = Form(description="The updated workflow"),
image: Optional[UploadFile] = File(description="The image file to upload", default=None),
workflow: Workflow = Body(description="The updated workflow", embed=True),
) -> WorkflowRecordDTO:
"""Updates a workflow"""
# parsed_data = json.loads(workflow)
validated_workflow = WorkflowValidator.validate_json(workflow)
if image is not None:
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
contents = await image.read()
try:
pil_image = Image.open(io.BytesIO(contents))
except Exception:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail="Failed to read image")
try:
ApiDependencies.invoker.services.workflow_thumbnails.save(
workflow_id=validated_workflow.id, image=pil_image
)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
else:
try:
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id=validated_workflow.id)
except WorkflowThumbnailFileNotFoundException:
pass
updated_workflow = ApiDependencies.invoker.services.workflow_records.update(workflow=validated_workflow)
thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(validated_workflow.id)
updated_workflow.thumbnail_url = thumbnail_url
return updated_workflow
return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow)
@workflows_router.delete(
@@ -101,8 +64,8 @@ async def delete_workflow(
workflow_id: str = Path(description="The workflow to delete"),
) -> None:
"""Deletes a workflow"""
ApiDependencies.invoker.services.workflow_records.delete(workflow_id)
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
ApiDependencies.invoker.services.workflow_records.delete(workflow_id)
@workflows_router.post(
@@ -113,44 +76,17 @@ async def delete_workflow(
},
)
async def create_workflow(
workflow: str = Form(description="The workflow to create"),
image: Optional[UploadFile] = File(description="The image file to upload", default=None),
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
) -> WorkflowRecordDTO:
"""Creates a workflow"""
# parsed_data = json.loads(workflow)
validated_workflow = WorkflowWithoutIDValidator.validate_json(workflow)
new_workflow = ApiDependencies.invoker.services.workflow_records.create(workflow=validated_workflow)
if image is not None:
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
contents = await image.read()
try:
pil_image = Image.open(io.BytesIO(contents))
except Exception:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail="Failed to read image")
try:
ApiDependencies.invoker.services.workflow_thumbnails.save(
workflow_id=new_workflow.workflow_id, image=pil_image
)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(new_workflow.workflow_id)
new_workflow.thumbnail_url = thumbnail_url
return new_workflow
return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow)
@workflows_router.get(
"/",
operation_id="list_workflows",
responses={
200: {"model": PaginatedResults[WorkflowRecordListItemDTO]},
200: {"model": PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]},
},
)
async def list_workflows(
@@ -162,37 +98,111 @@ async def list_workflows(
direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"),
category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
) -> PaginatedResults[WorkflowRecordListItemDTO]:
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
workflows = ApiDependencies.invoker.services.workflow_records.get_many(
order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, category=category
)
for workflow in workflows.items:
workflow.thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id)
return workflows
workflows_with_thumbnails.append(
WorkflowRecordListItemWithThumbnailDTO(
thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id),
**workflow.model_dump(),
)
)
return PaginatedResults[WorkflowRecordListItemWithThumbnailDTO](
items=workflows_with_thumbnails,
total=workflows.total,
page=workflows.page,
pages=workflows.pages,
per_page=workflows.per_page,
)
@workflows_router.put(
"/i/{workflow_id}/thumbnail",
operation_id="set_workflow_thumbnail",
responses={
200: {"model": WorkflowRecordDTO},
},
)
async def set_workflow_thumbnail(
workflow_id: str = Path(description="The workflow to update"),
image: UploadFile = File(description="The image file to upload"),
):
"""Sets a workflow's thumbnail image"""
try:
ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
contents = await image.read()
try:
pil_image = Image.open(io.BytesIO(contents))
except Exception:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail="Failed to read image")
try:
ApiDependencies.invoker.services.workflow_thumbnails.save(workflow_id, pil_image)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@workflows_router.delete(
"/i/{workflow_id}/thumbnail",
operation_id="delete_workflow_thumbnail",
responses={
200: {"model": WorkflowRecordDTO},
},
)
async def delete_workflow_thumbnail(
workflow_id: str = Path(description="The workflow to update"),
):
"""Removes a workflow's thumbnail image"""
try:
ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
try:
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
@workflows_router.get(
"i/{workflow_id}/thumbnail",
"/i/{workflow_id}/thumbnail",
operation_id="get_workflow_thumbnail",
responses={
200: {"description": "Thumbnail retrieved successfully"},
404: {"description": "Thumbnail not found"},
200: {
"description": "The workflow thumbnail was fetched successfully",
},
400: {"description": "Bad request"},
404: {"description": "The workflow thumbnail could not be found"},
},
status_code=200,
)
async def get_workflow_thumbnail(
workflow_id: str,
workflow_id: str = Path(description="The id of the workflow thumbnail to get"),
) -> FileResponse:
"""Gets the thumbnail for a workflow"""
"""Gets a workflow's thumbnail image"""
try:
path = ApiDependencies.invoker.services.workflow_thumbnails.get_path(workflow_id)
response = FileResponse(
path,
media_type="image/png",
filename=f"{workflow_id}.png",
filename=workflow_id + ".png",
content_disposition_type="inline",
)
response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
return response
except WorkflowThumbnailFileNotFoundException:
raise HTTPException(status_code=404, detail="Thumbnail not found")
except Exception:
raise HTTPException(status_code=404)

View File

@@ -101,7 +101,6 @@ class WorkflowRecordDTOBase(BaseModel):
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.")
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.")
opened_at: Union[datetime.datetime, str] = Field(description="The opened timestamp of the workflow.")
thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):
@@ -122,3 +121,11 @@ class WorkflowRecordListItemDTO(WorkflowRecordDTOBase):
WorkflowRecordListItemDTOValidator = TypeAdapter(WorkflowRecordListItemDTO)
class WorkflowRecordWithThumbnailDTO(WorkflowRecordDTO):
thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.")
class WorkflowRecordListItemWithThumbnailDTO(WorkflowRecordListItemDTO):
thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.")

View File

@@ -34,7 +34,7 @@ class WorkflowThumbnailFileStorageDisk(WorkflowThumbnailServiceBase):
try:
self._validate_storage_folders()
image_path = self._workflow_thumbnail_folder / (workflow_id + ".webp")
thumbnail = make_thumbnail(image, 512)
thumbnail = make_thumbnail(image, 256)
thumbnail.save(image_path, format="webp")
except Exception as e:

View File

@@ -149,6 +149,7 @@
"safetensors": "Safetensors",
"save": "Save",
"saveAs": "Save As",
"saveChanges": "Save Changes",
"settingsLabel": "Settings",
"simple": "Simple",
"somethingWentWrong": "Something went wrong",
@@ -1721,6 +1722,8 @@
"copyShareLinkForWorkflow": "Copy Share Link for Workflow",
"delete": "Delete",
"openLibrary": "Open Library",
"workflowThumbnail": "Workflow Thumbnail",
"saveChanges": "Save Changes",
"builder": {
"deleteAllElements": "Delete All Form Elements",
"resetAllNodeFields": "Reset All Node Fields",

View File

@@ -1,5 +1,6 @@
import type { FormControlProps } from '@invoke-ai/ui-library';
import { Flex, FormControl, FormControlGroup, FormLabel, Input, Textarea } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
@@ -11,19 +12,20 @@ import {
workflowNameChanged,
workflowNotesChanged,
workflowTagsChanged,
workflowThumbnailChanged,
workflowVersionChanged,
} from 'features/nodes/store/workflowSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
import { WorkflowThumbnailField } from './WorkflowThumbnailField';
import { WorkflowThumbnailEditor } from './WorkflowThumbnail/WorkflowThumbnailEditor';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
const { author, name, description, tags, version, contact, notes, thumbnail } = workflow;
const { id, author, name, description, tags, version, contact, notes } = workflow;
return {
id,
name,
author,
description,
@@ -31,14 +33,15 @@ const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
version,
contact,
notes,
thumbnail,
};
});
const WorkflowGeneralTab = () => {
const { author, name, description, tags, version, contact, notes, thumbnail } = useAppSelector(selector);
const { id, author, name, description, tags, version, contact, notes } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { data } = useGetWorkflowQuery(id ?? skipToken);
const handleChangeName = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(workflowNameChanged(e.target.value));
@@ -83,13 +86,6 @@ const WorkflowGeneralTab = () => {
[dispatch]
);
const handleChangeThumbnail = useCallback(
(localImageUrl: string | null) => {
dispatch(workflowThumbnailChanged(localImageUrl));
},
[dispatch]
);
const { t } = useTranslation();
return (
@@ -100,15 +96,14 @@ const WorkflowGeneralTab = () => {
<FormLabel>{t('nodes.workflowName')}</FormLabel>
<Input variant="darkFilled" value={name} onChange={handleChangeName} />
</FormControl>
<FormControl>
<FormLabel>{t('workflows.workflowThumbnail')}</FormLabel>
<WorkflowThumbnailEditor thumbnailUrl={data?.thumbnail_url || null} workflowId={id} />
</FormControl>
<FormControl>
<FormLabel>{t('nodes.workflowVersion')}</FormLabel>
<Input variant="darkFilled" value={version} onChange={handleChangeVersion} />
</FormControl>
<FormControl>
<FormLabel>Thumbnail</FormLabel>
<WorkflowThumbnailField imageUrl={thumbnail} onChange={handleChangeThumbnail} />
</FormControl>
<FormControl>
<FormLabel>{t('nodes.workflowAuthor')}</FormLabel>
<Input variant="darkFilled" value={author} onChange={handleChangeAuthor} />

View File

@@ -0,0 +1,63 @@
import { Button, Flex } from '@invoke-ai/ui-library';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import { toast } from 'features/toast/toast';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDeleteWorkflowThumbnailMutation, useSetWorkflowThumbnailMutation } from 'services/api/endpoints/workflows';
import { WorkflowThumbnailField } from './WorkflowThumbnailField';
export const WorkflowThumbnailEditor = ({
workflowId,
thumbnailUrl,
}: {
workflowId?: string;
thumbnailUrl: string | null;
}) => {
const { t } = useTranslation();
const [localThumbnailUrl, setLocalThumbnailUrl] = useState<string | null>(null);
const [canSaveChanges, setCanSaveChanges] = useState(false);
const [setThumbnail, { isLoading }] = useSetWorkflowThumbnailMutation();
const [deleteThumbnail, { isLoading: isDeleting }] = useDeleteWorkflowThumbnailMutation();
const handleLocalThumbnailUrlChange = useCallback((url: string | null) => {
setLocalThumbnailUrl(url);
setCanSaveChanges(true);
}, []);
const handleSaveChanges = useCallback(async () => {
if (!workflowId) {
return;
}
try {
if (localThumbnailUrl) {
const blob = await convertImageUrlToBlob(localThumbnailUrl);
if (!blob) {
return;
}
const file = new File([blob], 'workflow_thumbnail.png', { type: 'image/png' });
await setThumbnail({ workflow_id: workflowId, image: file }).unwrap();
} else {
await deleteThumbnail(workflowId).unwrap();
}
setCanSaveChanges(false);
toast({ status: 'success', title: 'Workflow thumbnail updated' });
} catch (error) {
toast({ status: 'error', title: 'Failed to update thumbnail' });
}
}, [deleteThumbnail, setThumbnail, workflowId, localThumbnailUrl]);
return (
<Flex alignItems="center" gap={4}>
<WorkflowThumbnailField imageUrl={thumbnailUrl} onChange={handleLocalThumbnailUrlChange} />
<Button size="sm" isLoading={isLoading || isDeleting} onClick={handleSaveChanges} isDisabled={!canSaveChanges}>
{t('common.saveChanges')}
</Button>
</Flex>
);
};

View File

@@ -10,7 +10,7 @@ export const WorkflowThumbnailField = ({
onChange,
}: {
imageUrl: string | null;
onChange: (localImageUrl: string | null) => void;
onChange: (localThumbnailUrl: string | null) => void;
}) => {
const [thumbnail, setThumbnail] = useState<File | null>(null);
@@ -48,7 +48,8 @@ export const WorkflowThumbnailField = ({
const handleResetImage = useCallback(() => {
setThumbnail(null);
}, []);
onChange(null);
}, [onChange]);
const { getInputProps, getRootProps } = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
@@ -64,9 +65,8 @@ export const WorkflowThumbnailField = ({
src={URL.createObjectURL(thumbnail)}
objectFit="cover"
objectPosition="50% 50%"
w={65}
h={65}
minWidth={65}
w={100}
h={100}
borderRadius="base"
/>
<IconButton
@@ -89,8 +89,8 @@ export const WorkflowThumbnailField = ({
<Tooltip label={t('stylePresets.uploadImage')}>
<Flex
as={Button}
w={65}
h={65}
w={100}
h={100}
opacity={0.3}
borderRadius="base"
alignItems="center"

View File

@@ -109,10 +109,6 @@ export const workflowSlice = createSlice({
state.name = action.payload;
state.isTouched = true;
},
workflowThumbnailChanged: (state, action: PayloadAction<string | null>) => {
state.thumbnail = action.payload;
state.isTouched = true;
},
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;

View File

@@ -1,20 +1,13 @@
import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import {
formFieldInitialValuesChanged,
selectWorkflowThumbnail,
workflowIDChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
import { formFieldInitialValuesChanged, workflowIDChanged, workflowSaved } from 'features/nodes/store/workflowSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues';
import { workflowUpdated } from 'features/workflowLibrary/store/actions';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useCreateWorkflowMutation, useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { SetRequired } from 'type-fest';
@@ -33,7 +26,6 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const getFormFieldInitialValues = useGetFormFieldInitialValues();
const thumbnail = useSelector(selectWorkflowThumbnail);
const [updateWorkflow, updateWorkflowResult] = useUpdateWorkflowMutation();
const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation();
const toast = useToast();
@@ -50,13 +42,11 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
isClosable: false,
});
try {
const blob = thumbnail ? await convertImageUrlToBlob(thumbnail) : null;
const image = blob ? new File([blob], 'thumbnail.png', { type: 'image/png' }) : null;
if (isWorkflowWithID(workflow)) {
await updateWorkflow({ workflow, image }).unwrap();
await updateWorkflow(workflow).unwrap();
dispatch(workflowUpdated());
} else {
const data = await createWorkflow({ workflow, image }).unwrap();
const data = await createWorkflow(workflow).unwrap();
dispatch(workflowIDChanged(data.workflow.id));
}
dispatch(workflowSaved());
@@ -83,7 +73,7 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
toast.close(toastRef.current);
}
}
}, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow, thumbnail]);
}, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow]);
return {
saveWorkflow,
isLoading: updateWorkflowResult.isLoading || createWorkflowResult.isLoading,

View File

@@ -1,11 +1,9 @@
import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import {
formFieldInitialValuesChanged,
selectWorkflowThumbnail,
workflowCategoryChanged,
workflowIDChanged,
workflowNameChanged,
@@ -16,7 +14,6 @@ import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/use
import { newWorkflowSaved } from 'features/workflowLibrary/store/actions';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
type SaveWorkflowAsArg = {
@@ -40,7 +37,6 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation();
const getFormFieldInitialValues = useGetFormFieldInitialValues();
const thumbnail = useSelector(selectWorkflowThumbnail);
const toast = useToast();
const toastRef = useRef<ToastId | undefined>();
const saveWorkflowAs = useCallback(
@@ -59,10 +55,8 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
workflow.id = undefined;
workflow.name = newName;
workflow.meta.category = category;
const blob = thumbnail ? await convertImageUrlToBlob(thumbnail) : null;
const image = blob ? new File([blob], 'thumbnail.png', { type: 'image/png' }) : null;
const data = await createWorkflow({ workflow, image }).unwrap();
const data = await createWorkflow(workflow).unwrap();
dispatch(workflowIDChanged(data.workflow.id));
dispatch(workflowNameChanged(data.workflow.name));
dispatch(workflowCategoryChanged(data.workflow.meta.category));
@@ -92,7 +86,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
}
}
},
[toast, t, createWorkflow, dispatch, getFormFieldInitialValues, thumbnail]
[toast, t, createWorkflow, dispatch, getFormFieldInitialValues]
);
return {
saveWorkflowAs,

View File

@@ -1,7 +1,6 @@
import type { paths } from 'services/api/schema';
import { api, buildV1Url, LIST_TAG } from '..';
import { Workflow, WorkflowWithoutID } from '../types';
/**
* Builds an endpoint URL for the workflows router
@@ -42,22 +41,13 @@ export const workflowsApi = api.injectEndpoints({
}),
createWorkflow: build.mutation<
paths['/api/v1/workflows/']['post']['responses']['200']['content']['application/json'],
{ workflow: WorkflowWithoutID; image: File | null }
paths['/api/v1/workflows/']['post']['requestBody']['content']['application/json']['workflow']
>({
query: ({ workflow, image }) => {
const formData = new FormData();
if (image) {
formData.append('image', image);
}
formData.append('workflow', JSON.stringify(workflow));
return {
url: buildWorkflowsUrl(),
method: 'POST',
body: formData,
};
},
query: (workflow) => ({
url: buildWorkflowsUrl(),
method: 'POST',
body: { workflow },
}),
invalidatesTags: [
{ type: 'Workflow', id: LIST_TAG },
{ type: 'WorkflowsRecent', id: LIST_TAG },
@@ -65,22 +55,14 @@ export const workflowsApi = api.injectEndpoints({
}),
updateWorkflow: build.mutation<
paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'],
{ workflow: Workflow; image: File | null }
paths['/api/v1/workflows/i/{workflow_id}']['patch']['requestBody']['content']['application/json']['workflow']
>({
query: ({ workflow, image }) => {
const formData = new FormData();
if (image) {
formData.append('image', image);
}
formData.append('workflow', JSON.stringify(workflow));
return {
url: buildWorkflowsUrl(`i/${workflow.id}`),
method: 'PATCH',
body: formData,
};
},
invalidatesTags: (response, error, { workflow }) => [
query: (workflow) => ({
url: buildWorkflowsUrl(`i/${workflow.id}`),
method: 'PATCH',
body: { workflow },
}),
invalidatesTags: (response, error, workflow) => [
{ type: 'WorkflowsRecent', id: LIST_TAG },
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow.id },
@@ -96,13 +78,41 @@ export const workflowsApi = api.injectEndpoints({
}),
providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }],
}),
setWorkflowThumbnail: build.mutation<void, { workflow_id: string; image: File }>({
query: ({ workflow_id, image }) => {
const formData = new FormData();
formData.append('image', image);
return {
url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`),
method: 'PUT',
body: formData,
};
},
invalidatesTags: (result, error, { workflow_id }) => [
{ type: 'Workflow', id: workflow_id },
{ type: 'WorkflowsRecent', id: LIST_TAG },
],
}),
deleteWorkflowThumbnail: build.mutation<void, string>({
query: (workflow_id) => ({
url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`),
method: 'DELETE',
}),
invalidatesTags: (result, error, workflow_id) => [
{ type: 'Workflow', id: workflow_id },
{ type: 'WorkflowsRecent', id: LIST_TAG },
],
}),
}),
});
export const {
useLazyGetWorkflowQuery,
useGetWorkflowQuery,
useCreateWorkflowMutation,
useDeleteWorkflowMutation,
useUpdateWorkflowMutation,
useListWorkflowsQuery,
useSetWorkflowThumbnailMutation,
useDeleteWorkflowThumbnailMutation,
} = workflowsApi;

View File

@@ -1410,7 +1410,7 @@ export type paths = {
patch?: never;
trace?: never;
};
"/api/v1/workflows/{workflow_id}/thumbnail": {
"/api/v1/workflows/i/{workflow_id}/thumbnail": {
parameters: {
query?: never;
header?: never;
@@ -1418,33 +1418,17 @@ export type paths = {
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload Workflow Thumbnail
* @description Uploads a thumbnail for a workflow
* Set Workflow Thumbnail
* @description Sets a workflow's thumbnail image
*/
post: operations["upload_workflow_thumbnail"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/workflowsi/{workflow_id}/thumbnail": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Workflow Thumbnail
* @description Gets the thumbnail for a workflow
*/
get: operations["get_workflow_thumbnail"];
put?: never;
put: operations["set_workflow_thumbnail"];
post?: never;
delete?: never;
/**
* Delete Workflow Thumbnail
* @description Removes a workflow's thumbnail image
*/
delete: operations["delete_workflow_thumbnail"];
options?: never;
head?: never;
patch?: never;
@@ -2251,16 +2235,8 @@ export type components = {
};
/** Body_create_workflow */
Body_create_workflow: {
/**
* Workflow
* @description The workflow to create
*/
workflow: string;
/**
* Image
* @description The image file to upload
*/
image?: Blob | null;
/** @description The workflow to create */
workflow: components["schemas"]["WorkflowWithoutID"];
};
/** Body_delete_images_from_list */
Body_delete_images_from_list: {
@@ -2377,6 +2353,15 @@ export type components = {
*/
image_names: string[];
};
/** Body_set_workflow_thumbnail */
Body_set_workflow_thumbnail: {
/**
* Image
* Format: binary
* @description The image file to upload
*/
image: Blob;
};
/** Body_star_images_in_list */
Body_star_images_in_list: {
/**
@@ -2416,16 +2401,8 @@ export type components = {
};
/** Body_update_workflow */
Body_update_workflow: {
/**
* Workflow
* @description The updated workflow
*/
workflow: string;
/**
* Image
* @description The image file to upload
*/
image?: Blob | null;
/** @description The updated workflow */
workflow: components["schemas"]["Workflow"];
};
/** Body_upload_image */
Body_upload_image: {
@@ -2437,15 +2414,6 @@ export type components = {
/** @description The metadata to associate with the image */
metadata?: components["schemas"]["JsonValue"] | null;
};
/** Body_upload_workflow_thumbnail */
Body_upload_workflow_thumbnail: {
/**
* File
* Format: binary
* @description The image file to upload
*/
file: Blob;
};
/**
* Boolean Collection Primitive
* @description A collection of boolean primitive values
@@ -16367,8 +16335,8 @@ export type components = {
/** Ui Order */
ui_order: number | null;
};
/** PaginatedResults[WorkflowRecordListItemDTO] */
PaginatedResults_WorkflowRecordListItemDTO_: {
/** PaginatedResults[WorkflowRecordListItemWithThumbnailDTO] */
PaginatedResults_WorkflowRecordListItemWithThumbnailDTO_: {
/**
* Page
* @description Current Page
@@ -16393,7 +16361,7 @@ export type components = {
* Items
* @description Items
*/
items: components["schemas"]["WorkflowRecordListItemDTO"][];
items: components["schemas"]["WorkflowRecordListItemWithThumbnailDTO"][];
};
/**
* Pair Tile with Image
@@ -21152,16 +21120,11 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at: string;
/**
* Thumbnail Url
* @description The URL of the workflow thumbnail.
*/
thumbnail_url?: string | null;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
};
/** WorkflowRecordListItemDTO */
WorkflowRecordListItemDTO: {
/** WorkflowRecordListItemWithThumbnailDTO */
WorkflowRecordListItemWithThumbnailDTO: {
/**
* Workflow Id
* @description The id of the workflow.
@@ -21187,11 +21150,6 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at: string;
/**
* Thumbnail Url
* @description The URL of the workflow thumbnail.
*/
thumbnail_url?: string | null;
/**
* Description
* @description The description of the workflow.
@@ -21199,6 +21157,11 @@ export type components = {
description: string;
/** @description The description of the workflow. */
category: components["schemas"]["WorkflowCategory"];
/**
* Thumbnail Url
* @description The URL of the workflow thumbnail.
*/
thumbnail_url?: string | null;
};
/**
* WorkflowRecordOrderBy
@@ -21206,6 +21169,41 @@ export type components = {
* @enum {string}
*/
WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name";
/** WorkflowRecordWithThumbnailDTO */
WorkflowRecordWithThumbnailDTO: {
/**
* Workflow Id
* @description The id of the workflow.
*/
workflow_id: string;
/**
* Name
* @description The name of the workflow.
*/
name: string;
/**
* Created At
* @description The created timestamp of the workflow.
*/
created_at: string;
/**
* Updated At
* @description The updated timestamp of the workflow.
*/
updated_at: string;
/**
* Opened At
* @description The opened timestamp of the workflow.
*/
opened_at: string;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
/**
* Thumbnail Url
* @description The URL of the workflow thumbnail.
*/
thumbnail_url?: string | null;
};
/** WorkflowWithoutID */
WorkflowWithoutID: {
/**
@@ -24163,7 +24161,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkflowRecordDTO"];
"application/json": components["schemas"]["WorkflowRecordWithThumbnailDTO"];
};
};
/** @description Validation Error */
@@ -24218,7 +24216,7 @@ export interface operations {
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_update_workflow"];
"application/json": components["schemas"]["Body_update_workflow"];
};
};
responses: {
@@ -24270,7 +24268,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResults_WorkflowRecordListItemDTO_"];
"application/json": components["schemas"]["PaginatedResults_WorkflowRecordListItemWithThumbnailDTO_"];
};
};
/** @description Validation Error */
@@ -24293,7 +24291,7 @@ export interface operations {
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_create_workflow"];
"application/json": components["schemas"]["Body_create_workflow"];
};
};
responses: {
@@ -24317,37 +24315,31 @@ export interface operations {
};
};
};
upload_workflow_thumbnail: {
set_workflow_thumbnail: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The workflow to update */
workflow_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_workflow_thumbnail"];
"multipart/form-data": components["schemas"]["Body_set_workflow_thumbnail"];
};
};
responses: {
/** @description Thumbnail uploaded successfully */
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["WorkflowRecordDTO"];
};
};
/** @description Invalid image format */
415: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
@@ -24359,33 +24351,27 @@ export interface operations {
};
};
};
get_workflow_thumbnail: {
delete_workflow_thumbnail: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The workflow to update */
workflow_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Thumbnail retrieved successfully */
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["WorkflowRecordDTO"];
};
};
/** @description Thumbnail not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {

View File

@@ -20,9 +20,6 @@ export type UpdateBoardArg = paths['/api/v1/boards/{board_id}']['patch']['parame
export type GraphAndWorkflowResponse =
paths['/api/v1/images/i/{image_name}/workflow']['get']['responses']['200']['content']['application/json'];
export type WorkflowWithoutID = S['WorkflowWithoutID'];
export type Workflow = S['Workflow'];
export type BatchConfig =
paths['/api/v1/queue/{queue_id}/enqueue_batch']['post']['requestBody']['content']['application/json'];