mirror of
https://github.com/makeplane/plane.git
synced 2026-01-26 08:09:47 -06:00
[WEB-2500] feat: Product updates modal (What's new in Plane) (#5690)
* [WEB-2500] feat: Product updates modal (What's new in Plane) * fix: build errors. * fix: lint errors resolved. * chore: minor improvements. * chore: minor fixes
This commit is contained in:
@@ -4,6 +4,7 @@ FROM python:3.12.5-alpine AS backend
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ FROM python:3.12.5-alpine AS backend
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
|
||||
|
||||
RUN apk --no-cache add \
|
||||
"bash~=5.2" \
|
||||
|
||||
@@ -18,3 +18,5 @@ from .admin import (
|
||||
InstanceAdminSignOutEndpoint,
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
)
|
||||
|
||||
from .changelog import ChangeLogEndpoint
|
||||
|
||||
35
apiserver/plane/license/api/views/changelog.py
Normal file
35
apiserver/plane/license/api/views/changelog.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Python imports
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# plane imports
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class ChangeLogEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def fetch_change_logs(self):
|
||||
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get(self, request):
|
||||
# Fetch the changelog
|
||||
if settings.INSTANCE_CHANGELOG_URL:
|
||||
data = self.fetch_change_logs()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "could not fetch changelog please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -177,6 +177,8 @@ class InstanceEndpoint(BaseAPIView):
|
||||
data["space_base_url"] = settings.SPACE_BASE_URL
|
||||
data["app_base_url"] = settings.APP_BASE_URL
|
||||
|
||||
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
|
||||
|
||||
instance_data = serializer.data
|
||||
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from plane.license.api.views import (
|
||||
InstanceAdminUserMeEndpoint,
|
||||
InstanceAdminSignOutEndpoint,
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
ChangeLogEndpoint
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -19,6 +20,11 @@ urlpatterns = [
|
||||
InstanceEndpoint.as_view(),
|
||||
name="instance",
|
||||
),
|
||||
path(
|
||||
"changelog/",
|
||||
ChangeLogEndpoint.as_view(),
|
||||
name="instance-changelog",
|
||||
),
|
||||
path(
|
||||
"admins/",
|
||||
InstanceAdminEndpoint.as_view(),
|
||||
|
||||
@@ -384,6 +384,9 @@ APP_BASE_URL = os.environ.get("APP_BASE_URL")
|
||||
|
||||
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
|
||||
|
||||
# Instance Changelog URL
|
||||
INSTANCE_CHANGELOG_URL = os.environ.get("INSTANCE_CHANGELOG_URL", "")
|
||||
|
||||
ATTACHMENT_MIME_TYPES = [
|
||||
# Images
|
||||
"image/jpeg",
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./version-number";
|
||||
export * from "./product-updates";
|
||||
export * from "./product-updates-modal";
|
||||
export * from "./product-updates-header";
|
||||
|
||||
26
web/ce/components/global/product-updates-header.tsx
Normal file
26
web/ce/components/global/product-updates-header.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// assets
|
||||
import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
|
||||
// package.json
|
||||
import packageJson from "package.json";
|
||||
|
||||
export const ProductUpdatesHeader = observer(() => (
|
||||
<div className="flex gap-2 mx-6 my-4 items-center justify-between flex-shrink-0">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex gap-2 text-xl font-medium">What's new</div>
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 mx-2 py-0.5 text-center text-xs font-medium rounded-full bg-custom-primary-100/20 text-custom-primary-100"
|
||||
)}
|
||||
>
|
||||
Version: v{packageJson.version}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-8">
|
||||
<Image src={PlaneLogo} alt="Plane" width={24} height={24} />
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
@@ -1,9 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export type ProductUpdatesModalProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer(() => <></>);
|
||||
@@ -1,21 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
|
||||
export type ProductUpdatesProps = {
|
||||
setIsChangeLogOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const ProductUpdates: FC<ProductUpdatesProps> = observer(() => (
|
||||
<CustomMenu.MenuItem>
|
||||
<Link
|
||||
href="https://go.plane.so/p-changelog"
|
||||
target="_blank"
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">What's new</span>
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
));
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./product-updates-modal";
|
||||
export * from "./empty-state";
|
||||
export * from "./latest-feature-block";
|
||||
export * from "./breadcrumb-link";
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import useSWR from "swr";
|
||||
// headless ui
|
||||
import { X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
// components
|
||||
import { Loader } from "@plane/ui";
|
||||
import { MarkdownRenderer } from "@/components/ui";
|
||||
// icons
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
// services
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const { data: updates } = useSWR("PRODUCT_UPDATES", () => workspaceService.getProductUpdates());
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={setIsOpen}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 h-full w-full">
|
||||
<div className="grid h-full w-full place-items-center p-4">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative min-w-[100%] overflow-hidden rounded-lg bg-custom-background-100 shadow-custom-shadow-md sm:min-w-[50%] sm:max-w-[50%]">
|
||||
<div className="flex max-h-[90vh] w-full flex-col p-4">
|
||||
<Dialog.Title as="h3" className="flex items-center justify-between text-lg font-semibold">
|
||||
<span>Product Updates</span>
|
||||
<span>
|
||||
<button type="button" onClick={() => setIsOpen(false)}>
|
||||
<X className="h-6 w-6 text-custom-text-200 hover:text-custom-text-100" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
{updates && updates.length > 0 ? (
|
||||
<div className="mt-4 h-full space-y-4 overflow-y-auto">
|
||||
{updates.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
<div className="flex items-center gap-3 text-xs text-custom-text-200">
|
||||
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-background-90 px-3 py-1.5 text-xs">
|
||||
{item.tag_name}
|
||||
</span>
|
||||
<span>{renderFormattedDate(item.published_at)}</span>
|
||||
{index === 0 && (
|
||||
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-primary px-3 py-1.5 text-xs text-white">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MarkdownRenderer markdown={item.body} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid w-full place-items-center">
|
||||
<Loader className="w-full space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
1
web/core/components/global/index.ts
Normal file
1
web/core/components/global/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./product-updates";
|
||||
62
web/core/components/global/product-updates/footer.tsx
Normal file
62
web/core/components/global/product-updates/footer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// assets
|
||||
import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
|
||||
|
||||
export const ProductUpdatesFooter = () => (
|
||||
<div className="flex items-center justify-between flex-shrink-0 gap-4 m-6 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="https://go.plane.so/p-docs"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
<a
|
||||
href="https://go.plane.so/p-changelog"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
Full changelog
|
||||
</a>
|
||||
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
<a
|
||||
href="mailto:support@plane.so"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
Support
|
||||
</a>
|
||||
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
<a
|
||||
href="https://go.plane.so/p-discord"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://plane.so/pages"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
getButtonStyling("accent-primary", "sm"),
|
||||
"flex gap-1.5 items-center text-center font-medium hover:underline underline-offset-2 outline-none"
|
||||
)}
|
||||
>
|
||||
<Image src={PlaneLogo} alt="Plane" width={12} height={12} />
|
||||
Powered by Plane Pages
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
2
web/core/components/global/product-updates/index.ts
Normal file
2
web/core/components/global/product-updates/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./modal";
|
||||
export * from "./footer";
|
||||
84
web/core/components/global/product-updates/modal.tsx
Normal file
84
web/core/components/global/product-updates/modal.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// editor
|
||||
import { DocumentReadOnlyEditorWithRef, EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// helpers
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { ProductUpdatesFooter } from "@/components/global";
|
||||
// plane web components
|
||||
import { ProductUpdatesHeader } from "@/plane-web/components/global";
|
||||
// services
|
||||
import { InstanceService } from "@/services/instance.service";
|
||||
|
||||
const instanceService = new InstanceService();
|
||||
|
||||
export type ProductUpdatesModalProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props) => {
|
||||
const { isOpen, handleClose } = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// swr
|
||||
const { data, isLoading, error } = useSWR(`INSTANCE_CHANGELOG`, () => instanceService.getInstanceChangeLog(), {
|
||||
shouldRetryOnError: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
||||
<ProductUpdatesHeader />
|
||||
<div className="flex flex-col h-[60vh] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5">
|
||||
{!isLoading && !!error ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full mb-8">
|
||||
<div className="text-lg font-medium">We are having trouble fetching the updates.</div>
|
||||
<div className="text-sm text-custom-text-200">
|
||||
Please visit{" "}
|
||||
<a
|
||||
href="https://go.plane.so/p-changelog"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
|
||||
>
|
||||
our changelogs
|
||||
</a>{" "}
|
||||
for the latest updates.
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5">
|
||||
{data?.id && (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
ref={editorRef}
|
||||
id={data.id}
|
||||
initialValue={data.description_html ?? "<p></p>"}
|
||||
containerClassName="p-0 border-none"
|
||||
mentionHandler={{
|
||||
highlights: () => Promise.resolve([]),
|
||||
}}
|
||||
embedHandler={{
|
||||
issue: {
|
||||
widgetCallback: () => <></>,
|
||||
},
|
||||
}}
|
||||
fileHandler={{
|
||||
getAssetSrc: () => "",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProductUpdatesFooter />
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -5,14 +5,16 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText, HelpCircle, MessagesSquare, MoveLeft, User } from "lucide-react";
|
||||
// ui
|
||||
import { CustomMenu, ToggleSwitch, Tooltip } from "@plane/ui";
|
||||
import { CustomMenu, Tooltip, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { ProductUpdatesModal } from "@/components/global";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useCommandPalette, useInstance, useTransient, useUserSettings } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { PlaneVersionNumber, ProductUpdates, ProductUpdatesModal } from "@/plane-web/components/global";
|
||||
import { PlaneVersionNumber } from "@/plane-web/components/global";
|
||||
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace";
|
||||
import { ENABLE_LOCAL_DB_CACHE } from "@/plane-web/constants/issues";
|
||||
|
||||
@@ -135,7 +137,15 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
|
||||
<span className="text-xs">Keyboard shortcuts</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<ProductUpdates setIsChangeLogOpen={setIsChangeLogOpen} />
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsChangeLogOpen(true)}
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">What's new</span>
|
||||
</button>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem>
|
||||
<a
|
||||
href="https://go.plane.so/p-discord"
|
||||
@@ -163,9 +173,8 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
|
||||
<Tooltip tooltipContent={`${isCollapsed ? "Expand" : "Hide"}`} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid place-items-center rounded-md p-1 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
|
||||
isCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
className={`grid place-items-center rounded-md p-1 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${isCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className={`h-4 w-4 duration-300 ${isCollapsed ? "rotate-180" : ""}`} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// types
|
||||
import type { IInstanceInfo } from "@plane/types";
|
||||
import type { IInstanceInfo, TPage } from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
@@ -25,4 +25,12 @@ export class InstanceService extends APIService {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async getInstanceChangeLog(): Promise<TPage> {
|
||||
return this.get("/api/instances/changelog/")
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user