[PAI-965] feat: ai cycle editability (#4746)

This commit is contained in:
Akshita Goyal
2025-12-02 14:40:59 +05:30
committed by GitHub
parent 79b419cf84
commit 5c7eedb4a1
11 changed files with 270 additions and 81 deletions
@@ -226,15 +226,6 @@ async def prepare_execution_data(request: ActionBatchExecutionRequest, user_id:
# No project_id found in original artifact - this might be okay for some entities
log.warning(f"No project_id found in original artifact {artifact_item.artifact_id} (entity: {entity_type})")
# Resolve project_id to full project object for UI display
try:
from pi.services.actions.artifacts.utils import resolve_project_id_to_object
tool_args = await resolve_project_id_to_object(tool_args)
log.info(f"Resolved project data for artifact {artifact_item.artifact_id}")
except Exception as e:
log.error(f"Error resolving project_id for artifact {artifact_item.artifact_id}: {e}")
# Create ActionArtifactVersion and use its UUID as step_id
try:
from pi.services.actions.artifacts.utils import convert_uuids_to_strings
+57 -1
View File
@@ -573,9 +573,9 @@ async def prepare_edited_artifact_data(entity_type: str, artifact_data: dict) ->
edited_preparation_functions = {
"workitem": prepare_edited_workitem_artifact_data,
"epic": prepare_edited_workitem_artifact_data,
"cycle": prepare_edited_cycle_artifact_data,
# TODO: Add other entity types later
# "project": prepare_edited_project_artifact_data,
# "cycle": prepare_edited_cycle_artifact_data,
# "module": prepare_edited_module_artifact_data,
# "comment": prepare_edited_comment_artifact_data,
# "state": prepare_edited_state_artifact_data,
@@ -588,6 +588,62 @@ async def prepare_edited_artifact_data(entity_type: str, artifact_data: dict) ->
return prepared_data
async def prepare_edited_cycle_artifact_data(artifact_data: dict) -> dict:
"""Prepare edited cycle artifact data for execution."""
clean_data: dict = {}
properties: dict[str, Any] = {}
# Essential top-level fields for cycles (from CYCLE_FIELDS schema)
# Note: start_date and end_date should be at top level for tool execution
essential_fields = {"name", "description", "project", "project_id", "start_date", "end_date"}
# Extract and preserve entity_info first (for executed artifacts)
entity_info = None
if isinstance(artifact_data, dict) and "entity_info" in artifact_data:
entity_info = artifact_data.get("entity_info")
log.info(f"Found entity_info in edited cycle artifact data: {entity_info}")
for key, value in artifact_data.items():
# Skip null/empty values
if value is None or (isinstance(value, list) and not value):
continue
if key in essential_fields:
# For dates, ensure they're strings (not objects)
if key in ["start_date", "end_date"] and isinstance(value, dict):
# Extract the date string from object format {"name": "2025-11-11"}
clean_data[key] = str(value.get("name", value))
else:
clean_data[key] = value
elif key == "owned_by" and value:
# Handle owned_by field (user who owns the cycle)
if isinstance(value, dict):
properties["owned_by"] = value
else:
# If it's just an ID, keep it as string
properties["owned_by"] = str(value)
elif key not in {"entity_info"}: # Skip entity_info
properties[key] = value
# Convert project object to project_id string (frontend sends project as object)
if "project" in clean_data and isinstance(clean_data["project"], dict):
project_obj = clean_data.pop("project")
if "id" in project_obj:
clean_data["project_id"] = str(project_obj["id"])
log.info(f"Converted project object to project_id: {clean_data["project_id"]}")
# Add properties if any
if properties:
clean_data["properties"] = properties
# Restore entity_info if it was present
if entity_info:
clean_data["entity_info"] = entity_info
log.info(f"Restored entity_info to clean cycle data: {entity_info}")
return clean_data
async def prepare_edited_workitem_artifact_data(artifact_data: dict) -> dict:
# Start with basic fields
clean_data: dict = {}
@@ -453,8 +453,8 @@ class PlaneSDKAdapter:
except Exception as e:
log.warning(f"Failed to get current user for cycle ownership: {e}")
# Prepare cycle data
cycle_data = {"name": name, **kwargs}
# log.debug(f"cycle_data payload prepared: {cycle_data}")
if start_date:
cycle_data["start_date"] = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
if end_date:
@@ -464,13 +464,21 @@ class PlaneSDKAdapter:
if owned_by:
cycle_data["owned_by"] = owned_by
# Create the request object
cycle_request = CycleCreateRequest(**cycle_data)
# Log the serialized request to see exactly what fields are included
try:
cycle_request.model_dump() if hasattr(cycle_request, "model_dump") else cycle_request.dict()
# log.debug(f"CycleCreateRequest model dump: {dump}")
except Exception as dump_error:
log.warning(f"Failed to dump CycleCreateRequest model: {dump_error}")
# WORKAROUND: Backend API requires project_id in body when dates are present.
# We monkey-patch model_dump() to inject project_id when needed.
if (start_date or end_date) and project_id:
original_model_dump = cycle_request.model_dump
def model_dump_with_project_id(*args, **kwargs):
result = original_model_dump(*args, **kwargs)
result["project_id"] = project_id
return result
# Use object.__setattr__ to bypass Pydantic's attribute protection
object.__setattr__(cycle_request, "model_dump", model_dump_with_project_id)
# Call SDK method
cycle = self.cycles.create_cycle(project_id=project_id, slug=workspace_slug, cycle_create_request=cycle_request)
+40 -21
View File
@@ -16,14 +16,16 @@ import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { useUser } from "@/hooks/store/user/user-user";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
projectId: string;
setActiveProject: (projectId: string) => void;
data?: ICycle | null;
data?: Partial<ICycle> | null;
isMobile?: boolean;
isBackwardDateEditEnabled?: boolean;
onChange?: (formData: Partial<ICycle> | null) => Promise<void>;
showActionButtons?: boolean;
handleClose?: () => void;
handleFormSubmit?: (values: Partial<ICycle>) => Promise<void>;
};
const defaultValues: Partial<ICycle> = {
@@ -43,6 +45,8 @@ export function CycleForm(props: Props) {
data,
isMobile = false,
isBackwardDateEditEnabled = false,
showActionButtons = true,
onChange,
} = props;
// plane hooks
const { t } = useTranslation();
@@ -50,9 +54,10 @@ export function CycleForm(props: Props) {
const { projectsWithCreatePermissions } = useUser();
// form data
const {
formState: { errors, isSubmitting },
formState: { errors, isDirty, isSubmitting, dirtyFields },
handleSubmit,
control,
watch,
reset,
} = useForm<ICycle>({
defaultValues: {
@@ -63,6 +68,10 @@ export function CycleForm(props: Props) {
end_date: data?.end_date || null,
},
});
const handleOnChange = () => {
if (!onChange) return;
onChange(watch());
};
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CYCLE, isMobile);
@@ -74,7 +83,7 @@ export function CycleForm(props: Props) {
}, [data, reset]);
return (
<form onSubmit={handleSubmit((formData) => handleFormSubmit(formData))}>
<form onSubmit={handleSubmit((formData) => handleFormSubmit?.(formData))}>
<div className="space-y-5 p-5">
<div className="flex items-center gap-x-3">
{!status && (
@@ -89,6 +98,7 @@ export function CycleForm(props: Props) {
if (!Array.isArray(val)) {
onChange(val);
setActiveProject(val);
handleOnChange();
}
}}
multiple={false}
@@ -124,7 +134,10 @@ export function CycleForm(props: Props) {
className="w-full text-base"
value={value}
inputSize="md"
onChange={onChange}
onChange={(e) => {
onChange(e.target.value);
handleOnChange();
}}
hasError={Boolean(errors?.name)}
tabIndex={getIndex("description")}
autoFocus
@@ -144,7 +157,10 @@ export function CycleForm(props: Props) {
className="w-full text-base resize-none min-h-24"
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}
onChange={(e) => {
onChange(e.target.value);
handleOnChange();
}}
tabIndex={getIndex("description")}
/>
)}
@@ -170,6 +186,7 @@ export function CycleForm(props: Props) {
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleOnChange();
}}
placeholder={{
from: "Start date",
@@ -187,20 +204,22 @@ export function CycleForm(props: Props) {
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={getIndex("submit")}>
{data
? isSubmitting
? t("common.updating")
: t("project_cycles.update_cycle")
: isSubmitting
? t("common.creating")
: t("project_cycles.create_cycle")}
</Button>
</div>
{showActionButtons && (
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={getIndex("submit")}>
{data
? isSubmitting
? t("common.updating")
: t("project_cycles.update_cycle")
: isSubmitting
? t("common.creating")
: t("project_cycles.create_cycle")}
</Button>
</div>
)}
</form>
);
}
@@ -9,11 +9,11 @@ export const DisplayDates = (props: {
}) => {
const { startDate, endDate, className } = props;
return (
<div className={cn("flex items-center gap-1 text-custom-text-300", className)}>
<CalendarLayoutIcon className="size-3 flex-shrink-0" />
<div className="text-sm"> {renderFormattedDate(startDate)}</div>
<div className={cn("flex items-center gap-1 text-custom-text-300 flex-wrap", className)}>
<CalendarLayoutIcon className="size-4 flex-shrink-0" />
<div className="text-sm flex-shrink-0"> {renderFormattedDate(startDate)}</div>
{startDate && endDate && <ArrowRight className="h-3 w-3 flex-shrink-0" />}
<div className="text-sm"> {renderFormattedDate(endDate)}</div>
<div className="text-sm flex-shrink-0"> {renderFormattedDate(endDate)}</div>
</div>
);
};
@@ -0,0 +1,81 @@
import { useState } from "react";
import { observer } from "mobx-react";
import type { ICycle } from "@plane/types";
import { Card } from "@plane/ui";
import { cn } from "@plane/utils";
import { CycleForm } from "@/components/cycles/form";
import { usePlatformOS } from "@/hooks/use-platform-os";
import type { TUpdatedArtifact, TArtifact } from "@/plane-web/types";
import { useCycleData } from "../useArtifactData";
import { PiChatArtifactsFooter } from "./footer";
interface TCycleDetailProps {
data: TArtifact;
updateArtifact: (data: TUpdatedArtifact) => Promise<void>;
workspaceSlug: string;
activeChatId: string;
}
export const CycleDetail = observer((props: TCycleDetailProps) => {
const { data, updateArtifact, workspaceSlug, activeChatId } = props;
// hooks
const { isMobile } = usePlatformOS();
const updatedData = useCycleData(data.artifact_id);
// state
const [isSaving, setIsSaving] = useState(false);
const [showSavedToast, setShowSavedToast] = useState(false);
const [error, setError] = useState<string | null>(null);
// derived values
const projectId = data.parameters?.project?.id;
// handlers
const handleOnSave = () => {
setIsSaving(false);
setShowSavedToast(true);
setTimeout(() => {
setShowSavedToast(false);
setError(null);
}, 1000);
};
const onChange = async (formData: Partial<ICycle> | null) => {
if (!formData) return;
setIsSaving(true);
await updateArtifact(formData)
.then(() => {
handleOnSave();
})
.catch((error) => {
console.error(error);
setError(error);
handleOnSave();
});
};
return (
<>
<Card className="relative max-w-[700px] rounded-xl shadow-lg p-0 space-y-0">
<CycleForm
onChange={onChange}
status
projectId={projectId ?? ""}
setActiveProject={() => {}}
data={updatedData}
isMobile={isMobile}
showActionButtons={false}
/>
<div
className={cn("absolute top-0 right-0 w-full h-full bg-custom-background-100 rounded-xl opacity-50", {
hidden: data.is_editable,
})}
/>
</Card>
<PiChatArtifactsFooter
artifactsData={data}
workspaceSlug={workspaceSlug}
activeChatId={activeChatId}
artifactId={data.artifact_id}
isSaving={isSaving}
showSavedToast={showSavedToast}
error={error}
/>
</>
);
});
@@ -21,10 +21,10 @@ export const ModuleDetail = observer((props: TModuleDetailProps) => {
// hooks
const { isMobile } = usePlatformOS();
const updatedData = useModuleData(data.artifact_id);
// state
const [isSaving, setIsSaving] = useState(false);
const [showSavedToast, setShowSavedToast] = useState(false);
const [error, setError] = useState<string | null>(null);
// state
const [isSaving, setIsSaving] = useState(false);
const [showSavedToast, setShowSavedToast] = useState(false);
const [error, setError] = useState<string | null>(null);
// derived values
const projectId = data.parameters?.project?.id;
// handlers
@@ -51,30 +51,31 @@ export const ModuleDetail = observer((props: TModuleDetailProps) => {
};
return (
<>
<Card className="relative max-w-[700px] rounded-xl shadow-lg p-0 space-y-0">
<ModuleForm
onChange={onChange}
status
projectId={projectId ?? ""}
setActiveProject={() => {}}
data={updatedData}
isMobile={isMobile}
showActionButtons={false}
<Card className="relative max-w-[700px] rounded-xl shadow-lg p-0 space-y-0">
<ModuleForm
onChange={onChange}
status
projectId={projectId ?? ""}
setActiveProject={() => {}}
data={updatedData}
isMobile={isMobile}
showActionButtons={false}
/>
<div
className={cn("absolute top-0 right-0 w-full h-full bg-custom-background-100 rounded-xl opacity-50", {
hidden: data.is_editable,
})}
/>
</Card>
<PiChatArtifactsFooter
artifactsData={data}
workspaceSlug={workspaceSlug}
activeChatId={activeChatId}
artifactId={data.artifact_id}
isSaving={isSaving}
showSavedToast={showSavedToast}
error={error}
/>
<div
className={cn("absolute top-0 right-0 w-full h-full bg-custom-background-100 rounded-xl opacity-50", {
hidden: data.is_editable,
})}
/>
</Card>
<PiChatArtifactsFooter
artifactsData={data}
workspaceSlug={workspaceSlug}
activeChatId={activeChatId}
artifactId={data.artifact_id}
isSaving={isSaving}
showSavedToast={showSavedToast}
error={error}
/></>
</>
);
});
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
import { cn } from "@plane/utils";
import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat";
import type { TArtifact, TUpdatedArtifact } from "@/plane-web/types";
import { CycleDetail } from "./cycle";
import { EpicDetail } from "./epic";
import { Header } from "./header";
import { ModuleDetail } from "./module";
@@ -27,7 +28,9 @@ const DetailCardRenderer = observer(
case "epic":
return <EpicDetail {...props} />;
case "module":
return <ModuleDetail {...props} />;
return <ModuleDetail {...props} />;
case "cycle":
return <CycleDetail {...props} />;
default:
return <TemplateDetail {...props} />;
}
@@ -1,7 +1,8 @@
import { observer } from "mobx-react";
import { CycleIcon } from "@plane/propel/icons";
import { useTemplateData } from "../useArtifactData";
import { Properties } from "./properties";
import { cn } from "@plane/utils";
import { DisplayDates } from "@/components/properties/dates";
import { useCycleData } from "../useArtifactData";
import { WithPreviewHOC } from "./with-preview-hoc";
type TProps = {
@@ -10,16 +11,24 @@ type TProps = {
export const CyclePreviewCard = observer((props: TProps) => {
const { artifactId } = props;
const data = useTemplateData(artifactId);
const properties = { ...data?.parameters?.properties, project: data?.parameters?.project };
const data = useCycleData(artifactId);
if (!data) return <></>;
return (
<WithPreviewHOC artifactId={data.artifact_id}>
<WithPreviewHOC artifactId={artifactId}>
<div className="flex gap-2 items-start">
<CycleIcon className="size-4 text-custom-text-100 my-0.5" />
<div className="flex flex-col">
<div className="truncate text-sm font-medium text-start capitalize">{data.parameters?.name || "Unknown"}</div>
{properties && <Properties {...properties} />}
<div className="flex flex-col gap-2">
<div className="truncate text-sm font-medium text-start capitalize">{data?.name || "Unknown"}</div>
{/* properties */}
{(data.start_date || data.end_date) && (
<div
className={cn(
"flex flex-wrap gap-2 items-center [&>*]:p-0 [&>*]:hover:bg-transparent text-sm text-custom-text-300"
)}
>
<DisplayDates startDate={data.start_date ?? null} endDate={data.end_date ?? null} />
</div>
)}
</div>
</div>
</WithPreviewHOC>
@@ -1,5 +1,5 @@
import { isEmpty } from "lodash-es";
import type { IModule, TIssue, TIssuePriorities, TPage } from "@plane/types";
import type { ICycle, IModule, TIssue, TIssuePriorities, TPage } from "@plane/types";
import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat";
import type { TArtifact, TUpdatedArtifact } from "@/plane-web/types";
@@ -81,10 +81,29 @@ export const useTemplateData = (artifactId: string): TArtifact | undefined => {
return getArtifact(artifactId);
};
export const useCycleData = (artifactId: string): Partial<ICycle> => {
const {
artifactsStore: { getArtifact, getArtifactByVersion },
} = usePiChat();
const originalData = getArtifact(artifactId);
const updatedData = getArtifactByVersion(artifactId, "updated");
const parameters = originalData?.parameters;
return !isEmpty(updatedData as Partial<ICycle>)
? (updatedData as Partial<ICycle>)
: {
name: parameters?.name,
description: parameters?.description || "",
start_date: parameters?.properties?.start_date?.name || null,
end_date: parameters?.properties?.end_date?.name || null,
};
};
export const useArtifactData = (artifactId: string, artifactType?: string): TUpdatedArtifact => {
const issueData = useWorkItemData(artifactId);
const templateData = useTemplateData(artifactId);
const pageData = usePageData(artifactId);
const cycleData = useCycleData(artifactId);
const moduleData = useModuleData(artifactId);
switch (artifactType) {
case "workitem":
@@ -93,6 +112,8 @@ export const useArtifactData = (artifactId: string, artifactType?: string): TUpd
return pageData;
case "epic":
return issueData;
case "cycle":
return cycleData;
case "module":
return moduleData;
default:
+1 -1
View File
@@ -263,4 +263,4 @@ export type TInstanceResponse =
};
// constants
export const EDITABLE_ARTIFACT_TYPES = ["workitem", "epic", "page", "module"];
export const EDITABLE_ARTIFACT_TYPES = ["workitem", "epic", "page", "cycle", "module"];