Merge branch 'preview' into feat/flat-list

This commit is contained in:
Palanikannan M
2024-10-22 13:06:39 +05:30
31 changed files with 297 additions and 167 deletions

View File

@@ -170,7 +170,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Admin Build and Push
uses: ./.github/actions/buildpush-action
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -196,7 +196,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Web Build and Push
uses: ./.github/actions/buildpush-action
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -222,7 +222,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Space Build and Push
uses: ./.github/actions/buildpush-action
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -248,7 +248,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Live Build and Push
uses: ./.github/actions/buildpush-action
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -274,7 +274,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Backend Build and Push
uses: ./.github/actions/buildpush-action
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -300,7 +300,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Proxy Build and Push
uses: ./.github/actions/buildpush-action
uses: ./.github/actions/build-push-ce
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -320,7 +320,7 @@ jobs:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
runs-on: ubuntu-20.04
needs: [ branch_build_setup ]
needs: [branch_build_setup]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -340,7 +340,7 @@ jobs:
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
@@ -353,7 +353,7 @@ jobs:
branch_build_push_space,
branch_build_push_live,
branch_build_push_apiserver,
branch_build_push_proxy
branch_build_push_proxy,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
@@ -380,4 +380,4 @@ jobs:
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
${{ github.workspace }}/deploy/selfhost/variables.env

View File

