diff --git a/.github/actions/buildpush-action/action.yml b/.github/actions/build-push-ce/action.yml similarity index 100% rename from .github/actions/buildpush-action/action.yml rename to .github/actions/build-push-ce/action.yml diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index b3c66bd87e..a675866f37 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -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 \ No newline at end of file + ${{ github.workspace }}/deploy/selfhost/variables.env diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index 4793d0cda9..65d9a38433 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -72,6 +72,8 @@ export const CustomImageBlock: React.FC = (props) => { const containerRef = useRef(null); const containerRect = useRef(null); const imageRef = useRef(null); + const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false); + const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); const updateAttributesSafely = useCallback( (attributes: Partial, errorMessage: string) => { @@ -145,8 +147,9 @@ export const CustomImageBlock: React.FC = (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 = (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 = (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 = (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 = (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 = (props) => { } )} onMouseDown={handleResizeStart} + onTouchStart={handleResizeStart} /> )} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index f743b0a3c1..bdb8280c5b 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -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) => 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(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 ( @@ -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} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index 67cb4e3292..36f1361ee8 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -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) => void; -}) => { +}; + +export const CustomImageUploader = (props: CustomImageUploaderProps) => { const { editor, failedToLoadImage, @@ -36,8 +29,8 @@ export const CustomImageUploader = (props: { // refs const fileInputRef = useRef(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( diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 6dd5f0f19e..2c5e2bb8d4 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -22,6 +22,7 @@ declare module "@tiptap/core" { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (file: File) => () => Promise | undefined; + restoreImage: (src: string) => () => Promise; 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(); - 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(); + 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), }; }, diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 7d5a7e8fcf..b54598554e 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -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 ""; diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index e430b88a8d..f7666bfe24 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -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(); 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, + }, }; }, diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index 0d505000c7..bb6c5b4ad8 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -11,6 +11,9 @@ export const ImageExtensionWithoutProps = () => height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, }); diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 7ba961cdb6..c884a43ee7 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -18,6 +18,9 @@ export const ReadOnlyImageExtension = (props: Pick) height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index 4d7279fffc..4eecf01d7e 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -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); }); diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index afb744f6cd..bb60b35a21 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -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; +}; diff --git a/packages/ui/src/popovers/popover.tsx b/packages/ui/src/popovers/popover.tsx index 30a168965f..4860a25a89 100644 --- a/packages/ui/src/popovers/popover.tsx +++ b/packages/ui/src/popovers/popover.tsx @@ -18,6 +18,7 @@ export const Popover = (props: TPopover) => { panelClassName = "", children, popoverButtonRef, + buttonRefClassName = "", } = props; // states const [referenceElement, setReferenceElement] = useState(null); @@ -38,7 +39,7 @@ export const Popover = (props: TPopover) => { return ( -
+
} className={cn( diff --git a/packages/ui/src/popovers/types.ts b/packages/ui/src/popovers/types.ts index 51b1e877a5..7801e2d853 100644 --- a/packages/ui/src/popovers/types.ts +++ b/packages/ui/src/popovers/types.ts @@ -5,6 +5,7 @@ export type TPopoverButtonDefaultOptions = { // button and button styling button?: ReactNode; buttonClassName?: string; + buttonRefClassName?: string; disabled?: boolean; }; diff --git a/space/core/services/file-upload.service.ts b/space/core/services/file-upload.service.ts index efb6cf946f..09e95f3c0f 100644 --- a/space/core/services/file-upload.service.ts +++ b/space/core/services/file-upload.service.ts @@ -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) => { diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts index bf4faabdb6..648e409e70 100644 --- a/space/helpers/editor.helper.ts +++ b/space/helpers/editor.helper.ts @@ -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) ?? ""; diff --git a/space/public/instance/intake-sent-dark.png b/space/public/instance/intake-sent-dark.png new file mode 100644 index 0000000000..70a62730e0 Binary files /dev/null and b/space/public/instance/intake-sent-dark.png differ diff --git a/space/public/instance/intake-sent-light.png b/space/public/instance/intake-sent-light.png new file mode 100644 index 0000000000..9425c07237 Binary files /dev/null and b/space/public/instance/intake-sent-light.png differ diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx index 167823fc2b..e4cde6cbb1 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx @@ -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 ( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx b/web/ce/components/projects/settings/intake/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx rename to web/ce/components/projects/settings/intake/header.tsx diff --git a/web/ce/components/projects/settings/intake/index.ts b/web/ce/components/projects/settings/intake/index.ts new file mode 100644 index 0000000000..49ac70fe21 --- /dev/null +++ b/web/ce/components/projects/settings/intake/index.ts @@ -0,0 +1 @@ +export * from "./header"; diff --git a/web/ce/constants/project/settings/features.tsx b/web/ce/constants/project/settings/features.tsx index 3fdccc9794..b9b39700d6 100644 --- a/web/ce/constants/project/settings/features.tsx +++ b/web/ce/constants/project/settings/features.tsx @@ -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 + ) => ReactNode; +}; export type TFeatureList = { - [key: string]: { - property: string; - title: string; - description: string; - icon: ReactNode; - isPro: boolean; - isEnabled: boolean; - }; + [key: string]: TProperties; }; export type TProjectFeatures = { diff --git a/web/core/components/issues/issue-detail/parent/root.tsx b/web/core/components/issues/issue-detail/parent/root.tsx index 1e8fbb01cd..c96ecd69ac 100644 --- a/web/core/components/issues/issue-detail/parent/root.tsx +++ b/web/core/components/issues/issue-detail/parent/root.tsx @@ -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 = 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 = observer((props) => { return ( <>
- + handleRedirection(workspaceSlug, parentIssue, isMobile)} + >
@@ -56,7 +62,7 @@ export const IssueParentDetail: FC = observer((props) => {
{(parentIssue?.name ?? "").substring(0, 50)}
- +
diff --git a/web/core/components/modules/module-card-item.tsx b/web/core/components/modules/module-card-item.tsx index ca85d65f7d..ac556d4a47 100644 --- a/web/core/components/modules/module-card-item.tsx +++ b/web/core/components/modules/module-card-item.tsx @@ -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 = 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 = 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) => { e.stopPropagation(); @@ -156,29 +162,14 @@ export const ModuleCardItem: React.FC = 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 = 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 = observer((props) => {
{moduleStatus && ( )}
); -}); \ No newline at end of file +}); diff --git a/web/core/components/modules/module-list-item.tsx b/web/core/components/modules/module-list-item.tsx index 64627b2aba..64dfe74527 100644 --- a/web/core/components/modules/module-list-item.tsx +++ b/web/core/components/modules/module-list-item.tsx @@ -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 = 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); diff --git a/web/core/components/project/settings/features-list.tsx b/web/core/components/project/settings/features-list.tsx index 91eb188024..f1f17f2d59 100644 --- a/web/core/components/project/settings/features-list.tsx +++ b/web/core/components/project/settings/features-list.tsx @@ -70,31 +70,40 @@ export const ProjectFeaturesList: FC = observer((props) => { return (
-
-
- {featureItem.icon} -
-
-
-

{featureItem.title}

- {featureItem.isPro && ( - - - - )} +
+
+
+ {featureItem.icon} +
+
+
+

{featureItem.title}

+ {featureItem.isPro && ( + + + + )} +
+

+ {featureItem.description} +

-

{featureItem.description}

-
- handleSubmit(featureItemKey, featureItem.property)} - disabled={!featureItem.isEnabled || !isAdmin} - size="sm" - /> + handleSubmit(featureItemKey, featureItem.property)} + disabled={!featureItem.isEnabled || !isAdmin} + size="sm" + /> +
+
+ {currentProjectDetails?.[featureItem.property as keyof IProject] && + featureItem.renderChildren && + featureItem.renderChildren(currentProjectDetails, isAdmin, handleSubmit)} +
); })} diff --git a/web/core/services/file-upload.service.ts b/web/core/services/file-upload.service.ts index efb6cf946f..09e95f3c0f 100644 --- a/web/core/services/file-upload.service.ts +++ b/web/core/services/file-upload.service.ts @@ -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) => { diff --git a/web/core/services/inbox/inbox-issue.service.ts b/web/core/services/inbox/inbox-issue.service.ts index f8fcf72c4e..61aaeb8490 100644 --- a/web/core/services/inbox/inbox-issue.service.ts +++ b/web/core/services/inbox/inbox-issue.service.ts @@ -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 { + 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 { + 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 { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/publish-intake-regenerate/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/core/store/inbox/project-inbox.store.ts b/web/core/store/inbox/project-inbox.store.ts index 2897825397..f6953debf8 100644 --- a/web/core/store/inbox/project-inbox.store.ts +++ b/web/core/store/inbox/project-inbox.store.ts @@ -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; // issue_id -> IInboxIssueStore inboxIssueIds: string[]; + intakeForms: Record; // computed inboxFilters: Partial; // computed project inbox filters inboxSorting: Partial; // computed project inbox sorting @@ -68,6 +70,9 @@ export interface IProjectInboxStore { ) => Promise; fetchInboxPaginationIssues: (workspaceSlug: string, projectId: string) => Promise; fetchInboxIssueById: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise; + fetchIntakeForms: (workspaceSlug: string, projectId: string) => Promise; + toggleIntakeForms: (workspaceSlug: string, projectId: string, isDisabled: boolean) => Promise; + regenerateIntakeForms: (workspaceSlug: string, projectId: string) => Promise; createInboxIssue: ( workspaceSlug: string, projectId: string, @@ -89,6 +94,7 @@ export class ProjectInboxStore implements IProjectInboxStore { inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined = undefined; inboxIssues: Record = {}; inboxIssueIds: string[] = []; + intakeForms: Record = {}; // 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 diff --git a/web/ee/components/projects/settings/intake/index.ts b/web/ee/components/projects/settings/intake/index.ts new file mode 100644 index 0000000000..4ffe826389 --- /dev/null +++ b/web/ee/components/projects/settings/intake/index.ts @@ -0,0 +1 @@ +export * from "ce/components/projects/settings/intake"; diff --git a/web/helpers/editor.helper.ts b/web/helpers/editor.helper.ts index 81689c7eed..801cb89964 100644 --- a/web/helpers/editor.helper.ts +++ b/web/helpers/editor.helper.ts @@ -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 (