diff --git a/apps/web/core/components/dropdowns/state/dropdown.tsx b/apps/web/core/components/dropdowns/state/dropdown.tsx index 3b816ebd9..f7ef5553d 100644 --- a/apps/web/core/components/dropdowns/state/dropdown.tsx +++ b/apps/web/core/components/dropdowns/state/dropdown.tsx @@ -28,7 +28,7 @@ export const StateDropdown: React.FC = observer((pr // fetch states if not provided const onDropdownOpen = async () => { - if (stateIds === undefined && workspaceSlug && projectId) { + if ((stateIds === undefined || stateIds.length === 0) && workspaceSlug && projectId) { setStateLoader(true); await fetchProjectStates(workspaceSlug.toString(), projectId); setStateLoader(false); diff --git a/apps/web/core/components/issues/issue-modal/base.tsx b/apps/web/core/components/issues/issue-modal/base.tsx index 8f8ed9e43..2f7b9d74f 100644 --- a/apps/web/core/components/issues/issue-modal/base.tsx +++ b/apps/web/core/components/issues/issue-modal/base.tsx @@ -366,7 +366,6 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( isDuplicateModalOpen: isDuplicateModalOpen, handleDuplicateIssueModal: handleDuplicateIssueModal, isProjectSelectionDisabled: isProjectSelectionDisabled, - storeType: storeType, }; return ( diff --git a/apps/web/core/components/issues/issue-modal/form.tsx b/apps/web/core/components/issues/issue-modal/form.tsx index 37f3e441a..e914ad55f 100644 --- a/apps/web/core/components/issues/issue-modal/form.tsx +++ b/apps/web/core/components/issues/issue-modal/form.tsx @@ -67,7 +67,8 @@ export interface IssueFormProps { handleDuplicateIssueModal: (isOpen: boolean) => void; handleDraftAndClose?: () => void; isProjectSelectionDisabled?: boolean; - storeType: EIssuesStoreType; + showActionButtons?: boolean; + dataResetProperties?: any[]; } export const IssueFormRoot: FC = observer((props) => { @@ -93,7 +94,8 @@ export const IssueFormRoot: FC = observer((props) => { handleDuplicateIssueModal, handleDraftAndClose, isProjectSelectionDisabled = false, - storeType, + showActionButtons = true, + dataResetProperties = [], } = props; // states @@ -178,6 +180,14 @@ export const IssueFormRoot: FC = observer((props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); + // Reset form when data prop changes + useEffect(() => { + if (data) { + reset({ ...DEFAULT_WORK_ITEM_FORM_VALUES, project_id: projectId, ...data }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...dataResetProperties]); + // Update the issue type id when the project id changes useEffect(() => { const issueTypeId = watch("type_id"); @@ -377,7 +387,7 @@ export const IssueFormRoot: FC = observer((props) => { disabled={!!data?.id || !!data?.sourceIssueId || isProjectSelectionDisabled} handleFormChange={handleFormChange} /> - {projectId && storeType !== EIssuesStoreType.EPIC && ( + {projectId && ( = observer((props) => { activeAdditionalPropertiesLength > 0 && "shadow-custom-shadow-xs" )} > -
+
= observer((props) => { setSelectedParentIssue={setSelectedParentIssue} />
-
- {!data?.id && ( -
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - role="button" - > - {}} size="sm" /> - {t("create_more")} -
- )} -
-
- -
-
- -
- - {moveToIssue && ( - + {}} size="sm" /> + {t("create_more")} +
)} +
+
+ +
+
+ +
+ + {moveToIssue && ( + + )} +
-
+ )} diff --git a/apps/web/core/components/project/create/common-attributes.tsx b/apps/web/core/components/project/create/common-attributes.tsx index 2f3fefe95..ba6b1cc6a 100644 --- a/apps/web/core/components/project/create/common-attributes.tsx +++ b/apps/web/core/components/project/create/common-attributes.tsx @@ -18,9 +18,11 @@ type Props = { isMobile: boolean; isChangeInIdentifierRequired: boolean; setIsChangeInIdentifierRequired: (value: boolean) => void; + handleFormOnChange?: () => void; }; const ProjectCommonAttributes: React.FC = (props) => { - const { setValue, isMobile, isChangeInIdentifierRequired, setIsChangeInIdentifierRequired } = props; + const { setValue, isMobile, isChangeInIdentifierRequired, setIsChangeInIdentifierRequired, handleFormOnChange } = + props; const { formState: { errors }, control, @@ -37,6 +39,7 @@ const ProjectCommonAttributes: React.FC = (props) => { if (e.target.value === "") setValue("identifier", ""); else setValue("identifier", projectIdentifierSanitizer(e.target.value).substring(0, 5)); onChange(e); + handleFormOnChange?.(); }; const handleIdentifierChange = (onChange: any) => (e: ChangeEvent) => { @@ -44,6 +47,7 @@ const ProjectCommonAttributes: React.FC = (props) => { const alphanumericValue = projectIdentifierSanitizer(value); setIsChangeInIdentifierRequired(false); onChange(alphanumericValue); + handleFormOnChange?.(); }; return (
@@ -128,7 +132,10 @@ const ProjectCommonAttributes: React.FC = (props) => { name="description" value={value} placeholder={t("description")} - onChange={onChange} + onChange={(e) => { + onChange(e); + handleFormOnChange?.(); + }} className="!h-24 text-sm focus:border-blue-400" hasError={Boolean(errors?.description)} tabIndex={getIndex("description")} diff --git a/apps/web/core/components/readonly/cycle.tsx b/apps/web/core/components/readonly/cycle.tsx new file mode 100644 index 000000000..51f63b762 --- /dev/null +++ b/apps/web/core/components/readonly/cycle.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { ContrastIcon } from "@plane/propel/icons"; +import { cn } from "@plane/utils"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; + +export type TReadonlyCycleProps = { + className?: string; + hideIcon?: boolean; + value: string | null; + placeholder?: string; + projectId: string | undefined; + workspaceSlug: string; +}; + +export const ReadonlyCycle: React.FC = observer((props) => { + const { className, hideIcon = false, value, placeholder, projectId, workspaceSlug } = props; + + const { t } = useTranslation(); + const { getCycleNameById, fetchAllCycles } = useCycle(); + const cycleName = value ? getCycleNameById(value) : null; + + useEffect(() => { + if (projectId) { + fetchAllCycles(workspaceSlug, projectId); + } + }, [projectId, workspaceSlug]); + + return ( +
+ {!hideIcon && } + {cycleName ?? placeholder ?? t("common.none")} +
+ ); +}); diff --git a/apps/web/core/components/readonly/date.tsx b/apps/web/core/components/readonly/date.tsx new file mode 100644 index 000000000..f5be3cbff --- /dev/null +++ b/apps/web/core/components/readonly/date.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Calendar } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { cn, renderFormattedDate, getDate } from "@plane/utils"; + +export type TReadonlyDateProps = { + className?: string; + hideIcon?: boolean; + value: Date | string | null; + placeholder?: string; + formatToken?: string; +}; + +export const ReadonlyDate: React.FC = observer((props) => { + const { className, hideIcon = false, value, placeholder, formatToken } = props; + + const { t } = useTranslation(); + const formattedDate = value ? renderFormattedDate(getDate(value), formatToken) : null; + + return ( +
+ {!hideIcon && } + {formattedDate ?? placeholder ?? t("common.none")} +
+ ); +}); diff --git a/apps/web/core/components/readonly/estimate.tsx b/apps/web/core/components/readonly/estimate.tsx new file mode 100644 index 000000000..ea71ce3d5 --- /dev/null +++ b/apps/web/core/components/readonly/estimate.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { Triangle } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { EEstimateSystem } from "@plane/types"; +import { cn, convertMinutesToHoursMinutesString } from "@plane/utils"; +// hooks +import { useProjectEstimates } from "@/hooks/store/estimates"; +import { useEstimate } from "@/hooks/store/estimates/use-estimate"; + +export type TReadonlyEstimateProps = { + className?: string; + hideIcon?: boolean; + value: string | undefined | null; + placeholder?: string; + projectId: string | undefined; + workspaceSlug: string; +}; + +export const ReadonlyEstimate: React.FC = observer((props) => { + const { className, hideIcon = false, value, placeholder, projectId, workspaceSlug } = props; + + const { t } = useTranslation(); + const { currentActiveEstimateIdByProjectId, getEstimateById, getProjectEstimates } = useProjectEstimates(); + + const currentActiveEstimateId = projectId ? currentActiveEstimateIdByProjectId(projectId) : undefined; + const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined; + const { estimatePointById } = useEstimate(currentActiveEstimateId); + + const estimatePoint = value ? estimatePointById(value) : null; + + const displayValue = estimatePoint + ? currentActiveEstimate?.type === EEstimateSystem.TIME + ? convertMinutesToHoursMinutesString(Number(estimatePoint.value)) + : estimatePoint.value + : null; + + useEffect(() => { + if (projectId) { + getProjectEstimates(workspaceSlug, projectId); + } + }, [projectId, workspaceSlug]); + + return ( +
+ {!hideIcon && } + {displayValue ?? placeholder ?? t("common.none")} +
+ ); +}); diff --git a/apps/web/core/components/readonly/index.tsx b/apps/web/core/components/readonly/index.tsx new file mode 100644 index 000000000..66e7a06b7 --- /dev/null +++ b/apps/web/core/components/readonly/index.tsx @@ -0,0 +1,10 @@ +// Readonly components for displaying single values instead of interactive dropdowns +// These components handle their own data fetching using internal hooks +export { ReadonlyState, type TReadonlyStateProps } from "./state"; +export { ReadonlyPriority, type TReadonlyPriorityProps } from "./priority"; +export { ReadonlyMember, type TReadonlyMemberProps } from "./member"; +export { ReadonlyLabels, type TReadonlyLabelsProps } from "./labels"; +export { ReadonlyCycle, type TReadonlyCycleProps } from "./cycle"; +export { ReadonlyDate, type TReadonlyDateProps } from "./date"; +export { ReadonlyEstimate, type TReadonlyEstimateProps } from "./estimate"; +export { ReadonlyModule, type TReadonlyModuleProps } from "./module"; diff --git a/apps/web/core/components/readonly/labels.tsx b/apps/web/core/components/readonly/labels.tsx new file mode 100644 index 000000000..87c254e57 --- /dev/null +++ b/apps/web/core/components/readonly/labels.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useLabel } from "@/hooks/store/use-label"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +export type TReadonlyLabelsProps = { + className?: string; + hideIcon?: boolean; + value: string[]; + placeholder?: string; + projectId: string | undefined; + workspaceSlug: string; +}; + +export const ReadonlyLabels: React.FC = observer((props) => { + const { className, value, projectId, workspaceSlug } = props; + + const { getLabelById, fetchProjectLabels } = useLabel(); + const { isMobile } = usePlatformOS(); + const labels = value + .map((labelId) => getLabelById(labelId)) + .filter((label): label is NonNullable => Boolean(label)); + + useEffect(() => { + if (projectId) { + fetchProjectLabels(workspaceSlug?.toString(), projectId); + } + }, [projectId, workspaceSlug]); + + return ( +
+ {labels && ( + <> + l?.name).join(", ")} + isMobile={isMobile} + disabled={labels.length === 0} + > +
+ + {value.length} + Labels +
+
+ + )} +
+ ); +}); diff --git a/apps/web/core/components/readonly/member.tsx b/apps/web/core/components/readonly/member.tsx new file mode 100644 index 000000000..4ad288352 --- /dev/null +++ b/apps/web/core/components/readonly/member.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { observer } from "mobx-react"; +import { LucideIcon } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; +// components +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +// hooks +import { useMember } from "@/hooks/store/use-member"; + +export type TReadonlyMemberProps = { + className?: string; + icon?: LucideIcon; + hideIcon?: boolean; + value: string | string[]; + placeholder?: string; + multiple?: boolean; + projectId?: string; +}; + +export const ReadonlyMember: React.FC = observer((props) => { + const { className, icon: Icon, hideIcon = false, value, placeholder, multiple = false } = props; + + const { t } = useTranslation(); + const { getUserDetails } = useMember(); + const memberIds = Array.isArray(value) ? value : value ? [value] : []; + const members = memberIds.map((id) => getUserDetails(id)).filter(Boolean); + + if (members.length === 0) { + return ( +
+ {} + {placeholder ?? t("common.none")} +
+ ); + } + + if (multiple) { + return ( +
+ {!hideIcon && Icon && } + +
+ ); + } + + const member = members[0]; + + return ( +
+ {!hideIcon && Icon && } +
+
+ + {member?.display_name?.charAt(0) ?? member?.email?.charAt(0) ?? "?"} + +
+ {member?.display_name ?? member?.email} +
+
+ ); +}); diff --git a/apps/web/core/components/readonly/module.tsx b/apps/web/core/components/readonly/module.tsx new file mode 100644 index 000000000..ce248145c --- /dev/null +++ b/apps/web/core/components/readonly/module.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { Layers } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; +// hooks +import { useModule } from "@/hooks/store/use-module"; + +export type TReadonlyModuleProps = { + className?: string; + hideIcon?: boolean; + value: string | string[] | null; + placeholder?: string; + projectId: string | undefined; + multiple?: boolean; + showCount?: boolean; + workspaceSlug: string; +}; + +export const ReadonlyModule: React.FC = observer((props) => { + const { + className, + hideIcon = false, + value, + placeholder, + multiple = false, + showCount = true, + workspaceSlug, + projectId, + } = props; + + const { t } = useTranslation(); + const { getModuleById, fetchModules } = useModule(); + + const moduleIds = Array.isArray(value) ? value : value ? [value] : []; + const modules = moduleIds.map((id) => getModuleById(id)).filter(Boolean); + + useEffect(() => { + if (moduleIds.length > 0 && projectId) { + fetchModules(workspaceSlug, projectId); + } + }, [value, projectId, workspaceSlug]); + + if (modules.length === 0) { + return ( +
+ {!hideIcon && } + {placeholder ?? t("common.none")} +
+ ); + } + + if (multiple) { + const displayText = + showCount && modules.length > 1 ? `${modules[0]?.name} +${modules.length - 1}` : modules[0]?.name; + + return ( +
+ {!hideIcon && } + {displayText} +
+ ); + } + + const moduleItem = modules[0]; + return ( +
+ {!hideIcon && } + {moduleItem?.name} +
+ ); +}); diff --git a/apps/web/core/components/readonly/priority.tsx b/apps/web/core/components/readonly/priority.tsx new file mode 100644 index 000000000..cac1b2de2 --- /dev/null +++ b/apps/web/core/components/readonly/priority.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane imports +import { ISSUE_PRIORITIES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { PriorityIcon } from "@plane/propel/icons"; +import { TIssuePriorities } from "@plane/types"; +import { cn } from "@plane/utils"; + +export type TReadonlyPriorityProps = { + className?: string; + hideIcon?: boolean; + value: TIssuePriorities | undefined | null; + placeholder?: string; +}; + +export const ReadonlyPriority: React.FC = observer((props) => { + const { className, hideIcon = false, value, placeholder } = props; + + const { t } = useTranslation(); + const priorityDetails = ISSUE_PRIORITIES.find((p) => p.key === value); + + return ( +
+ {!hideIcon && } + {priorityDetails?.title ?? placeholder ?? t("common.none")} +
+ ); +}); diff --git a/apps/web/core/components/readonly/state.tsx b/apps/web/core/components/readonly/state.tsx new file mode 100644 index 000000000..13c7f98b6 --- /dev/null +++ b/apps/web/core/components/readonly/state.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Loader } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useProjectState } from "@/hooks/store/use-project-state"; + +export type TReadonlyStateProps = { + className?: string; + iconSize?: string; + hideIcon?: boolean; + value: string | undefined | null; + placeholder?: string; + projectId: string | undefined; + workspaceSlug: string; +}; + +export const ReadonlyState: React.FC = observer((props) => { + const { className, iconSize = "size-4", hideIcon = false, value, placeholder, projectId, workspaceSlug } = props; + // states + const [stateLoader, setStateLoader] = useState(false); + const { t } = useTranslation(); + const { getStateById, getProjectStateIds, fetchProjectStates } = useProjectState(); + // derived values + const stateIds = getProjectStateIds(projectId); + const state = getStateById(value); + + // fetch states if not provided + const fetchStates = async () => { + if ((stateIds === undefined || stateIds.length === 0) && projectId) { + setStateLoader(true); + try { + await fetchProjectStates(workspaceSlug, projectId); + } finally { + setStateLoader(false); + } + } + }; + + useEffect(() => { + fetchStates(); + }, [projectId, workspaceSlug]); + + if (stateLoader) { + return ( + + + + + ); + } + + return ( +
+ {!hideIcon && ( + + )} + {state?.name ?? placeholder ?? t("common.none")} +
+ ); +});