@@ -72,6 +72,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
@@ -145,8 +147,9 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
...prevSize,
width: ensurePixelString(nodeWidth),
height: ensurePixelString(nodeHeight),
aspectRatio: nodeAspectRatio,
}));
}, [nodeWidth, nodeHeight]);
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
@@ -159,7 +162,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
[size]
[size.aspectRatio]
);
const handleResizeEnd = useCallback(() => {
@@ -182,11 +185,15 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
window.addEventListener("touchmove", handleResize);
window.addEventListener("touchend", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
window.removeEventListener("touchmove", handleResize);
window.removeEventListener("touchend", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
@@ -203,7 +210,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = remoteImageSrc && initialResizeComplete;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
@@ -231,9 +238,26 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={(e) => {
console.error("Error loading image", e);
setFailedToLoadImage(true);
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
await editor?.commands.restoreImage?.(node.attrs.src);
imageRef.current.src = remoteImageSrc;
} catch {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", e);
} finally {
setHasErroredOnFirstLoad(false);
setHasTriedRestoringImageOnce(true);
}
}}
width={size.width}
className={cn("image-component block rounded-md", {
@@ -284,6 +308,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
</>
)}

View File

@@ -1,21 +1,23 @@
import { useEffect, useRef, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
export type CustomImageNodeViewProps = {
export type CustomImageComponentProps = {
getPos: () => number;
editor: Editor;
node: ProsemirrorNode & {
node: NodeViewProps["node"] & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
updateAttributes: (attrs: ImageAttributes) => void;
selected: boolean;
};
export type CustomImageNodeViewProps = NodeViewProps & CustomImageComponentProps;
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { src: remoteImageSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
@@ -37,14 +39,13 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
// the image is already uploaded if the image-component node has src attribute
// and we need to remove the blob from our file system
useEffect(() => {
const remoteImageSrc = node.attrs.src;
if (remoteImageSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
}, [node.attrs.src]);
}, [remoteImageSrc]);
return (
<NodeViewWrapper>
@@ -55,7 +56,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
editorContainer={editorContainer}
editor={editor}
// @ts-expect-error function not expected here, but will still work
src={editor?.commands?.getImageSource?.(node.attrs.src)}
src={editor?.commands?.getImageSource?.(remoteImageSrc)}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}

View File

@@ -1,27 +1,20 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
export const CustomImageUploader = (props: {
editor: Editor;
failedToLoadImage: boolean;
getPos: () => number;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomImageComponentProps & {
maxFileSize: number;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
selected: boolean;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;
setIsUploaded: (isUploaded: boolean) => void;
updateAttributes: (attrs: Record<string, any>) => void;
}) => {
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
failedToLoadImage,
@@ -36,8 +29,8 @@ export const CustomImageUploader = (props: {
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const { id: imageEntityId } = node.attrs;
// derived values
const imageEntityId = node.attrs.id;
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const onUpload = useCallback(

View File

@@ -22,6 +22,7 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
restoreImage: (src: string) => () => Promise<void>;
getImageSource?: (path: string) => () => string;
};
}
@@ -40,8 +41,8 @@ export const CustomImageExtension = (props: TFileHandler) => {
const {
getAssetSrc,
upload,
delete: deleteImage,
restore: restoreImage,
delete: deleteImageFn,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;
@@ -85,22 +86,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
await restoreImage(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
});
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@@ -110,11 +95,29 @@ export const CustomImageExtension = (props: TFileHandler) => {
addProseMirrorPlugins() {
return [
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
if (!node.attrs.src?.startsWith("http")) return;
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
await restoreImageFn(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
});
},
addStorage() {
return {
fileMap: new Map(),
@@ -179,6 +182,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
const fileUrl = await upload(file);
return fileUrl;
},
restoreImage: (src: string) => async () => {
await restoreImageFn(src);
},
getImageSource: (path: string) => () => getAssetSrc(path),
};
},

View File

@@ -144,7 +144,10 @@ export const CoreEditorExtensions = (args: TArguments) => {
if (editor.storage.imageComponent.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
editor.isActive("table") ||
editor.isActive("codeBlock") ||
editor.isActive("image") ||
editor.isActive("imageComponent");
if (shouldHidePlaceholder) return "";

View File

@@ -11,9 +11,9 @@ import { CustomImageNode } from "@/extensions";
export const ImageExtension = (fileHandler: TFileHandler) => {
const {
delete: deleteImage,
getAssetSrc,
restore: restoreImage,
delete: deleteImageFn,
restore: restoreImageFn,
validation: { maxFileSize },
} = fileHandler;
@@ -24,10 +24,11 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addProseMirrorPlugins() {
return [
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
];
},
@@ -35,12 +36,14 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
if (!node.attrs.src?.startsWith("http")) return;
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
await restoreImage(src);
await restoreImageFn(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
@@ -65,6 +68,9 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},

View File

@@ -11,6 +11,9 @@ export const ImageExtensionWithoutProps = () =>
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
});

View File

@@ -18,6 +18,9 @@ export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">)
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},

View File

@@ -25,6 +25,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
if (node.type.name !== nodeType) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
// if the src is just a id (private bucket), then we don't need to handle restore from here but
// only while it fails to load
if (!node.attrs.src?.startsWith("http")) return;
addedImages.push(node as ImageNode);
});

View File

@@ -96,3 +96,16 @@ export type TInboxIssuePaginationInfo = TPaginationInfo & {
export type TInboxIssueWithPagination = TInboxIssuePaginationInfo & {
results: TInboxIssue[];
};
export type TInboxForm = {
anchor: string;
id: string;
is_disabled: boolean;
};
export type TInboxIssueForm = {
name: string;
description: string;
username: string;
email: string;
};

View File

@@ -18,6 +18,7 @@ export const Popover = (props: TPopover) => {
panelClassName = "",
children,
popoverButtonRef,
buttonRefClassName = "",
} = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
@@ -38,7 +39,7 @@ export const Popover = (props: TPopover) => {
return (
<HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
<div ref={setReferenceElement} className="w-full">
<div ref={setReferenceElement} className={cn("w-full", buttonRefClassName)}>
<HeadlessReactPopover.Button
ref={popoverButtonRef as Ref<HTMLButtonElement>}
className={cn(

View File

@@ -5,6 +5,7 @@ export type TPopoverButtonDefaultOptions = {
// button and button styling
button?: ReactNode;
buttonClassName?: string;
buttonRefClassName?: string;
disabled?: boolean;
};

View File

@@ -16,6 +16,7 @@ export class FileUploadService extends APIService {
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
withCredentials: false,
})
.then((response) => response?.data)
.catch((error) => {

View File

@@ -4,7 +4,6 @@ import { TFileHandler } from "@plane/editor";
import { MAX_FILE_SIZE } from "@/constants/common";
// helpers
import { getFileURL } from "@/helpers/file.helper";
import { checkURLValidity } from "@/helpers/string.helper";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
@@ -34,7 +33,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
return {
getAssetSrc: (path) => {
if (!path) return "";
if (checkURLValidity(path)) {
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";
@@ -42,14 +41,14 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
},
upload: uploadFile,
delete: async (src: string) => {
if (checkURLValidity(src)) {
if (src?.startsWith("http")) {
await fileService.deleteOldEditorAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? "");
}
},
restore: async (src: string) => {
if (checkURLValidity(src)) {
if (src?.startsWith("http")) {
await fileService.restoreOldEditorAsset(workspaceId, src);
} else {
await fileService.restoreNewAsset(anchor, src);
@@ -73,7 +72,7 @@ export const getReadOnlyEditorFileHandlers = (
return {
getAssetSrc: (path) => {
if (!path) return "";
if (checkURLValidity(path)) {
if (path?.startsWith("http")) {
return path;
} else {
return getEditorAssetSrc(anchor, path) ?? "";

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -2,7 +2,7 @@
// components
import { AppHeader, ContentWrapper } from "@/components/core";
import { ProjectInboxHeader } from "./header";
import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake";
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
return (

View File

@@ -0,0 +1 @@
export * from "./header";

View File

@@ -1,16 +1,23 @@
import { ReactNode } from "react";
import { FileText, Layers, Timer } from "lucide-react";
import { IProject } from "@plane/types";
import { ContrastIcon, DiceIcon, Intake } from "@plane/ui";
export type TProperties = {
property: string;
title: string;
description: string;
icon: ReactNode;
isPro: boolean;
isEnabled: boolean;
renderChildren?: (
currentProjectDetails: IProject,
isAdmin: boolean,
handleSubmit: (featureKey: string, featureProperty: string) => Promise<void>
) => ReactNode;
};
export type TFeatureList = {
[key: string]: {
property: string;
title: string;
description: string;
icon: ReactNode;
isPro: boolean;
isEnabled: boolean;
};
[key: string]: TProperties;
};
export type TProjectFeatures = {

View File

@@ -2,14 +2,15 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { MinusCircle } from "lucide-react";
import { TIssue } from "@plane/types";
// component
// ui
import { CustomMenu } from "@plane/ui";
import { ControlLink, CustomMenu } from "@plane/ui";
// hooks
import { useIssues, useProjectState } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
@@ -29,6 +30,8 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
// hooks
const { issueMap } = useIssues();
const { getProjectStates } = useProjectState();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
@@ -42,7 +45,10 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
return (
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<Link href={`/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`} target="_blank">
<ControlLink
href={`/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}
onClick={() => handleRedirection(workspaceSlug, parentIssue, isMobile)}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
@@ -56,7 +62,7 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
</div>
<span className="truncate text-custom-text-100">{(parentIssue?.name ?? "").substring(0, 50)}</span>
</div>
</Link>
</ControlLink>
<CustomMenu ellipsis optionsClassName="p-1.5">
<div className="border-b border-custom-border-300 text-xs font-medium text-custom-text-200">

View File

@@ -7,12 +7,21 @@ import { useParams, usePathname, useSearchParams } from "next/navigation";
import { Info, SquareUser } from "lucide-react";
// ui
import { IModule } from "@plane/types";
import { Card, FavoriteStar, LayersIcon, LinearProgressIndicator, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui";
import {
Card,
FavoriteStar,
LayersIcon,
LinearProgressIndicator,
TOAST_TYPE,
Tooltip,
setPromiseToast,
setToast,
} from "@plane/ui";
// components
import { DateRangeDropdown } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { ModuleQuickActions } from "@/components/modules";
import { ModuleStatusDropdown } from "@/components/modules/module-status-dropdown";
import { ModuleStatusDropdown } from "@/components/modules/module-status-dropdown";
// constants
import { PROGRESS_STATE_GROUPS_DETAILS } from "@/constants/common";
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
@@ -21,11 +30,10 @@ import { MODULE_STATUS } from "@/constants/module";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { generateQueryParams } from "@/helpers/router.helper";
// hooks
import { useEventTracker, useMember, useModule, useProjectEstimates, useUserPermissions } from "@/hooks/store";
import { useEventTracker, useMember, useModule, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
type Props = {
@@ -46,7 +54,6 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites, updateModuleDetails } = useModule();
const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const moduleDetails = getModuleById(moduleId);
@@ -57,7 +64,6 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const isDisabled = !isEditingAllowed || !!moduleDetails?.archived_at;
const renderIcon = Boolean(moduleDetails?.start_date) || Boolean(moduleDetails?.target_date);
const { isMobile } = usePlatformOS();
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -156,29 +162,14 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
if (!moduleDetails) return null;
/**
* NOTE: This completion percentage calculation is based on the total issues count.
* when estimates are available and estimate type is points, we should consider the estimate point count
* when estimates are available and estimate type is not points, then by default we consider the issue count
*/
const isEstimateEnabled =
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId.toString()) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
const moduleTotalIssues = isEstimateEnabled
? moduleDetails?.total_estimate_points || 0
: moduleDetails.backlog_issues +
moduleDetails.unstarted_issues +
moduleDetails.started_issues +
moduleDetails.completed_issues +
moduleDetails.cancelled_issues;
const moduleCompletedIssues = isEstimateEnabled
? moduleDetails?.completed_estimate_points || 0
: moduleDetails.completed_issues;
const moduleTotalIssues =
moduleDetails.backlog_issues +
moduleDetails.unstarted_issues +
moduleDetails.started_issues +
moduleDetails.completed_issues +
moduleDetails.cancelled_issues;
const moduleCompletedIssues = moduleDetails.completed_issues;
// const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
@@ -186,11 +177,11 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const issueCount = module
? !moduleTotalIssues || moduleTotalIssues === 0
? `0 ${isEstimateEnabled ? `Point` : `Issue`}`
? `0 Issue`
: moduleTotalIssues === moduleCompletedIssues
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? `s` : ``}`
: `${moduleCompletedIssues}/${moduleTotalIssues} ${isEstimateEnabled ? `Points` : `Issues`}`
: `0 ${isEstimateEnabled ? `Point` : `Issue`}`;
: `${moduleCompletedIssues}/${moduleTotalIssues} Issues`
: `0 Issue`;
const moduleLeadDetails = moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
@@ -213,9 +204,9 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-2" onClick={handleEventPropagation}>
{moduleStatus && (
<ModuleStatusDropdown
isDisabled={isDisabled}
moduleDetails={moduleDetails}
handleModuleDetailsChange={handleModuleDetailsChange}
isDisabled={isDisabled}
moduleDetails={moduleDetails}
handleModuleDetailsChange={handleModuleDetailsChange}
/>
)}
<button onClick={openModuleOverview}>
@@ -252,9 +243,9 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
}}
onSelect={(val) => {
handleModuleDetailsChange({
start_date: (val?.from ? renderFormattedPayloadDate(val.from) : null),
target_date: (val?.to ? renderFormattedPayloadDate(val.to) : null)
})
start_date: val?.from ? renderFormattedPayloadDate(val.from) : null,
target_date: val?.to ? renderFormattedPayloadDate(val.to) : null,
});
}}
placeholder={{
from: "Start date",
@@ -288,4 +279,4 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
</div>
</div>
);
});
});

View File

@@ -13,11 +13,9 @@ import { ModuleListItemAction, ModuleQuickActions } from "@/components/modules";
// helpers
import { generateQueryParams } from "@/helpers/router.helper";
// hooks
import { useModule, useProjectEstimates } from "@/hooks/store";
import { useModule } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
type Props = {
moduleId: string;
@@ -35,27 +33,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
// store hooks
const { getModuleById } = useModule();
const { isMobile } = usePlatformOS();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const moduleDetails = getModuleById(moduleId);
if (!moduleDetails) return null;
/**
* NOTE: This completion percentage calculation is based on the total issues count.
* when estimates are available and estimate type is points, we should consider the estimate point count
* when estimates are available and estimate type is not points, then by default we consider the issue count
*/
const isEstimateEnabled =
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId?.toString()) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
const completionPercentage = isEstimateEnabled
? ((moduleDetails?.completed_estimate_points || 0) / (moduleDetails?.total_estimate_points || 0)) * 100
: ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
const completionPercentage =
((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);

View File

@@ -70,31 +70,40 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
return (
<div
key={featureItemKey}
className="flex items-center justify-between gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4 last:border-b-0"
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4"
>
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
{featureItem.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{featureItem.title}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge />
</Tooltip>
)}
<div key={featureItemKey} className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
{featureItem.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{featureItem.title}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge />
</Tooltip>
)}
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
{featureItem.description}
</p>
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">{featureItem.description}</p>
</div>
</div>
<ToggleSwitch
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
onChange={() => handleSubmit(featureItemKey, featureItem.property)}
disabled={!featureItem.isEnabled || !isAdmin}
size="sm"
/>
<ToggleSwitch
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
onChange={() => handleSubmit(featureItemKey, featureItem.property)}
disabled={!featureItem.isEnabled || !isAdmin}
size="sm"
/>
</div>
<div className="pl-14">
{currentProjectDetails?.[featureItem.property as keyof IProject] &&
featureItem.renderChildren &&
featureItem.renderChildren(currentProjectDetails, isAdmin, handleSubmit)}
</div>
</div>
);
})}

View File

@@ -16,6 +16,7 @@ export class FileUploadService extends APIService {
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
withCredentials: false,
})
.then((response) => response?.data)
.catch((error) => {

View File

@@ -1,5 +1,5 @@
// types
import type { TInboxIssue, TIssue, TInboxIssueWithPagination } from "@plane/types";
import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TInboxForm } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// helpers
@@ -75,4 +75,30 @@ export class InboxIssueService extends APIService {
throw error?.response?.data;
});
}
async retrievePublishForm(workspaceSlug: string, projectId: string): Promise<TInboxForm> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/publish-intake/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updatePublishForm(workspaceSlug: string, projectId: string, is_disabled: boolean): Promise<TInboxIssue> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/publish-intake/`, {
is_disabled,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async regeneratePublishForm(workspaceSlug: string, projectId: string): Promise<TInboxIssue> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/publish-intake-regenerate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -12,6 +12,7 @@ import {
TInboxIssueSorting,
TInboxIssuePaginationInfo,
TInboxIssueSortingOrderByQueryParam,
TInboxForm,
} from "@plane/types";
// helpers
import { EInboxIssueCurrentTab, EInboxIssueStatus, EPastDurationFilters, getCustomDates } from "@/helpers/inbox.helper";
@@ -39,6 +40,7 @@ export interface IProjectInboxStore {
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined;
inboxIssues: Record<string, IInboxIssueStore>; // issue_id -> IInboxIssueStore
inboxIssueIds: string[];
intakeForms: Record<string, TInboxForm>;
// computed
inboxFilters: Partial<TInboxIssueFilter>; // computed project inbox filters
inboxSorting: Partial<TInboxIssueSorting>; // computed project inbox sorting
@@ -68,6 +70,9 @@ export interface IProjectInboxStore {
) => Promise<void>;
fetchInboxPaginationIssues: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchInboxIssueById: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise<TInboxIssue>;
fetchIntakeForms: (workspaceSlug: string, projectId: string) => Promise<void>;
toggleIntakeForms: (workspaceSlug: string, projectId: string, isDisabled: boolean) => Promise<void>;
regenerateIntakeForms: (workspaceSlug: string, projectId: string) => Promise<void>;
createInboxIssue: (
workspaceSlug: string,
projectId: string,
@@ -89,6 +94,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined = undefined;
inboxIssues: Record<string, IInboxIssueStore> = {};
inboxIssueIds: string[] = [];
intakeForms: Record<string, TInboxForm> = {};
// services
inboxIssueService;
@@ -103,6 +109,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
inboxIssuePaginationInfo: observable,
inboxIssues: observable,
inboxIssueIds: observable,
intakeForms: observable,
// computed
inboxFilters: computed,
inboxSorting: computed,
@@ -310,6 +317,45 @@ export class ProjectInboxStore implements IProjectInboxStore {
}
};
fetchIntakeForms = async (workspaceSlug: string, projectId: string) => {
try {
const intakeForms = await this.inboxIssueService.retrievePublishForm(workspaceSlug, projectId);
if (intakeForms)
runInAction(() => {
set(this.intakeForms, projectId, intakeForms);
});
} catch {
console.error("Error fetching the publish forms");
}
};
toggleIntakeForms = async (workspaceSlug: string, projectId: string, isDisabled: boolean) => {
try {
runInAction(() => {
set(this.intakeForms, projectId, { ...this.intakeForms[projectId], is_disabled: isDisabled });
});
await this.inboxIssueService.updatePublishForm(workspaceSlug, projectId, isDisabled);
} catch {
console.error("Error fetching the publish forms");
runInAction(() => {
set(this.intakeForms, projectId, { ...this.intakeForms[projectId], is_disabled: !isDisabled });
});
}
};
regenerateIntakeForms = async (workspaceSlug: string, projectId: string) => {
try {
const form = await this.inboxIssueService.regeneratePublishForm(workspaceSlug, projectId);
if (form) {
runInAction(() => {
set(this.intakeForms, projectId, form);
});
}
} catch {
console.error("Error fetching the publish forms");
}
};
/**
* @description fetch intake issues with paginated data
* @param workspaceSlug

View File

@@ -0,0 +1 @@
export * from "ce/components/projects/settings/intake";

View File

@@ -2,7 +2,6 @@
import { TFileHandler } from "@plane/editor";
// helpers
import { getBase64Image, getFileURL } from "@/helpers/file.helper";
import { checkURLValidity } from "@/helpers/string.helper";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
@@ -46,7 +45,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
return {
getAssetSrc: (path) => {
if (!path) return "";
if (checkURLValidity(path)) {
if (path?.startsWith("http")) {
return path;
} else {
return (
@@ -60,7 +59,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
},
upload: uploadFile,
delete: async (src: string) => {
if (checkURLValidity(src)) {
if (src?.startsWith("http")) {
await fileService.deleteOldWorkspaceAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(
@@ -73,7 +72,7 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
}
},
restore: async (src: string) => {
if (checkURLValidity(src)) {
if (src?.startsWith("http")) {
await fileService.restoreOldEditorAsset(workspaceId, src);
} else {
await fileService.restoreNewAsset(workspaceSlug, src);
@@ -97,7 +96,7 @@ export const getReadOnlyEditorFileHandlers = (
return {
getAssetSrc: (path) => {
if (!path) return "";
if (checkURLValidity(path)) {
if (path?.startsWith("http")) {
return path;
} else {
return (