Compare commits

..

1 Commits

Author SHA1 Message Date
VipinDevelops 5a1f8e6e39 Merge branch 'refactor-editor_package' into preview 2025-06-02 19:14:53 +05:30
17 changed files with 161 additions and 308 deletions
-190
View File
@@ -1,190 +0,0 @@
name: Test Pull Request
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize", "ready_for_review"]
paths:
- 'apiserver/**'
- '.github/workflows/test-pull-request.yml'
jobs:
test-apiserver:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: plane
POSTGRES_USER: plane
POSTGRES_DB: plane
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache Python dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements/test.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install Python dependencies
run: |
cd apiserver
python -m pip install --upgrade pip
pip install -r requirements/test.txt
- name: Set up test environment
run: |
cd apiserver
cat > .env << EOF
# Basic Django settings
DEBUG=1
SECRET_KEY=test-secret-key-for-ci-only-do-not-use-in-production
# Database Configuration
DATABASE_URL=postgres://plane:plane@localhost:5432/plane
# Redis Configuration
REDIS_URL=redis://localhost:6379
# Email Backend for Testing
EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
# CORS Settings for Testing
CORS_ALLOW_ALL_ORIGINS=True
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002
# Disable SSL and security features for testing
SECURE_SSL_REDIRECT=False
SECURE_HSTS_SECONDS=0
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
# Instance settings
INSTANCE_KEY=test-instance-key-for-ci
SKIP_ENV_VAR=1
# File upload settings for testing
FILE_SIZE_LIMIT=5242880
USE_MINIO=0
# Base URLs for testing
WEB_URL=http://localhost:8000
APP_BASE_URL=http://localhost:3000
ADMIN_BASE_URL=http://localhost:3001
SPACE_BASE_URL=http://localhost:3002
LIVE_BASE_URL=http://localhost:3100
# Session settings
SESSION_COOKIE_AGE=604800
SESSION_COOKIE_NAME=session-id
ADMIN_SESSION_COOKIE_AGE=3600
# API settings
API_KEY_RATE_LIMIT=60/minute
# Disable external services for testing
ENABLE_SIGNUP=1
POSTHOG_API_KEY=
ANALYTICS_SECRET_KEY=
GITHUB_ACCESS_TOKEN=
UNSPLASH_ACCESS_KEY=
# RabbitMQ/Celery settings (will be mocked in tests)
RABBITMQ_HOST=localhost
RABBITMQ_PORT=5672
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_VHOST=/
# AWS/Storage settings (will be mocked in tests)
AWS_ACCESS_KEY_ID=test-access-key
AWS_SECRET_ACCESS_KEY=test-secret-key
AWS_S3_BUCKET_NAME=test-uploads
AWS_REGION=us-east-1
EOF
- name: Run unit tests
run: |
cd apiserver
python run_tests.py -u -v --coverage
- name: Run contract tests
run: |
cd apiserver
python run_tests.py -c -v
- name: Show coverage summary
if: always()
run: |
cd apiserver
python -m coverage report --show-missing
- name: Upload coverage reports
uses: codecov/codecov-action@v4
if: always() && env.CODECOV_TOKEN != ''
with:
file: ./apiserver/coverage.xml
flags: apiserver
name: apiserver-coverage
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Generate coverage badge
if: always()
run: |
cd apiserver
coverage-badge -o coverage.svg
continue-on-error: true
test-summary:
if: always() && github.event.pull_request.draft == false
needs: [test-apiserver]
runs-on: ubuntu-latest
steps:
- name: Test Results Summary
run: |
echo "# Test Results Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.test-apiserver.result }}" == "success" ]]; then
echo "✅ **API Server Tests**: PASSED" >> $GITHUB_STEP_SUMMARY
echo "All tests completed successfully with coverage reporting." >> $GITHUB_STEP_SUMMARY
else
echo "❌ **API Server Tests**: FAILED" >> $GITHUB_STEP_SUMMARY
echo "Tests failed. Please check the logs for details." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Test Categories Executed" >> $GITHUB_STEP_SUMMARY
echo "- **Unit Tests**: Fast, isolated component tests" >> $GITHUB_STEP_SUMMARY
echo "- **Contract Tests**: API endpoint verification" >> $GITHUB_STEP_SUMMARY
+5 -2
View File
@@ -36,7 +36,7 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
Profile
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -159,13 +159,14 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
# Get the workspace
@@ -178,6 +179,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = (
+12 -57
View File
@@ -12,29 +12,6 @@ Tests are organized into the following categories:
- **App tests**: Test the web application API endpoints (under `/api/`).
- **Smoke tests**: Basic tests to verify that the application runs correctly.
## Continuous Integration (CI)
Tests run automatically on pull requests via GitHub Actions:
### Automated Testing Workflow
When a pull request is created or updated with changes to `apiserver/**` files, the `test-pull-request.yml` workflow automatically:
1. **Sets up test environment**: PostgreSQL 14, Redis 7, Python 3.11
2. **Runs unit tests**: Fast, isolated component tests with coverage
3. **Runs contract tests**: API endpoint verification
4. **Generates coverage reports**: Enforces 90% threshold with HTML, terminal, and XML formats
5. **Uploads to Codecov**: If token is configured
### CI Environment Variables
The CI automatically configures comprehensive environment variables including:
- Database and Redis connections
- Security settings (disabled for testing)
- Base URLs for all components
- File upload and storage settings
- External service configurations (mocked)
## API vs App Endpoints
Plane has two types of API endpoints:
@@ -55,8 +32,6 @@ Plane has two types of API endpoints:
## Running Tests
### Local Testing
To run all tests:
```bash
@@ -79,19 +54,20 @@ python -m pytest plane/tests/contract/app/
python -m pytest plane/tests/smoke/
```
### Using the Test Runner
For convenience, we provide helper scripts:
For convenience, we also provide a helper script:
```bash
# Using Python script directly
python run_tests.py --coverage --verbose # Full test suite with coverage
python run_tests.py -u -v # Unit tests only
python run_tests.py -c -v # Contract tests only
python run_tests.py -p -v # Parallel execution
# Run all tests
./run_tests.py
# Using shell wrapper
./run_tests.sh --coverage --verbose # Full test suite with coverage
# Run only unit tests
./run_tests.py -u
# Run contract tests with coverage report
./run_tests.py -c -o
# Run tests in parallel
./run_tests.py -p
```
## Fixtures
@@ -158,30 +134,9 @@ Generate a coverage report with:
```bash
python -m pytest --cov=plane --cov-report=term --cov-report=html
# Or using the test runner
python run_tests.py --coverage
```
This creates an HTML report in the `htmlcov/` directory and enforces the 90% coverage threshold.
## CI Troubleshooting
### Common CI Issues
1. **Test failures**: Check the GitHub Actions logs for specific error messages
2. **Coverage below threshold**: Add tests for uncovered code
3. **Database connection issues**: Ensure PostgreSQL service is healthy in CI
4. **Redis connection issues**: Ensure Redis service is healthy in CI
### Local Setup for CI Testing
Make sure you have the test dependencies installed:
```bash
pip install -r requirements/test.txt
```
Set up your local environment with PostgreSQL and Redis, or use the provided Docker setup.
This creates an HTML report in the `htmlcov/` directory.
## Migration from Old Tests
+3 -3
View File
@@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User",
"last_name": "User"
}
@@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"],
last_name=user_data["last_name"]
)
user.set_password(user_data["password"])
user.save()
@@ -75,4 +75,4 @@ def plane_server(live_server):
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
return live_server
+27 -18
View File
@@ -6,20 +6,36 @@ import sys
def main():
parser = argparse.ArgumentParser(description="Run Plane tests")
parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only")
parser.add_argument(
"-c", "--contract", action="store_true", help="Run contract tests only"
"-u", "--unit",
action="store_true",
help="Run unit tests only"
)
parser.add_argument(
"-s", "--smoke", action="store_true", help="Run smoke tests only"
"-c", "--contract",
action="store_true",
help="Run contract tests only"
)
parser.add_argument(
"-o", "--coverage", action="store_true", help="Generate coverage report"
"-s", "--smoke",
action="store_true",
help="Run smoke tests only"
)
parser.add_argument(
"-p", "--parallel", action="store_true", help="Run tests in parallel"
"-o", "--coverage",
action="store_true",
help="Generate coverage report"
)
parser.add_argument(
"-p", "--parallel",
action="store_true",
help="Run tests in parallel"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Verbose output"
)
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
args = parser.parse_args()
# Build command
@@ -40,14 +56,7 @@ def main():
# Add coverage
if args.coverage:
cmd.extend(
[
"--cov=plane",
"--cov-report=term",
"--cov-report=html",
"--cov-report=xml",
]
)
cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"])
# Add parallel
if args.parallel:
@@ -62,10 +71,10 @@ def main():
# Print command
print(f"Running: {' '.join(cmd)}")
# Execute command
result = subprocess.run(cmd)
# Check coverage thresholds if coverage is enabled
if args.coverage:
print("Checking coverage thresholds...")
@@ -74,9 +83,9 @@ def main():
if coverage_result.returncode != 0:
print("Coverage below threshold (90%)")
sys.exit(coverage_result.returncode)
sys.exit(result.returncode)
if __name__ == "__main__":
main()
main()
@@ -0,0 +1,14 @@
import { Editor } from "@tiptap/core";
import { LinkViewContainer } from "@/components/editors/link-view-container";
export const LinkContainer = ({
editor,
containerRef,
}: {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
}) => (
<>
<LinkViewContainer editor={editor} containerRef={containerRef} />
</>
);
@@ -91,6 +91,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
tabIndex={tabIndex}
disabledExtensions={disabledExtensions}
/>
);
};
@@ -3,7 +3,7 @@ import { Editor } from "@tiptap/react";
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
import { TAIHandler, TDisplayConfig, TExtensions } from "@/types";
type IPageRenderer = {
aiHandler?: TAIHandler;
@@ -13,10 +13,20 @@ type IPageRenderer = {
editorContainerClassName: string;
id: string;
tabIndex?: number;
disabledExtensions: TExtensions[];
};
export const PageRenderer = (props: IPageRenderer) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
const {
aiHandler,
bubbleMenuEnabled,
displayConfig,
editor,
editorContainerClassName,
id,
tabIndex,
disabledExtensions,
} = props;
return (
<div className="frame-renderer flex-grow w-full">
@@ -30,7 +40,7 @@ export const PageRenderer = (props: IPageRenderer) => {
{editor.isEditable && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<BlockMenu editor={editor} disabledExtensions={disabledExtensions} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
@@ -83,6 +83,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
disabledExtensions={disabledExtensions}
/>
);
};
@@ -9,6 +9,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { TDisplayConfig } from "@/types";
// components
import { LinkViewContainer } from "./link-view-container";
import { LinkContainer } from "@/plane-editor/components/link-container";
interface EditorContainerProps {
children: ReactNode;
@@ -96,7 +97,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
)}
>
{children}
<LinkViewContainer editor={editor} containerRef={containerRef} />
<LinkContainer editor={editor} containerRef={containerRef} />
</div>
</>
);
@@ -3,6 +3,7 @@ import { Editor, useEditorState } from "@tiptap/react";
import { FC, useCallback, useEffect, useState } from "react";
// components
import { LinkView, LinkViewProps } from "@/components/links";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
interface LinkViewContainerProps {
editor: Editor;
@@ -17,7 +18,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
linkExtensionStorage: editor.storage.link,
linkExtensionStorage: getExtensionStorage(editor, "link"),
}),
});
@@ -109,7 +110,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
// Close link view when bubble menu opens
useEffect(() => {
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
if (editorState.linkExtensionStorage?.isBubbleMenuOpen && isOpen) {
setIsOpen(false);
}
}, [editorState.linkExtensionStorage, isOpen]);
@@ -4,9 +4,11 @@ import { useCallback, useEffect, useRef } from "react";
import tippy, { Instance } from "tippy.js";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { TExtensions } from "@/types";
interface BlockMenuProps {
editor: Editor;
disabledExtensions?: TExtensions[];
}
export const BlockMenu = (props: BlockMenuProps) => {
@@ -79,6 +79,7 @@ declare module "@tiptap/core" {
export type CustomLinkStorage = {
isPreviewOpen: boolean;
posToInsert: { from: number; to: number };
isBubbleMenuOpen: boolean;
};
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
@@ -10,7 +10,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
// hooks
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
// plane web imports
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces";
@@ -42,10 +42,16 @@ const MembersSettingsPage = observer(() => {
return (
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<SettingsHeading title={t(getProjectSettingsPageLabelI18nKey("members", "common.members"))} />
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectTeamspaceList projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectMemberList projectId={projectId} workspaceSlug={workspaceSlug} />
<section className={`w-full`}>
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<div className="text-lg font-semibold">
{t(getProjectSettingsPageLabelI18nKey("members", "common.members"))}
</div>
</div>
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectTeamspaceList projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectMemberList projectId={projectId} workspaceSlug={workspaceSlug} />
</section>
</SettingsContentWrapper>
);
});
+31 -25
View File
@@ -12,6 +12,8 @@ import { ProjectMemberListItem, SendProjectInvitationModal } from "@/components/
import { MembersSettingsLoader } from "@/components/ui";
// hooks
import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
import { SettingsHeading } from "../settings";
type TProjectMemberListProps = {
projectId: string;
@@ -56,31 +58,35 @@ export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((pr
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<div className="flex items-center justify-between gap-4 py-2 overflow-x-hidden border-b border-custom-border-100">
<div className="text-base font-semibold">{t("common.members")}</div>
<div className="ml-auto flex items-center justify-start gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isAdmin && (
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("PROJECT_SETTINGS_MEMBERS_PAGE_HEADER");
setInviteModal(true);
}}
>
{t("add_member")}
</Button>
)}
</div>
<SettingsHeading
title={t(getProjectSettingsPageLabelI18nKey("members", "common.members"))}
appendToRight={
<div className="flex gap-2">
<div className="ml-auto flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isAdmin && (
<Button
variant="primary"
onClick={() => {
setTrackElement("PROJECT_SETTINGS_MEMBERS_PAGE_HEADER");
setInviteModal(true);
}}
>
{t("add_member")}
</Button>
)}
</div>
}
/>
{!projectMemberIds ? (
<MembersSettingsLoader />
) : (
@@ -16,7 +16,7 @@ export const SettingsContentWrapper = observer((props: TProps) => {
"md:px-16": size === "lg",
})}
>
<div className="pb-10 w-full">{children}</div>
<div className="pb-20 w-full">{children}</div>
</div>
);
});
+34 -1
View File
@@ -1,10 +1,43 @@
import { useRef } from "react";
import { useEffect, useRef } from "react";
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { useUserSettings } from "@/hooks/store";
export const SettingsContentLayout = observer(({ children }: { children: React.ReactNode }) => {
// refs
const ref = useRef<HTMLDivElement>(null);
const scrolledRef = useRef(false);
// store hooks
const { toggleIsScrolled, isScrolled } = useUserSettings();
useEffect(() => {
toggleIsScrolled(false);
const container = ref.current;
if (!container) return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
if (container.scrollHeight > container.clientHeight || scrolledRef.current) {
const _isScrolled = scrollTop > 0;
toggleIsScrolled(_isScrolled);
}
};
// Throttle the scroll handler to improve performance
// Set trailing to true to ensure the last call runs after the delay
const throttledHandleScroll = throttle(handleScroll, 150);
container.addEventListener("scroll", throttledHandleScroll);
return () => {
container.removeEventListener("scroll", throttledHandleScroll);
// Cancel any pending throttled invocations when unmounting
throttledHandleScroll.cancel();
};
}, []);
useEffect(() => {
scrolledRef.current = isScrolled;
}, [isScrolled]);
return (
<div className="w-full h-full min-h-full overflow-y-scroll " ref={ref}>
{children}