Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion backend/src/workflow/manager/api/services/workflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def portal(self):
def portal_workflow(self):
"""Returns the portal_workflow tool."""
return getToolByName(self.context, "portal_workflow")

@property
@memoize
def portal_types(self):
"""Returns the portal_types tool."""
return getToolByName(self.context, "portal_types")

# --- Selected Object Properties (Driven by __init__) ---

Expand Down Expand Up @@ -189,7 +195,28 @@ def assignable_types(self):
key=lambda v: v['title']
)

def get_assigned_types_for(self, workflow_id):
def get_assignable_types_for(self, workflow_id):
"""
Returns a list of content types that do not currently have the
specified workflow assigned.
"""
assigned_types = self._get_assigned_types_for(workflow_id)

vocab_factory = getUtility(IVocabularyFactory,
name="plone.app.vocabularies.ReallyUserFriendlyTypes")
all_types = vocab_factory(self.context)

assignable_types = []
for term in all_types:
if term.value not in assigned_types:
assignable_types.append({
"id": term.value,
"title": term.title
})

return sorted(assignable_types, key=lambda v: v['title'])

def _get_assigned_types_for(self, workflow_id):
"""Returns a list of content type IDs assigned to a workflow."""
assigned = []
for p_type, chain in self.portal_workflow.listChainOverrides():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ def _serialize_workflow(workflow, base):
}
for t in workflow.transitions.objectValues()
],
"assigned_types": workflow_base.get_assigned_types_for(workflow.id),
"assigned_types": workflow_base._get_assigned_types_for(workflow.id),
"context_data": {
"assignable_types": workflow_base.get_assignable_types_for(workflow.id),
"managed_permissions": workflow_base.managed_permissions,
"available_roles": list(workflow.getAvailableRoles()),
"groups": workflow_base.getGroups()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function assignWorkflow(workflowId: string, contentType: string) {
type: ASSIGN_WORKFLOW,
request: {
op: 'post',
path: `/@workflows/${workflowId}/@assign`, // Fixed path
path: `/@workflow-assign/${workflowId}`,
data: {
type_id: contentType,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import Toast from '@plone/volto/components/manage/Toast/Toast';
import { useIntl, defineMessages } from 'react-intl';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import add from '@plone/volto/icons/add.svg';
import adduser from '@plone/volto/icons/add-user.svg';
import contentexisting from '@plone/volto/icons/content-existing.svg';
import checkboxChecked from '@plone/volto/icons/checkbox-checked.svg';
import blank from '@plone/volto/icons/blank.svg';
import CreateState from '../States/CreateState';
import CreateTransition from '../Transitions/CreateTransition';
import WorkflowValidation from './WorkflowValidation';
import AssignWorkflow from './AssignWorkflow';

const messages = defineMessages({
validationSuccessTitle: {
Expand Down Expand Up @@ -50,6 +51,10 @@ const ActionsToolbar = ({ workflowId }: { workflowId: string }) => {
const intl = useIntl();
const [isCreateStateOpen, setCreateStateOpen] = useState(false);
const [isCreateTransitionOpen, setCreateTransitionOpen] = useState(false);
const [isAssignDialogOpen, setAssignDialogOpen] = useState(false);
const workflow = useAppSelector(
(state) => state.workflow.workflow.currentWorkflow,
);
const validation = useAppSelector((state) => state.workflow.validation);
const wasLoading = usePrevious(validation.loading);

Expand Down Expand Up @@ -121,8 +126,8 @@ const ActionsToolbar = ({ workflowId }: { workflowId: string }) => {
)}
{validation.loading ? 'Checking...' : 'Sanity Check'}
</Button>
<Button variant="secondary">
<Icon name={adduser} size="20px" />
<Button variant="secondary" onPress={() => setAssignDialogOpen(true)}>
<Icon name={contentexisting} size="20px" />
Assign
</Button>
</ButtonGroup>
Expand All @@ -143,6 +148,11 @@ const ActionsToolbar = ({ workflowId }: { workflowId: string }) => {
isOpen={isCreateTransitionOpen}
onClose={() => setCreateTransitionOpen(false)}
/>
<AssignWorkflow
workflow={workflow}
isOpen={isAssignDialogOpen}
onClose={() => setAssignDialogOpen(false)}
/>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import {
Button,
ButtonGroup,
Content,
Dialog,
DialogContainer,
Heading,
Item,
Picker,
ProgressCircle,
} from '@adobe/react-spectrum';
import { toast } from 'react-toastify';
import Toast from '@plone/volto/components/manage/Toast/Toast';
import { useIntl, defineMessages } from 'react-intl';
import { assignWorkflow, getWorkflow } from '../../actions';
import { useAppDispatch } from '../../types';
import type { AssignWorkflowProps } from '../../types/workflow';

const messages = defineMessages({
title: { id: 'Assign Workflow', defaultMessage: 'Assign Workflow' },
label: {
id: 'Select the content type you would like to assign this workflow to.',
defaultMessage:
'Select the content type you would like to assign this workflow to.',
},
placeholder: {
id: 'Select a content type...',
defaultMessage: 'Select a content type...',
},
assign: {
id: 'Assign',
defaultMessage: 'Assign',
},
assigning: {
id: 'Assigning...',
defaultMessage: 'Assigning...',
},
cancel: {
id: 'Cancel',
defaultMessage: 'Cancel',
},
success: {
id: 'Workflow Assigned',
defaultMessage: 'Workflow Assigned',
},
successContent: {
id: 'The workflow has been assigned successfully.',
defaultMessage: 'The workflow has been assigned successfully.',
},
error: { id: 'Assignment Failed', defaultMessage: 'Assignment Failed' },
});

const AssignWorkflow: React.FC<AssignWorkflowProps> = ({
workflow,
isOpen,
onClose,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

useEffect(() => {
if (!isOpen) {
setSelectedTypeId(null);
setIsSubmitting(false);
}
}, [isOpen]);

const handleAssign = async () => {
if (!selectedTypeId || !workflow?.id) return;

setIsSubmitting(true);
try {
await dispatch(assignWorkflow(workflow.id, selectedTypeId));
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.successContent)}
/>,
);
await dispatch(getWorkflow(workflow.id));
onClose();
} catch (error: any) {
toast.error(
<Toast
error
title={intl.formatMessage(messages.error)}
content={error.message || 'An unknown error occurred.'}
Comment on lines +86 to +91
Copy link

Copilot AI Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 'any' type for error handling is not recommended. Consider using a more specific error type or 'unknown' type for better type safety.

Suggested change
} catch (error: any) {
toast.error(
<Toast
error
title={intl.formatMessage(messages.error)}
content={error.message || 'An unknown error occurred.'}
} catch (error: unknown) {
let errorMessage = 'An unknown error occurred.';
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof (error as { message?: unknown }).message === 'string'
) {
errorMessage = (error as { message: string }).message;
}
toast.error(
<Toast
error
title={intl.formatMessage(messages.error)}
content={errorMessage}

Copilot uses AI. Check for mistakes.
/>,
);
} finally {
setIsSubmitting(false);
}
};

if (!isOpen || !workflow) {
return null;
}

const assignableTypes = workflow.context_data?.assignable_types || [];

return (
<DialogContainer onDismiss={onClose}>
<Dialog>
<Heading>{intl.formatMessage(messages.title)}</Heading>
<Content>
<Picker
label={intl.formatMessage(messages.label)}
placeholder={intl.formatMessage(messages.placeholder)}
items={assignableTypes}
selectedKey={selectedTypeId}
onSelectionChange={(key) => setSelectedTypeId(key as string)}
isDisabled={isSubmitting || assignableTypes.length === 0}
marginTop="size-200"
width="100%"
>
{(item) => <Item key={item.id}>{item.title}</Item>}
</Picker>
</Content>
<ButtonGroup>
<Button
variant="secondary"
onPress={onClose}
isDisabled={isSubmitting}
>
{intl.formatMessage(messages.cancel)}
</Button>
<Button
variant="accent"
onPress={handleAssign}
isDisabled={!selectedTypeId || isSubmitting}
>
{isSubmitting && (
<ProgressCircle
aria-label="Assigning workflow"
size="S"
isIndeterminate
/>
)}
{isSubmitting
? intl.formatMessage(messages.assigning)
: intl.formatMessage(messages.assign)}
</Button>
</ButtonGroup>
</Dialog>
</DialogContainer>
);
};

export default AssignWorkflow;
12 changes: 12 additions & 0 deletions frontend/packages/volto-workflow-manager/src/types/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,19 @@ export interface GroupInfo {
title: string;
}

export interface AssignableType {
id: string;
title: string;
}

export interface AssignWorkflowProps {
workflow: Workflow | null | undefined;
isOpen: boolean;
onClose: () => void;
}

export interface ContextData {
assignable_types: AssignableType[];
available_roles: string[];
groups: GroupInfo[];
managed_permissions: PermissionInfo[];
Expand Down
Loading