Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a1f8e6e39 |
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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>({
|
||||
|
||||
+11
-5
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user