diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 664368033..ad214c52a 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer): unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) assignees = serializers.SerializerMethodField(read_only=True) - labels = serializers.SerializerMethodField(read_only=True) total_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True) @@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer): members = [ { "avatar": assignee.avatar, - "first_name": assignee.first_name, "display_name": assignee.display_name, "id": assignee.id, } - for issue_cycle in obj.issue_cycle.all() + for issue_cycle in obj.issue_cycle.prefetch_related("issue__assignees").all() for assignee in issue_cycle.issue.assignees.all() ] # Use a set comprehension to return only the unique objects @@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer): unique_list = [dict(item) for item in unique_objects] return unique_list - - def get_labels(self, obj): - labels = [ - { - "name": label.name, - "color": label.color, - "id": label.id, - } - for issue_cycle in obj.issue_cycle.all() - for label in issue_cycle.issue.labels.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in labels} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list class Meta: model = Cycle diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 093c8ff78..c72b8d423 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1094,7 +1094,7 @@ class ProjectMemberEndpoint(BaseAPIView): project_id=project_id, workspace__slug=slug, member__is_bot=False, - ).select_related("project", "member") + ).select_related("project", "member", "workspace") serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index f7b06c625..d779d47e5 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -58,20 +58,23 @@ def archive_old_issues(): # Check if Issues if issues: + # Set the archive time to current time + archive_at = timezone.now() + issues_to_update = [] for issue in issues: - issue.archived_at = timezone.now() + issue.archived_at = archive_at issues_to_update.append(issue) # Bulk Update the issues and log the activity if issues_to_update: - updated_issues = Issue.objects.bulk_update( + Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"archived_at": str(issue.archived_at)}), + requested_data=json.dumps({"archived_at": str(archive_at)}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, @@ -79,7 +82,7 @@ def archive_old_issues(): subscriber=False, epoch = int(timezone.now().timestamp()) ) - for issue in updated_issues + for issue in issues_to_update ] return except Exception as e: @@ -139,7 +142,7 @@ def close_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) [ issue_activity.delay( type="issue.activity.updated", @@ -151,7 +154,7 @@ def close_old_issues(): subscriber=False, epoch = int(timezone.now().timestamp()) ) - for issue in updated_issues + for issue in issues_to_update ] return except Exception as e: diff --git a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py index f30062371..19a1449af 100644 --- a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py +++ b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py @@ -26,19 +26,19 @@ def workspace_member_props(old_props): "calendar_date_range": old_props.get("calendarDateRange", ""), }, "display_properties": { - "assignee": old_props.get("properties", {}).get("assignee",None), - "attachment_count": old_props.get("properties", {}).get("attachment_count", None), - "created_on": old_props.get("properties", {}).get("created_on", None), - "due_date": old_props.get("properties", {}).get("due_date", None), - "estimate": old_props.get("properties", {}).get("estimate", None), - "key": old_props.get("properties", {}).get("key", None), - "labels": old_props.get("properties", {}).get("labels", None), - "link": old_props.get("properties", {}).get("link", None), - "priority": old_props.get("properties", {}).get("priority", None), - "start_date": old_props.get("properties", {}).get("start_date", None), - "state": old_props.get("properties", {}).get("state", None), - "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None), - "updated_on": old_props.get("properties", {}).get("updated_on", None), + "assignee": old_props.get("properties", {}).get("assignee", True), + "attachment_count": old_props.get("properties", {}).get("attachment_count", True), + "created_on": old_props.get("properties", {}).get("created_on", True), + "due_date": old_props.get("properties", {}).get("due_date", True), + "estimate": old_props.get("properties", {}).get("estimate", True), + "key": old_props.get("properties", {}).get("key", True), + "labels": old_props.get("properties", {}).get("labels", True), + "link": old_props.get("properties", {}).get("link", True), + "priority": old_props.get("properties", {}).get("priority", True), + "start_date": old_props.get("properties", {}).get("start_date", True), + "state": old_props.get("properties", {}).get("state", True), + "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True), + "updated_on": old_props.get("properties", {}).get("updated_on", True), }, } return new_props diff --git a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py index b84f5f9fc..cd9aa6902 100644 --- a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py +++ b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py @@ -1,50 +1,42 @@ # Generated by Django 4.2.3 on 2023-09-15 06:55 -from django.conf import settings from django.db import migrations, models +from django.conf import settings import django.db.models.deletion import uuid - -def update_issue_activity(apps, schema_editor): - IssueActivityModel = apps.get_model("db", "IssueActivity") - updated_issue_activity = [] - for obj in IssueActivityModel.objects.all(): - if obj.field == "blocks": - obj.field = "blocked_by" - updated_issue_activity.append(obj) - IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100) - - class Migration(migrations.Migration): - dependencies = [ - ('db', '0044_auto_20230913_0709'), + ("db", "0044_auto_20230913_0709"), ] operations = [ migrations.CreateModel( - name='GlobalView', + name="GlobalView", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='View Name')), - ('description', models.TextField(blank=True, verbose_name='View Description')), - ('query', models.JSONField(verbose_name='View Query')), - ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), - ('query_data', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At"),), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),), + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True,),), + ("name", models.CharField(max_length=255, verbose_name="View Name")), + ("description", models.TextField(blank=True, verbose_name="View Description"),), + ("query", models.JSONField(verbose_name="View Query")), + ("access", models.PositiveSmallIntegerField(choices=[(0, "Private"), (1, "Public")], default=1),), + ("query_data", models.JSONField(default=dict)), + ("created_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="%(class)s_created_by", to=settings.AUTH_USER_MODEL, verbose_name="Created By",),), + ("updated_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="%(class)s_updated_by", to=settings.AUTH_USER_MODEL, verbose_name="Last Modified By",),), + ("workspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="global_views", to="db.workspace",),), ], options={ - 'verbose_name': 'Global View', - 'verbose_name_plural': 'Global Views', - 'db_table': 'global_views', - 'ordering': ('-created_at',), + "verbose_name": "Global View", + "verbose_name_plural": "Global Views", + "db_table": "global_views", + "ordering": ("-created_at",), }, ), - migrations.RunPython(update_issue_activity), + migrations.AddField( + model_name="issueactivity", + name="epoch", + field=models.FloatField(null=True), + ), ] diff --git a/apiserver/plane/db/migrations/0046_auto_20230919_1421.py b/apiserver/plane/db/migrations/0046_auto_20230919_1421.py deleted file mode 100644 index 4005a94d4..000000000 --- a/apiserver/plane/db/migrations/0046_auto_20230919_1421.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 4.2.3 on 2023-09-19 14:21 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -def update_epoch(apps, schema_editor): - IssueActivity = apps.get_model('db', 'IssueActivity') - updated_issue_activity = [] - for obj in IssueActivity.objects.all(): - obj.epoch = int(obj.created_at.timestamp()) - updated_issue_activity.append(obj) - IssueActivity.objects.bulk_update(updated_issue_activity, ["epoch"], batch_size=100) - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0045_auto_20230915_0655'), - ] - - operations = [ - migrations.CreateModel( - name='GlobalView', - fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='View Name')), - ('description', models.TextField(blank=True, verbose_name='View Description')), - ('query', models.JSONField(verbose_name='View Query')), - ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), - ('query_data', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')), - ], - options={ - 'verbose_name': 'Global View', - 'verbose_name_plural': 'Global Views', - 'db_table': 'global_views', - 'ordering': ('-created_at',), - }, - ), - migrations.AddField( - model_name='issueactivity', - name='epoch', - field=models.FloatField(null=True), - ), - migrations.RunPython(update_epoch), - ] diff --git a/apiserver/plane/db/migrations/0046_auto_20230926_1015.py b/apiserver/plane/db/migrations/0046_auto_20230926_1015.py new file mode 100644 index 000000000..8bce37d95 --- /dev/null +++ b/apiserver/plane/db/migrations/0046_auto_20230926_1015.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2023-09-26 10:15 + +from django.db import migrations + + +def update_issue_activity(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.all(): + obj.epoch = int(obj.created_at.timestamp()) + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["epoch"], + batch_size=5000, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0045_auto_20230915_0655'), + ] + + operations = [ + migrations.RunPython(update_issue_activity), + ] diff --git a/apiserver/plane/db/migrations/0047_auto_20230921_0758.py b/apiserver/plane/db/migrations/0047_auto_20230921_0758.py deleted file mode 100644 index 4344963cd..000000000 --- a/apiserver/plane/db/migrations/0047_auto_20230921_0758.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 4.2.3 on 2023-09-21 07:58 - - -from django.db import migrations - - -def update_priority_history(apps, schema_editor): - IssueActivity = apps.get_model("db", "IssueActivity") - updated_issue_activity = [] - for obj in IssueActivity.objects.all(): - if obj.field == "priority": - obj.new_value = obj.new_value or "none" - obj.old_value = obj.old_value or "none" - updated_issue_activity.append(obj) - IssueActivity.objects.bulk_update( - updated_issue_activity, ["new_value", "old_value"], batch_size=100 - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("db", "0046_auto_20230919_1421"), - ] - - operations = [ - migrations.RunPython(update_priority_history), - ] diff --git a/apiserver/plane/db/migrations/0047_auto_20230926_1029.py b/apiserver/plane/db/migrations/0047_auto_20230926_1029.py new file mode 100644 index 000000000..da64e11c8 --- /dev/null +++ b/apiserver/plane/db/migrations/0047_auto_20230926_1029.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.5 on 2023-09-26 10:29 + +from django.db import migrations + + +def update_issue_activity_priority(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="priority"): + # Set the old and new value to none if it is empty for Priority + obj.new_value = obj.new_value or "none" + obj.old_value = obj.old_value or "none" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value"], + batch_size=1000, + ) + +def update_issue_activity_blocked(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="blocks"): + # Set the field to blocked_by + obj.field = "blocked_by" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["field"], + batch_size=1000, + ) + + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0046_auto_20230926_1015'), + ] + + operations = [ + migrations.RunPython(update_issue_activity_priority), + migrations.RunPython(update_issue_activity_blocked), + ] diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index af80b04fa..4775dcbfa 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -11,6 +11,11 @@ http { client_max_body_size ${FILE_SIZE_LIMIT}; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Permissions-Policy "interest-cohort=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + location / { proxy_pass http://web:3000/; } @@ -20,6 +25,7 @@ http { } location /spaces/ { + rewrite ^/spaces/?$ /spaces/login break; proxy_pass http://space:3000/spaces/; } @@ -27,4 +33,4 @@ http { proxy_pass http://plane-minio:9000/uploads/; } } -} \ No newline at end of file +} diff --git a/space/components/accounts/sign-in.tsx b/space/components/accounts/sign-in.tsx index d3c29103d..c6a151d44 100644 --- a/space/components/accounts/sign-in.tsx +++ b/space/components/accounts/sign-in.tsx @@ -33,7 +33,7 @@ export const SignInView = observer(() => { const onSignInSuccess = (response: any) => { const isOnboarded = response?.user?.onboarding_step?.profile_complete || false; - const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/"; + const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login"; userStore.setCurrentUser(response?.user); @@ -41,7 +41,7 @@ export const SignInView = observer(() => { router.push(`/onboarding?next_path=${nextPath}`); return; } - router.push((nextPath ?? "/").toString()); + router.push((nextPath ?? "/login").toString()); }; const handleGoogleSignIn = async ({ clientId, credential }: any) => { diff --git a/space/components/views/index.ts b/space/components/views/index.ts index 84d36cd29..f54d11bdd 100644 --- a/space/components/views/index.ts +++ b/space/components/views/index.ts @@ -1 +1 @@ -export * from "./home"; +export * from "./login"; diff --git a/space/components/views/home.tsx b/space/components/views/login.tsx similarity index 88% rename from space/components/views/home.tsx rename to space/components/views/login.tsx index 999fce073..d01a22681 100644 --- a/space/components/views/home.tsx +++ b/space/components/views/login.tsx @@ -4,7 +4,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { SignInView, UserLoggedIn } from "components/accounts"; -export const HomeView = observer(() => { +export const LoginView = observer(() => { const { user: userStore } = useMobxStore(); if (!userStore.currentUser) return ; diff --git a/space/pages/index.tsx b/space/pages/index.tsx deleted file mode 100644 index fe0b7d33a..000000000 --- a/space/pages/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; - -// components -import { HomeView } from "components/views"; - -const HomePage = () => ; - -export default HomePage; diff --git a/space/pages/login/index.tsx b/space/pages/login/index.tsx new file mode 100644 index 000000000..a80eff873 --- /dev/null +++ b/space/pages/login/index.tsx @@ -0,0 +1,8 @@ +import React from "react"; + +// components +import { LoginView } from "components/views"; + +const LoginPage = () => ; + +export default LoginPage; \ No newline at end of file diff --git a/web/components/core/filters/issues-view-filter.tsx b/web/components/core/filters/issues-view-filter.tsx index afb7eb2b0..f5b0f477a 100644 --- a/web/components/core/filters/issues-view-filter.tsx +++ b/web/components/core/filters/issues-view-filter.tsx @@ -93,7 +93,7 @@ export const IssuesFilterView: React.FC = () => { {replaceUnderscoreIfSnakeCase(option.type)} View + {replaceUnderscoreIfSnakeCase(option.type)} Layout } position="bottom" > diff --git a/web/components/core/views/board-view/single-board.tsx b/web/components/core/views/board-view/single-board.tsx index 33bff386f..8f851527d 100644 --- a/web/components/core/views/board-view/single-board.tsx +++ b/web/components/core/views/board-view/single-board.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/router"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { Draggable } from "react-beautiful-dnd"; // components -import { BoardHeader, SingleBoardIssue } from "components/core"; +import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core"; // ui import { CustomMenu } from "components/ui"; // icons @@ -34,31 +34,39 @@ type Props = { viewProps: IIssueViewProps; }; -export const SingleBoard: React.FC = ({ - addIssueToGroup, - currentState, - groupTitle, - disableUserActions, - disableAddIssueOption = false, - dragDisabled, - handleIssueAction, - handleDraftIssueAction, - handleTrashBox, - openIssuesListModal, - handleMyIssueOpen, - removeIssue, - user, - userAuth, - viewProps, -}) => { +export const SingleBoard: React.FC = (props) => { + const { + addIssueToGroup, + currentState, + groupTitle, + disableUserActions, + disableAddIssueOption = false, + dragDisabled, + handleIssueAction, + handleDraftIssueAction, + handleTrashBox, + openIssuesListModal, + handleMyIssueOpen, + removeIssue, + user, + userAuth, + viewProps, + } = props; + // collapse/expand const [isCollapsed, setIsCollapsed] = useState(true); + const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + const { displayFilters, groupedIssues } = viewProps; const router = useRouter(); const { cycleId, moduleId } = router.query; + const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; + const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; + const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues"; + const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; // Check if it has at least 4 tickets since it is enough to accommodate the Calendar height @@ -67,6 +75,24 @@ export const SingleBoard: React.FC = ({ const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; + const onCreateClick = () => { + setIsInlineCreateIssueFormOpen(true); + + const boardListElement = document.getElementById(`board-list-${groupTitle}`); + + // timeout is needed because the animation + // takes time to complete & we can scroll only after that + const timeoutId = setTimeout(() => { + if (boardListElement) + boardListElement.scrollBy({ + top: boardListElement.scrollHeight, + left: 0, + behavior: "smooth", + }); + clearTimeout(timeoutId); + }, 10); + }; + return (
= ({ )}
= ({ > <>{provided.placeholder} + + setIsInlineCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" + ? "labels_list" + : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + />
{displayFilters?.group_by !== "created_by" && (
@@ -178,7 +218,11 @@ export const SingleBoard: React.FC = ({ @@ -224,7 +257,9 @@ export const SingleList: React.FC = ({ position="right" noBorder > - Create new + setIsCreateIssueFormOpen(true)}> + Create new + {openIssuesListModal && ( Add an existing issue @@ -285,6 +320,33 @@ export const SingleList: React.FC = ({ ) : (
Loading...
)} + + setIsCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by!]: groupTitle, + }} + /> + + {!disableAddIssueOption && !isCreateIssueFormOpen && ( +
+ +
+ )}
diff --git a/web/components/cycles/single-cycle-list.tsx b/web/components/cycles/single-cycle-list.tsx index ec01da9e7..a4c21128a 100644 --- a/web/components/cycles/single-cycle-list.tsx +++ b/web/components/cycles/single-cycle-list.tsx @@ -149,6 +149,10 @@ export const SingleCycleList: React.FC = ({ color: group.color, })); + const completedIssues = cycle.completed_issues + cycle.cancelled_issues; + + const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0; + return (
@@ -307,7 +311,7 @@ export const SingleCycleList: React.FC = ({ ) : cycleStatus === "completed" ? ( - {100} % + {Math.round(percentage)} % ) : ( diff --git a/web/components/gantt-chart/sidebar.tsx b/web/components/gantt-chart/sidebar.tsx index 92e7a603d..4e38d958c 100644 --- a/web/components/gantt-chart/sidebar.tsx +++ b/web/components/gantt-chart/sidebar.tsx @@ -85,6 +85,7 @@ export const GanttSidebar: React.FC = ({ {(droppableProvided) => (
= ({
)}
+
+ setIsCreateIssueFormOpen(false)} + onSuccess={() => { + const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`); + + const timeoutId = setTimeout(() => { + if (ganttSidebar) + ganttSidebar.scrollBy({ + top: ganttSidebar.scrollHeight, + left: 0, + behavior: "smooth", + }); + clearTimeout(timeoutId); + }, 10); + }} + prePopulatedData={{ + start_date: new Date(Date.now()).toISOString().split("T")[0], + target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0], + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} + /> + + {!isCreateIssueFormOpen && ( + + )} +
); }; diff --git a/web/components/issues/activity.tsx b/web/components/issues/activity.tsx index fe322afe9..e6f54f512 100644 --- a/web/components/issues/activity.tsx +++ b/web/components/issues/activity.tsx @@ -7,9 +7,9 @@ import { useRouter } from "next/router"; import { ActivityIcon, ActivityMessage } from "components/core"; import { CommentCard } from "components/issues/comment"; // ui -import { Icon, Loader } from "components/ui"; +import { Icon, Loader, Tooltip } from "components/ui"; // helpers -import { timeAgo } from "helpers/date-time.helper"; +import { render24HourFormatTime, renderLongDateFormat, timeAgo } from "helpers/date-time.helper"; // types import { IIssueActivity, IIssueComment } from "types"; @@ -120,9 +120,15 @@ export const IssueActivitySection: React.FC = ({ )}{" "} {message}{" "} - - {timeAgo(activityItem.created_at)} - + + + {timeAgo(activityItem.created_at)} + +
diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx index 6fc4f4218..8347f555b 100644 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ b/web/components/issues/delete-draft-issue-modal.tsx @@ -4,6 +4,8 @@ import { useRouter } from "next/router"; import { mutate } from "swr"; +import useUser from "hooks/use-user"; + // headless ui import { Dialog, Transition } from "@headlessui/react"; // services @@ -16,7 +18,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // ui import { SecondaryButton, DangerButton } from "components/ui"; // types -import type { IIssue, ICurrentUserResponse } from "types"; +import type { IIssue } from "types"; // fetch-keys import { PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys"; @@ -24,12 +26,11 @@ type Props = { isOpen: boolean; handleClose: () => void; data: IIssue | null; - user?: ICurrentUserResponse; onSubmit?: () => Promise | void; }; export const DeleteDraftIssueModal: React.FC = (props) => { - const { isOpen, handleClose, data, user, onSubmit } = props; + const { isOpen, handleClose, data, onSubmit } = props; const [isDeleteLoading, setIsDeleteLoading] = useState(false); @@ -40,6 +41,8 @@ export const DeleteDraftIssueModal: React.FC = (props) => { const { setToastAlert } = useToast(); + const { user } = useUser(); + useEffect(() => { setIsDeleteLoading(false); }, [isOpen]); diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index aac5dede7..7433da82c 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -66,6 +66,7 @@ interface IssueFormProps { createMore: boolean; setCreateMore: React.Dispatch>; handleClose: () => void; + handleDiscard: () => void; status: boolean; user: ICurrentUserResponse | undefined; fieldsToShow: ( @@ -97,6 +98,7 @@ export const DraftIssueForm: FC = (props) => { status, user, fieldsToShow, + handleDiscard, } = props; const [stateModal, setStateModal] = useState(false); @@ -569,7 +571,7 @@ export const DraftIssueForm: FC = (props) => { {}} size="md" />
- Discard + Discard diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index c060c72f6..b6479d067 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -97,6 +97,11 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = setActiveProject(null); }; + const onDiscard = () => { + clearDraftIssueLocalStorage(); + onClose(); + }; + useEffect(() => { setPreloadedData(prePopulateDataProps ?? {}); @@ -141,7 +146,7 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project); - if (prePopulateData && prePopulateData.project) + if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project); // if data is not present, set active project to the project @@ -180,16 +185,8 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = await issuesService .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user) .then(async () => { - mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); - if (displayFilters.layout === "calendar") mutate(calendarFetchKey); - if (displayFilters.layout === "gantt_chart") - mutate(ganttFetchKey, { - start_target_date: true, - order_by: "sort_order", - }); - if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); if (groupedIssues) mutateMyIssues(); setToastAlert({ @@ -200,8 +197,6 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug as string)); - - if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); }) .catch(() => { setToastAlert({ @@ -396,6 +391,7 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = createMore={createMore} setCreateMore={setCreateMore} handleClose={onClose} + handleDiscard={onDiscard} projectId={activeProject ?? ""} setActiveProject={setActiveProject} status={data ? true : false} diff --git a/web/components/issues/my-issues/my-issues-view-options.tsx b/web/components/issues/my-issues/my-issues-view-options.tsx index 1cbc467a8..cceb69141 100644 --- a/web/components/issues/my-issues/my-issues-view-options.tsx +++ b/web/components/issues/my-issues/my-issues-view-options.tsx @@ -51,7 +51,7 @@ export const MyIssuesViewOptions: React.FC = () => { {replaceUnderscoreIfSnakeCase(option.type)} View + {replaceUnderscoreIfSnakeCase(option.type)} Layout } position="bottom" > diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index 2adb7c6f9..d9c6fd303 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -1,6 +1,7 @@ import React from "react"; // next imports import Link from "next/link"; +import { useRouter } from "next/router"; // lucide icons import { ChevronDown, @@ -37,6 +38,7 @@ export interface ISubIssues { issueId: string, issue?: IIssue | null ) => void; + setPeekParentId: (id: string) => void; } export const SubIssues: React.FC = ({ @@ -52,38 +54,54 @@ export const SubIssues: React.FC = ({ handleIssuesLoader, copyText, handleIssueCrudOperation, -}) => ( -
- {issue && ( -
-
- {issue?.sub_issues_count > 0 && ( - <> - {issuesLoader.sub_issues.includes(issue?.id) ? ( -
- -
- ) : ( -
handleIssuesLoader({ key: "visibility", issueId: issue?.id })} - > - {issuesLoader && issuesLoader.visibility.includes(issue?.id) ? ( - - ) : ( - - )} -
- )} - - )} -
+ setPeekParentId, +}) => { + const router = useRouter(); - - + const openPeekOverview = (issue_id: string) => { + const { query } = router; + + setPeekParentId(parentIssue?.id); + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue_id }, + }); + }; + + return ( +
+ {issue && ( +
+
+ {issue?.sub_issues_count > 0 && ( + <> + {issuesLoader.sub_issues.includes(issue?.id) ? ( +
+ +
+ ) : ( +
handleIssuesLoader({ key: "visibility", issueId: issue?.id })} + > + {issuesLoader && issuesLoader.visibility.includes(issue?.id) ? ( + + ) : ( + + )} +
+ )} + + )} +
+ +
openPeekOverview(issue?.id)} + > -
- -
+
+ +
+ +
+ + {editable && ( + handleIssueCrudOperation("edit", parentIssue?.id, issue)} + > +
+ + Edit issue +
+
+ )} + + {editable && ( + handleIssueCrudOperation("delete", parentIssue?.id, issue)} + > +
+ + Delete issue +
+
+ )} -
- - {editable && ( handleIssueCrudOperation("edit", parentIssue?.id, issue)} + onClick={() => + copyText(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`) + } >
- - Edit issue + + Copy issue link
- )} +
+
- {editable && ( - handleIssueCrudOperation("delete", parentIssue?.id, issue)} - > -
- - Delete issue + {editable && ( + <> + {issuesLoader.delete.includes(issue?.id) ? ( +
+
- - )} - - - copyText(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`) - } - > -
- - Copy issue link -
-
- + ) : ( +
{ + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + removeIssueFromSubIssues(parentIssue?.id, issue); + }} + > + +
+ )} + + )}
+ )} - {editable && ( - <> - {issuesLoader.delete.includes(issue?.id) ? ( -
- -
- ) : ( -
{ - handleIssuesLoader({ key: "delete", issueId: issue?.id }); - removeIssueFromSubIssues(parentIssue?.id, issue); - }} - > - -
- )} - - )} -
- )} - - {issuesLoader.visibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( - - )} -
-); + {issuesLoader.visibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( + + )} +
+ ); +}; diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index 9fc77992e..a713d6fb8 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -27,6 +27,7 @@ export interface ISubIssuesRootList { issueId: string, issue?: IIssue | null ) => void; + setPeekParentId: (id: string) => void; } export const SubIssuesRootList: React.FC = ({ @@ -41,6 +42,7 @@ export const SubIssuesRootList: React.FC = ({ handleIssuesLoader, copyText, handleIssueCrudOperation, + setPeekParentId, }) => { const { data: issues, isLoading } = useSWR( workspaceSlug && projectId && parentIssue && parentIssue?.id @@ -81,6 +83,7 @@ export const SubIssuesRootList: React.FC = ({ handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} + setPeekParentId={setPeekParentId} /> ))} diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 305a9150b..352546eab 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -10,6 +10,7 @@ import { ExistingIssuesListModal } from "components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { SubIssuesRootList } from "./issues-list"; import { ProgressBar } from "./progressbar"; +import { IssuePeekOverview } from "components/issues/peek-overview"; // ui import { CustomMenu } from "components/ui"; // hooks @@ -41,7 +42,11 @@ export interface ISubIssuesRootLoadersHandler { export const SubIssuesRoot: React.FC = ({ parentIssue, user }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId, peekIssue } = router.query as { + workspaceSlug: string; + projectId: string; + peekIssue: string; + }; const { memberRole } = useProjectMyMembership(); const { setToastAlert } = useToast(); @@ -55,6 +60,8 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = : null ); + const [peekParentId, setPeekParentId] = React.useState(""); + const [issuesLoader, setIssuesLoader] = React.useState({ visibility: [parentIssue?.id], delete: [], @@ -230,15 +237,48 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} + setPeekParentId={setPeekParentId} />
)} + +
+ + + Add sub-issue + + } + buttonClassName="whitespace-nowrap" + position="left" + noBorder + noChevron + > + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("create", parentIssue?.id); + }} + > + Create new + + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("existing", parentIssue?.id); + }} + > + Add an existing issue + + +
) : ( isEditable && ( -
-
No sub issues are available
- <> +
+
No Sub-Issues yet
+
@@ -268,7 +308,7 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = Add an existing issue - +
) )} @@ -323,6 +363,13 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = )} )} + + peekParentId && peekIssue && mutateSubIssues(peekParentId)} + projectId={projectId ?? ""} + workspaceSlug={workspaceSlug ?? ""} + readOnly={!isEditable} + />
); }; diff --git a/web/components/labels/single-label.tsx b/web/components/labels/single-label.tsx index be981e510..c163a3735 100644 --- a/web/components/labels/single-label.tsx +++ b/web/components/labels/single-label.tsx @@ -1,11 +1,13 @@ -import React from "react"; +import React, { useRef, useState } from "react"; +//hook +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // ui import { CustomMenu } from "components/ui"; // types import { IIssueLabels } from "types"; //icons -import { RectangleGroupIcon, PencilIcon } from "@heroicons/react/24/outline"; +import { PencilIcon } from "@heroicons/react/24/outline"; import { Component, X } from "lucide-react"; type Props = { @@ -20,9 +22,14 @@ export const SingleLabel: React.FC = ({ addLabelToGroup, editLabel, handleLabelDelete, -}) => ( -
-
+}) => { + const [isMenuActive, setIsMenuActive] = useState(false); + const actionSectionRef = useRef(null); + + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + + return ( +
= ({ />
{label.name}
-
-
- - -
- } +
+ setIsMenuActive(!isMenuActive)}> + +
+ } + > + addLabelToGroup(label)}> + + + Convert to group + + + editLabel(label)}> + + + Edit label + + + +
+
- -
-
-
-); + ); +}; diff --git a/web/components/profile/profile-issues-view-options.tsx b/web/components/profile/profile-issues-view-options.tsx index 4f36faa04..30dcb8df4 100644 --- a/web/components/profile/profile-issues-view-options.tsx +++ b/web/components/profile/profile-issues-view-options.tsx @@ -81,7 +81,7 @@ export const ProfileIssuesViewOptions: React.FC = () => { {replaceUnderscoreIfSnakeCase(option.type)} View + {replaceUnderscoreIfSnakeCase(option.type)} Layout } position="bottom" > diff --git a/web/components/profile/profile-issues-view.tsx b/web/components/profile/profile-issues-view.tsx index 035e8b990..9d6a53ffe 100644 --- a/web/components/profile/profile-issues-view.tsx +++ b/web/components/profile/profile-issues-view.tsx @@ -227,7 +227,8 @@ export const ProfileIssuesView = () => { router.pathname.includes("my-issues")) ?? false; - const disableAddIssueOption = isSubscribedIssuesRoute || isMySubscribedIssues; + const disableAddIssueOption = + isSubscribedIssuesRoute || isMySubscribedIssues || user?.id !== userId; return ( <> diff --git a/web/components/workspace/sidebar-quick-action.tsx b/web/components/workspace/sidebar-quick-action.tsx index 8923abc14..1c614f694 100644 --- a/web/components/workspace/sidebar-quick-action.tsx +++ b/web/components/workspace/sidebar-quick-action.tsx @@ -44,7 +44,9 @@ export const WorkspaceSidebarQuickAction = () => { > -
+
@@ -266,6 +267,7 @@ const Profile: NextPage = () => { placeholder="Enter your last name" autoComplete="off" className="!px-3 !py-2 rounded-md font-medium" + maxLength={24} />
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx index de6ad561e..65007db3c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx @@ -189,10 +189,12 @@ const SingleCycle: React.FC = () => { {cycleStatus === "completed" && ( setTransferIssuesModal(true)} /> )} - +
+ +
{ onClose={() => setAnalyticsModal(false)} />
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 6e7e1bca4..028e95540 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -96,7 +96,7 @@ const ProjectModules: NextPage = () => { {replaceUnderscoreIfSnakeCase(option.type)} View + {replaceUnderscoreIfSnakeCase(option.type)} Layout } position="bottom" > diff --git a/web/pages/[workspaceSlug]/settings/index.tsx b/web/pages/[workspaceSlug]/settings/index.tsx index 4f576aa0a..7d0d8b1b6 100644 --- a/web/pages/[workspaceSlug]/settings/index.tsx +++ b/web/pages/[workspaceSlug]/settings/index.tsx @@ -337,10 +337,10 @@ const WorkspaceSettings: NextPage = () => {
- The danger zone of the project delete page is a critical area that - requires careful consideration and attention. When deleting a project, all - of the data and resources within that project will be permanently removed - and cannot be recovered. + The danger zone of the workspace delete page is a critical area that + requires careful consideration and attention. When deleting a workspace, + all of the data and resources within that workspace will be permanently + removed and cannot be recovered.
{ className="!text-sm" outline > - Delete my project + Delete my workspace