Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cee833cd45 | |||
| 27c6d0e545 | |||
| 63bb868e23 | |||
| edeeb70efb | |||
| 7005ae2b53 | |||
| 21d7a1865c | |||
| f65b9a4dcb | |||
| 6d216f2607 | |||
| 4958be7898 | |||
| a40e44c6d5 | |||
| 44af90dc6c | |||
| f01d82ad1e | |||
| ac6fef3073 | |||
| c64c15948b | |||
| e58b68b6fc | |||
| 68325866ef | |||
| 80198f5fda | |||
| 6ac28ad614 | |||
| c021ffddf2 | |||
| f8997446e2 | |||
| 9487c02954 | |||
| 0188cabbde | |||
| 392a6e0137 | |||
| 7e62c60748 | |||
| 9bff707fb5 | |||
| 0d599ef2dc | |||
| e183a0cc63 | |||
| 958a3676af | |||
| 59a0925d34 | |||
| fbbf58481d | |||
| 6356bb1dbb | |||
| d08bce35a3 | |||
| 9297448ec8 | |||
| 5329326602 | |||
| 062fc9dbc0 | |||
| fde8630c5b | |||
| aa0b2c0be4 | |||
| f70eae2f3b | |||
| cf8823fa96 | |||
| 5f3d02606c | |||
| aeed6590b7 | |||
| 1f18b08655 | |||
| e4dd2a6c07 | |||
| bcb9c73634 | |||
| 952eee8d55 | |||
| da469dac18 | |||
| 6fac320a05 | |||
| cc7b34e399 | |||
| 2d6c26a5d6 | |||
| f1acd46e15 | |||
| c023f7d89b | |||
| 8fa45ef9a6 | |||
| 8bcc295061 | |||
| 1b080012ab | |||
| f6dfca4fdc | |||
| 3de655cbd4 | |||
| 376f781052 | |||
| 827f47809b | |||
| dd11ebf335 | |||
| 0c35e196be | |||
| 6303847026 | |||
| 214692f5b2 | |||
| b7198234de | |||
| 7e0ac10fe8 | |||
| f9d154dd82 | |||
| 1c6a2fb7dd | |||
| 5c272db83b | |||
| 602ae01b0b | |||
| cd3fa94b9c | |||
| 51c2ea6fcb | |||
| 64752de3a8 | |||
| 84578a2764 | |||
| 126575d22a | |||
| d3af913ec7 | |||
| db4ecee475 | |||
| 527c4ece57 | |||
| 23b0d4339d | |||
| 1478e66dc4 | |||
| 9ed4591edc |
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
full_build_push:
|
||||
if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: full
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
slim_build_push:
|
||||
if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }}
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: slim
|
||||
|
||||
@@ -299,31 +299,6 @@ jobs:
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
attach_assets_to_build:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Attach Assets to Release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
|
||||
- name: Attach Assets
|
||||
id: attach_assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-assets
|
||||
retention-days: 2
|
||||
path: |
|
||||
${{ 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
|
||||
|
||||
publish_release:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Build Release
|
||||
@@ -337,7 +312,6 @@ jobs:
|
||||
branch_build_push_live,
|
||||
branch_build_push_apiserver,
|
||||
branch_build_push_proxy,
|
||||
attach_assets_to_build,
|
||||
]
|
||||
env:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
@@ -348,6 +322,8 @@ jobs:
|
||||
- name: Update Assets
|
||||
run: |
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
|
||||
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -362,6 +338,7 @@ jobs:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ github.workspace }}/deploy/selfhost/setup.sh
|
||||
${{ github.workspace }}/deploy/selfhost/swarm.sh
|
||||
${{ github.workspace }}/deploy/selfhost/restore.sh
|
||||
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
|
||||
${{ github.workspace }}/deploy/selfhost/variables.env
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
full_build_push:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: full
|
||||
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
+130
-4
@@ -62,17 +62,143 @@ To ensure consistency throughout the source code, please keep these rules in min
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- Try Plane Cloud and the self hosting platform and give feedback
|
||||
- Add new integrations
|
||||
- Add or update translations
|
||||
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
|
||||
- Share your thoughts and suggestions with us
|
||||
- Help create tutorials and blog posts
|
||||
- Request a feature by submitting a proposal
|
||||
- Report a bug
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
|
||||
## Contributing to language support
|
||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||
|
||||
### Understanding translation structure
|
||||
|
||||
#### File organization
|
||||
Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks:
|
||||
|
||||
```
|
||||
packages/i18n/src/locales/
|
||||
├── en/
|
||||
│ ├── core.json # Critical translations
|
||||
│ └── translations.json
|
||||
├── fr/
|
||||
│ └── translations.json
|
||||
└── [language]/
|
||||
└── translations.json
|
||||
```
|
||||
#### Nested structure
|
||||
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"issue": {
|
||||
"label": "Work item",
|
||||
"title": {
|
||||
"label": "Work item title"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Translation formatting guide
|
||||
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
||||
|
||||
#### Examples
|
||||
- **Simple variables**
|
||||
```json
|
||||
{
|
||||
"greeting": "Hello, {name}!"
|
||||
}
|
||||
```
|
||||
|
||||
- **Pluralization**
|
||||
```json
|
||||
{
|
||||
"items": "{count, plural, one {Work item} other {Work items}}"
|
||||
}
|
||||
```
|
||||
|
||||
### Contributing guidelines
|
||||
|
||||
#### Updating existing translations
|
||||
1. Locate the key in `locales/<language>/translations.json`.
|
||||
|
||||
2. Update the value while ensuring the key structure remains intact.
|
||||
3. Preserve any existing ICU formats (e.g., variables, pluralization).
|
||||
|
||||
#### Adding new translation keys
|
||||
1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed.
|
||||
|
||||
2. Keep the nesting structure consistent across all languages.
|
||||
|
||||
3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages.
|
||||
|
||||
### Adding new languages
|
||||
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
||||
|
||||
1. **Update type definitions**
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
|
||||
```typescript
|
||||
// types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
|
||||
2. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
|
||||
```typescript
|
||||
// constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" }
|
||||
];
|
||||
```
|
||||
|
||||
3. **Create translation files**
|
||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||
|
||||
2. Add a `translations.json` file inside the folder.
|
||||
|
||||
3. Copy the structure from an existing translation file and translate all keys.
|
||||
|
||||
4. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
|
||||
```typescript
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
case "your-lang":
|
||||
return import("../locales/your-lang/translations.json");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quality checklist
|
||||
Before submitting your contribution, please ensure the following:
|
||||
|
||||
- All translation keys exist in every language file.
|
||||
- Nested structures match across all language files.
|
||||
- ICU message formats are correctly implemented.
|
||||
- All languages load without errors in the application.
|
||||
- Dynamic values and pluralization work as expected.
|
||||
- There are no missing or untranslated keys.
|
||||
|
||||
#### Pro tips
|
||||
- When in doubt, refer to the English translations for context.
|
||||
- Verify pluralization works with different numbers.
|
||||
- Ensure dynamic values (e.g., `{name}`) are correctly interpolated.
|
||||
- Double-check that nested key access paths are accurate.
|
||||
|
||||
Happy translating! 🌍✨
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.24.1",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.24.1"
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from lxml import html
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
@@ -79,6 +80,7 @@ class IssueSerializer(BaseSerializer):
|
||||
data["assignees"] = ProjectMember.objects.filter(
|
||||
project_id=self.context.get("project_id"),
|
||||
is_active=True,
|
||||
role__gte=15,
|
||||
member_id__in=data["assignees"],
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
@@ -138,47 +140,61 @@ class IssueSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# Then assign it to default assignee, if it is a valid assignee
|
||||
if default_assignee_id is not None and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True
|
||||
).exists():
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -194,39 +210,45 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
|
||||
@@ -121,8 +121,6 @@ from .exporter import ExporterHistorySerializer
|
||||
|
||||
from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||
|
||||
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||
|
||||
from .favorite import UserFavoriteSerializer
|
||||
|
||||
from .draft import (
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
|
||||
|
||||
# Third party frameworks
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class DashboardSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = DeprecatedDashboard
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WidgetSerializer(BaseSerializer):
|
||||
is_visible = serializers.BooleanField(read_only=True)
|
||||
widget_filters = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeprecatedWidget
|
||||
fields = ["id", "key", "is_visible", "widget_filters"]
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
@@ -35,6 +36,7 @@ from plane.db.models import (
|
||||
State,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,6 +120,17 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
return data
|
||||
|
||||
def get_valid_assignees(self, assignees, project_id):
|
||||
if not assignees:
|
||||
return []
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
member_id__in=assignees
|
||||
).values_list('member_id', flat=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
assignees = validated_data.pop("assignee_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
@@ -133,48 +146,63 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
created_by_id = issue.created_by_id
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
valid_assignee_ids = self.get_valid_assignees(assignees, project_id)
|
||||
if valid_assignee_ids is not None and len(valid_assignee_ids):
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=user_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user_id in valid_assignee_ids
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# Then assign it to default assignee, if it is a valid assignee
|
||||
if default_assignee_id is not None and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True
|
||||
).exists():
|
||||
try:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -188,41 +216,48 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
created_by_id = instance.created_by_id
|
||||
updated_by_id = instance.updated_by_id
|
||||
|
||||
if assignees is not None:
|
||||
valid_assignee_ids = self.get_valid_assignees(assignees, project_id)
|
||||
if valid_assignee_ids is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=user_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user_id in valid_assignee_ids
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
@@ -506,6 +541,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
"asset",
|
||||
"attributes",
|
||||
# "issue_id",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"asset_url",
|
||||
|
||||
@@ -2,7 +2,6 @@ from .analytic import urlpatterns as analytic_urls
|
||||
from .api import urlpatterns as api_urls
|
||||
from .asset import urlpatterns as asset_urls
|
||||
from .cycle import urlpatterns as cycle_urls
|
||||
from .dashboard import urlpatterns as dashboard_urls
|
||||
from .estimate import urlpatterns as estimate_urls
|
||||
from .external import urlpatterns as external_urls
|
||||
from .intake import urlpatterns as intake_urls
|
||||
@@ -23,7 +22,6 @@ urlpatterns = [
|
||||
*analytic_urls,
|
||||
*asset_urls,
|
||||
*cycle_urls,
|
||||
*dashboard_urls,
|
||||
*estimate_urls,
|
||||
*external_urls,
|
||||
*intake_urls,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/dashboard/",
|
||||
DashboardEndpoint.as_view(),
|
||||
name="dashboard",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
|
||||
DashboardEndpoint.as_view(),
|
||||
name="dashboard",
|
||||
),
|
||||
path(
|
||||
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
|
||||
WidgetsEndpoint.as_view(),
|
||||
name="widgets",
|
||||
),
|
||||
]
|
||||
@@ -210,8 +210,6 @@ from .webhook.base import (
|
||||
WebhookSecretRegenerateEndpoint,
|
||||
)
|
||||
|
||||
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
from .error_404 import custom_404_view
|
||||
|
||||
from .notification.base import MarkAllReadNotificationViewSet
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -679,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
[self.save_project_cover(asset, project_id) for asset in assets]
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
|
||||
assets.update(issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the issue is deleted creating
|
||||
# an integrity error
|
||||
try:
|
||||
assets.update(issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
assets.update(comment_id=entity_id)
|
||||
# For some cases, the bulk api is called after the comment is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(comment_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
assets.update(page_id=entity_id)
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the draft issue is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
JSONField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.serializers import (
|
||||
DashboardSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
WidgetSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
DeprecatedDashboard,
|
||||
DeprecatedDashboardWidget,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
DeprecatedWidget,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
|
||||
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__lt=timezone.now().date(),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
created_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
created_by_id=request.user.id,
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
state__group="completed",
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"assigned_issues_count": assigned_issues,
|
||||
"pending_issues_count": pending_issues_count,
|
||||
"completed_issues_count": completed_issues_count,
|
||||
"created_issues_count": created_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_assigned_issues(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_type = request.GET.get("issue_type", None)
|
||||
|
||||
# get all the assigned issues
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related(
|
||||
"related_issue"
|
||||
).select_related("issue"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role=5, is_active=True
|
||||
).exists():
|
||||
assigned_issues = assigned_issues.filter(created_by=request.user)
|
||||
|
||||
# Priority Ordering
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
assigned_issues = assigned_issues.annotate(
|
||||
priority_order=Case(
|
||||
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = assigned_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
).count()
|
||||
completed_issues = assigned_issues.filter(state__group__in=["completed"])[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
completed_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": completed_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
).count()
|
||||
overdue_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
overdue_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": overdue_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
).count()
|
||||
upcoming_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
upcoming_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": upcoming_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid issue type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_created_issues(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_type = request.GET.get("issue_type", None)
|
||||
|
||||
# get all the assigned issues
|
||||
created_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
created_by=request.user,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
created_issues = created_issues.annotate(
|
||||
priority_order=Case(
|
||||
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = created_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
).count()
|
||||
completed_issues = created_issues.filter(state__group__in=["completed"])[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(completed_issues, many=True).data,
|
||||
"count": completed_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
).count()
|
||||
overdue_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(overdue_issues, many=True).data,
|
||||
"count": overdue_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
).count()
|
||||
upcoming_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(upcoming_issues, many=True).data,
|
||||
"count": upcoming_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid issue type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_issues_by_state_groups(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
extra_filters = {}
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role=5, is_active=True
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
issues_by_state_groups = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters, **extra_filters)
|
||||
.values("state__group")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# default state
|
||||
all_groups = {state: 0 for state in state_order}
|
||||
|
||||
# Update counts for existing groups
|
||||
for entry in issues_by_state_groups:
|
||||
all_groups[entry["state__group"]] = entry["count"]
|
||||
|
||||
# Prepare output including all groups with their counts
|
||||
output_data = [
|
||||
{"state": group, "count": count} for group, count in all_groups.items()
|
||||
]
|
||||
|
||||
return Response(output_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_issues_by_priority(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
extra_filters = {}
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role=5, is_active=True
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
issues_by_priority = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters, **extra_filters)
|
||||
.values("priority")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# default priority
|
||||
all_groups = {priority: 0 for priority in priority_order}
|
||||
|
||||
# Update counts for existing groups
|
||||
for entry in issues_by_priority:
|
||||
all_groups[entry["priority"]] = entry["count"]
|
||||
|
||||
# Prepare output including all groups with their counts
|
||||
output_data = [
|
||||
{"priority": group, "count": count} for group, count in all_groups.items()
|
||||
]
|
||||
|
||||
return Response(output_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_recent_activity(self, request, slug):
|
||||
queryset = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
actor=request.user,
|
||||
).select_related("actor", "workspace", "issue", "project")[:8]
|
||||
|
||||
return Response(
|
||||
IssueActivitySerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
def dashboard_recent_projects(self, request, slug):
|
||||
project_ids = (
|
||||
IssueActivity.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
actor=request.user,
|
||||
)
|
||||
.values_list("project_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Extract project IDs from the recent projects
|
||||
unique_project_ids = set(project_id for project_id in project_ids)
|
||||
|
||||
# Fetch additional projects only if needed
|
||||
if len(unique_project_ids) < 4:
|
||||
additional_projects = Project.objects.filter(
|
||||
project_projectmember__member=request.user,
|
||||
project_projectmember__is_active=True,
|
||||
archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
).exclude(id__in=unique_project_ids)
|
||||
|
||||
# Append additional project IDs to the existing list
|
||||
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
||||
|
||||
return Response(list(unique_project_ids)[:4], status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_recent_collaborators(self, request, slug):
|
||||
project_members_with_activities = (
|
||||
WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True)
|
||||
.annotate(
|
||||
active_issue_count=Count(
|
||||
Case(
|
||||
When(
|
||||
member__issue_assignee__issue__state__group__in=[
|
||||
"unstarted",
|
||||
"started",
|
||||
],
|
||||
member__issue_assignee__issue__workspace__slug=slug,
|
||||
member__issue_assignee__issue__project__project_projectmember__member=request.user,
|
||||
member__issue_assignee__issue__project__project_projectmember__is_active=True,
|
||||
then=F("member__issue_assignee__issue__id"),
|
||||
),
|
||||
distinct=True,
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
user_id=F("member_id"),
|
||||
)
|
||||
.values("user_id", "active_issue_count")
|
||||
.order_by("-active_issue_count")
|
||||
.distinct()
|
||||
)
|
||||
return Response((project_members_with_activities), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DashboardEndpoint(BaseAPIView):
|
||||
def create(self, request, slug):
|
||||
serializer = DashboardSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug, dashboard_id=None):
|
||||
if not dashboard_id:
|
||||
dashboard_type = request.GET.get("dashboard_type", None)
|
||||
if dashboard_type == "home":
|
||||
dashboard, created = DeprecatedDashboard.objects.get_or_create(
|
||||
type_identifier=dashboard_type,
|
||||
owned_by=request.user,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
if created:
|
||||
widgets_to_fetch = [
|
||||
"overview_stats",
|
||||
"assigned_issues",
|
||||
"created_issues",
|
||||
"issues_by_state_groups",
|
||||
"issues_by_priority",
|
||||
"recent_activity",
|
||||
"recent_projects",
|
||||
"recent_collaborators",
|
||||
]
|
||||
|
||||
updated_dashboard_widgets = []
|
||||
for widget_key in widgets_to_fetch:
|
||||
widget = DeprecatedWidget.objects.filter(
|
||||
key=widget_key
|
||||
).values_list("id", flat=True)
|
||||
if widget:
|
||||
updated_dashboard_widgets.append(
|
||||
DeprecatedDashboardWidget(
|
||||
widget_id=widget, dashboard_id=dashboard.id
|
||||
)
|
||||
)
|
||||
|
||||
DeprecatedDashboardWidget.objects.bulk_create(
|
||||
updated_dashboard_widgets, batch_size=100
|
||||
)
|
||||
|
||||
widgets = (
|
||||
DeprecatedWidget.objects.annotate(
|
||||
is_visible=Exists(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
is_visible=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
dashboard_filters=Subquery(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
filters__isnull=False,
|
||||
)
|
||||
.exclude(filters={})
|
||||
.values("filters")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
widget_filters=Case(
|
||||
When(
|
||||
dashboard_filters__isnull=False,
|
||||
then=F("dashboard_filters"),
|
||||
),
|
||||
default=F("filters"),
|
||||
output_field=JSONField(),
|
||||
)
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"dashboard": DashboardSerializer(dashboard).data,
|
||||
"widgets": WidgetSerializer(widgets, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Please specify a valid dashboard type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
widget_key = request.GET.get("widget_key", "overview_stats")
|
||||
|
||||
WIDGETS_MAPPER = {
|
||||
"overview_stats": dashboard_overview_stats,
|
||||
"assigned_issues": dashboard_assigned_issues,
|
||||
"created_issues": dashboard_created_issues,
|
||||
"issues_by_state_groups": dashboard_issues_by_state_groups,
|
||||
"issues_by_priority": dashboard_issues_by_priority,
|
||||
"recent_activity": dashboard_recent_activity,
|
||||
"recent_projects": dashboard_recent_projects,
|
||||
"recent_collaborators": dashboard_recent_collaborators,
|
||||
}
|
||||
|
||||
func = WIDGETS_MAPPER.get(widget_key)
|
||||
if func is not None:
|
||||
response = func(self, request=request, slug=slug)
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid widget key"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class WidgetsEndpoint(BaseAPIView):
|
||||
def patch(self, request, dashboard_id, widget_id):
|
||||
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=widget_id, dashboard_id=dashboard_id
|
||||
).first()
|
||||
dashboard_widget.is_visible = request.data.get(
|
||||
"is_visible", dashboard_widget.is_visible
|
||||
)
|
||||
dashboard_widget.sort_order = request.data.get(
|
||||
"sort_order", dashboard_widget.sort_order
|
||||
)
|
||||
dashboard_widget.filters = request.data.get("filters", dashboard_widget.filters)
|
||||
dashboard_widget.save()
|
||||
return Response({"message": "successfully updated"}, status=status.HTTP_200_OK)
|
||||
+8
-6
@@ -3,7 +3,7 @@ import os
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
# Third party import
|
||||
import litellm
|
||||
from openai import OpenAI
|
||||
import requests
|
||||
|
||||
from rest_framework import status
|
||||
@@ -116,12 +116,14 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
|
||||
if provider.lower() == "gemini":
|
||||
model = f"gemini/{model}"
|
||||
|
||||
response = litellm.completion(
|
||||
client = OpenAI(api_key=api_key)
|
||||
chat_completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": final_text}],
|
||||
api_key=api_key,
|
||||
messages=[
|
||||
{"role": "user", "content": final_text}
|
||||
]
|
||||
)
|
||||
text = response.choices[0].message.content.strip()
|
||||
text = chat_completion.choices[0].message.content
|
||||
return text, None
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
@@ -175,7 +177,7 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
api_key, model, provider = get_llm_config()
|
||||
|
||||
|
||||
if not api_key or not model or not provider:
|
||||
return Response(
|
||||
{"error": "LLM provider API key and model are required"},
|
||||
|
||||
@@ -174,14 +174,17 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
intake_id = Intake.objects.filter(
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
if not intake:
|
||||
return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
filters = issue_filters(request.GET, "GET", "issue__")
|
||||
intake_issue = (
|
||||
IntakeIssue.objects.filter(
|
||||
intake_id=intake_id.id, project_id=project_id, **filters
|
||||
intake_id=intake.id, project_id=project_id, **filters
|
||||
)
|
||||
.select_related("issue")
|
||||
.prefetch_related("issue__labels")
|
||||
|
||||
@@ -547,7 +547,7 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
the requesting user then dont show the issue
|
||||
"""
|
||||
|
||||
@@ -1116,8 +1116,22 @@ class IssueMetaEndpoint(BaseAPIView):
|
||||
|
||||
class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
def strict_str_to_int(self, s):
|
||||
if not s.isdigit() and not (s.startswith('-') and s[1:].isdigit()):
|
||||
raise ValueError("Invalid integer string")
|
||||
return int(s)
|
||||
|
||||
def get(self, request, slug, project_identifier, issue_identifier):
|
||||
|
||||
|
||||
# Check if the issue identifier is a valid integer
|
||||
try:
|
||||
issue_identifier = self.strict_str_to_int(issue_identifier)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"error": "Invalid issue identifier"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Fetch the project
|
||||
project = Project.objects.get(
|
||||
identifier__iexact=project_identifier,
|
||||
@@ -1240,7 +1254,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
the requesting user then dont show the issue
|
||||
"""
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from django.utils import timezone
|
||||
from django.db.models import Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id, comment_id):
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
|
||||
try:
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
actor_id=request.user.id,
|
||||
comment_id=comment_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Reaction already exists for the user"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
|
||||
@@ -55,6 +55,20 @@ class LabelViewSet(BaseViewSet):
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Check if the label name is unique within the project
|
||||
if (
|
||||
"name" in request.data
|
||||
and Label.objects.filter(
|
||||
project_id=kwargs["project_id"], name=request.data["name"]
|
||||
)
|
||||
.exclude(pk=kwargs["pk"])
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Label with the same name already exists in the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# call the parent method to perform the update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@@ -74,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
Label(
|
||||
name=label.get("name", "Migrated"),
|
||||
description=label.get("description", "Migrated Issue"),
|
||||
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
|
||||
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
|
||||
@@ -280,7 +280,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.first().module.name}
|
||||
{"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Django modules
|
||||
from django.db.models import Q
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
@@ -31,16 +32,21 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def patch(self, request, slug, favorite_id):
|
||||
|
||||
@@ -100,8 +100,20 @@ class ResetPasswordEndpoint(View):
|
||||
def post(self, request, uidb64, token):
|
||||
try:
|
||||
# Decode the id from the uidb64
|
||||
id = smart_str(urlsafe_base64_decode(uidb64))
|
||||
user = User.objects.get(id=id)
|
||||
try:
|
||||
id = smart_str(urlsafe_base64_decode(uidb64))
|
||||
user = User.objects.get(id=id)
|
||||
except (ValueError, User.DoesNotExist):
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
|
||||
error_message="INVALID_PASSWORD_TOKEN",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
"accounts/reset-password?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# check if the token is valid for the user
|
||||
if not PasswordResetTokenGenerator().check_token(user, token):
|
||||
|
||||
@@ -9,10 +9,10 @@ from celery import shared_task
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
@@ -32,6 +32,7 @@ from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.bgtasks.webhook_task import webhook_activity
|
||||
from plane.utils.issue_relation_mapper import get_inverse_relation
|
||||
from plane.utils.valid_uuid import is_valid_uuid
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
@@ -790,14 +791,15 @@ def create_cycle_issue_activity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_cycle.name,
|
||||
new_value=new_cycle.name,
|
||||
old_value=old_cycle.name if old_cycle else "",
|
||||
new_value=new_cycle.name if new_cycle else "",
|
||||
field="cycles",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
|
||||
to {new_cycle.name if new_cycle else ""}""",
|
||||
old_identifier=old_cycle.id if old_cycle else None,
|
||||
new_identifier=new_cycle.id if new_cycle else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -851,7 +853,7 @@ def delete_cycle_issue_activity(
|
||||
issues = requested_data.get("issues")
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
if current_issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
@@ -893,11 +895,11 @@ def create_module_issue_activity(
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
new_value=module.name if module else "",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
comment=f"added module {module.name if module else ''}",
|
||||
new_identifier=requested_data.get("module_id"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1413,7 +1415,7 @@ def delete_issue_relation_activity(
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f'deleted {requested_data.get("relation_type")} relation',
|
||||
comment=f"deleted {requested_data.get('relation_type')} relation",
|
||||
old_identifier=requested_data.get("related_issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1567,6 +1569,10 @@ def issue_activity(
|
||||
try:
|
||||
issue_activities = []
|
||||
|
||||
# check if project_id is valid
|
||||
if not is_valid_uuid(str(project_id)):
|
||||
return
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
from django.utils import timezone
|
||||
from django.db import DatabaseError
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
|
||||
).first()
|
||||
|
||||
if recent_visited:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
# Check if the database is available
|
||||
try:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
except DatabaseError:
|
||||
pass
|
||||
else:
|
||||
recent_visited_count = UserRecentVisit.objects.filter(
|
||||
user_id=user_id, workspace_id=workspace.id
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.2.18 on 2025-02-25 15:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0091_issuecomment_edited_at_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="deprecateddashboardwidget",
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="created_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="dashboard",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="updated_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="widget",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DeprecatedDashboard",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DeprecatedDashboardWidget",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DeprecatedWidget",
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,6 @@ from .api import APIActivityLog, APIToken
|
||||
from .asset import FileAsset
|
||||
from .base import BaseModel
|
||||
from .cycle import Cycle, CycleIssue, CycleUserProperties
|
||||
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
|
||||
from .deploy_board import DeployBoard
|
||||
from .draft import (
|
||||
DraftIssue,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from ..mixins import TimeAuditModel
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class DeprecatedDashboard(BaseModel):
|
||||
DASHBOARD_CHOICES = (
|
||||
("workspace", "Workspace"),
|
||||
("project", "Project"),
|
||||
("home", "Home"),
|
||||
("team", "Team"),
|
||||
("user", "User"),
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
identifier = models.UUIDField(null=True)
|
||||
owned_by = models.ForeignKey(
|
||||
"db.User", on_delete=models.CASCADE, related_name="dashboards"
|
||||
)
|
||||
is_default = models.BooleanField(default=False)
|
||||
type_identifier = models.CharField(
|
||||
max_length=30,
|
||||
choices=DASHBOARD_CHOICES,
|
||||
verbose_name="Dashboard Type",
|
||||
default="home",
|
||||
)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the dashboard"""
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "DeprecatedDashboard"
|
||||
verbose_name_plural = "DeprecatedDashboards"
|
||||
db_table = "deprecated_dashboards"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class DeprecatedWidget(TimeAuditModel):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
key = models.CharField(max_length=255)
|
||||
filters = models.JSONField(default=dict)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the widget"""
|
||||
return f"{self.key}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "DeprecatedWidget"
|
||||
verbose_name_plural = "DeprecatedWidgets"
|
||||
db_table = "deprecated_widgets"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class DeprecatedDashboardWidget(BaseModel):
|
||||
widget = models.ForeignKey(
|
||||
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
)
|
||||
dashboard = models.ForeignKey(
|
||||
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
filters = models.JSONField(default=dict)
|
||||
properties = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the dashboard"""
|
||||
return f"{self.dashboard.name} {self.widget.key}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ("widget", "dashboard", "deleted_at")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["widget", "dashboard"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Deprecated Dashboard Widget"
|
||||
verbose_name_plural = "Deprecated Dashboard Widgets"
|
||||
db_table = "deprecated_dashboard_widgets"
|
||||
ordering = ("-created_at",)
|
||||
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
)
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -77,7 +77,7 @@ def convert_to_utc(
|
||||
current_datetime_in_project_tz = timezone.now().astimezone(local_tz)
|
||||
current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc)
|
||||
|
||||
if utc_datetime.date() == current_datetime_in_utc.date():
|
||||
if localized_datetime.date() == current_datetime_in_project_tz.date():
|
||||
return current_datetime_in_utc
|
||||
|
||||
return utc_datetime
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import uuid
|
||||
|
||||
def is_valid_uuid(uuid_str):
|
||||
try:
|
||||
uuid.UUID(uuid_str, version=4)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -4,7 +4,7 @@
|
||||
Django==4.2.18
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
# postgres
|
||||
psycopg==3.1.18
|
||||
psycopg-binary==3.1.18
|
||||
psycopg-c==3.1.18
|
||||
@@ -37,7 +37,7 @@ uvicorn==0.29.0
|
||||
# sockets
|
||||
channels==4.1.0
|
||||
# ai
|
||||
litellm==1.51.0
|
||||
openai==1.63.2
|
||||
# slack
|
||||
slack-sdk==3.27.1
|
||||
# apm
|
||||
@@ -66,4 +66,4 @@ PyJWT==2.8.0
|
||||
opentelemetry-api==1.28.1
|
||||
opentelemetry-sdk==1.28.1
|
||||
opentelemetry-instrumentation-django==0.49b1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# debug toolbar
|
||||
django-debug-toolbar==4.3.0
|
||||
# formatter
|
||||
ruff==0.4.2
|
||||
ruff==0.9.7
|
||||
|
||||
+136
-14
@@ -55,18 +55,30 @@ Installing plane is a very easy and minimal step process.
|
||||
- User context used must have access to docker services. In most cases, use sudo su to switch as root user
|
||||
- Use the terminal (or gitbash) window to run all the future steps
|
||||
|
||||
### Downloading Latest Stable Release
|
||||
### Downloading Latest Release
|
||||
|
||||
```
|
||||
mkdir plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
```
|
||||
|
||||
#### For *Docker Compose* based setup
|
||||
|
||||
```
|
||||
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
#### For *Docker Swarm* based setup
|
||||
|
||||
```
|
||||
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/swarm.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Proceed with setup
|
||||
@@ -77,8 +89,9 @@ Lets get started by running the `./setup.sh` command.
|
||||
|
||||
This will prompt you with the below options.
|
||||
|
||||
#### Docker Compose
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
Select an Action you want to perform:
|
||||
1) Install (x86_64)
|
||||
2) Start
|
||||
3) Stop
|
||||
@@ -87,17 +100,42 @@ Select a Action you want to perform:
|
||||
6) View Logs
|
||||
7) Backup Data
|
||||
8) Exit
|
||||
|
||||
Action [2]: 1
|
||||
```
|
||||
|
||||
For the 1st time setup, type "1" as action input.
|
||||
|
||||
This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that
|
||||
This will create a folder `plane-app` and will download 2 files inside that
|
||||
|
||||
- `docker-compose.yaml`
|
||||
- `plane.env`
|
||||
|
||||
Again the `options [1-8]` will be popped up and this time hit `8` to exit.
|
||||
Again the `options [1-8]` will be popped up, and this time hit `8` to exit.
|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
```bash
|
||||
Select an Action you want to perform:
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 1
|
||||
```
|
||||
|
||||
For the 1st time setup, type "1" as action input.
|
||||
|
||||
This will create a create a folder `plane-app` and will download 2 files inside that
|
||||
|
||||
- `docker-compose.yaml`
|
||||
- `plane.env`
|
||||
|
||||
Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +154,7 @@ There are many other settings you can play with, but we suggest you configure `E
|
||||
|
||||
---
|
||||
|
||||
### Continue with setup - Start Server
|
||||
### Continue with setup - Start Server (Docker Compose)
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices
|
||||
|
||||
@@ -147,9 +185,11 @@ You have successfully self hosted `Plane` instance. Access the application by go
|
||||
|
||||
---
|
||||
|
||||
### Stopping the Server
|
||||
### Stopping the Server / Remove Stack
|
||||
|
||||
In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that.
|
||||
In case you want to make changes to `plane.env` variables, we suggest you to stop the services before doing that.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices
|
||||
|
||||
@@ -171,14 +211,34 @@ If all goes well, you must see something like this
|
||||
|
||||

|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to stop the sevices
|
||||
|
||||
```bash
|
||||
Select an Action you want to perform:
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 2
|
||||
```
|
||||
|
||||
If all goes well, you will see the confirmation from docker cli
|
||||
|
||||
---
|
||||
|
||||
### Restarting the Server
|
||||
### Restarting the Server / Redeploy Stack
|
||||
|
||||
In case you want to make changes to `.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with RESTART option.
|
||||
In case you want to make changes to `plane.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with `RESTART` / `REDEPLOY` option.
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices
|
||||
|
||||
#### Docker Compose
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
1) Install (x86_64)
|
||||
@@ -197,14 +257,32 @@ If all goes well, you must see something like this
|
||||
|
||||

|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
```bash
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 4
|
||||
```
|
||||
|
||||
If all goes well, you will see the confirmation from docker cli
|
||||
|
||||
---
|
||||
|
||||
### Upgrading Plane Version
|
||||
### Upgrading Plane Version
|
||||
|
||||
It is always advised to keep Plane up to date with the latest release.
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
1) Install (x86_64)
|
||||
@@ -231,13 +309,41 @@ Once done, choose `8` to exit from prompt.
|
||||
|
||||
Once done with making changes in `plane.env` file, jump on to `Start Server`
|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
|
||||
|
||||
```bash
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 5
|
||||
```
|
||||
|
||||
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
|
||||
|
||||
Once done, choose `7` to exit from prompt.
|
||||
|
||||
> It is very important for you to validate the `plane.env` for the new changes.
|
||||
|
||||
Once done with making changes in `plane.env` file, jump on to `Redeploy Stack`
|
||||
|
||||
---
|
||||
|
||||
### View Logs
|
||||
|
||||
There would a time when you might want to check what is happening inside the API, Worker or any other container.
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `6` to view logs.
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options.
|
||||
|
||||
This time select `6` to view logs.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
@@ -253,7 +359,22 @@ Select a Action you want to perform:
|
||||
Action [2]: 6
|
||||
```
|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
|
||||
```bash
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 6
|
||||
```
|
||||
|
||||
#### Service Menu Options for Logs
|
||||
This will further open sub-menu with list of services
|
||||
```bash
|
||||
Select a Service you want to view the logs for:
|
||||
@@ -267,9 +388,10 @@ Select a Service you want to view the logs for:
|
||||
8) Redis
|
||||
9) Postgres
|
||||
10) Minio
|
||||
11) RabbitMQ
|
||||
0) Back to Main Menu
|
||||
|
||||
Service:
|
||||
Service: 3
|
||||
```
|
||||
|
||||
Select any of the service to view the logs e.g. `3`. Expect something similar to this
|
||||
@@ -323,7 +445,7 @@ Similarly, you can view the logs of other services.
|
||||
|
||||
---
|
||||
|
||||
### Backup Data
|
||||
### Backup Data (Docker Compose)
|
||||
|
||||
There would a time when you might want to backup your data from docker volumes to external storage like S3 or drives.
|
||||
|
||||
@@ -355,7 +477,7 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
|
||||
|
||||
---
|
||||
|
||||
### Restore Data
|
||||
### Restore Data (Docker Compose)
|
||||
|
||||
When you want to restore the previously backed-up data, follow the instructions below.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ x-redis-env: &redis-env
|
||||
x-minio-env: &minio-env
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
|
||||
|
||||
x-aws-s3-env: &aws-s3-env
|
||||
AWS_REGION: ${AWS_REGION:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
@@ -28,8 +28,7 @@ x-proxy-env: &proxy-env
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
|
||||
x-mq-env: &mq-env
|
||||
# RabbitMQ Settings
|
||||
x-mq-env: &mq-env # RabbitMQ Settings
|
||||
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
|
||||
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
|
||||
@@ -50,33 +49,27 @@ x-app-env: &app-env
|
||||
USE_MINIO: ${USE_MINIO:-1}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
|
||||
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
ADMIN_BASE_URL: ${ADMIN_BASE_URL}
|
||||
SPACE_BASE_URL: ${SPACE_BASE_URL}
|
||||
APP_BASE_URL: ${APP_BASE_URL}
|
||||
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
|
||||
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node web/server.js web
|
||||
deploy:
|
||||
replicas: ${WEB_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
|
||||
space:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node space/server.js space
|
||||
deploy:
|
||||
replicas: ${SPACE_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
@@ -84,42 +77,39 @@ services:
|
||||
|
||||
admin:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node admin/server.js admin
|
||||
deploy:
|
||||
replicas: ${ADMIN_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
|
||||
live:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node live/dist/server.js live
|
||||
environment:
|
||||
<<: [ *live-env ]
|
||||
<<: [*live-env]
|
||||
deploy:
|
||||
replicas: ${LIVE_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
|
||||
api:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-api.sh
|
||||
deploy:
|
||||
replicas: ${API_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_api:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
@@ -127,14 +117,15 @@ services:
|
||||
|
||||
worker:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-worker.sh
|
||||
deploy:
|
||||
replicas: ${WORKER_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -143,14 +134,15 @@ services:
|
||||
|
||||
beat-worker:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-beat.sh
|
||||
deploy:
|
||||
replicas: ${BEAT_WORKER_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_beat-worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -159,23 +151,27 @@ services:
|
||||
|
||||
migrator:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: "no"
|
||||
command: ./bin/docker-entrypoint-migrator.sh
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_migrator:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
# Comment this if you already have a database running
|
||||
plane-db:
|
||||
image: postgres:15.7-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: postgres -c 'max_connections=1000'
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *db-env
|
||||
volumes:
|
||||
@@ -183,24 +179,32 @@ services:
|
||||
|
||||
plane-redis:
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
|
||||
plane-mq:
|
||||
image: rabbitmq:3.13.6-management-alpine
|
||||
restart: always
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *mq-env
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
|
||||
# Comment this if you using any external s3 compatible storage
|
||||
plane-minio:
|
||||
image: minio/minio:latest
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: server /export --console-address ":9090"
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *minio-env
|
||||
volumes:
|
||||
@@ -209,13 +213,17 @@ services:
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
- target: 80
|
||||
published: ${NGINX_PORT:-80}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
@@ -224,7 +232,6 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
|
||||
uploads:
|
||||
logs_api:
|
||||
logs_worker:
|
||||
|
||||
@@ -457,12 +457,13 @@ function viewLogs(){
|
||||
echo " 8) Redis"
|
||||
echo " 9) Postgres"
|
||||
echo " 10) Minio"
|
||||
echo " 11) RabbitMQ"
|
||||
echo " 0) Back to Main Menu"
|
||||
echo
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
|
||||
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
|
||||
echo "Invalid selection. Please enter a number between 1 and 11."
|
||||
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do
|
||||
echo "Invalid selection. Please enter a number between 0 and 11."
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
done
|
||||
|
||||
@@ -481,6 +482,7 @@ function viewLogs(){
|
||||
8) viewSpecificLogs "plane-redis";;
|
||||
9) viewSpecificLogs "plane-db";;
|
||||
10) viewSpecificLogs "plane-minio";;
|
||||
11) viewSpecificLogs "plane-mq";;
|
||||
0) askForAction;;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
@@ -499,6 +501,7 @@ function viewLogs(){
|
||||
redis) viewSpecificLogs "plane-redis";;
|
||||
postgres) viewSpecificLogs "plane-db";;
|
||||
minio) viewSpecificLogs "plane-minio";;
|
||||
rabbitmq) viewSpecificLogs "plane-mq";;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
else
|
||||
|
||||
Executable
+612
@@ -0,0 +1,612 @@
|
||||
#!/bin/bash
|
||||
|
||||
BRANCH=${BRANCH:-master}
|
||||
SERVICE_FOLDER=plane-app
|
||||
SCRIPT_DIR=$PWD
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE="stable"
|
||||
export DOCKERHUB_USER=makeplane
|
||||
|
||||
export GH_REPO=makeplane/plane
|
||||
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
|
||||
export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
|
||||
|
||||
OS_NAME=$(uname)
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
cat <<"EOF"
|
||||
--------------------------------------------
|
||||
____ _ /////////
|
||||
| _ \| | __ _ _ __ ___ /////////
|
||||
| |_) | |/ _` | '_ \ / _ \ ///// /////
|
||||
| __/| | (_| | | | | __/ ///// /////
|
||||
|_| |_|\__,_|_| |_|\___| ////
|
||||
////
|
||||
--------------------------------------------
|
||||
Project management tool from the future
|
||||
--------------------------------------------
|
||||
EOF
|
||||
}
|
||||
|
||||
function checkLatestRelease(){
|
||||
echo "Checking for the latest release..." >&2
|
||||
local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
|
||||
if [ -z "$latest_release" ]; then
|
||||
echo "Failed to check for the latest release. Exiting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo $latest_release
|
||||
}
|
||||
|
||||
# Function to read stack name from env file
|
||||
function readStackName() {
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
local saved_stack_name=$(grep "^STACK_NAME=" "$DOCKER_ENV_PATH" | cut -d'=' -f2)
|
||||
if [ -n "$saved_stack_name" ]; then
|
||||
stack_name=$saved_stack_name
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to get stack name (either from env or user input)
|
||||
function getStackName() {
|
||||
read -p "Enter stack name [plane]: " input_stack_name
|
||||
if [ -z "$input_stack_name" ]; then
|
||||
input_stack_name="plane"
|
||||
fi
|
||||
stack_name=$input_stack_name
|
||||
updateEnvFile "STACK_NAME" "$stack_name" "$DOCKER_ENV_PATH"
|
||||
echo "Using stack name: $stack_name"
|
||||
}
|
||||
|
||||
function syncEnvFile(){
|
||||
echo "Syncing environment variables..." >&2
|
||||
if [ -f "$PLANE_INSTALL_DIR/plane.env.bak" ]; then
|
||||
# READ keys of plane.env and update the values from plane.env.bak
|
||||
while IFS= read -r line
|
||||
do
|
||||
# ignore if the line is empty or starts with #
|
||||
if [ -z "$line" ] || [[ $line == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
key=$(echo "$line" | cut -d'=' -f1)
|
||||
value=$(getEnvValue "$key" "$PLANE_INSTALL_DIR/plane.env.bak")
|
||||
if [ -n "$value" ]; then
|
||||
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
done < "$DOCKER_ENV_PATH"
|
||||
|
||||
value=$(getEnvValue "STACK_NAME" "$PLANE_INSTALL_DIR/plane.env.bak")
|
||||
if [ -n "$value" ]; then
|
||||
updateEnvFile "STACK_NAME" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
fi
|
||||
echo "Environment variables synced successfully" >&2
|
||||
rm -f $PLANE_INSTALL_DIR/plane.env.bak
|
||||
}
|
||||
|
||||
function getEnvValue() {
|
||||
local key=$1
|
||||
local file=$2
|
||||
|
||||
if [ -z "$key" ] || [ -z "$file" ]; then
|
||||
echo "Invalid arguments supplied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -eq 0 ]; then
|
||||
local value
|
||||
value=$(grep "^$key=" "$file" | cut -d'=' -f2)
|
||||
echo "$value"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function updateEnvFile() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
local file=$3
|
||||
|
||||
if [ -z "$key" ] || [ -z "$value" ] || [ -z "$file" ]; then
|
||||
echo "Invalid arguments supplied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
# check if key exists in the file
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "$key=$value" >> "$file"
|
||||
return
|
||||
else
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
value=$(echo "$value" | sed 's/|/\\|/g')
|
||||
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
|
||||
else
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "File not found: $file"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function download() {
|
||||
cd $SCRIPT_DIR || exit 1
|
||||
TS=$(date +%s)
|
||||
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yml" ]
|
||||
then
|
||||
mv $PLANE_INSTALL_DIR/docker-compose.yml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml
|
||||
fi
|
||||
|
||||
echo $RELEASE_DOWNLOAD_URL
|
||||
echo $FALLBACK_DOWNLOAD_URL
|
||||
echo $APP_RELEASE
|
||||
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
else
|
||||
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
echo "Failed to download variables.env. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DOCKER_ENV_PATH" ];
|
||||
then
|
||||
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/archive/$TS.env"
|
||||
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/plane.env.bak"
|
||||
fi
|
||||
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH
|
||||
|
||||
syncEnvFile
|
||||
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
|
||||
}
|
||||
function deployStack() {
|
||||
# Check if docker compose file and env file exist
|
||||
if [ ! -f "$DOCKER_FILE_PATH" ] || [ ! -f "$DOCKER_ENV_PATH" ]; then
|
||||
echo "Configuration files not found"
|
||||
echo "Downloading it now......"
|
||||
APP_RELEASE=$(checkLatestRelease)
|
||||
download
|
||||
fi
|
||||
if [ -z "$stack_name" ]; then
|
||||
getStackName
|
||||
fi
|
||||
echo "Starting ${stack_name} stack..."
|
||||
|
||||
# Pull envs
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
set -o allexport; source $DOCKER_ENV_PATH; set +o allexport;
|
||||
else
|
||||
echo "Environment file not found: $DOCKER_ENV_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deploy the stack
|
||||
docker stack deploy -c $DOCKER_FILE_PATH $stack_name
|
||||
|
||||
echo "Waiting for services to be deployed..."
|
||||
sleep 10
|
||||
|
||||
# Check migrator service
|
||||
local migrator_service=$(docker service ls --filter name=${stack_name}_migrator -q)
|
||||
if [ -n "$migrator_service" ]; then
|
||||
echo ">> Waiting for Data Migration to finish"
|
||||
while docker service ls --filter name=${stack_name}_migrator | grep -q "running"; do
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Get the most recent container for the migrator service
|
||||
local migrator_container=$(docker ps -a --filter name=${stack_name}_migrator --latest -q)
|
||||
|
||||
if [ -n "$migrator_container" ]; then
|
||||
# Get the exit code of the container
|
||||
local exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container)
|
||||
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
echo "Server failed to start ❌"
|
||||
echo "Migration failed with exit code: $exit_code"
|
||||
echo "Please check the logs for the 'migrator' service and resolve the issue(s)."
|
||||
echo "Stop the services by running the command: ./swarm.sh stop"
|
||||
exit 1
|
||||
else
|
||||
echo " Data Migration completed successfully ✅"
|
||||
fi
|
||||
else
|
||||
echo "Warning: Could not find migrator container to check exit status"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check API service
|
||||
local api_service=$(docker service ls --filter name=${stack_name}_api -q)
|
||||
while docker service ls --filter name=${stack_name}_api | grep -q "running"; do
|
||||
local running_container=$(docker ps --filter "name=${stack_name}_api" --filter "status=running" -q)
|
||||
if [ -n "$running_container" ]; then
|
||||
if docker container logs $running_container 2>/dev/null | grep -q "Application Startup Complete"; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$api_service" ]; then
|
||||
echo "Plane Server failed to start ❌"
|
||||
echo "Please check the logs for the 'api' service and resolve the issue(s)."
|
||||
echo "Stop the services by running the command: ./swarm.sh stop"
|
||||
exit 1
|
||||
fi
|
||||
echo " Plane Server started successfully ✅"
|
||||
echo ""
|
||||
echo " You can access the application at $WEB_URL"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function removeStack() {
|
||||
if [ -z "$stack_name" ]; then
|
||||
echo "Stack name not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Removing ${stack_name} stack..."
|
||||
docker stack rm "$stack_name"
|
||||
echo "Waiting for services to be removed..."
|
||||
while docker stack ls | grep -q "$stack_name"; do
|
||||
sleep 1
|
||||
done
|
||||
sleep 20
|
||||
echo "Services stopped successfully ✅"
|
||||
}
|
||||
|
||||
function viewStatus() {
|
||||
echo "Checking status of ${stack_name} stack..."
|
||||
if [ -z "$stack_name" ]; then
|
||||
echo "Stack name not found"
|
||||
exit 1
|
||||
fi
|
||||
docker stack ps "$stack_name"
|
||||
}
|
||||
|
||||
function redeployStack() {
|
||||
removeStack
|
||||
echo "ReDeploying ${stack_name} stack..."
|
||||
deployStack
|
||||
}
|
||||
|
||||
function upgrade() {
|
||||
|
||||
echo "Checking status of ${stack_name} stack..."
|
||||
if [ -z "$stack_name" ]; then
|
||||
echo "Stack name not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local latest_release=$(checkLatestRelease)
|
||||
|
||||
echo ""
|
||||
echo "Current release: $APP_RELEASE"
|
||||
|
||||
if [ "$latest_release" == "$APP_RELEASE" ]; then
|
||||
echo ""
|
||||
echo "You are already using the latest release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Latest release: $latest_release"
|
||||
echo ""
|
||||
|
||||
# Check for confirmation to upgrade
|
||||
echo "Do you want to upgrade to the latest release ($latest_release)?"
|
||||
read -p "Continue? [y/N]: " confirm
|
||||
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export APP_RELEASE=$latest_release
|
||||
|
||||
# check if stack exists
|
||||
echo "Upgrading ${stack_name} stack..."
|
||||
|
||||
# check env file and take backup
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
cp "$DOCKER_ENV_PATH" "${DOCKER_ENV_PATH}.bak"
|
||||
fi
|
||||
|
||||
download
|
||||
redeployStack
|
||||
}
|
||||
|
||||
function viewSpecificLogs() {
|
||||
local service=$1
|
||||
|
||||
# Input validation
|
||||
if [ -z "$service" ]; then
|
||||
echo "Error: Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Main loop for service logs
|
||||
while true; do
|
||||
# Get all running containers for the service
|
||||
local running_containers=$(docker ps --filter "name=${stack_name}_${service}" --filter "status=running" -q)
|
||||
|
||||
# If no running containers found, try service logs
|
||||
if [ -z "$running_containers" ]; then
|
||||
echo "No running containers found for ${stack_name}_${service}, checking service logs..."
|
||||
if docker service inspect ${stack_name}_${service} >/dev/null 2>&1; then
|
||||
echo "Press Ctrl+C or 'q' to exit logs"
|
||||
docker service logs ${stack_name}_${service} -f
|
||||
break
|
||||
else
|
||||
echo "Error: No running containers or services found for ${stack_name}_${service}"
|
||||
return 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
# If multiple containers are running, let user choose
|
||||
if [ $(echo "$running_containers" | grep -v '^$' | wc -l) -gt 1 ]; then
|
||||
clear
|
||||
echo "Multiple containers found for ${stack_name}_${service}:"
|
||||
local i=1
|
||||
# Use regular arrays instead of associative arrays
|
||||
container_ids=()
|
||||
container_names=()
|
||||
|
||||
while read -r container_id; do
|
||||
if [ -n "$container_id" ]; then
|
||||
local container_name=$(docker inspect --format '{{.Name}}' "$container_id" | sed 's/\///')
|
||||
container_ids[$i]=$container_id
|
||||
container_names[$i]=$container_name
|
||||
echo "[$i] ${container_names[$i]} (${container_ids[$i]})"
|
||||
i=$((i+1))
|
||||
fi
|
||||
done <<< "$running_containers"
|
||||
|
||||
echo -e "\nPlease select a container number:"
|
||||
read -r selection
|
||||
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${container_ids[$selection]}" ]; then
|
||||
local selected_container=${container_ids[$selection]}
|
||||
clear
|
||||
echo "Showing logs for container: ${container_names[$selection]}"
|
||||
echo "Press Ctrl+C or 'q' to return to container selection"
|
||||
|
||||
# Start watching logs in the background
|
||||
docker container logs -f "$selected_container" &
|
||||
local log_pid=$!
|
||||
|
||||
while true; do
|
||||
read -r -n 1 input
|
||||
if [[ $input == "q" ]]; then
|
||||
kill $log_pid 2>/dev/null
|
||||
wait $log_pid 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
clear
|
||||
else
|
||||
echo "Error: Invalid selection"
|
||||
sleep 2
|
||||
fi
|
||||
else
|
||||
# Single container case
|
||||
local container_name=$(docker inspect --format '{{.Name}}' "$running_containers" | sed 's/\///')
|
||||
echo "Showing logs for container: $container_name"
|
||||
echo "Press Ctrl+C or 'q' to exit logs"
|
||||
docker container logs -f "$running_containers" &
|
||||
local log_pid=$!
|
||||
|
||||
while true; do
|
||||
read -r -n 1 input
|
||||
if [[ $input == "q" ]]; then
|
||||
kill $log_pid 2>/dev/null
|
||||
wait $log_pid 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
function viewLogs(){
|
||||
|
||||
ARG_SERVICE_NAME=$2
|
||||
if [ -z "$ARG_SERVICE_NAME" ];
|
||||
then
|
||||
echo
|
||||
echo "Select a Service you want to view the logs for:"
|
||||
echo " 1) Web"
|
||||
echo " 2) Space"
|
||||
echo " 3) API"
|
||||
echo " 4) Worker"
|
||||
echo " 5) Beat-Worker"
|
||||
echo " 6) Migrator"
|
||||
echo " 7) Proxy"
|
||||
echo " 8) Redis"
|
||||
echo " 9) Postgres"
|
||||
echo " 10) Minio"
|
||||
echo " 11) RabbitMQ"
|
||||
echo " 0) Back to Main Menu"
|
||||
echo
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
|
||||
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do
|
||||
echo "Invalid selection. Please enter a number between 0 and 11."
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
done
|
||||
|
||||
if [ -z "$DOCKER_SERVICE_NAME" ];
|
||||
then
|
||||
echo "INVALID SERVICE NAME SUPPLIED"
|
||||
else
|
||||
case $DOCKER_SERVICE_NAME in
|
||||
1) viewSpecificLogs "web";;
|
||||
2) viewSpecificLogs "space";;
|
||||
3) viewSpecificLogs "api";;
|
||||
4) viewSpecificLogs "worker";;
|
||||
5) viewSpecificLogs "beat-worker";;
|
||||
6) viewSpecificLogs "migrator";;
|
||||
7) viewSpecificLogs "proxy";;
|
||||
8) viewSpecificLogs "plane-redis";;
|
||||
9) viewSpecificLogs "plane-db";;
|
||||
10) viewSpecificLogs "plane-minio";;
|
||||
11) viewSpecificLogs "plane-mq";;
|
||||
0) askForAction;;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
fi
|
||||
elif [ -n "$ARG_SERVICE_NAME" ];
|
||||
then
|
||||
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
|
||||
case $ARG_SERVICE_NAME in
|
||||
web) viewSpecificLogs "web";;
|
||||
space) viewSpecificLogs "space";;
|
||||
api) viewSpecificLogs "api";;
|
||||
worker) viewSpecificLogs "worker";;
|
||||
beat-worker) viewSpecificLogs "beat-worker";;
|
||||
migrator) viewSpecificLogs "migrator";;
|
||||
proxy) viewSpecificLogs "proxy";;
|
||||
redis) viewSpecificLogs "plane-redis";;
|
||||
postgres) viewSpecificLogs "plane-db";;
|
||||
minio) viewSpecificLogs "plane-minio";;
|
||||
rabbitmq) viewSpecificLogs "plane-mq";;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
else
|
||||
echo "INVALID SERVICE NAME SUPPLIED"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
function askForAction() {
|
||||
# Rest of askForAction remains the same but use $stack_name instead of $STACK_NAME
|
||||
local DEFAULT_ACTION=$1
|
||||
|
||||
if [ -z "$DEFAULT_ACTION" ]; then
|
||||
echo
|
||||
echo "Select an Action you want to perform:"
|
||||
echo " 1) Deploy Stack"
|
||||
echo " 2) Remove Stack"
|
||||
echo " 3) View Stack Status"
|
||||
echo " 4) Redeploy Stack"
|
||||
echo " 5) Upgrade"
|
||||
echo " 6) View Logs"
|
||||
echo " 7) Exit"
|
||||
echo
|
||||
read -p "Action [3]: " ACTION
|
||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
||||
echo "$ACTION: invalid selection."
|
||||
read -p "Action [3]: " ACTION
|
||||
done
|
||||
|
||||
if [ -z "$ACTION" ]; then
|
||||
ACTION=3
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "deploy" ]; then
|
||||
deployStack
|
||||
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "remove" ]; then
|
||||
removeStack
|
||||
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "status" ]; then
|
||||
viewStatus
|
||||
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "redeploy" ]; then
|
||||
redeployStack
|
||||
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]; then
|
||||
upgrade
|
||||
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]; then
|
||||
viewLogs "$@"
|
||||
elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "exit" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "INVALID ACTION SUPPLIED"
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialize stack name at script start
|
||||
|
||||
if [ -z "$stack_name" ]; then
|
||||
readStackName
|
||||
fi
|
||||
|
||||
# Sync environment variables
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
DOCKERHUB_USER=$(getEnvValue "DOCKERHUB_USER" "$DOCKER_ENV_PATH")
|
||||
APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH")
|
||||
|
||||
if [ -z "$DOCKERHUB_USER" ]; then
|
||||
DOCKERHUB_USER=makeplane
|
||||
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "$APP_RELEASE" ]; then
|
||||
APP_RELEASE=stable
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Main execution
|
||||
print_header
|
||||
askForAction "$@"
|
||||
@@ -5,6 +5,9 @@ WEB_REPLICAS=1
|
||||
SPACE_REPLICAS=1
|
||||
ADMIN_REPLICAS=1
|
||||
API_REPLICAS=1
|
||||
WORKER_REPLICAS=1
|
||||
BEAT_WORKER_REPLICAS=1
|
||||
LIVE_REPLICAS=1
|
||||
|
||||
NGINX_PORT=80
|
||||
WEB_URL=http://${APP_DOMAIN}
|
||||
|
||||
+3
-3
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.24.1",
|
||||
"description": "",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -14,7 +15,6 @@
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.15.0",
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
|
||||
+5
-4
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -22,12 +24,11 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.4.1"
|
||||
"turbo": "^2.4.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts"
|
||||
"main": "./src/index.ts",
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
IIssueDisplayProperties,
|
||||
} from "@plane/types";
|
||||
import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
|
||||
export const ALL_ISSUES = "All Issues";
|
||||
|
||||
@@ -149,25 +145,24 @@ export const ISSUE_ORDER_BY_OPTIONS: {
|
||||
{ key: "-priority", titleTranslationKey: "common.priority" },
|
||||
];
|
||||
|
||||
export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] =
|
||||
[
|
||||
"assignee",
|
||||
"start_date",
|
||||
"due_date",
|
||||
"labels",
|
||||
"key",
|
||||
"priority",
|
||||
"state",
|
||||
"sub_issue_count",
|
||||
"link",
|
||||
"attachment_count",
|
||||
"estimate",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"modules",
|
||||
"cycle",
|
||||
"issue_type",
|
||||
];
|
||||
export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [
|
||||
"assignee",
|
||||
"start_date",
|
||||
"due_date",
|
||||
"labels",
|
||||
"key",
|
||||
"priority",
|
||||
"state",
|
||||
"sub_issue_count",
|
||||
"link",
|
||||
"attachment_count",
|
||||
"estimate",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"modules",
|
||||
"cycle",
|
||||
"issue_type",
|
||||
];
|
||||
|
||||
export const ISSUE_DISPLAY_PROPERTIES: {
|
||||
key: keyof IIssueDisplayProperties;
|
||||
@@ -215,3 +210,144 @@ export const ISSUE_DISPLAY_PROPERTIES: {
|
||||
{ key: "modules", titleTranslationKey: "common.module" },
|
||||
{ key: "cycle", titleTranslationKey: "common.cycle" },
|
||||
];
|
||||
|
||||
export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [
|
||||
"state",
|
||||
"priority",
|
||||
"assignee",
|
||||
"labels",
|
||||
"modules",
|
||||
"cycle",
|
||||
"start_date",
|
||||
"due_date",
|
||||
"estimate",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"link",
|
||||
"attachment_count",
|
||||
"sub_issue_count",
|
||||
];
|
||||
|
||||
export const SPREADSHEET_PROPERTY_DETAILS: {
|
||||
[key in keyof IIssueDisplayProperties]: {
|
||||
i18n_title: string;
|
||||
ascendingOrderKey: TIssueOrderByOptions;
|
||||
ascendingOrderTitle: string;
|
||||
descendingOrderKey: TIssueOrderByOptions;
|
||||
descendingOrderTitle: string;
|
||||
icon: string;
|
||||
};
|
||||
} = {
|
||||
assignee: {
|
||||
i18n_title: "common.assignees",
|
||||
ascendingOrderKey: "assignees__first_name",
|
||||
ascendingOrderTitle: "A",
|
||||
descendingOrderKey: "-assignees__first_name",
|
||||
descendingOrderTitle: "Z",
|
||||
icon: "Users",
|
||||
},
|
||||
created_on: {
|
||||
i18n_title: "common.sort.created_on",
|
||||
ascendingOrderKey: "-created_at",
|
||||
ascendingOrderTitle: "New",
|
||||
descendingOrderKey: "created_at",
|
||||
descendingOrderTitle: "Old",
|
||||
icon: "CalendarDays",
|
||||
},
|
||||
due_date: {
|
||||
i18n_title: "common.order_by.due_date",
|
||||
ascendingOrderKey: "-target_date",
|
||||
ascendingOrderTitle: "New",
|
||||
descendingOrderKey: "target_date",
|
||||
descendingOrderTitle: "Old",
|
||||
icon: "CalendarCheck2",
|
||||
},
|
||||
estimate: {
|
||||
i18n_title: "common.estimate",
|
||||
ascendingOrderKey: "estimate_point__key",
|
||||
ascendingOrderTitle: "Low",
|
||||
descendingOrderKey: "-estimate_point__key",
|
||||
descendingOrderTitle: "High",
|
||||
icon: "Triangle",
|
||||
},
|
||||
labels: {
|
||||
i18n_title: "common.labels",
|
||||
ascendingOrderKey: "labels__name",
|
||||
ascendingOrderTitle: "A",
|
||||
descendingOrderKey: "-labels__name",
|
||||
descendingOrderTitle: "Z",
|
||||
icon: "Tag",
|
||||
},
|
||||
modules: {
|
||||
i18n_title: "common.modules",
|
||||
ascendingOrderKey: "issue_module__module__name",
|
||||
ascendingOrderTitle: "A",
|
||||
descendingOrderKey: "-issue_module__module__name",
|
||||
descendingOrderTitle: "Z",
|
||||
icon: "DiceIcon",
|
||||
},
|
||||
cycle: {
|
||||
i18n_title: "common.cycle",
|
||||
ascendingOrderKey: "issue_cycle__cycle__name",
|
||||
ascendingOrderTitle: "A",
|
||||
descendingOrderKey: "-issue_cycle__cycle__name",
|
||||
descendingOrderTitle: "Z",
|
||||
icon: "ContrastIcon",
|
||||
},
|
||||
priority: {
|
||||
i18n_title: "common.priority",
|
||||
ascendingOrderKey: "priority",
|
||||
ascendingOrderTitle: "None",
|
||||
descendingOrderKey: "-priority",
|
||||
descendingOrderTitle: "Urgent",
|
||||
icon: "Signal",
|
||||
},
|
||||
start_date: {
|
||||
i18n_title: "common.order_by.start_date",
|
||||
ascendingOrderKey: "-start_date",
|
||||
ascendingOrderTitle: "New",
|
||||
descendingOrderKey: "start_date",
|
||||
descendingOrderTitle: "Old",
|
||||
icon: "CalendarClock",
|
||||
},
|
||||
state: {
|
||||
i18n_title: "common.state",
|
||||
ascendingOrderKey: "state__name",
|
||||
ascendingOrderTitle: "A",
|
||||
descendingOrderKey: "-state__name",
|
||||
descendingOrderTitle: "Z",
|
||||
icon: "DoubleCircleIcon",
|
||||
},
|
||||
updated_on: {
|
||||
i18n_title: "common.sort.updated_on",
|
||||
ascendingOrderKey: "-updated_at",
|
||||
ascendingOrderTitle: "New",
|
||||
descendingOrderKey: "updated_at",
|
||||
descendingOrderTitle: "Old",
|
||||
icon: "CalendarDays",
|
||||
},
|
||||
link: {
|
||||
i18n_title: "common.link",
|
||||
ascendingOrderKey: "-link_count",
|
||||
ascendingOrderTitle: "Most",
|
||||
descendingOrderKey: "link_count",
|
||||
descendingOrderTitle: "Least",
|
||||
icon: "Link2",
|
||||
},
|
||||
attachment_count: {
|
||||
i18n_title: "common.attachment",
|
||||
ascendingOrderKey: "-attachment_count",
|
||||
ascendingOrderTitle: "Most",
|
||||
descendingOrderKey: "attachment_count",
|
||||
descendingOrderTitle: "Least",
|
||||
icon: "Paperclip",
|
||||
},
|
||||
sub_issue_count: {
|
||||
i18n_title: "issue.display.properties.sub_issue",
|
||||
ascendingOrderKey: "-sub_issues_count",
|
||||
ascendingOrderTitle: "Most",
|
||||
descendingOrderKey: "sub_issues_count",
|
||||
descendingOrderTitle: "Least",
|
||||
icon: "LayersIcon",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./common";
|
||||
export * from "./filter";
|
||||
export * from "./layout";
|
||||
export * from "./modal";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// plane imports
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export const DEFAULT_WORK_ITEM_FORM_VALUES: Partial<TIssue> = {
|
||||
project_id: "",
|
||||
type_id: null,
|
||||
name: "",
|
||||
description_html: "",
|
||||
estimate_point: null,
|
||||
state_id: "",
|
||||
parent_id: null,
|
||||
priority: "none",
|
||||
assignee_ids: [],
|
||||
label_ids: [],
|
||||
cycle_id: null,
|
||||
module_ids: null,
|
||||
start_date: null,
|
||||
target_date: null,
|
||||
};
|
||||
@@ -271,12 +271,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
||||
href: `/workspace-views/all-issues/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
"active-cycles": {
|
||||
key: "active_cycles",
|
||||
labelTranslationKey: "cycles",
|
||||
href: `/active-cycles/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
analytics: {
|
||||
key: "analytics",
|
||||
labelTranslationKey: "analytics",
|
||||
@@ -298,7 +292,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
||||
};
|
||||
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["active-cycles"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"],
|
||||
@@ -312,8 +305,8 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
|
||||
href: `/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
notifications: {
|
||||
key: "notifications",
|
||||
inbox: {
|
||||
key: "inbox",
|
||||
labelTranslationKey: "notification.label",
|
||||
href: `/notifications/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
@@ -328,6 +321,6 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
|
||||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"],
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import React from "react";
|
||||
// components
|
||||
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
@@ -35,7 +36,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
user,
|
||||
} = props;
|
||||
|
||||
const extensions = [];
|
||||
const extensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
@@ -10,7 +11,13 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -20,7 +27,7 @@ interface IDocumentReadOnlyEditor {
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
@@ -41,7 +48,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
const extensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { FC, ReactNode } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
@@ -71,7 +71,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
onClick={handleContainerClick}
|
||||
onMouseLeave={handleContainerMouseLeave}
|
||||
className={cn(
|
||||
"editor-container cursor-text relative",
|
||||
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
|
||||
menuRef.current.remove();
|
||||
menuRef.current.style.visibility = "visible";
|
||||
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup.current = tippy(document.body, {
|
||||
getReferenceClientRect: null,
|
||||
content: menuRef.current,
|
||||
|
||||
@@ -34,6 +34,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
menuRef.current.remove();
|
||||
menuRef.current.style.visibility = "visible";
|
||||
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup.current = tippy(document.body, {
|
||||
getReferenceClientRect: null,
|
||||
content: menuRef.current,
|
||||
|
||||
@@ -6,15 +6,15 @@ import { cn } from "@plane/utils";
|
||||
import { TextAlignItem } from "@/components/menus";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
import { EditorStateType } from "./root";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
onClose: () => void;
|
||||
editorState: EditorStateType;
|
||||
};
|
||||
|
||||
export const TextAlignmentSelector: React.FC<Props> = (props) => {
|
||||
const { editor, onClose } = props;
|
||||
|
||||
const { editor, editorState } = props;
|
||||
const menuItem = TextAlignItem(editor);
|
||||
|
||||
const textAlignmentOptions: {
|
||||
@@ -32,10 +32,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
|
||||
menuItem.command({
|
||||
alignment: "left",
|
||||
}),
|
||||
isActive: () =>
|
||||
menuItem.isActive({
|
||||
alignment: "left",
|
||||
}),
|
||||
isActive: () => editorState.left,
|
||||
},
|
||||
{
|
||||
itemKey: "text-align",
|
||||
@@ -45,10 +42,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
|
||||
menuItem.command({
|
||||
alignment: "center",
|
||||
}),
|
||||
isActive: () =>
|
||||
menuItem.isActive({
|
||||
alignment: "center",
|
||||
}),
|
||||
isActive: () => editorState.center,
|
||||
},
|
||||
{
|
||||
itemKey: "text-align",
|
||||
@@ -58,10 +52,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
|
||||
menuItem.command({
|
||||
alignment: "right",
|
||||
}),
|
||||
isActive: () =>
|
||||
menuItem.isActive({
|
||||
alignment: "right",
|
||||
}),
|
||||
isActive: () => editorState.right,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -74,7 +65,6 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.command();
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ALargeSmall, Ban } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import { BackgroundColorItem, TextColorItem } from "../menu-items";
|
||||
import { EditorStateType } from "./root";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
editorState: EditorStateType;
|
||||
};
|
||||
|
||||
export const BubbleMenuColorSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
const { editor, isOpen, setIsOpen, editorState } = props;
|
||||
|
||||
const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key }));
|
||||
const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key }));
|
||||
const activeTextColor = editorState.color;
|
||||
const activeBackgroundColor = editorState.backgroundColor;
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection } from "@tiptap/react";
|
||||
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react";
|
||||
import { FC, useEffect, useState, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
BackgroundColorItem,
|
||||
BoldItem,
|
||||
BubbleMenuColorSelector,
|
||||
BubbleMenuLinkSelector,
|
||||
@@ -11,8 +12,12 @@ import {
|
||||
CodeItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
TextAlignItem,
|
||||
TextColorItem,
|
||||
UnderLineItem,
|
||||
} from "@/components/menus";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
@@ -20,16 +25,61 @@ import { TextAlignmentSelector } from "./alignment-selector";
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
// states
|
||||
export interface EditorStateType {
|
||||
code: boolean;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strike: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
center: boolean;
|
||||
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
|
||||
backgroundColor:
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
const basicFormattingOptions = props.editor.isActive("code")
|
||||
? [CodeItem(props.editor)]
|
||||
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
|
||||
const formattingItems = {
|
||||
code: CodeItem(props.editor),
|
||||
bold: BoldItem(props.editor),
|
||||
italic: ItalicItem(props.editor),
|
||||
underline: UnderLineItem(props.editor),
|
||||
strike: StrikeThroughItem(props.editor),
|
||||
textAlign: TextAlignItem(props.editor),
|
||||
};
|
||||
|
||||
const editorState: EditorStateType = useEditorState({
|
||||
editor: props.editor,
|
||||
selector: ({ editor }: { editor: Editor }) => ({
|
||||
code: formattingItems.code.isActive(),
|
||||
bold: formattingItems.bold.isActive(),
|
||||
italic: formattingItems.italic.isActive(),
|
||||
underline: formattingItems.underline.isActive(),
|
||||
strike: formattingItems.strike.isActive(),
|
||||
left: formattingItems.textAlign.isActive({ alignment: "left" }),
|
||||
right: formattingItems.textAlign.isActive({ alignment: "right" }),
|
||||
center: formattingItems.textAlign.isActive({ alignment: "center" }),
|
||||
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
|
||||
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
|
||||
}),
|
||||
});
|
||||
|
||||
const basicFormattingOptions = editorState.code
|
||||
? [formattingItems.code]
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
@@ -51,6 +101,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
duration: [300, 0],
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
@@ -60,7 +111,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown() {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (menuRef.current?.contains(e.target as Node)) return;
|
||||
|
||||
function handleMouseMove() {
|
||||
if (!props.editor.state.selection.empty) {
|
||||
setIsSelecting(true);
|
||||
@@ -70,7 +123,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
|
||||
function handleMouseUp() {
|
||||
setIsSelecting(false);
|
||||
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
@@ -84,27 +136,28 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
}, [props.editor]);
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps}>
|
||||
{!isSelecting && (
|
||||
<div className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg">
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||
>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("table") && (
|
||||
<BubbleMenuNodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen((prev) => !prev);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<BubbleMenuNodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen((prev) => !prev);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("code") && (
|
||||
{!editorState.code && (
|
||||
<div className="px-2">
|
||||
<BubbleMenuLinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
@@ -114,21 +167,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("code") && (
|
||||
</div>
|
||||
)}
|
||||
{!editorState.code && (
|
||||
<div className="px-2">
|
||||
<BubbleMenuColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
editorState={editorState}
|
||||
setIsOpen={() => {
|
||||
setIsColorSelectorOpen((prev) => !prev);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 px-2">
|
||||
{basicFormattingOptions.map((item) => (
|
||||
<button
|
||||
@@ -141,7 +195,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
className={cn(
|
||||
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
|
||||
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -149,15 +203,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TextAlignmentSelector
|
||||
editor={props.editor}
|
||||
onClose={() => {
|
||||
const editor = props.editor as Editor;
|
||||
if (!editor) return;
|
||||
const pos = editor.state.selection.to;
|
||||
editor.commands.setTextSelection(pos ?? 0);
|
||||
}}
|
||||
/>
|
||||
<TextAlignmentSelector editor={props.editor} editorState={editorState} />
|
||||
</div>
|
||||
)}
|
||||
</BubbleMenu>
|
||||
|
||||
@@ -142,8 +142,8 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||
icon: UnderlineIcon,
|
||||
});
|
||||
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
|
||||
key: "strikethrough",
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strike"> => ({
|
||||
key: "strike",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
command: () => toggleStrike(editor),
|
||||
@@ -218,24 +218,33 @@ export const HorizontalRuleItem = (editor: Editor) =>
|
||||
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
|
||||
key: "text-color",
|
||||
name: "Color",
|
||||
isActive: ({ color }) => editor.isActive("customColor", { color }),
|
||||
command: ({ color }) => toggleTextColor(color, editor),
|
||||
isActive: (props) => editor.isActive("customColor", { color: props?.color }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
toggleTextColor(props.color, editor);
|
||||
},
|
||||
icon: Palette,
|
||||
});
|
||||
|
||||
export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({
|
||||
key: "background-color",
|
||||
name: "Background color",
|
||||
isActive: ({ color }) => editor.isActive("customColor", { backgroundColor: color }),
|
||||
command: ({ color }) => toggleBackgroundColor(color, editor),
|
||||
isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
toggleBackgroundColor(props.color, editor);
|
||||
},
|
||||
icon: Palette,
|
||||
});
|
||||
|
||||
export const TextAlignItem = (editor: Editor): EditorMenuItem<"text-align"> => ({
|
||||
key: "text-align",
|
||||
name: "Text align",
|
||||
isActive: ({ alignment }) => editor.isActive({ textAlign: alignment }),
|
||||
command: ({ alignment }) => setTextAlign(alignment, editor),
|
||||
isActive: (props) => editor.isActive({ textAlign: props?.alignment }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
setTextAlign(props.alignment, editor);
|
||||
},
|
||||
icon: AlignCenter,
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
|
||||
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strike">[] = [
|
||||
{
|
||||
itemKey: "bold",
|
||||
renderKey: "bold",
|
||||
@@ -113,7 +113,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
itemKey: "strikethrough",
|
||||
itemKey: "strike",
|
||||
renderKey: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
icon: Strikethrough,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TDisplayConfig } from "@/types";
|
||||
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
|
||||
fontSize: "large-font",
|
||||
fontStyle: "sans-serif",
|
||||
lineSpacing: "regular",
|
||||
};
|
||||
|
||||
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
|
||||
|
||||
@@ -26,12 +26,12 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
class: "list-disc pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
|
||||
@@ -106,6 +106,8 @@ export const CustomColorExtension = Mark.create({
|
||||
};
|
||||
},
|
||||
|
||||
// @ts-expect-error types are incorrect
|
||||
// TODO: check this and update types
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
@@ -38,11 +39,11 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string;
|
||||
imageFromFileSystem: string | undefined;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
src: string;
|
||||
src: string | undefined;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
@@ -62,8 +63,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
width: ensurePixelString(nodeWidth, "35%"),
|
||||
height: ensurePixelString(nodeHeight, "auto"),
|
||||
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
|
||||
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@@ -144,8 +145,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
useLayoutEffect(() => {
|
||||
setSize((prevSize) => ({
|
||||
...prevSize,
|
||||
width: ensurePixelString(nodeWidth),
|
||||
height: ensurePixelString(nodeHeight),
|
||||
width: ensurePixelString(nodeWidth) ?? "35%",
|
||||
height: ensurePixelString(nodeHeight) ?? "auto",
|
||||
aspectRatio: nodeAspectRatio,
|
||||
}));
|
||||
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
|
||||
@@ -210,6 +211,8 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
|
||||
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
|
||||
const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
|
||||
// show the image upload status only when the resolvedImageSrc is not ready
|
||||
const showUploadStatus = !resolvedImageSrc;
|
||||
// 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 = resolvedImageSrc && 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)
|
||||
@@ -247,7 +250,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
try {
|
||||
setHasErroredOnFirstLoad(true);
|
||||
// this is a type error from tiptap, don't remove await until it's fixed
|
||||
if (!imgNodeSrc) {
|
||||
throw new Error("No source image to restore from");
|
||||
}
|
||||
await editor?.commands.restoreImage?.(imgNodeSrc);
|
||||
if (!imageRef.current) {
|
||||
throw new Error("Image reference not found");
|
||||
}
|
||||
if (!resolvedImageSrc) {
|
||||
throw new Error("No resolved image source available");
|
||||
}
|
||||
imageRef.current.src = resolvedImageSrc;
|
||||
} catch {
|
||||
// if the image failed to even restore, then show the error state
|
||||
@@ -270,6 +282,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
|
||||
}}
|
||||
/>
|
||||
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
|
||||
{showImageUtils && (
|
||||
<ImageToolbarRoot
|
||||
containerClassName={
|
||||
@@ -277,7 +290,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
image={{
|
||||
src: resolvedImageSrc,
|
||||
aspectRatio: size.aspectRatio,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={editor.storage.imageComponent.maxFileSize}
|
||||
maxFileSize={editor.storage.imageComponent?.maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
|
||||
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
@@ -38,6 +38,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const onUpload = useCallback(
|
||||
(url: string) => {
|
||||
if (url) {
|
||||
if (!imageEntityId) return;
|
||||
setIsUploaded(true);
|
||||
// Update the node view's src attribute post upload
|
||||
updateAttributes({ src: url });
|
||||
@@ -68,6 +69,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
);
|
||||
// hooks
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
blockId: imageEntityId ?? "",
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
@@ -82,7 +84,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
|
||||
// the meta data of the image component
|
||||
const meta = useMemo(
|
||||
() => imageComponentImageFileMap?.get(imageEntityId),
|
||||
() => imageComponentImageFileMap?.get(imageEntityId ?? ""),
|
||||
[imageComponentImageFileMap, imageEntityId]
|
||||
);
|
||||
|
||||
@@ -96,7 +98,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
if (meta.hasOpenedFileInputOnce) return;
|
||||
fileInputRef.current.click();
|
||||
hasTriggeredFilePickerRef.current = true;
|
||||
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
|
||||
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
export const ImageUploadStatus: React.FC<Props> = (props) => {
|
||||
const { editor, nodeId } = props;
|
||||
// Displayed status that will animate smoothly
|
||||
const [displayStatus, setDisplayStatus] = useState(0);
|
||||
// Animation frame ID for cleanup
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
// subscribe to image upload status
|
||||
const uploadStatus: number | undefined = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const animateToValue = (start: number, end: number, startTime: number) => {
|
||||
const duration = 200;
|
||||
|
||||
const animation = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
// Calculate current display value
|
||||
const currentValue = Math.floor(start + (end - start) * easeOutCubic);
|
||||
setDisplayStatus(currentValue);
|
||||
|
||||
// Continue animation if not complete
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
|
||||
}
|
||||
};
|
||||
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
|
||||
};
|
||||
animateToValue(displayStatus, uploadStatus == undefined ? 100 : uploadStatus, performance.now());
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [uploadStatus]);
|
||||
|
||||
if (uploadStatus === undefined) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-1 right-1 z-20 bg-black/60 rounded text-xs font-medium w-10 text-center">
|
||||
{displayStatus}%
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,12 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
@@ -21,7 +21,8 @@ declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageComponent: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
@@ -32,6 +33,7 @@ export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
|
||||
|
||||
export interface UploadImageExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
}
|
||||
|
||||
@@ -39,6 +41,7 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File })
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
assetsUploadStatus,
|
||||
getAssetSrc,
|
||||
upload,
|
||||
delete: deleteImageFn,
|
||||
@@ -105,7 +108,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
@@ -128,13 +130,14 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
assetsUploadStatus,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
@@ -182,12 +185,15 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
uploadImage: (file: File) => async () => {
|
||||
const fileUrl = await upload(file);
|
||||
uploadImage: (blockId, file) => async () => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src: string) => async () => {
|
||||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
@@ -56,6 +56,7 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
assetsUploadStatus: {},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ import {
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -50,17 +50,16 @@ type TArguments = {
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
|
||||
|
||||
return [
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
class: "list-disc pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
@@ -109,12 +108,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageExtension(fileHandler),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
@@ -148,11 +141,11 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomMentionExtension(mentionHandler),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (!editor.isEditable) return;
|
||||
if (!editor.isEditable) return "";
|
||||
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
|
||||
if (editor.storage.imageComponent.uploadInProgress) return "";
|
||||
if (editor.storage.imageComponent?.uploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") ||
|
||||
@@ -179,4 +172,18 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageExtension(fileHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ export const CustomImageComponentWithoutProps = () =>
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
assetsUploadStatus: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
|
||||
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend({
|
||||
|
||||
@@ -27,31 +27,30 @@ import {
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
const { disabledExtensions, fileHandler, mentionHandler } = props;
|
||||
|
||||
return [
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
class: "list-disc pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
@@ -94,16 +93,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension({
|
||||
getAssetSrc: fileHandler.getAssetSrc,
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomReadOnlyImageExtension({
|
||||
getAssetSrc: fileHandler.getAssetSrc,
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
@@ -140,4 +129,18 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ReadOnlyImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomReadOnlyImageExtension(fileHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/typ
|
||||
// plane editor extensions
|
||||
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
|
||||
// local types
|
||||
import { TExtensionProps } from "./root";
|
||||
import { TExtensionProps, TSlashCommandAdditionalOption } from "./root";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: TSlashCommandSectionKeys;
|
||||
@@ -54,7 +54,7 @@ export type TSlashCommandSection = {
|
||||
export const getSlashCommandFilteredSections =
|
||||
(args: TExtensionProps) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const { additionalOptions, disabledExtensions } = args;
|
||||
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
key: "general",
|
||||
@@ -176,15 +176,6 @@ export const getSlashCommandFilteredSections =
|
||||
icon: <Code2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
commandKey: "image",
|
||||
key: "image",
|
||||
title: "Image",
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
commandKey: "callout",
|
||||
key: "callout",
|
||||
@@ -284,8 +275,24 @@ export const getSlashCommandFilteredSections =
|
||||
},
|
||||
];
|
||||
|
||||
const internalAdditionalOptions: TSlashCommandAdditionalOption[] = [];
|
||||
if (!disabledExtensions?.includes("image")) {
|
||||
internalAdditionalOptions.push({
|
||||
commandKey: "image",
|
||||
key: "image",
|
||||
title: "Image",
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
section: "general",
|
||||
pushAfter: "code",
|
||||
});
|
||||
}
|
||||
|
||||
[
|
||||
...(additionalOptions ?? []),
|
||||
...internalAdditionalOptions,
|
||||
...(externalAdditionalOptions ?? []),
|
||||
...coreEditorAdditionalSlashCommandOptions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
|
||||
@@ -69,6 +69,7 @@ const renderItems = () => {
|
||||
|
||||
const tippyContainer =
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: tippyContainer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Extension, InputRule } from "@tiptap/core";
|
||||
import {
|
||||
TypographyOptions,
|
||||
emDash,
|
||||
@@ -26,7 +26,7 @@ export const CustomTypographyExtension = Extension.create<TypographyOptions>({
|
||||
name: "typography",
|
||||
|
||||
addInputRules() {
|
||||
const rules = [];
|
||||
const rules: InputRule[] = [];
|
||||
|
||||
if (this.options.emDash !== false) {
|
||||
rules.push(emDash(this.options.emDash));
|
||||
|
||||
@@ -111,7 +111,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
// value is null when intentionally passed where syncing is not yet
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value == null) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
|
||||
if (editor && !editor.isDestroyed && !editor.storage.imageComponent?.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
if (editor.state.selection) {
|
||||
@@ -125,16 +125,23 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
}, [editor, value, id]);
|
||||
|
||||
// update assets upload status
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const assetsUploadStatus = fileHandler.assetsUploadStatus;
|
||||
editor.commands.updateAssetsUploadStatus?.(assetsUploadStatus);
|
||||
}, [editor, fileHandler.assetsUploadStatus]);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
blur: () => editor.commands.blur(),
|
||||
blur: () => editor?.commands.blur(),
|
||||
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||
const resolvedPos = pos ?? editor.state.selection.from;
|
||||
const resolvedPos = pos ?? editor?.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
getCurrentCursorPosition: () => editor.state.selection.from,
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
@@ -142,7 +149,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (editor.state.selection) {
|
||||
if (editor?.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
@@ -214,7 +221,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
|
||||
isEditorReadyToDiscard: () => editor?.storage.imageComponent?.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { insertImagesSafely } from "@/extensions/drop";
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
type TUploaderArgs = {
|
||||
blockId: string;
|
||||
editor: Editor;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
maxFileSize: number;
|
||||
@@ -13,14 +14,16 @@ type TUploaderArgs = {
|
||||
};
|
||||
|
||||
export const useUploader = (args: TUploaderArgs) => {
|
||||
const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
|
||||
const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
|
||||
// states
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const setImageUploadInProgress = (isUploading: boolean) => {
|
||||
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||
if (editor.storage.imageComponent) {
|
||||
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||
}
|
||||
};
|
||||
setImageUploadInProgress(true);
|
||||
setUploading(true);
|
||||
@@ -49,7 +52,7 @@ export const useUploader = (args: TUploaderArgs) => {
|
||||
reader.readAsDataURL(fileWithTrimmedName);
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
||||
// here for now
|
||||
const url: string = await editor?.commands.uploadImage(fileWithTrimmedName);
|
||||
const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("Something went wrong while uploading the image");
|
||||
|
||||
@@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -20,7 +20,7 @@ interface CustomReadOnlyEditorProps {
|
||||
extensions?: Extensions;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
initialValue?: string;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
provider?: HocuspocusProvider;
|
||||
@@ -99,14 +99,11 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
getDocumentInfo: () => {
|
||||
if (!editor) return;
|
||||
return {
|
||||
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor.state),
|
||||
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||
};
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor.state),
|
||||
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
if (!editor) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TRealtimeConfig,
|
||||
TUserDetails,
|
||||
@@ -43,7 +44,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
};
|
||||
|
||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
|
||||
|
||||
export type TFileHandler = {
|
||||
export type TReadOnlyFileHandler = {
|
||||
getAssetSrc: (path: string) => Promise<string>;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
|
||||
export type TFileHandler = TReadOnlyFileHandler & {
|
||||
assetsUploadStatus: Record<string, number>; // blockId => progress percentage
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
validation: {
|
||||
/**
|
||||
* @description max file size in bytes
|
||||
@@ -19,7 +23,10 @@ export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
|
||||
|
||||
export type TEditorFontSize = "small-font" | "large-font";
|
||||
|
||||
export type TEditorLineSpacing = "regular" | "small";
|
||||
|
||||
export type TDisplayConfig = {
|
||||
fontStyle?: TEditorFontStyle;
|
||||
fontSize?: TEditorFontSize;
|
||||
lineSpacing?: TEditorLineSpacing;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
@@ -31,7 +32,7 @@ export type TEditorCommands =
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "strike"
|
||||
| "bulleted-list"
|
||||
| "numbered-list"
|
||||
| "to-do-list"
|
||||
@@ -44,12 +45,16 @@ export type TEditorCommands =
|
||||
| "text-color"
|
||||
| "background-color"
|
||||
| "text-align"
|
||||
| "callout";
|
||||
| "callout"
|
||||
| "attachment";
|
||||
|
||||
export type TCommandExtraProps = {
|
||||
image: {
|
||||
savedSelection: Selection | null;
|
||||
};
|
||||
attachment: {
|
||||
savedSelection: Selection | null;
|
||||
};
|
||||
"text-color": {
|
||||
color: string | undefined;
|
||||
};
|
||||
@@ -155,7 +160,7 @@ export interface IReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key";
|
||||
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image";
|
||||
|
||||
@@ -2,4 +2,4 @@ export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
|
||||
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type UploadImage = (blockId: string, file: File) => Promise<string>;
|
||||
|
||||
@@ -252,7 +252,7 @@ ul[data-type="taskList"] li[data-checked="true"] {
|
||||
|
||||
div[data-type="horizontalRule"] {
|
||||
line-height: 0;
|
||||
padding: 0.25rem 0;
|
||||
padding: var(--divider-padding-top) 0 var(--divider-padding-bottom) 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -335,10 +335,10 @@ p.editor-paragraph-block {
|
||||
/* tailwind typography */
|
||||
.prose :where(h1.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
&:not(:first-child) {
|
||||
padding-top: 28px;
|
||||
padding-top: var(--heading-1-padding-top);
|
||||
}
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--heading-1-padding-bottom);
|
||||
font-size: var(--font-size-h1);
|
||||
line-height: var(--line-height-h1);
|
||||
font-weight: 600;
|
||||
@@ -346,10 +346,10 @@ p.editor-paragraph-block {
|
||||
|
||||
.prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
&:not(:first-child) {
|
||||
padding-top: 28px;
|
||||
padding-top: var(--heading-2-padding-top);
|
||||
}
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--heading-2-padding-bottom);
|
||||
font-size: var(--font-size-h2);
|
||||
line-height: var(--line-height-h2);
|
||||
font-weight: 600;
|
||||
@@ -357,10 +357,10 @@ p.editor-paragraph-block {
|
||||
|
||||
.prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
&:not(:first-child) {
|
||||
padding-top: 28px;
|
||||
padding-top: var(--heading-3-padding-top);
|
||||
}
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--heading-3-padding-bottom);
|
||||
font-size: var(--font-size-h3);
|
||||
line-height: var(--line-height-h3);
|
||||
font-weight: 600;
|
||||
@@ -368,10 +368,10 @@ p.editor-paragraph-block {
|
||||
|
||||
.prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
&:not(:first-child) {
|
||||
padding-top: 28px;
|
||||
padding-top: var(--heading-4-padding-top);
|
||||
}
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--heading-4-padding-bottom);
|
||||
font-size: var(--font-size-h4);
|
||||
line-height: var(--line-height-h4);
|
||||
font-weight: 600;
|
||||
@@ -379,10 +379,10 @@ p.editor-paragraph-block {
|
||||
|
||||
.prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
&:not(:first-child) {
|
||||
padding-top: 20px;
|
||||
padding-top: var(--heading-5-padding-top);
|
||||
}
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--heading-5-padding-bottom);
|
||||
font-size: var(--font-size-h5);
|
||||
line-height: var(--line-height-h5);
|
||||
font-weight: 600;
|
||||
@@ -390,10 +390,10 @@ p.editor-paragraph-block {
|
||||
|
||||
.prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
&:not(:first-child) {
|
||||
padding-top: 20px;
|
||||
padding-top: var(--heading-6-padding-top);
|
||||
}
|
||||
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--heading-6-padding-bottom);
|
||||
font-size: var(--font-size-h6);
|
||||
line-height: var(--line-height-h6);
|
||||
font-weight: 600;
|
||||
@@ -405,16 +405,16 @@ p.editor-paragraph-block {
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-top: 4px;
|
||||
padding-top: var(--paragraph-padding-top);
|
||||
}
|
||||
|
||||
&:not(td p.editor-paragraph-block, th p.editor-paragraph-block) {
|
||||
&:last-child {
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: var(--paragraph-padding-bottom);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: var(--paragraph-padding-between);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ p.editor-paragraph-block {
|
||||
}
|
||||
|
||||
p.editor-paragraph-block + p.editor-paragraph-block {
|
||||
padding-top: 8px !important;
|
||||
padding-top: var(--paragraph-padding-between) !important;
|
||||
}
|
||||
|
||||
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block,
|
||||
|
||||
@@ -57,4 +57,48 @@
|
||||
--font-style: monospace;
|
||||
}
|
||||
/* end font styles */
|
||||
|
||||
/* spacing */
|
||||
&.line-spacing-regular {
|
||||
--heading-1-padding-top: 28px;
|
||||
--heading-1-padding-bottom: 4px;
|
||||
--heading-2-padding-top: 28px;
|
||||
--heading-2-padding-bottom: 4px;
|
||||
--heading-3-padding-top: 28px;
|
||||
--heading-3-padding-bottom: 4px;
|
||||
--heading-4-padding-top: 28px;
|
||||
--heading-4-padding-bottom: 4px;
|
||||
--heading-5-padding-top: 20px;
|
||||
--heading-5-padding-bottom: 4px;
|
||||
--heading-6-padding-top: 20px;
|
||||
--heading-6-padding-bottom: 4px;
|
||||
--paragraph-padding-top: 4px;
|
||||
--paragraph-padding-bottom: 4px;
|
||||
--paragraph-padding-between: 8px;
|
||||
--list-spacing-y: 8px;
|
||||
--divider-padding-top: 4px;
|
||||
--divider-padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&.line-spacing-small {
|
||||
--heading-1-padding-top: 16px;
|
||||
--heading-1-padding-bottom: 4px;
|
||||
--heading-2-padding-top: 16px;
|
||||
--heading-2-padding-bottom: 4px;
|
||||
--heading-3-padding-top: 16px;
|
||||
--heading-3-padding-bottom: 4px;
|
||||
--heading-4-padding-top: 16px;
|
||||
--heading-4-padding-bottom: 4px;
|
||||
--heading-5-padding-top: 12px;
|
||||
--heading-5-padding-bottom: 4px;
|
||||
--heading-6-padding-top: 12px;
|
||||
--heading-6-padding-bottom: 4px;
|
||||
--paragraph-padding-top: 2px;
|
||||
--paragraph-padding-bottom: 2px;
|
||||
--paragraph-padding-between: 4px;
|
||||
--list-spacing-y: 0px;
|
||||
--divider-padding-top: 0px;
|
||||
--divider-padding-bottom: 4px;
|
||||
}
|
||||
/* end spacing */
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@/styles/*": ["src/styles/*"],
|
||||
"@/plane-editor/*": ["src/ce/*"]
|
||||
},
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*", "index.d.ts"],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
"next.js",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@plane/hooks",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "React hooks that are shared across multiple apps internally",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@plane/i18n",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "I18n shared across multiple apps internally",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -8,6 +8,8 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "中文", value: "zh-CN" },
|
||||
{ label: "Русский", value: "ru" },
|
||||
{ label: "Italian", value: "it" },
|
||||
];
|
||||
|
||||
export const STORAGE_KEY = "userLanguage";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"workspace": "Workspace",
|
||||
"views": "Views",
|
||||
"analytics": "Analytics",
|
||||
"work_items": "Work items",
|
||||
"work_items": "Work Items",
|
||||
"cycles": "Cycles",
|
||||
"modules": "Modules",
|
||||
"intake": "Intake",
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"Cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"archive": "Archive",
|
||||
"restor": "Restore",
|
||||
"restore": "Restore",
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting",
|
||||
@@ -461,6 +461,8 @@
|
||||
"states": "States",
|
||||
"state": "State",
|
||||
"state_groups": "State groups",
|
||||
"state_group": "State group",
|
||||
"priorities": "Priorities",
|
||||
"priority": "Priority",
|
||||
"team_project": "Team project",
|
||||
"project": "Project",
|
||||
@@ -469,12 +471,16 @@
|
||||
"module": "Module",
|
||||
"modules": "Modules",
|
||||
"labels": "Labels",
|
||||
"label": "Label",
|
||||
"assignees": "Assignees",
|
||||
"assignee": "Assignee",
|
||||
"created_by": "Created by",
|
||||
"none": "None",
|
||||
"link": "Link",
|
||||
"estimates": "Estimates",
|
||||
"estimate": "Estimate",
|
||||
"created_at": "Created at",
|
||||
"completed_at": "Completed at",
|
||||
"layout": "Layout",
|
||||
"filters": "Filters",
|
||||
"display": "Display",
|
||||
@@ -492,6 +498,7 @@
|
||||
"epic": "Epic",
|
||||
"epics": "Epics",
|
||||
"work_item": "Work item",
|
||||
"work_items": "Work items",
|
||||
"sub_work_item": "Sub-work item",
|
||||
"add": "Add",
|
||||
"warning": "Warning",
|
||||
@@ -516,7 +523,6 @@
|
||||
"add_more": "Add more",
|
||||
"defaults": "Defaults",
|
||||
"add_label": "Add label",
|
||||
"estimates": "Estimates",
|
||||
"customize_time_range": "Customize time range",
|
||||
"loading": "Loading",
|
||||
"attachments": "Attachments",
|
||||
@@ -612,6 +618,7 @@
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"copy_link": "Copy link",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"remove_relation": "Remove relation",
|
||||
"subscribe": "Subscribe",
|
||||
@@ -654,8 +661,6 @@
|
||||
"select": "Select",
|
||||
"upgrade": "Upgrade",
|
||||
"add_seats": "Add Seats",
|
||||
"label": "Label",
|
||||
"priorities": "Priorities",
|
||||
"projects": "Projects",
|
||||
"workspace": "Workspace",
|
||||
"workspaces": "Workspaces",
|
||||
@@ -674,6 +679,16 @@
|
||||
"disconnecting": "Disconnecting",
|
||||
"installing": "Installing",
|
||||
"install": "Install",
|
||||
"reset": "Reset",
|
||||
"live": "Live",
|
||||
"change_history": "Change History",
|
||||
"coming_soon": "Coming soon",
|
||||
"members": "Members",
|
||||
"you": "You",
|
||||
"upgrade_cta": {
|
||||
"higher_subscription": "Upgrade to higher subscription",
|
||||
"talk_to_sales": "Talk to Sales"
|
||||
},
|
||||
"category": "Category",
|
||||
"categories": "Categories",
|
||||
"saving": "Saving",
|
||||
@@ -681,7 +696,8 @@
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting",
|
||||
"pending": "Pending",
|
||||
"invite": "Invite"
|
||||
"invite": "Invite",
|
||||
"view": "View"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -799,7 +815,8 @@
|
||||
"sub_issue_count": "Sub-work item count",
|
||||
"attachment_count": "Attachment count",
|
||||
"created_on": "Created on",
|
||||
"sub_issue": "Sub-work item"
|
||||
"sub_issue": "Sub-work item",
|
||||
"work_item_count": "Work item count"
|
||||
},
|
||||
"extra": {
|
||||
"show_sub_issues": "Show sub-work items",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"activity": "Actividad",
|
||||
"appearance": "Apariencia",
|
||||
"notifications": "Notificaciones",
|
||||
"connections": "Conexiones",
|
||||
"workspaces": "Espacios de trabajo",
|
||||
"create_workspace": "Crear espacio de trabajo",
|
||||
"invitations": "Invitaciones",
|
||||
@@ -291,6 +292,7 @@
|
||||
"workspace_logo": "Logo del espacio de trabajo",
|
||||
"new_issue": "Nuevo elemento de trabajo",
|
||||
"your_work": "Tu trabajo",
|
||||
"workspace_dashboards": "Paneles de control",
|
||||
"drafts": "Borradores",
|
||||
"projects": "Proyectos",
|
||||
"views": "Vistas",
|
||||
@@ -457,7 +459,7 @@
|
||||
"Cancel": "Cancelar",
|
||||
"edit": "Editar",
|
||||
"archive": "Archivar",
|
||||
"restor": "Restaurar",
|
||||
"restore": "Restaurar",
|
||||
"open_in_new_tab": "Abrir en nueva pestaña",
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando",
|
||||
@@ -631,6 +633,8 @@
|
||||
"states": "Estados",
|
||||
"state": "Estado",
|
||||
"state_groups": "Grupos de estados",
|
||||
"state_group": "Grupos de estado",
|
||||
"priorities": "Prioridades",
|
||||
"priority": "Prioridad",
|
||||
"team_project": "Proyecto de equipo",
|
||||
"project": "Proyecto",
|
||||
@@ -639,12 +643,16 @@
|
||||
"module": "Módulo",
|
||||
"modules": "Módulos",
|
||||
"labels": "Etiquetas",
|
||||
"label": "Etiqueta",
|
||||
"assignees": "Asignados",
|
||||
"assignee": "Asignado",
|
||||
"created_by": "Creado por",
|
||||
"none": "Ninguno",
|
||||
"link": "Enlace",
|
||||
"estimates": "Estimaciones",
|
||||
"estimate": "Estimación",
|
||||
"created_at": "Creado en",
|
||||
"completed_at": "Completado en",
|
||||
"layout": "Diseño",
|
||||
"filters": "Filtros",
|
||||
"display": "Mostrar",
|
||||
@@ -662,6 +670,7 @@
|
||||
"epic": "Epic",
|
||||
"epics": "Epics",
|
||||
"work_item": "Elemento de trabajo",
|
||||
"work_items": "Elementos de trabajo",
|
||||
"sub_work_item": "Sub-elemento de trabajo",
|
||||
"add": "Agregar",
|
||||
"warning": "Advertencia",
|
||||
@@ -686,7 +695,6 @@
|
||||
"add_more": "Agregar más",
|
||||
"defaults": "Valores predeterminados",
|
||||
"add_label": "Agregar etiqueta",
|
||||
"estimates": "Estimaciones",
|
||||
"customize_time_range": "Personalizar rango de tiempo",
|
||||
"loading": "Cargando",
|
||||
"attachments": "Archivos adjuntos",
|
||||
@@ -824,8 +832,6 @@
|
||||
"select": "Seleccionar",
|
||||
"upgrade": "Mejorar",
|
||||
"add_seats": "Agregar asientos",
|
||||
"label": "Etiqueta",
|
||||
"priorities": "Prioridades",
|
||||
"projects": "Proyectos",
|
||||
"workspace": "Espacio de trabajo",
|
||||
"workspaces": "Espacios de trabajo",
|
||||
@@ -844,6 +850,16 @@
|
||||
"disconnecting": "Desconectando",
|
||||
"installing": "Instalando",
|
||||
"install": "Instalar",
|
||||
"reset": "Reiniciar",
|
||||
"live": "En vivo",
|
||||
"change_history": "Historial de cambios",
|
||||
"coming_soon": "Próximamente",
|
||||
"members": "Miembros",
|
||||
"you": "Tú",
|
||||
"upgrade_cta": {
|
||||
"higher_subscription": "Mejorar a una suscripción más alta",
|
||||
"talk_to_sales": "Hablar con ventas"
|
||||
},
|
||||
"category": "Categoría",
|
||||
"categories": "Categorías",
|
||||
"saving": "Guardando",
|
||||
@@ -851,7 +867,8 @@
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando",
|
||||
"pending": "Pendiente",
|
||||
"invite": "Invitar"
|
||||
"invite": "Invitar",
|
||||
"view": "Ver"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -969,7 +986,8 @@
|
||||
"sub_issue_count": "Cantidad de sub-elementos",
|
||||
"attachment_count": "Cantidad de archivos adjuntos",
|
||||
"created_on": "Creado el",
|
||||
"sub_issue": "Sub-elemento de trabajo"
|
||||
"sub_issue": "Sub-elemento de trabajo",
|
||||
"work_item_count": "Recuento de elementos de trabajo"
|
||||
},
|
||||
"extra": {
|
||||
"show_sub_issues": "Mostrar sub-elementos",
|
||||
|
||||
@@ -457,7 +457,7 @@
|
||||
"Cancel": "Annuler",
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restor": "Restaurer",
|
||||
"restore": "Restaurer",
|
||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||
"delete": "Supprimer",
|
||||
"deleting": "Suppression",
|
||||
@@ -631,6 +631,8 @@
|
||||
"states": "États",
|
||||
"state": "État",
|
||||
"state_groups": "Groupes d'états",
|
||||
"state_group": "Groupe d'état",
|
||||
"priorities": "Priorités",
|
||||
"priority": "Priorité",
|
||||
"team_project": "Projet d'équipe",
|
||||
"project": "Projet",
|
||||
@@ -639,12 +641,16 @@
|
||||
"module": "Module",
|
||||
"modules": "Modules",
|
||||
"labels": "Étiquettes",
|
||||
"label": "Étiquette",
|
||||
"assignees": "Assignés",
|
||||
"assignee": "Assigné",
|
||||
"created_by": "Créé par",
|
||||
"none": "Aucun",
|
||||
"link": "Lien",
|
||||
"estimates": "Estimations",
|
||||
"estimate": "Estimation",
|
||||
"created_at": "Créé le",
|
||||
"completed_at": "Terminé le",
|
||||
"layout": "Disposition",
|
||||
"filters": "Filtres",
|
||||
"display": "Affichage",
|
||||
@@ -662,6 +668,7 @@
|
||||
"epic": "Epic",
|
||||
"epics": "Epics",
|
||||
"work_item": "Élément de travail",
|
||||
"work_items": "Éléments de travail",
|
||||
"sub_work_item": "Sous-élément de travail",
|
||||
"add": "Ajouter",
|
||||
"warning": "Avertissement",
|
||||
@@ -686,7 +693,6 @@
|
||||
"add_more": "Ajouter plus",
|
||||
"defaults": "Par défaut",
|
||||
"add_label": "Ajouter une étiquette",
|
||||
"estimates": "Estimations",
|
||||
"customize_time_range": "Personnaliser la plage de temps",
|
||||
"loading": "Chargement",
|
||||
"attachments": "Pièces jointes",
|
||||
@@ -824,8 +830,6 @@
|
||||
"select": "Sélectionner",
|
||||
"upgrade": "Mettre à niveau",
|
||||
"add_seats": "Ajouter des sièges",
|
||||
"label": "Étiquette",
|
||||
"priorities": "Priorités",
|
||||
"projects": "Projets",
|
||||
"workspace": "Espace de travail",
|
||||
"workspaces": "Espaces de travail",
|
||||
@@ -844,6 +848,16 @@
|
||||
"disconnecting": "Déconnexion",
|
||||
"installing": "Installation",
|
||||
"install": "Installer",
|
||||
"reset": "Réinitialiser",
|
||||
"live": "En direct",
|
||||
"change_history": "Historique des modifications",
|
||||
"coming_soon": "À venir",
|
||||
"members": "Membres",
|
||||
"you": "Vous",
|
||||
"upgrade_cta": {
|
||||
"higher_subscription": "Passer à une abonnement plus élevé",
|
||||
"talk_to_sales": "Parler aux ventes"
|
||||
},
|
||||
"category": "Catégorie",
|
||||
"categories": "Catégories",
|
||||
"saving": "Enregistrement",
|
||||
@@ -851,7 +865,8 @@
|
||||
"delete": "Supprimer",
|
||||
"deleting": "Suppression",
|
||||
"pending": "En attente",
|
||||
"invite": "Inviter"
|
||||
"invite": "Inviter",
|
||||
"view": "Afficher"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -969,7 +984,8 @@
|
||||
"sub_issue_count": "Nombre de sous-éléments",
|
||||
"attachment_count": "Nombre de pièces jointes",
|
||||
"created_on": "Créé le",
|
||||
"sub_issue": "Sous-élément de travail"
|
||||
"sub_issue": "Sous-élément de travail",
|
||||
"work_item_count": "Nombre d'éléments de travail"
|
||||
},
|
||||
"extra": {
|
||||
"show_sub_issues": "Afficher les sous-éléments",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -457,7 +457,7 @@
|
||||
"Cancel": "キャンセル",
|
||||
"edit": "編集",
|
||||
"archive": "アーカイブ",
|
||||
"restor": "復元",
|
||||
"restore": "復元",
|
||||
"open_in_new_tab": "新しいタブで開く",
|
||||
"delete": "削除",
|
||||
"deleting": "削除中",
|
||||
@@ -631,6 +631,8 @@
|
||||
"states": "ステータス",
|
||||
"state": "ステータス",
|
||||
"state_groups": "ステータスグループ",
|
||||
"state_group": "ステート グループ",
|
||||
"priorities": "優先度",
|
||||
"priority": "優先度",
|
||||
"team_project": "チームプロジェクト",
|
||||
"project": "プロジェクト",
|
||||
@@ -639,12 +641,16 @@
|
||||
"module": "モジュール",
|
||||
"modules": "モジュール",
|
||||
"labels": "ラベル",
|
||||
"label": "ラベル",
|
||||
"assignees": "担当者",
|
||||
"assignee": "担当者",
|
||||
"created_by": "作成者",
|
||||
"none": "なし",
|
||||
"link": "リンク",
|
||||
"estimates": "見積もり",
|
||||
"estimate": "見積もり",
|
||||
"created_at": "クリエイテッド アット",
|
||||
"completed_at": "コンプリーテッド アット",
|
||||
"layout": "レイアウト",
|
||||
"filters": "フィルター",
|
||||
"display": "表示",
|
||||
@@ -662,6 +668,7 @@
|
||||
"epic": "エピック",
|
||||
"epics": "エピック",
|
||||
"work_item": "作業項目",
|
||||
"work_items": "作業項目",
|
||||
"sub_work_item": "サブ作業項目",
|
||||
"add": "追加",
|
||||
"warning": "警告",
|
||||
@@ -686,7 +693,6 @@
|
||||
"add_more": "さらに追加",
|
||||
"defaults": "デフォルト",
|
||||
"add_label": "ラベルを追加",
|
||||
"estimates": "見積もり",
|
||||
"customize_time_range": "期間をカスタマイズ",
|
||||
"loading": "読み込み中",
|
||||
"attachments": "添付ファイル",
|
||||
@@ -824,8 +830,6 @@
|
||||
"select": "選択",
|
||||
"upgrade": "アップグレード",
|
||||
"add_seats": "シートを追加",
|
||||
"label": "ラベル",
|
||||
"priorities": "優先度",
|
||||
"projects": "プロジェクト",
|
||||
"workspace": "ワークスペース",
|
||||
"workspaces": "ワークスペース",
|
||||
@@ -844,6 +848,16 @@
|
||||
"disconnecting": "切断中",
|
||||
"installing": "インストール中",
|
||||
"install": "インストール",
|
||||
"reset": "リセット",
|
||||
"live": "ライブ",
|
||||
"change_history": "変更履歴",
|
||||
"coming_soon": "近日公開",
|
||||
"members": "メンバー",
|
||||
"you": "あなた",
|
||||
"upgrade_cta": {
|
||||
"higher_subscription": "高いサブスクリプションにアップグレード",
|
||||
"talk_to_sales": "トーク トゥ セールス"
|
||||
},
|
||||
"category": "カテゴリー",
|
||||
"categories": "カテゴリーズ",
|
||||
"saving": "セービング",
|
||||
@@ -851,7 +865,8 @@
|
||||
"delete": "デリート",
|
||||
"deleting": "デリーティング",
|
||||
"pending": "保留中",
|
||||
"invite": "招待"
|
||||
"invite": "招待",
|
||||
"view": "ビュー"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -969,7 +984,8 @@
|
||||
"sub_issue_count": "サブ作業項目数",
|
||||
"attachment_count": "添付ファイル数",
|
||||
"created_on": "作成日",
|
||||
"sub_issue": "サブ作業項目"
|
||||
"sub_issue": "サブ作業項目",
|
||||
"work_item_count": "作業項目数"
|
||||
},
|
||||
"extra": {
|
||||
"show_sub_issues": "サブ作業項目を表示",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -457,7 +457,7 @@
|
||||
"Cancel": "取消",
|
||||
"edit": "编辑",
|
||||
"archive": "归档",
|
||||
"restor": "恢复",
|
||||
"restore": "恢复",
|
||||
"open_in_new_tab": "在新标签页中打开",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中",
|
||||
@@ -631,6 +631,8 @@
|
||||
"states": "状态",
|
||||
"state": "状态",
|
||||
"state_groups": "状态组",
|
||||
"state_group": "状态组",
|
||||
"priorities": "优先级",
|
||||
"priority": "优先级",
|
||||
"team_project": "团队项目",
|
||||
"project": "项目",
|
||||
@@ -639,12 +641,16 @@
|
||||
"module": "模块",
|
||||
"modules": "模块",
|
||||
"labels": "标签",
|
||||
"label": "标签",
|
||||
"assignees": "负责人",
|
||||
"assignee": "负责人",
|
||||
"created_by": "创建者",
|
||||
"none": "无",
|
||||
"link": "链接",
|
||||
"estimates": "估算",
|
||||
"estimate": "估算",
|
||||
"created_at": "创建于",
|
||||
"completed_at": "完成于",
|
||||
"layout": "布局",
|
||||
"filters": "筛选",
|
||||
"display": "显示",
|
||||
@@ -662,6 +668,7 @@
|
||||
"epic": "史诗",
|
||||
"epics": "史诗",
|
||||
"work_item": "工作项",
|
||||
"work_items": "工作项",
|
||||
"sub_work_item": "子工作项",
|
||||
"add": "添加",
|
||||
"warning": "警告",
|
||||
@@ -686,7 +693,6 @@
|
||||
"add_more": "添加更多",
|
||||
"defaults": "默认值",
|
||||
"add_label": "添加标签",
|
||||
"estimates": "估算",
|
||||
"customize_time_range": "自定义时间范围",
|
||||
"loading": "加载中",
|
||||
"attachments": "附件",
|
||||
@@ -824,8 +830,6 @@
|
||||
"select": "选择",
|
||||
"upgrade": "升级",
|
||||
"add_seats": "添加席位",
|
||||
"label": "标签",
|
||||
"priorities": "优先级",
|
||||
"projects": "项目",
|
||||
"workspace": "工作区",
|
||||
"workspaces": "工作区",
|
||||
@@ -844,6 +848,16 @@
|
||||
"disconnecting": "正在断开连接",
|
||||
"installing": "正在安装",
|
||||
"install": "安装",
|
||||
"reset": "重置",
|
||||
"live": "实时",
|
||||
"change_history": "变更历史",
|
||||
"coming_soon": "即将推出",
|
||||
"members": "成员",
|
||||
"you": "你",
|
||||
"upgrade_cta": {
|
||||
"higher_subscription": "升级到更高订阅",
|
||||
"talk_to_sales": "联系销售"
|
||||
},
|
||||
"category": "类别",
|
||||
"categories": "类别",
|
||||
"saving": "保存中",
|
||||
@@ -851,7 +865,8 @@
|
||||
"delete": "删除",
|
||||
"deleting": "删除中",
|
||||
"pending": "待处理",
|
||||
"invite": "邀请"
|
||||
"invite": "邀请",
|
||||
"view": "查看"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -969,7 +984,8 @@
|
||||
"sub_issue_count": "子工作项数量",
|
||||
"attachment_count": "附件数量",
|
||||
"created_on": "创建于",
|
||||
"sub_issue": "子工作项"
|
||||
"sub_issue": "子工作项",
|
||||
"work_item_count": "工作项数量"
|
||||
},
|
||||
"extra": {
|
||||
"show_sub_issues": "显示子工作项",
|
||||
|
||||
@@ -147,6 +147,10 @@ export class TranslationStore {
|
||||
return import("../locales/ja/translations.json");
|
||||
case "zh-CN":
|
||||
return import("../locales/zh-CN/translations.json");
|
||||
case "ru":
|
||||
return import("../locales/ru/translations.json");
|
||||
case "it":
|
||||
return import("../locales/it/translations.json");
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
|
||||
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN" | "ru" | "it";
|
||||
|
||||
export interface ILanguageOption {
|
||||
label: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@plane/logger",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Logger shared across multiple apps internally",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user