Merge branch 'develop' of https://github.com/makeplane/plane into develop

This commit is contained in:
vamsi 2023-05-05 19:46:45 +05:30
commit c16c5b1cb2
99 changed files with 1978 additions and 1402 deletions

View File

@ -1,5 +1,4 @@
# Replace with your instance Public IP
# NEXT_PUBLIC_API_BASE_URL = "http://localhost"
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
@ -10,3 +9,12 @@ NEXT_PUBLIC_ENABLE_SENTRY=0
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID=""
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
OPENAI_API_KEY=""
GPT_ENGINE=""

View File

@ -1,4 +1,4 @@
name: Build Api Server Docker Image
name: Build and Push Backend Docker Image
on:
push:
@ -10,11 +10,8 @@ on:
jobs:
build_push_backend:
name: Build Api Server Docker Image
name: Build and Push Api Server Docker Image
runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
@ -28,20 +25,33 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Github Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.1.0
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-backend
- name: Extract metadata (tags, labels) for Docker (Github)
id: dkrmeta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-backend
- name: Build Api Server
- name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
@ -50,5 +60,18 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: ./apiserver
file: ./apiserver/Dockerfile.api
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@ -1,4 +1,4 @@
name: Build Frontend Docker Image
name: Build and Push Frontend Docker Image
on:
push:
@ -12,9 +12,6 @@ jobs:
build_push_frontend:
name: Build Frontend Docker Image
runs-on: ubuntu-20.04
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
@ -35,13 +32,26 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
registry: "registry.hub.docker.com"
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker (Docker Hub)
id: ghmeta
uses: docker/metadata-action@v4.3.0
with:
images: makeplane/plane-frontend
- name: Extract metadata (tags, labels) for Docker (Github)
id: meta
uses: docker/metadata-action@v4.3.0
with:
images: ghcr.io/${{ github.repository }}-frontend
- name: Build Frontend Server
- name: Build and Push to GitHub Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
@ -50,5 +60,18 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.ghmeta.outputs.tags }}
labels: ${{ steps.ghmeta.outputs.labels }}
- name: Build and Push to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./apps/app/Dockerfile.web
platforms: linux/arm64,linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha
tags: ${{ steps.dkrmeta.outputs.tags }}
labels: ${{ steps.dkrmeta.outputs.labels }}

View File

@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
@ -16,7 +17,7 @@ FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
@ -26,9 +27,16 @@ RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM python:3.11.1-alpine3.17 AS backend
@ -108,6 +116,16 @@ COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
COPY nginx/supervisor.conf /code/supervisor.conf
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@ -26,7 +26,7 @@
</a>
</p>
Meet Plane. An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
@ -58,11 +58,18 @@ cd plane
> If running in a cloud env replace localhost with public facing IP address of the VM
- Export Environment Variables
```bash
set -a
source .env
set +a
```
- Run Docker compose up
```bash
docker-compose up
docker-compose -f docker-compose-hub.yml up
```
<strong>You can use the default email and password for your first login `captain@plane.so` and `password123`.</strong>

View File

@ -1,28 +0,0 @@
DJANGO_SETTINGS_MODULE="plane.settings.production"
# Database
DATABASE_URL=postgres://plane:xyzzyspoon@db:5432/plane
# Cache
REDIS_URL=redis://redis:6379/
# SMTP
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT="587"
EMAIL_USE_TLS="1"
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
# AWS
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
AWS_S3_ENDPOINT_URL=""
# FE
WEB_URL="localhost/"
# OAUTH
GITHUB_CLIENT_SECRET=""
# Flags
DISABLE_COLLECTSTATIC=1
DOCKERIZED=1
# GPT Envs
OPENAI_API_KEY=0
GPT_ENGINE=0

View File

@ -19,10 +19,29 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
assignees = serializers.SerializerMethodField()
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in members}
# 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
fields = "__all__"

View File

@ -2,9 +2,13 @@
from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint
from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate
fields = "__all__"
@ -27,6 +31,8 @@ class EstimatePointSerializer(BaseSerializer):
class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Estimate

View File

@ -2,12 +2,14 @@
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import Importer
class ImporterSerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = Importer

View File

@ -30,6 +30,7 @@ from plane.db.models import (
CycleFavorite,
IssueLink,
IssueAttachment,
User,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@ -413,10 +414,11 @@ class CycleDateCheckEndpoint(BaseAPIView):
try:
start_date = request.data.get("start_date", False)
end_date = request.data.get("end_date", False)
cycle_id = request.data.get("cycle_id", False)
if not start_date or not end_date:
return Response(
{"error": "Start date and end date both are required"},
{"error": "Start date and end date are required"},
status=status.HTTP_400_BAD_REQUEST,
)
@ -428,6 +430,11 @@ class CycleDateCheckEndpoint(BaseAPIView):
project_id=project_id,
)
if cycle_id:
cycles = cycles.filter(
~Q(pk=cycle_id),
)
if cycles.exists():
return Response(
{
@ -501,6 +508,12 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.order_by("name", "-is_favorite")
)
@ -545,6 +558,12 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.order_by("name", "-is_favorite")
)
@ -557,7 +576,7 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
)
except Exception as e:
capture_exception(e)
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@ -618,6 +637,12 @@ class CompletedCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.order_by("name", "-is_favorite")
)
@ -693,6 +718,12 @@ class DraftCyclesEndpoint(BaseAPIView):
filter=Q(issue_cycle__issue__state__group="backlog"),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
)
)
.order_by("name", "-is_favorite")
)

View File

@ -53,11 +53,11 @@ class BulkEstimatePointEndpoint(BaseViewSet):
try:
estimates = Estimate.objects.filter(
workspace__slug=slug, project_id=project_id
).prefetch_related("points")
).prefetch_related("points").select_related("workspace", "project")
serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@ -211,7 +211,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
try:
EstimatePoint.objects.bulk_update(
updated_estimate_points, ["value"], batch_size=10
updated_estimate_points, ["value"], batch_size=10,
)
except IntegrityError as e:
return Response(

View File

@ -39,7 +39,6 @@ class EstimatePoint(ProjectBaseModel):
return f"{self.estimate.name} <{self.key}> <{self.value}>"
class Meta:
unique_together = ["value", "estimate"]
verbose_name = "Estimate Point"
verbose_name_plural = "Estimate Points"
db_table = "estimate_points"

View File

@ -3,6 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
@ -12,10 +13,10 @@ RUN turbo prune --scope=app --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
@ -26,9 +27,17 @@ RUN yarn install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM node:18-alpine AS runner
WORKDIR /app
@ -43,8 +52,20 @@ COPY --from=installer /app/apps/app/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
# COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone/node_modules ./apps/app/node_modules
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/app/.next ./apps/app/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1

View File

@ -821,7 +821,7 @@ export const CommandPalette: React.FC = () => {
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Billings and Plans
Billing and Plans
</div>
</Command.Item>
<Command.Item
@ -839,7 +839,7 @@ export const CommandPalette: React.FC = () => {
>
<div className="flex items-center gap-2 text-brand-secondary">
<SettingIcon className="h-4 w-4 text-brand-secondary" />
Import/Export
Import/ Export
</div>
</Command.Item>
</>

View File

@ -44,7 +44,7 @@ export const AllBoards: React.FC<Props> = ({
return (
<>
{groupedByIssues ? (
<div className="horizontal-scroll-enable flex h-[calc(100vh-140px)] gap-x-4">
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
{Object.keys(groupedByIssues).map((singleGroup, index) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;

View File

@ -392,7 +392,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<LinkIcon className="h-3.5 w-3.5 text-brand-secondary" />
<LinkIcon className="h-3.5 w-3.5" />
{issue.link_count}
</div>
</Tooltip>
@ -402,7 +402,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-brand-secondary" />
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45" />
{issue.attachment_count}
</div>
</Tooltip>

View File

@ -229,7 +229,7 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
return calendarIssues ? (
<DragDropContext onDragEnd={onDragEnd}>
<div className="-m-2 h-full overflow-y-auto rounded-lg text-brand-secondary">
<div className="-m-2 h-full overflow-y-auto rounded-lg p-8 text-brand-secondary">
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">

View File

@ -121,7 +121,7 @@ export const GptAssistantModal: React.FC<Props> = ({
return (
<div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-surface-2 p-4 shadow ${
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-base p-4 shadow ${
isOpen ? "block" : "hidden"
}`}
>
@ -138,7 +138,7 @@ export const GptAssistantModal: React.FC<Props> = ({
</div>
)}
{response !== "" && (
<div className="text-sm page-block-section">
<div className="page-block-section text-sm">
Response:
<RemirrorRichTextEditor
value={`<p>${response}</p>`}

View File

@ -134,7 +134,7 @@ export const IssuesFilterView: React.FC = () => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
<div className="relative divide-y-2 divide-brand-base">
<div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && (

View File

@ -353,7 +353,7 @@ export const IssuesView: React.FC<Props> = ({
console.log(e);
});
},
[workspaceSlug, projectId, cycleId, params]
[workspaceSlug, projectId, cycleId, params, selectedGroup, setToastAlert]
);
const removeIssueFromModule = useCallback(
@ -396,7 +396,7 @@ export const IssuesView: React.FC<Props> = ({
console.log(e);
});
},
[workspaceSlug, projectId, moduleId, params]
[workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert]
);
const handleTrashBox = useCallback(
@ -442,39 +442,35 @@ export const IssuesView: React.FC<Props> = ({
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
<>
<div
className={`flex items-center justify-between gap-2 ${
issueView === "list" ? (areFiltersApplied ? "mt-6 px-8" : "") : "-mt-2"
}`}
>
<FilterList filters={filters} setFilters={setFilters} />
{areFiltersApplied && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view
</PrimaryButton>
)}
</div>
{areFiltersApplied && (
<div className={`${issueView === "list" ? "mt-4" : "my-4"} border-t border-brand-base`} />
)}
</>
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FilterList filters={filters} setFilters={setFilters} />
{areFiltersApplied && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view
</PrimaryButton>
)}
</div>
{<div className="mt-3 border-t border-brand-base" />}
</>
)}
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">

View File

@ -314,7 +314,7 @@ export const SingleListIssue: React.FC<Props> = ({
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<LinkIcon className="h-3.5 w-3.5 text-brand-secondary" />
<LinkIcon className="h-3.5 w-3.5" />
{issue.link_count}
</div>
</Tooltip>
@ -324,7 +324,7 @@ export const SingleListIssue: React.FC<Props> = ({
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-brand-secondary">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-brand-secondary" />
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45" />
{issue.attachment_count}
</div>
</Tooltip>

View File

@ -65,7 +65,11 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
completedCycles.completed_cycles.length > 0 ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span>Completed cycles are not editable.</span>
</div>
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">

View File

@ -1,21 +1,10 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useEffect } from "react";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DateSelect, Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// helpers
import {
getDateRangeStatus,
isDateGreaterThanToday,
isDateRangeValid,
} from "helpers/date-time.helper";
// types
import { ICycle } from "types";
@ -34,13 +23,6 @@ const defaultValues: Partial<ICycle> = {
};
export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const [isDateValid, setIsDateValid] = useState(true);
const {
register,
formState: { errors, isSubmitting },
@ -60,43 +42,6 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
});
};
const cycleStatus =
data?.start_date && data?.end_date ? getDateRangeStatus(data?.start_date, data?.end_date) : "";
const dateChecker = async (payload: any) => {
if (isDateGreaterThanToday(payload.end_date)) {
await cyclesService
.cycleDateCheck(workspaceSlug as string, projectId as string, payload)
.then((res) => {
if (res.status) {
setIsDateValid(true);
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
})
.catch((err) => {
console.log(err);
});
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
}
};
const checkEmptyDate =
(watch("start_date") === "" && watch("end_date") === "") ||
(!watch("start_date") && !watch("end_date"));
useEffect(() => {
reset({
...defaultValues,
@ -147,30 +92,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<DateSelect
label="Start date"
value={value}
onChange={(val) => {
onChange(val);
if (val && watch("end_date")) {
if (isDateRangeValid(val, `${watch("end_date")}`)) {
cycleStatus != "current" &&
dateChecker({
start_date: val,
end_date: watch("end_date"),
});
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message:
"The date you have entered is invalid. Please check and enter a valid date.",
});
}
}
}}
/>
<DateSelect label="Start date" value={value} onChange={(val) => onChange(val)} />
)}
/>
</div>
@ -179,30 +101,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control}
name="end_date"
render={({ field: { value, onChange } }) => (
<DateSelect
label="End date"
value={value}
onChange={(val) => {
onChange(val);
if (watch("start_date") && val) {
if (isDateRangeValid(`${watch("start_date")}`, val)) {
cycleStatus != "current" &&
dateChecker({
start_date: watch("start_date"),
end_date: val,
});
} else {
setIsDateValid(false);
setToastAlert({
type: "error",
title: "Error!",
message:
"The date you have entered is invalid. Please check and enter a valid date.",
});
}
}
}}
/>
<DateSelect label="End date" value={value} onChange={(val) => onChange(val)} />
)}
/>
</div>
@ -211,18 +110,7 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
</div>
<div className="-mx-5 mt-5 flex justify-end gap-2 border-t border-brand-base px-5 pt-5">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
type="submit"
className={
checkEmptyDate
? "cursor-pointer"
: isDateValid
? "cursor-pointer"
: "cursor-not-allowed"
}
disabled={checkEmptyDate ? false : isDateValid ? false : true}
loading={isSubmitting}
>
<PrimaryButton type="submit" loading={isSubmitting}>
{status
? isSubmitting
? "Updating Cycle..."

View File

@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components
import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus } from "helpers/date-time.helper";
import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper";
// types
import type { ICycle } from "types";
// fetch keys
@ -128,6 +128,21 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
});
};
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(
workspaceSlug as string,
projectId as string,
payload
);
console.log(res);
return res.status;
} catch (err) {
console.log(err);
return false;
}
};
const handleFormSubmit = async (formData: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
@ -135,8 +150,63 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
...formData,
};
if (!data) await createCycle(payload);
else await updateCycle(data.id, payload);
if (payload.start_date && payload.end_date) {
if (!isDateGreaterThanToday(payload.end_date)) {
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
return;
}
const isDateValid = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
});
if (data?.start_date && data?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
cycle_id: data.id,
});
if (isDateValidForExistingCycle) {
await updateCycle(data.id, payload);
return;
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
return;
}
}
if (isDateValid) {
if (data) {
await updateCycle(data.id, payload);
} else {
await createCycle(payload);
}
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
}
} else {
if (data) {
await updateCycle(data.id, payload);
} else {
await createCycle(payload);
}
}
};
return (

View File

@ -370,7 +370,11 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span className="text-xs italic text-brand-secondary">
{cycleStatus === "upcoming"
? "Cycle is yet to start."
@ -444,7 +448,11 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span className="text-xs italic text-brand-secondary">
No issues found. Please add issue.
</span>

View File

@ -148,7 +148,11 @@ export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) =>
))
) : (
<div className="flex w-full items-center justify-center gap-4 p-5 text-sm">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span className="text-center text-brand-secondary">
You dont have any current cycle. Please create one to transfer the
issues.

View File

@ -39,7 +39,7 @@ export const TransferIssues: React.FC<Props> = ({ handleClick }) => {
return (
<div className="-mt-2 mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-brand-secondary">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon height={14} width={14} className="fill-current text-brand-secondary" />
<span>Completed cycles are not editable.</span>
</div>

View File

@ -3,14 +3,14 @@ import React from "react";
import type { Props } from "./types";
export const ExclamationIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 14 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.55321 11.042C7.70668 11.042 7.83359 10.9918 7.93394 10.8915C8.03428 10.7911 8.08446 10.6642 8.08446 10.5107V7.30553C8.08446 7.16387 8.03133 7.04286 7.92508 6.94251C7.81883 6.84217 7.69487 6.79199 7.55321 6.79199C7.39973 6.79199 7.27283 6.84217 7.17248 6.94251C7.07213 7.04286 7.02196 7.16977 7.02196 7.32324V10.5285C7.02196 10.6701 7.07508 10.7911 7.18133 10.8915C7.28758 10.9918 7.41154 11.042 7.55321 11.042ZM7.50008 5.48158C7.66536 5.48158 7.80407 5.42845 7.91623 5.3222C8.02838 5.21595 8.08446 5.08019 8.08446 4.91491C8.08446 4.74963 8.02838 4.60796 7.91623 4.48991C7.80407 4.37185 7.66536 4.31283 7.50008 4.31283C7.3348 4.31283 7.19609 4.37185 7.08394 4.48991C6.97178 4.60796 6.91571 4.74963 6.91571 4.91491C6.91571 5.08019 6.97178 5.21595 7.08394 5.3222C7.19609 5.42845 7.3348 5.48158 7.50008 5.48158ZM7.50008 14.5837C6.49661 14.5837 5.56397 14.4036 4.70217 14.0436C3.84036 13.6835 3.09071 13.1847 2.45321 12.5472C1.81571 11.9097 1.31692 11.16 0.956852 10.2982C0.596783 9.43644 0.416748 8.5038 0.416748 7.50033C0.416748 6.50866 0.596783 5.58192 0.956852 4.72012C1.31692 3.85831 1.81571 3.10866 2.45321 2.47116C3.09071 1.83366 3.84036 1.33192 4.70217 0.965951C5.56397 0.599978 6.49661 0.416992 7.50008 0.416992C8.49175 0.416992 9.41848 0.599978 10.2803 0.965951C11.1421 1.33192 11.8917 1.83366 12.5292 2.47116C13.1667 3.10866 13.6685 3.85831 14.0345 4.72012C14.4004 5.58192 14.5834 6.50866 14.5834 7.50033C14.5834 8.5038 14.4004 9.43644 14.0345 10.2982C13.6685 11.16 13.1667 11.9097 12.5292 12.5472C11.8917 13.1847 11.1421 13.6835 10.2803 14.0436C9.41848 14.4036 8.49175 14.5837 7.50008 14.5837ZM7.50008 13.5212C9.15286 13.5212 10.5695 12.9309 11.7501 11.7503C12.9306 10.5698 13.5209 9.1531 13.5209 7.50033C13.5209 5.84755 12.9306 4.43088 11.7501 3.25033C10.5695 2.06977 9.15286 1.47949 7.50008 1.47949C5.8473 1.47949 4.43064 2.06977 3.25008 3.25033C2.06953 4.43088 1.47925 5.84755 1.47925 7.50033C1.47925 9.1531 2.06953 10.5698 3.25008 11.7503C4.43064 12.9309 5.8473 13.5212 7.50008 13.5212Z" fill="#858E96"/>
</svg>
);
<svg
width={width}
height={height}
className={className}
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.55321 11.042C7.70668 11.042 7.83359 10.9918 7.93394 10.8915C8.03428 10.7911 8.08446 10.6642 8.08446 10.5107V7.30553C8.08446 7.16387 8.03133 7.04286 7.92508 6.94251C7.81883 6.84217 7.69487 6.79199 7.55321 6.79199C7.39973 6.79199 7.27283 6.84217 7.17248 6.94251C7.07213 7.04286 7.02196 7.16977 7.02196 7.32324V10.5285C7.02196 10.6701 7.07508 10.7911 7.18133 10.8915C7.28758 10.9918 7.41154 11.042 7.55321 11.042ZM7.50008 5.48158C7.66536 5.48158 7.80407 5.42845 7.91623 5.3222C8.02838 5.21595 8.08446 5.08019 8.08446 4.91491C8.08446 4.74963 8.02838 4.60796 7.91623 4.48991C7.80407 4.37185 7.66536 4.31283 7.50008 4.31283C7.3348 4.31283 7.19609 4.37185 7.08394 4.48991C6.97178 4.60796 6.91571 4.74963 6.91571 4.91491C6.91571 5.08019 6.97178 5.21595 7.08394 5.3222C7.19609 5.42845 7.3348 5.48158 7.50008 5.48158ZM7.50008 14.5837C6.49661 14.5837 5.56397 14.4036 4.70217 14.0436C3.84036 13.6835 3.09071 13.1847 2.45321 12.5472C1.81571 11.9097 1.31692 11.16 0.956852 10.2982C0.596783 9.43644 0.416748 8.5038 0.416748 7.50033C0.416748 6.50866 0.596783 5.58192 0.956852 4.72012C1.31692 3.85831 1.81571 3.10866 2.45321 2.47116C3.09071 1.83366 3.84036 1.33192 4.70217 0.965951C5.56397 0.599978 6.49661 0.416992 7.50008 0.416992C8.49175 0.416992 9.41848 0.599978 10.2803 0.965951C11.1421 1.33192 11.8917 1.83366 12.5292 2.47116C13.1667 3.10866 13.6685 3.85831 14.0345 4.72012C14.4004 5.58192 14.5834 6.50866 14.5834 7.50033C14.5834 8.5038 14.4004 9.43644 14.0345 10.2982C13.6685 11.16 13.1667 11.9097 12.5292 12.5472C11.8917 13.1847 11.1421 13.6835 10.2803 14.0436C9.41848 14.4036 8.49175 14.5837 7.50008 14.5837ZM7.50008 13.5212C9.15286 13.5212 10.5695 12.9309 11.7501 11.7503C12.9306 10.5698 13.5209 9.1531 13.5209 7.50033C13.5209 5.84755 12.9306 4.43088 11.7501 3.25033C10.5695 2.06977 9.15286 1.47949 7.50008 1.47949C5.8473 1.47949 4.43064 2.06977 3.25008 3.25033C2.06953 4.43088 1.47925 5.84755 1.47925 7.50033C1.47925 9.1531 2.06953 10.5698 3.25008 11.7503C4.43064 12.9309 5.8473 13.5212 7.50008 13.5212Z" />
</svg>
);

View File

@ -81,7 +81,7 @@ export const SelectRepository: React.FC<Props> = ({
{userRepositories && options.length < totalCount && (
<button
type="button"
className="w-full p-1 text-center text-[0.6rem] text-gray-500 hover:bg-hover-gray"
className="w-full p-1 text-center text-[0.6rem] text-brand-secondary hover:bg-brand-surface-2"
onClick={() => setSize(size + 1)}
disabled={isValidating}
>

View File

@ -63,7 +63,11 @@ const IntegrationGuide = () => {
services. This tool will guide you to relocate the issue to Plane.
</div>
</div>
<a href="https://docs.plane.so" target="_blank" rel="noopener noreferrer">
<a
href="https://docs.plane.so/importers/github"
target="_blank"
rel="noopener noreferrer"
>
<div className="flex flex-shrink-0 cursor-pointer items-center gap-2 whitespace-nowrap text-sm font-medium text-[#3F76FF] hover:text-opacity-80">
Read More
<ArrowRightIcon width={"18px"} color={"#3F76FF"} />
@ -124,7 +128,7 @@ const IntegrationGuide = () => {
{importerServices ? (
importerServices.length > 0 ? (
<div className="space-y-2">
<div className="divide-y">
<div className="divide-y divide-brand-base">
{importerServices.map((service) => (
<SingleImport
key={service.id}

View File

@ -6,6 +6,8 @@ import { TrashIcon } from "@heroicons/react/24/outline";
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IImporterService } from "types";
// constants
import { IMPORTERS_EXPORTERS_LIST } from "constants/workspace";
type Props = {
service: IImporterService;
@ -13,17 +15,16 @@ type Props = {
handleDelete: () => void;
};
const importersList: { [key: string]: string } = {
github: "GitHub",
};
export const SingleImport: React.FC<Props> = ({ service, refreshing, handleDelete }) => (
<div className="flex items-center justify-between gap-2 py-3">
<div>
<h4 className="flex items-center gap-2 text-sm">
<span>
Import from <span className="font-medium">{importersList[service.service]}</span> to{" "}
<span className="font-medium">{service.project_detail.name}</span>
Import from{" "}
<span className="font-medium">
{IMPORTERS_EXPORTERS_LIST.find((i) => i.provider === service.service)?.title}
</span>{" "}
to <span className="font-medium">{service.project_detail.name}</span>
</span>
<span
className={`rounded px-2 py-0.5 text-xs capitalize ${

View File

@ -231,6 +231,16 @@ export const IssueActivitySection: React.FC = () => {
action = `${activityItem.verb} the`;
} else if (activityItem.field === "estimate") {
action = "updated the";
} else if (activityItem.field === "cycles") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the cycle to"
: "removed the cycle";
} else if (activityItem.field === "modules") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the module to"
: "removed the module";
}
// for values that are after the action clause
let value: any = activityItem.new_value ? activityItem.new_value : activityItem.old_value;
@ -282,6 +292,18 @@ export const IssueActivitySection: React.FC = () => {
value = "description";
} else if (activityItem.field === "attachment") {
value = "attachment";
} else if (activityItem.field === "cycles") {
const cycles =
activityItem.new_value && activityItem.new_value !== ""
? activityItem.new_value
: activityItem.old_value;
value = cycles ? addSpaceIfCamelCase(cycles) : "None";
} else if (activityItem.field === "modules") {
const modules =
activityItem.new_value && activityItem.new_value !== ""
? activityItem.new_value
: activityItem.old_value;
value = modules ? addSpaceIfCamelCase(modules) : "None";
} else if (activityItem.field === "link") {
value = "link";
} else if (activityItem.field === "estimate_point") {

View File

@ -82,7 +82,7 @@ export const IssueAttachments = () => {
} uploaded on ${renderLongDateFormat(file.updated_at)}`}
>
<span>
<ExclamationIcon className="h-3 w-3" />
<ExclamationIcon className="h-3 w-3 fill-current text-brand-base" />
</span>
</Tooltip>
</div>

View File

@ -82,8 +82,8 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
const isNotAllowed = false;
return (
<div className="border-b border-brand-base last:border-b-0 mx-6">
<div key={issue.id} className="flex items-center justify-between gap-2 py-3">
<div className="border-b border-brand-base bg-brand-base px-4 py-2.5 last:border-b-0">
<div key={issue.id} className="flex items-center justify-between gap-2">
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties?.key && (
@ -91,13 +91,13 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
tooltipHeading="Issue ID"
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-gray-400">
<span className="flex-shrink-0 text-xs text-brand-secondary">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
)}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="break-all text-sm text-brand-base">
<span className="text-[0.825rem] text-brand-base">
{truncateText(issue.name, 50)}
</span>
</Tooltip>
@ -127,7 +127,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
/>
)}
{properties.sub_issue_count && (
<div className="flex items-center gap-1 rounded-md border border-brand-base px-3 py-1.5 text-xs shadow-sm">
<div className="flex items-center gap-1 rounded-md border border-brand-base px-2 py-1 text-xs text-brand-secondary shadow-sm">
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
@ -136,10 +136,10 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
{issue.label_details.map((label) => (
<span
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs"
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
@ -171,20 +171,20 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
</Tooltip>
)}
{properties.link && (
<div className="flex items-center rounded-md shadow-sm px-2.5 py-1 cursor-default text-xs border border-gray-200">
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
<div className="flex items-center gap-1 text-gray-500">
<LinkIcon className="h-3.5 w-3.5 text-gray-500" />
<div className="flex items-center gap-1 text-brand-secondary">
<LinkIcon className="h-3.5 w-3.5 text-brand-secondary" />
{issue.link_count}
</div>
</Tooltip>
</div>
)}
{properties.attachment_count && (
<div className="flex items-center rounded-md shadow-sm px-2.5 py-1 cursor-default text-xs border border-gray-200">
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm">
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
<div className="flex items-center gap-1 text-gray-500">
<PaperClipIcon className="h-3.5 w-3.5 text-gray-500 -rotate-45" />
<div className="flex items-center gap-1 text-brand-secondary">
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45 text-brand-secondary" />
{issue.attachment_count}
</div>
</Tooltip>

View File

@ -78,7 +78,7 @@ export const SidebarCycleSelect: React.FC<Props> = ({
</span>
</Tooltip>
}
value={issueCycle?.cycle_detail.id}
value={issueCycle ? issueCycle.cycle_detail.id : null}
onChange={(value: any) => {
!value
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")

View File

@ -82,7 +82,7 @@ export const SidebarModuleSelect: React.FC<Props> = ({
</span>
</Tooltip>
}
value={issueModule?.module_detail?.id}
value={issueModule ? issueModule.module_detail?.id : null}
onChange={(value: any) => {
!value
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")

View File

@ -416,7 +416,11 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ issues, module, isOpen,
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span className="text-xs italic text-brand-secondary">
Invalid date. Please enter valid date.
</span>
@ -488,7 +492,11 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ issues, module, isOpen,
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<ExclamationIcon height={14} width={14} />
<ExclamationIcon
height={14}
width={14}
className="fill-current text-brand-secondary"
/>
<span className="text-xs italic text-brand-secondary">
No issues found. Please add issue.
</span>

View File

@ -44,7 +44,8 @@ export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule })
const { setToastAlert } = useToast();
const completionPercentage = (module.completed_issues / module.total_issues) * 100;
const completionPercentage =
((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100;
const handleDeleteModule = () => {
if (!module) return;

View File

@ -15,8 +15,10 @@ import issuesService from "services/issues.service";
import aiService from "services/ai.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { GptAssistantModal } from "components/core";
// ui
import { Input, Loader, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
import { Input, Loader, PrimaryButton, SecondaryButton } from "components/ui";
// types
import { IPageBlock } from "types";
// fetch-keys
@ -25,9 +27,9 @@ import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
type Props = {
handleClose: () => void;
data?: IPageBlock;
handleAiAssistance?: (response: string) => void;
setIsSyncing?: React.Dispatch<React.SetStateAction<boolean>>;
focus?: keyof IPageBlock;
setGptAssistantModal: () => void;
};
const defaultValues = {
@ -48,11 +50,12 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
export const CreateUpdateBlockInline: React.FC<Props> = ({
handleClose,
data,
handleAiAssistance,
setIsSyncing,
focus,
setGptAssistantModal,
}) => {
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
@ -230,87 +233,101 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
}, [createPageBlock, updatePageBlock, data, handleSubmit]);
return (
<form
className="divide-y divide-brand-base rounded-[10px] border border-brand-base shadow"
onSubmit={data ? handleSubmit(updatePageBlock) : handleSubmit(createPageBlock)}
>
<div className="pt-2">
<div className="flex justify-between">
<Input
id="name"
name="name"
placeholder="Title"
register={register}
className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent py-1 text-lg font-medium"
autoComplete="off"
maxLength={255}
/>
</div>
<div className="page-block-section relative -mt-2 text-brand-secondary">
<Controller
name="description"
control={control}
render={({ field: { value } }) => (
<RemirrorRichTextEditor
value={
!value || (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Write something..."
customClassName="text-sm"
noBorder
borderOnFocus={false}
/>
)}
/>
<div className="m-2 mt-6 flex">
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1 ${
iAmFeelingLucky ? "cursor-wait bg-brand-surface-1" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
</>
<div className="relative">
<form
className="divide-y divide-brand-base rounded-[10px] border border-brand-base shadow"
onSubmit={data ? handleSubmit(updatePageBlock) : handleSubmit(createPageBlock)}
>
<div className="pt-2">
<div className="flex justify-between">
<Input
id="name"
name="name"
placeholder="Title"
register={register}
className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent py-1 text-lg font-medium"
autoComplete="off"
maxLength={255}
/>
</div>
<div className="page-block-section relative -mt-2 text-brand-secondary">
<Controller
name="description"
control={control}
render={({ field: { value } }) => (
<RemirrorRichTextEditor
value={
!value || (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Write something..."
customClassName="text-sm"
noBorder
borderOnFocus={false}
/>
)}
</button>
{data && (
/>
<div className="m-2 mt-6 flex">
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1 ${
iAmFeelingLucky ? "cursor-wait bg-brand-surface-1" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
<button
type="button"
className="ml-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1"
onClick={() => {
onClose();
setGptAssistantModal();
}}
onClick={() => setGptAssistantModal(true)}
>
<SparklesIcon className="h-4 w-4" />
AI
</button>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" disabled={watch("name") === ""} loading={isSubmitting}>
{data
? isSubmitting
? "Updating..."
: "Update block"
: isSubmitting
? "Adding..."
: "Add block"}
</PrimaryButton>
</div>
</form>
<div className="flex items-center justify-end gap-2 p-4">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" disabled={watch("name") === ""} loading={isSubmitting}>
{data
? isSubmitting
? "Updating..."
: "Update block"
: isSubmitting
? "Adding..."
: "Add block"}
</PrimaryButton>
</div>
</form>
<GptAssistantModal
block={data ? data : undefined}
isOpen={gptAssistantModal}
handleClose={() => setGptAssistantModal(false)}
inset="top-8 left-0"
content={watch("description_html")}
htmlContent={watch("description_html")}
onResponse={(response) => {
if (data && handleAiAssistance) handleAiAssistance(response);
else {
setValue("description", {});
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
}
}}
projectId={projectId?.toString() ?? ""}
/>
</div>
);
};

View File

@ -2,12 +2,11 @@ import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import dynamic from "next/dynamic";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// services
@ -21,7 +20,7 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages";
// ui
import { CustomMenu, Loader } from "components/ui";
import { CustomMenu } from "components/ui";
// icons
import { LayerDiagonalIcon } from "components/icons";
import { ArrowPathIcon, LinkIcon } from "@heroicons/react/20/solid";
@ -46,15 +45,6 @@ type Props = {
index: number;
};
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader className="mx-4 mt-6">
<Loader.Item height="100px" width="100%" />
</Loader>
),
});
export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, index }) => {
const [isSyncing, setIsSyncing] = useState(false);
const [createBlockForm, setCreateBlockForm] = useState(false);
@ -291,7 +281,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, index
{...provided.dragHandleProps}
>
<CreateUpdateBlockInline
setGptAssistantModal={() => setGptAssistantModal((prev) => !prev)}
handleAiAssistance={handleAiAssistance}
handleClose={() => setCreateBlockForm(false)}
data={block}
setIsSyncing={setIsSyncing}

View File

@ -162,7 +162,7 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<span>
<ExclamationIcon className="h-4 w-4 text-gray-400" />
<ExclamationIcon className="h-4 w-4 fill-current text-brand-secondary" />
</span>
</Tooltip>
<CustomMenu verticalEllipsis>

View File

@ -161,7 +161,7 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<span>
<ExclamationIcon className="h-4 w-4 text-gray-400" />
<ExclamationIcon className="h-4 w-4 fill-current text-brand-secondary" />
</span>
</Tooltip>

View File

@ -1,6 +1,7 @@
export * from "./create-project-modal";
export * from "./delete-project-modal";
export * from "./sidebar-list";
export * from "./settings-header"
export * from "./single-integration-card";
export * from "./single-project-card";
export * from "./single-sidebar-project";

View File

@ -0,0 +1,13 @@
import SettingsNavbar from "layouts/settings-navbar";
export const SettingsHeader = () => (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">Project Settings</h3>
<p className="mt-1 text-brand-secondary">
This information will be displayed to every member of the project.
</p>
</div>
<SettingsNavbar />
</div>
);

View File

@ -172,8 +172,8 @@ export const SingleSidebarProject: React.FC<Props> = ({
<a
className={`group flex items-center rounded-md p-2 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-brand-base text-brand-secondary"
: "text-brand-secondary hover:bg-brand-surface-1 hover:text-brand-secondary focus:bg-brand-base focus:text-brand-secondary"
? "bg-brand-surface-2 text-brand-base"
: "text-brand-secondary hover:bg-brand-surface-2 hover:text-brand-secondary focus:bg-brand-surface-2 focus:text-brand-secondary"
} ${sidebarCollapse ? "justify-center" : ""}`}
>
<div className="grid place-items-center">

View File

@ -6,5 +6,6 @@ export * from "./help-section";
export * from "./issues-list";
export * from "./issues-pie-chart";
export * from "./issues-stats";
export * from "./settings-header";
export * from "./sidebar-dropdown";
export * from "./sidebar-menu";

View File

@ -0,0 +1,13 @@
import SettingsNavbar from "layouts/settings-navbar";
export const SettingsHeader = () => (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">Workspace Settings</h3>
<p className="mt-1 text-brand-secondary">
This information will be displayed to every member of the workspace.
</p>
</div>
<SettingsNavbar />
</div>
);

View File

@ -47,8 +47,8 @@ export const WorkspaceSidebarMenu: React.FC = () => {
? router.asPath === link.href
: router.asPath.includes(link.href)
)
? "bg-brand-base text-brand-base"
: "text-brand-secondary hover:bg-brand-surface-1 hover:text-brand-secondary focus:bg-brand-base focus:text-brand-secondary"
? "bg-brand-surface-2 text-brand-base"
: "text-brand-secondary hover:bg-brand-surface-2 hover:text-brand-secondary focus:bg-brand-surface-2 focus:text-brand-secondary"
} group flex w-full items-center gap-3 rounded-md p-2 text-sm font-medium outline-none ${
sidebarCollapse ? "justify-center" : ""
}`}

View File

@ -9,8 +9,8 @@ type Props = {
};
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar }) => (
<div className="flex w-full flex-row flex-wrap items-center justify-between gap-y-4 border-b border-brand-base bg-brand-sidebar px-5 py-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-y-4 border border-b border-brand-base bg-brand-sidebar px-5 py-4">
<div className="flex items-center gap-2">
<div className="block md:hidden">
<button
type="button"

View File

@ -18,22 +18,20 @@ const Sidebar: React.FC<SidebarProps> = ({ toggleSidebar, setToggleSidebar }) =>
const { collapsed: sidebarCollapse } = useTheme();
return (
<nav className="relative z-20 h-screen">
<div
className={`${sidebarCollapse ? "" : "w-auto md:w-[17rem]"} fixed inset-y-0 top-0 ${
toggleSidebar ? "left-0" : "-left-full md:left-0"
} flex h-full flex-col bg-brand-sidebar duration-300 md:relative`}
>
<div className="flex h-full flex-1 flex-col border-r border-brand-base">
<div className="flex h-full flex-1 flex-col">
<WorkspaceSidebarDropdown />
<WorkspaceSidebarMenu />
<ProjectSidebarList />
<WorkspaceHelpSection setSidebarActive={setToggleSidebar} />
</div>
</div>
<div
className={`z-20 h-full flex-shrink-0 border-r border-brand-base ${
sidebarCollapse ? "" : "w-auto md:w-[17rem]"
} fixed inset-y-0 top-0 ${
toggleSidebar ? "left-0" : "-left-full md:left-0"
} flex h-full flex-col bg-brand-sidebar duration-300 md:relative`}
>
<div className="flex h-full flex-1 flex-col">
<WorkspaceSidebarDropdown />
<WorkspaceSidebarMenu />
<ProjectSidebarList />
<WorkspaceHelpSection setSidebarActive={setToggleSidebar} />
</div>
</nav>
</div>
);
};

View File

@ -11,7 +11,6 @@ import useIssuesView from "hooks/use-issues-view";
import Container from "layouts/container";
import AppHeader from "layouts/app-layout/app-header";
import AppSidebar from "layouts/app-layout/app-sidebar";
import SettingsNavbar from "layouts/settings-navbar";
// components
import { NotAuthorizedView, JoinProject } from "components/auth-screens";
import { CommandPalette } from "components/command-palette";
@ -30,7 +29,6 @@ type Meta = {
type Props = {
meta?: Meta;
children: React.ReactNode;
noPadding?: boolean;
noHeader?: boolean;
bg?: "primary" | "secondary";
breadcrumbs?: JSX.Element;
@ -47,7 +45,6 @@ export const ProjectAuthorizationWrapper: React.FC<Props> = (props) => (
const ProjectAuthorizationWrapped: React.FC<Props> = ({
meta,
children,
noPadding = false,
noHeader = false,
bg = "primary",
breadcrumbs,
@ -68,8 +65,9 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
return (
<Container meta={meta}>
<CommandPalette />
<div className="flex h-screen w-full overflow-x-hidden">
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
{loading ? (
<div className="grid h-full w-full place-items-center p-4">
<div className="flex flex-col items-center gap-3 text-center">
@ -107,7 +105,15 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
type="project"
/>
) : (
<main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto">
<main
className={`relative flex h-full w-full flex-col overflow-hidden ${
bg === "primary"
? "bg-brand-surface-1"
: bg === "secondary"
? "bg-brand-sidebar"
: "bg-brand-base"
}`}
>
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}
@ -116,29 +122,10 @@ const ProjectAuthorizationWrapped: React.FC<Props> = ({
setToggleSidebar={setToggleSidebar}
/>
)}
<div
className={`flex w-full flex-grow flex-col ${
noPadding || issueView === "list" ? "" : settingsLayout ? "p-8 lg:px-28" : "p-8"
} ${
bg === "primary"
? "bg-brand-surface-1"
: bg === "secondary"
? "bg-brand-sidebar"
: "bg-brand-base"
}`}
>
{settingsLayout && (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">Project Settings</h3>
<p className="mt-1 text-brand-secondary">
This information will be displayed to every member of the project.
</p>
</div>
<SettingsNavbar />
</div>
)}
{children}
<div className="h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
{children}
</div>
</div>
</main>
)}

View File

@ -32,25 +32,21 @@ type Meta = {
type Props = {
meta?: Meta;
children: React.ReactNode;
noPadding?: boolean;
noHeader?: boolean;
bg?: "primary" | "secondary";
breadcrumbs?: JSX.Element;
left?: JSX.Element;
right?: JSX.Element;
profilePage?: boolean;
};
export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
meta,
children,
noPadding = false,
noHeader = false,
bg = "primary",
breadcrumbs,
left,
right,
profilePage = false,
}) => {
const [toggleSidebar, setToggleSidebar] = useState(false);
@ -101,7 +97,7 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
<UserAuthorizationLayout>
<Container meta={meta}>
<CommandPalette />
<div className="flex h-screen w-full overflow-x-hidden">
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<NotAuthorizedView
@ -117,7 +113,15 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
type="workspace"
/>
) : (
<main className="flex h-screen w-full min-w-0 flex-col overflow-y-auto">
<main
className={`relative flex h-full w-full flex-col overflow-hidden ${
bg === "primary"
? "bg-brand-surface-1"
: bg === "secondary"
? "bg-brand-sidebar"
: "bg-brand-base"
}`}
>
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}
@ -126,33 +130,10 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
setToggleSidebar={setToggleSidebar}
/>
)}
<div
className={`flex w-full flex-grow flex-col ${
noPadding ? "" : settingsLayout || profilePage ? "p-8 lg:px-28" : "p-8"
} ${
bg === "primary"
? "bg-brand-surface-1"
: bg === "secondary"
? "bg-brand-surface-1"
: "bg-brand-base"
}`}
>
{(settingsLayout || profilePage) && (
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">
{profilePage ? "Profile" : "Workspace"} Settings
</h3>
<p className="mt-1 text-brand-secondary">
{profilePage
? "This information will be visible to only you."
: "This information will be displayed to every member of the workspace."}
</p>
</div>
<SettingsNavbar profilePage={profilePage} />
</div>
)}
{children}
<div className="h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
{children}
</div>
</div>
</main>
)}

View File

@ -30,7 +30,7 @@ const SettingsNavbar: React.FC<Props> = ({ profilePage = false }) => {
href: `/${workspaceSlug}/settings/integrations`,
},
{
label: "Import/Export",
label: "Import/ Export",
href: `/${workspaceSlug}/settings/import-export`,
},
];

View File

@ -1,6 +1,10 @@
require("dotenv").config({ path: ".env" });
const { withSentryConfig } = require("@sentry/nextjs");
const path = require("path");
const extraImageDomains = (process.env.NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS ?? "").split(",").filter((domain) => domain.length > 0);
const extraImageDomains = (process.env.NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS ?? "")
.split(",")
.filter((domain) => domain.length > 0);
const nextConfig = {
reactStrictMode: false,

View File

@ -23,6 +23,7 @@
"@types/react-datepicker": "^4.8.0",
"axios": "^1.1.3",
"cmdk": "^0.2.0",
"dotenv": "^16.0.3",
"js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",
"next": "12.3.2",

View File

@ -45,7 +45,7 @@ const WorkspacePage: NextPage = () => {
isOpen={isProductUpdatesModalOpen}
setIsOpen={setIsProductUpdatesModalOpen}
/>
<div className="h-full w-full">
<div className="p-8">
<div className="flex flex-col gap-8">
<div
className="text-brand-muted-1 flex flex-col justify-between gap-x-2 gap-y-6 rounded-lg bg-brand-base px-8 py-6 md:flex-row md:items-center md:py-3"

View File

@ -43,7 +43,6 @@ const MyIssuesPage: NextPage = () => {
<BreadcrumbItem title="My Issues" />
</Breadcrumbs>
}
noPadding
right={
<div className="flex items-center gap-2">
{myIssues && myIssues.length > 0 && (
@ -52,7 +51,7 @@ const MyIssuesPage: NextPage = () => {
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-brand-base bg-transparent px-3 py-1.5 text-xs hover:bg-brand-surface-1 hover:text-brand-base focus:outline-none ${
open ? "bg-brand-surface-1 text-brand-base" : "text-brand-muted-1"
open ? "bg-brand-surface-1 text-brand-base" : "text-brand-secondary"
}`}
>
<span>View</span>
@ -69,29 +68,27 @@ const MyIssuesPage: NextPage = () => {
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-1/2 z-10 mr-5 mt-1 w-screen max-w-xs translate-x-1/2 transform overflow-hidden rounded-lg bg-brand-base p-3 shadow-lg">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate") return null;
<div className="space-y-2 py-3">
<h4 className="text-sm text-brand-secondary">Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate") return null;
return (
<button
key={key}
type="button"
className={`rounded border border-theme px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-brand-accent bg-brand-accent text-white"
: "border-brand-base"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</Popover.Panel>
@ -107,7 +104,7 @@ const MyIssuesPage: NextPage = () => {
document.dispatchEvent(e);
}}
>
<PlusIcon className="w-4 h-4" />
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</div>
@ -117,55 +114,42 @@ const MyIssuesPage: NextPage = () => {
{myIssues ? (
<>
{myIssues.length > 0 ? (
<div className="flex flex-col space-y-5">
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div className="rounded-[10px] border border-brand-base bg-brand-base">
<div
className={`flex items-center justify-start bg-brand-surface-1 px-4 py-2.5 ${
open ? "rounded-t-[10px]" : "rounded-[10px]"
}`}
>
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${
!open ? "-rotate-90 transform" : ""
}`}
/>
</span>
<h2 className="font-medium leading-5">My Issues</h2>
<span className="rounded-full bg-brand-surface-2 py-0.5 px-3 text-sm text-brand-secondary">
{myIssues.length}
</span>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{myIssues.map((issue: IIssue) => (
<MyIssuesListItem
key={issue.id}
issue={issue}
properties={properties}
projectId={issue.project}
/>
))}
</Disclosure.Panel>
</Transition>
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div>
<div className="flex items-center px-4 py-2.5">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<h2 className="font-medium leading-5">My Issues</h2>
<span className="rounded-full bg-brand-surface-2 py-0.5 px-3 text-sm text-brand-secondary">
{myIssues.length}
</span>
</div>
</Disclosure.Button>
</div>
)}
</Disclosure>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
{myIssues.map((issue: IIssue) => (
<MyIssuesListItem
key={issue.id}
issue={issue}
properties={properties}
projectId={issue.project}
/>
))}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
) : (
<div className="flex h-full w-full flex-col items-center justify-center px-4">
<EmptySpace

View File

@ -11,6 +11,7 @@ import { Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// fetch-keys
import { USER_ACTIVITY } from "constants/fetch-keys";
import SettingsNavbar from "layouts/settings-navbar";
const ProfileActivity = () => {
const { data: userActivity } = useSWR(USER_ACTIVITY, () => userService.getUserActivity());
@ -25,20 +26,30 @@ const ProfileActivity = () => {
<BreadcrumbItem title="My Profile Activity" />
</Breadcrumbs>
}
profilePage
>
{userActivity ? (
userActivity.results.length > 0 ? (
<Feeds activities={userActivity.results} />
) : null
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
<div className="px-24 py-8">
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">Profile Settings</h3>
<p className="mt-1 text-brand-secondary">
This information will be visible to only you.
</p>
</div>
<SettingsNavbar profilePage />
</div>
{userActivity ? (
userActivity.results.length > 0 ? (
<Feeds activities={userActivity.results} />
) : null
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</WorkspaceAuthorizationLayout>
);
};

View File

@ -24,6 +24,7 @@ import type { NextPage } from "next";
import type { IUser } from "types";
// constants
import { USER_ROLES } from "constants/workspace";
import SettingsNavbar from "layouts/settings-navbar";
const defaultValues: Partial<IUser> = {
avatar: "",
@ -130,7 +131,6 @@ const Profile: NextPage = () => {
<BreadcrumbItem title="My Profile" />
</Breadcrumbs>
}
profilePage
>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
@ -144,145 +144,158 @@ const Profile: NextPage = () => {
userImage
/>
{myProfile ? (
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Profile Picture</h4>
<p className="text-sm text-brand-secondary">
Max file size is 5MB. Supported file types are .jpg and .png.
<div className="px-24 py-8">
<div className="mb-12 space-y-6">
<div>
<h3 className="text-3xl font-semibold">Profile Settings</h3>
<p className="mt-1 text-brand-secondary">
This information will be visible to only you.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="flex items-center gap-4">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!watch("avatar") || watch("avatar") === "" ? (
<div className="h-12 w-12 rounded-md bg-brand-surface-2 p-2">
<UserIcon className="h-full w-full text-brand-secondary" />
</div>
) : (
<div className="relative h-12 w-12 overflow-hidden">
<Image
src={watch("avatar")}
alt={myProfile.first_name}
layout="fill"
objectFit="cover"
className="rounded-md"
onClick={() => setIsImageUploadModalOpen(true)}
priority
/>
</div>
)}
</button>
<div className="flex items-center gap-2">
<SecondaryButton
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
Upload
</SecondaryButton>
{myProfile.avatar && myProfile.avatar !== "" && (
<DangerButton
onClick={() => handleDelete(myProfile.avatar, true)}
loading={isRemoving}
<SettingsNavbar profilePage />
</div>
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Profile Picture</h4>
<p className="text-sm text-brand-secondary">
Max file size is 5MB. Supported file types are .jpg and .png.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="flex items-center gap-4">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!watch("avatar") || watch("avatar") === "" ? (
<div className="h-12 w-12 rounded-md bg-brand-surface-2 p-2">
<UserIcon className="h-full w-full text-brand-secondary" />
</div>
) : (
<div className="relative h-12 w-12 overflow-hidden">
<Image
src={watch("avatar")}
alt={myProfile.first_name}
layout="fill"
objectFit="cover"
className="rounded-md"
onClick={() => setIsImageUploadModalOpen(true)}
priority
/>
</div>
)}
</button>
<div className="flex items-center gap-2">
<SecondaryButton
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
{isRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
Upload
</SecondaryButton>
{myProfile.avatar && myProfile.avatar !== "" && (
<DangerButton
onClick={() => handleDelete(myProfile.avatar, true)}
loading={isRemoving}
>
{isRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Full Name</h4>
<p className="text-sm text-brand-secondary">
This name will be reflected on all the projects you are working on.
</p>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Full Name</h4>
<p className="text-sm text-brand-secondary">
This name will be reflected on all the projects you are working on.
</p>
</div>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6">
<Input
name="first_name"
id="first_name"
register={register}
error={errors.first_name}
placeholder="Enter your first name"
autoComplete="off"
validations={{
required: "This field is required.",
}}
/>
<Input
name="last_name"
register={register}
error={errors.last_name}
id="last_name"
placeholder="Enter your last name"
autoComplete="off"
/>
</div>
</div>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6">
<Input
name="first_name"
id="first_name"
register={register}
error={errors.first_name}
placeholder="Enter your first name"
autoComplete="off"
validations={{
required: "This field is required.",
}}
/>
<Input
name="last_name"
register={register}
error={errors.last_name}
id="last_name"
placeholder="Enter your last name"
autoComplete="off"
/>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Email</h4>
<p className="text-sm text-brand-secondary">
The email address that you are using.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="email"
name="email"
autoComplete="off"
register={register}
error={errors.name}
className="w-full"
disabled
/>
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Email</h4>
<p className="text-sm text-brand-secondary">The email address that you are using.</p>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Role</h4>
<p className="text-sm text-brand-secondary">Add your role.</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="role"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
width="w-full"
input
position="right"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="email"
name="email"
autoComplete="off"
register={register}
error={errors.name}
className="w-full"
disabled
/>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Theme</h4>
<p className="text-sm text-brand-secondary">
Select or customize your interface color scheme.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<ThemeSwitch />
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Role</h4>
<p className="text-sm text-brand-secondary">Add your role.</p>
<div className="sm:text-right">
<SecondaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update profile"}
</SecondaryButton>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="role"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
width="w-full"
input
position="right"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-brand-base">Theme</h4>
<p className="text-sm text-brand-secondary">
Select or customize your interface color scheme.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<ThemeSwitch />
</div>
</div>
<div className="sm:text-right">
<SecondaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update profile"}
</SecondaryButton>
</div>
</div>
) : (

View File

@ -124,7 +124,7 @@ const ProjectCycles: NextPage = () => {
handleClose={() => setCreateUpdateCycleModal(false)}
data={selectedCycle}
/>
<div className="space-y-8">
<div className="space-y-8 p-8">
<div className="flex flex-col gap-5">
{currentAndUpcomingCycles && currentAndUpcomingCycles.current_cycle.length > 0 && (
<h3 className="text-3xl font-semibold text-brand-base">Current Cycle</h3>

View File

@ -122,7 +122,6 @@ const IssueDetailsPage: NextPage = () => {
return (
<ProjectAuthorizationWrapper
noPadding
breadcrumbs={
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem

View File

@ -77,7 +77,7 @@ const ProjectModules: NextPage = () => {
document.dispatchEvent(e);
}}
>
<PlusIcon className="w-4 h-4" />
<PlusIcon className="h-4 w-4" />
Add Module
</PrimaryButton>
}
@ -89,7 +89,7 @@ const ProjectModules: NextPage = () => {
/>
{modules ? (
modules.length > 0 ? (
<div className="space-y-5">
<div className="space-y-5 p-8">
<div className="flex flex-col gap-5">
<h3 className="text-3xl font-semibold text-brand-base">Modules</h3>

View File

@ -312,7 +312,7 @@ const SinglePage: NextPage = () => {
}
>
{pageDetails ? (
<div className="h-full w-full space-y-4 rounded-md border border-brand-base bg-brand-base p-4">
<div className="space-y-4 p-4">
<div className="flex items-center justify-between gap-2 px-3">
<button
type="button"
@ -543,7 +543,6 @@ const SinglePage: NextPage = () => {
<CreateUpdateBlockInline
handleClose={() => setCreateBlockForm(false)}
focus="name"
setGptAssistantModal={() => {}}
/>
</div>
)}

View File

@ -25,7 +25,7 @@ import { RecentPagesList, CreateUpdatePageModal, TPagesListProps } from "compone
import { Input, PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import {ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import { ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
// types
import { IPage, TPageViewProps } from "types";
import type { NextPage } from "next";
@ -195,7 +195,7 @@ const ProjectPages: NextPage = () => {
</PrimaryButton>
}
>
<div className="space-y-4">
<div className="space-y-4 p-8">
<form
onSubmit={handleSubmit(createPage)}
className="relative mb-12 flex items-center justify-between gap-2 rounded-[6px] border border-brand-base p-2 shadow"

View File

@ -20,6 +20,7 @@ import { IProject, IWorkspace } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, PROJECT_MEMBERS } from "constants/fetch-keys";
import { SettingsHeader } from "components/project";
const defaultValues: Partial<IProject> = {
project_lead: null,
@ -103,7 +104,8 @@ const ControlSettings: NextPage = () => {
</Breadcrumbs>
}
>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onSubmit)} className="px-24 py-8">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">

View File

@ -27,6 +27,7 @@ import { IEstimate, IProject } from "types";
import type { NextPage } from "next";
// fetch-keys
import { ESTIMATES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
import { SettingsHeader } from "components/project";
const EstimatesSettings: NextPage = () => {
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
@ -98,6 +99,14 @@ const EstimatesSettings: NextPage = () => {
return (
<>
<CreateUpdateEstimateModal
isOpen={estimateFormOpen}
data={estimateToUpdate}
handleClose={() => {
setEstimateFormOpen(false);
setEstimateToUpdate(undefined);
}}
/>
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
@ -109,68 +118,63 @@ const EstimatesSettings: NextPage = () => {
</Breadcrumbs>
}
>
<CreateUpdateEstimateModal
isOpen={estimateFormOpen}
data={estimateToUpdate}
handleClose={() => {
setEstimateFormOpen(false);
setEstimateToUpdate(undefined);
}}
/>
<section className="flex items-center justify-between">
<h3 className="text-2xl font-semibold">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7">
<div className="flex items-center gap-2">
<span
className="flex cursor-pointer items-center gap-2 text-theme"
onClick={() => {
setEstimateToUpdate(undefined);
setEstimateFormOpen(true);
}}
>
<PlusIcon className="h-4 w-4" />
Create New Estimate
</span>
{projectDetails?.estimate && (
<SecondaryButton onClick={disableEstimates}>Disable Estimates</SecondaryButton>
)}
<div className="px-24 py-8">
<SettingsHeader />
<section className="flex items-center justify-between">
<h3 className="text-2xl font-semibold">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7">
<div className="flex items-center gap-2">
<span
className="flex cursor-pointer items-center gap-2 text-theme"
onClick={() => {
setEstimateToUpdate(undefined);
setEstimateFormOpen(true);
}}
>
<PlusIcon className="h-4 w-4" />
Create New Estimate
</span>
{projectDetails?.estimate && (
<SecondaryButton onClick={disableEstimates}>Disable Estimates</SecondaryButton>
)}
</div>
</div>
</div>
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="mt-4 mb-8 divide-y divide-brand-base rounded-xl border border-brand-base bg-brand-base px-6">
{estimatesList.map((estimate) => (
<SingleEstimate
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="mt-4 divide-y divide-brand-base rounded-xl border border-brand-base bg-brand-base px-6">
{estimatesList.map((estimate) => (
<SingleEstimate
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
/>
))}
</section>
) : (
<div className="mt-5">
<EmptyState
type="estimate"
title="Create New Estimate"
description="Estimates help you communicate the complexity of an issue. You can create your own estimate and communicate with your team."
imgURL={emptyEstimate}
action={() => {
setEstimateToUpdate(undefined);
setEstimateFormOpen(true);
}}
/>
))}
</section>
</div>
)
) : (
<div className="mt-5">
<EmptyState
type="estimate"
title="Create New Estimate"
description="Estimates help you communicate the complexity of an issue. You can create your own estimate and communicate with your team."
imgURL={emptyEstimate}
action={() => {
setEstimateToUpdate(undefined);
setEstimateFormOpen(true);
}}
/>
</div>
)
) : (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</ProjectAuthorizationWrapper>
</>
);

View File

@ -22,6 +22,7 @@ import { IProject } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
import { SettingsHeader } from "components/project";
const featuresList = [
{
@ -134,54 +135,57 @@ const FeaturesSettings: NextPage = () => {
</Breadcrumbs>
}
>
<section className="space-y-8">
<h3 className="text-2xl font-semibold">Features</h3>
<div className="space-y-5">
{featuresList.map((feature) => (
<div
key={feature.property}
className="flex items-center justify-between gap-x-8 gap-y-2 rounded-[10px] border border-brand-base bg-brand-base p-5"
>
<div className="flex items-start gap-3">
{feature.icon}
<div>
<h4 className="text-lg font-semibold">{feature.title}</h4>
<p className="text-sm text-brand-secondary">{feature.description}</p>
<div className="px-24 py-8">
<SettingsHeader />
<section className="space-y-8">
<h3 className="text-2xl font-semibold">Features</h3>
<div className="space-y-5">
{featuresList.map((feature) => (
<div
key={feature.property}
className="flex items-center justify-between gap-x-8 gap-y-2 rounded-[10px] border border-brand-base bg-brand-base p-5"
>
<div className="flex items-start gap-3">
{feature.icon}
<div>
<h4 className="text-lg font-semibold">{feature.title}</h4>
<p className="text-sm text-brand-secondary">{feature.description}</p>
</div>
</div>
<ToggleSwitch
value={projectDetails?.[feature.property as keyof IProject]}
onChange={() => {
trackEventServices.trackMiscellaneousEvent(
{
workspaceId: (projectDetails?.workspace as any)?.id,
workspaceSlug,
projectId,
projectIdentifier: projectDetails?.identifier,
projectName: projectDetails?.name,
},
!projectDetails?.[feature.property as keyof IProject]
? getEventType(feature.title, true)
: getEventType(feature.title, false)
);
handleSubmit({
[feature.property]: !projectDetails?.[feature.property as keyof IProject],
});
}}
size="lg"
/>
</div>
<ToggleSwitch
value={projectDetails?.[feature.property as keyof IProject]}
onChange={() => {
trackEventServices.trackMiscellaneousEvent(
{
workspaceId: (projectDetails?.workspace as any)?.id,
workspaceSlug,
projectId,
projectIdentifier: projectDetails?.identifier,
projectName: projectDetails?.name,
},
!projectDetails?.[feature.property as keyof IProject]
? getEventType(feature.title, true)
: getEventType(feature.title, false)
);
handleSubmit({
[feature.property]: !projectDetails?.[feature.property as keyof IProject],
});
}}
size="lg"
/>
</div>
))}
</div>
<div className="flex items-center gap-2">
<a href="https://plane.so/" target="_blank" rel="noreferrer">
<SecondaryButton outline>Plane is open-source, view Roadmap</SecondaryButton>
</a>
<a href="https://github.com/makeplane/plane" target="_blank" rel="noreferrer">
<SecondaryButton outline>Star us on GitHub</SecondaryButton>
</a>
</div>
</section>
))}
</div>
<div className="flex items-center gap-2">
<a href="https://plane.so/" target="_blank" rel="noreferrer">
<SecondaryButton outline>Plane is open-source, view Roadmap</SecondaryButton>
</a>
<a href="https://github.com/makeplane/plane" target="_blank" rel="noreferrer">
<SecondaryButton outline>Star us on GitHub</SecondaryButton>
</a>
</div>
</section>
</div>
</ProjectAuthorizationWrapper>
);
};

View File

@ -12,7 +12,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services
import projectService from "services/project.service";
// components
import { DeleteProjectModal } from "components/project";
import { DeleteProjectModal, SettingsHeader } from "components/project";
import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker";
// hooks
@ -151,7 +151,8 @@ const GeneralSettings: NextPage = () => {
router.push(`/${workspaceSlug}/projects`);
}}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onSubmit)} className="py-8 px-24">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
@ -222,7 +223,7 @@ const GeneralSettings: NextPage = () => {
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Cover Photo</h4>
<p className="text-sm text-gray-500">
<p className="text-sm text-brand-secondary">
Select your cover photo from the given library.
</p>
</div>

View File

@ -10,12 +10,13 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
import IntegrationService from "services/integration";
import projectService from "services/project.service";
// components
import { SingleIntegration } from "components/project";
import { SettingsHeader, SingleIntegration } from "components/project";
// ui
import { EmptySpace, EmptySpaceItem, Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { PlusIcon, PuzzlePieceIcon } from "@heroicons/react/24/outline";
import { ExclamationIcon } from "components/icons";
// types
import { IProject } from "types";
import type { NextPage } from "next";
@ -53,44 +54,61 @@ const ProjectIntegrations: NextPage = () => {
</Breadcrumbs>
}
>
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<section className="space-y-8">
<h3 className="text-2xl font-semibold">Integrations</h3>
<div className="space-y-5">
{workspaceIntegrations.map((integration) => (
<SingleIntegration
key={integration.integration_detail.id}
integration={integration}
<div className="px-24 py-8">
<SettingsHeader />
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<section className="space-y-8">
<div className="flex flex-col items-start gap-3">
<h3 className="text-2xl font-semibold">Integrations</h3>
<div className="flex items-center gap-3 rounded-[10px] border border-brand-accent/75 bg-brand-accent/5 p-4 text-sm text-brand-base">
<ExclamationIcon
height={24}
width={24}
className="fill-current text-brand-base"
/>
<p className="leading-5">
Integrations and importers are only available on the cloud version. We plan to
open-source our SDKs in the near future so that the community can request or
contribute integrations as needed.
</p>
</div>
</div>
<div className="space-y-5">
{workspaceIntegrations.map((integration) => (
<SingleIntegration
key={integration.integration_detail.id}
integration={integration}
/>
))}
</div>
</section>
) : (
<div className="grid h-full w-full place-items-center">
<EmptySpace
title="You haven't added any integration yet."
description="Add GitHub and other integrations to sync your project issues."
Icon={PuzzlePieceIcon}
>
<EmptySpaceItem
title="Add new integration"
Icon={PlusIcon}
action={() => {
router.push(`/${workspaceSlug}/settings/integrations`);
}}
/>
))}
</EmptySpace>
</div>
</section>
)
) : (
<div className="grid h-full w-full place-items-center">
<EmptySpace
title="You haven't added any integration yet."
description="Add GitHub and other integrations to sync your project issues."
Icon={PuzzlePieceIcon}
>
<EmptySpaceItem
title="Add new integration"
Icon={PlusIcon}
action={() => {
router.push(`/${workspaceSlug}/settings/integrations`);
}}
/>
</EmptySpace>
</div>
)
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</ProjectAuthorizationWrapper>
);
};

View File

@ -7,8 +7,6 @@ import useSWR from "swr";
// services
import projectService from "services/project.service";
import issuesService from "services/issues.service";
// lib
import { requiredAdmin } from "lib/auth";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components
@ -24,10 +22,11 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssueLabels, UserAuth } from "types";
import type { GetServerSidePropsContext, NextPage } from "next";
import { IIssueLabels } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECT_DETAILS, PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import { SettingsHeader } from "components/project";
const LabelsSettings: NextPage = () => {
// create/edit label form
@ -103,39 +102,57 @@ const LabelsSettings: NextPage = () => {
</Breadcrumbs>
}
>
<section className="grid grid-cols-12 gap-10">
<div className="col-span-12 sm:col-span-5">
<h3 className="text-2xl font-semibold">Labels</h3>
<p className="text-brand-secondary">Manage the labels of this project.</p>
<PrimaryButton onClick={newLabel} size="sm" className="mt-4">
<span className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
New label
</span>
</PrimaryButton>
</div>
<div className="col-span-12 space-y-5 sm:col-span-7">
{labelForm && (
<CreateUpdateLabelInline
labelForm={labelForm}
setLabelForm={setLabelForm}
isUpdating={isUpdating}
labelToUpdate={labelToUpdate}
ref={scrollToRef}
/>
)}
<>
{issueLabels ? (
issueLabels.map((label) => {
const children = issueLabels?.filter((l) => l.parent === label.id);
<div className="px-24 py-8">
<SettingsHeader />
<section className="grid grid-cols-12 gap-10">
<div className="col-span-12 sm:col-span-5">
<h3 className="text-2xl font-semibold">Labels</h3>
<p className="text-brand-secondary">Manage the labels of this project.</p>
<PrimaryButton onClick={newLabel} size="sm" className="mt-4">
<span className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
New label
</span>
</PrimaryButton>
</div>
<div className="col-span-12 space-y-5 sm:col-span-7">
{labelForm && (
<CreateUpdateLabelInline
labelForm={labelForm}
setLabelForm={setLabelForm}
isUpdating={isUpdating}
labelToUpdate={labelToUpdate}
ref={scrollToRef}
/>
)}
<>
{issueLabels ? (
issueLabels.map((label) => {
const children = issueLabels?.filter((l) => l.parent === label.id);
if (children && children.length === 0) {
if (!label.parent)
if (children && children.length === 0) {
if (!label.parent)
return (
<SingleLabel
key={label.id}
label={label}
addLabelToGroup={() => addLabelToGroup(label)}
editLabel={(label) => {
editLabel(label);
scrollToRef.current?.scrollIntoView({
behavior: "smooth",
});
}}
handleLabelDelete={handleLabelDelete}
/>
);
} else
return (
<SingleLabel
<SingleLabelGroup
key={label.id}
label={label}
addLabelToGroup={() => addLabelToGroup(label)}
labelChildren={children}
addLabelToGroup={addLabelToGroup}
editLabel={(label) => {
editLabel(label);
scrollToRef.current?.scrollIntoView({
@ -145,34 +162,19 @@ const LabelsSettings: NextPage = () => {
handleLabelDelete={handleLabelDelete}
/>
);
} else
return (
<SingleLabelGroup
key={label.id}
label={label}
labelChildren={children}
addLabelToGroup={addLabelToGroup}
editLabel={(label) => {
editLabel(label);
scrollToRef.current?.scrollIntoView({
behavior: "smooth",
});
}}
handleLabelDelete={handleLabelDelete}
/>
);
})
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
</div>
</section>
})
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
</div>
</section>
</div>
</ProjectAuthorizationWrapper>
</>
);

View File

@ -27,6 +27,7 @@ import type { NextPage } from "next";
import { PROJECT_INVITATIONS, PROJECT_MEMBERS, WORKSPACE_DETAILS } from "constants/fetch-keys";
// constants
import { ROLE } from "constants/workspace";
import { SettingsHeader } from "components/project";
const MembersSettings: NextPage = () => {
const [inviteModal, setInviteModal] = useState(false);
@ -141,120 +142,123 @@ const MembersSettings: NextPage = () => {
</Breadcrumbs>
}
>
<section className="space-y-8">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
{!projectMembers || !projectInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-6 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<Image
src={member.avatar}
alt={member.first_name}
layout="fill"
objectFit="cover"
className="rounded-lg"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="mt-0.5 text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member.member && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
Pending
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!activeWorkspace || !projectDetails) return;
projectService
.updateProjectMember(
activeWorkspace.slug,
projectDetails.id,
member.id,
{
role: value,
}
)
.then((res) => {
setToastAlert({
type: "success",
message: "Member role updated successfully.",
title: "Success",
});
mutateMembers(
(prevData: any) =>
prevData.map((m: any) =>
m.id === member.id ? { ...m, ...res, role: value } : m
),
false
);
})
.catch((err) => {
console.log(err);
});
}}
position="right"
>
{Object.keys(ROLE).map((key) => (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
))}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) setSelectedRemoveMember(member.id);
else setSelectedInviteRemoveMember(member.id);
}}
>
<span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove member</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
<div className="px-24 py-8">
<SettingsHeader />
<section className="space-y-8">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
)}
</section>
{!projectMembers || !projectInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-6 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<Image
src={member.avatar}
alt={member.first_name}
layout="fill"
objectFit="cover"
className="rounded-lg"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="mt-0.5 text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member.member && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
Pending
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!activeWorkspace || !projectDetails) return;
projectService
.updateProjectMember(
activeWorkspace.slug,
projectDetails.id,
member.id,
{
role: value,
}
)
.then((res) => {
setToastAlert({
type: "success",
message: "Member role updated successfully.",
title: "Success",
});
mutateMembers(
(prevData: any) =>
prevData.map((m: any) =>
m.id === member.id ? { ...m, ...res, role: value } : m
),
false
);
})
.catch((err) => {
console.log(err);
});
}}
position="right"
>
{Object.keys(ROLE).map((key) => (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
))}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) setSelectedRemoveMember(member.id);
else setSelectedInviteRemoveMember(member.id);
}}
>
<span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove member</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
)}
</section>
</div>
</ProjectAuthorizationWrapper>
</>
);

View File

@ -28,6 +28,7 @@ import { getStatesList, orderStateGroups } from "helpers/state.helper";
import type { NextPage } from "next";
// fetch-keys
import { STATES_LIST } from "constants/fetch-keys";
import { SettingsHeader } from "components/project";
const StatesSettings: NextPage = () => {
const [activeGroup, setActiveGroup] = useState<StateGroup>(null);
@ -66,79 +67,82 @@ const StatesSettings: NextPage = () => {
</Breadcrumbs>
}
>
<div className="grid grid-cols-12 gap-10">
<div className="col-span-12 sm:col-span-5">
<h3 className="text-2xl font-semibold text-brand-base">States</h3>
<p className="text-brand-secondary">Manage the states of this project.</p>
</div>
<div className="col-span-12 space-y-8 sm:col-span-7">
{states && projectDetails ? (
Object.keys(orderedStateGroups).map((key) => {
if (orderedStateGroups[key].length !== 0)
return (
<div key={key}>
<div className="mb-2 flex w-full justify-between">
<h4 className="font-medium capitalize">{key}</h4>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setActiveGroup(key as keyof StateGroup)}
>
<PlusIcon className="h-4 w-4" />
Add
</button>
</div>
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base">
{key === activeGroup && (
<CreateUpdateStateInline
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
data={null}
selectedGroup={key as keyof StateGroup}
/>
)}
{orderedStateGroups[key].map((state, index) =>
state.id !== selectedState ? (
<SingleState
key={state.id}
index={index}
state={state}
statesList={statesList}
handleEditState={() => setSelectedState(state.id)}
handleDeleteState={() => setSelectDeleteState(state.id)}
<div className="px-24 py-8">
<SettingsHeader />
<div className="grid grid-cols-12 gap-10">
<div className="col-span-12 sm:col-span-5">
<h3 className="text-2xl font-semibold text-brand-base">States</h3>
<p className="text-brand-secondary">Manage the states of this project.</p>
</div>
<div className="col-span-12 space-y-8 sm:col-span-7">
{states && projectDetails ? (
Object.keys(orderedStateGroups).map((key) => {
if (orderedStateGroups[key].length !== 0)
return (
<div key={key}>
<div className="mb-2 flex w-full justify-between">
<h4 className="font-medium capitalize">{key}</h4>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setActiveGroup(key as keyof StateGroup)}
>
<PlusIcon className="h-4 w-4" />
Add
</button>
</div>
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base">
{key === activeGroup && (
<CreateUpdateStateInline
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
data={null}
selectedGroup={key as keyof StateGroup}
/>
) : (
<div
className="border-b border-brand-base last:border-b-0"
key={state.id}
>
<CreateUpdateStateInline
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
data={
statesList?.find((state) => state.id === selectedState) ?? null
}
selectedGroup={key as keyof StateGroup}
)}
{orderedStateGroups[key].map((state, index) =>
state.id !== selectedState ? (
<SingleState
key={state.id}
index={index}
state={state}
statesList={statesList}
handleEditState={() => setSelectedState(state.id)}
handleDeleteState={() => setSelectDeleteState(state.id)}
/>
</div>
)
)}
) : (
<div
className="border-b border-brand-base last:border-b-0"
key={state.id}
>
<CreateUpdateStateInline
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
data={
statesList?.find((state) => state.id === selectedState) ?? null
}
selectedGroup={key as keyof StateGroup}
/>
</div>
)
)}
</div>
</div>
</div>
);
})
) : (
<Loader className="space-y-5 md:w-2/3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
);
})
) : (
<Loader className="space-y-5 md:w-2/3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</div>
</div>
</ProjectAuthorizationWrapper>

View File

@ -97,7 +97,7 @@ const ProjectViews: NextPage = () => {
/>
{views ? (
views.length > 0 ? (
<div className="space-y-5">
<div className="space-y-5 p-8">
<h3 className="text-3xl font-semibold text-brand-base">Views</h3>
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base">
{views.map((view) => (

View File

@ -83,7 +83,7 @@ const ProjectsPage: NextPage = () => {
data={projects?.find((item) => item.id === deleteProject) ?? null}
/>
{projects ? (
<>
<div className="p-8">
{projects.length === 0 ? (
<EmptyState
type="project"
@ -103,7 +103,7 @@ const ProjectsPage: NextPage = () => {
))}
</div>
)}
</>
</div>
) : (
<Loader className="grid grid-cols-3 gap-4">
<Loader.Item height="100px" />

View File

@ -8,6 +8,7 @@ import useSWR from "swr";
import workspaceService from "services/workspace.service";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// ui
import { SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -34,37 +35,40 @@ const BillingSettings: NextPage = () => {
title={`${activeWorkspace?.name ?? "Workspace"}`}
link={`/${workspaceSlug}`}
/>
<BreadcrumbItem title="Members Settings" />
<BreadcrumbItem title="Billing & Plans Settings" />
</Breadcrumbs>
}
>
<section className="space-y-8">
<div>
<h3 className="text-3xl font-bold leading-6">Billing & Plans</h3>
<p className="mt-4 text-sm text-brand-secondary">[Free launch preview] plan Pro</p>
</div>
<div className="space-y-8 md:w-2/3">
<div className="px-24 py-8">
<SettingsHeader />
<section className="space-y-8">
<div>
<div className="w-80 rounded-md border border-brand-base bg-brand-base p-4 text-center">
<h4 className="text-md mb-1 leading-6">Payment due</h4>
<h2 className="text-3xl font-extrabold">--</h2>
<h3 className="text-3xl font-bold leading-6">Billing & Plans</h3>
<p className="mt-4 text-sm text-brand-secondary">[Free launch preview] plan Pro</p>
</div>
<div className="space-y-8 md:w-2/3">
<div>
<div className="w-80 rounded-md border border-brand-base bg-brand-base p-4 text-center">
<h4 className="text-md mb-1 leading-6">Payment due</h4>
<h2 className="text-3xl font-extrabold">--</h2>
</div>
</div>
<div>
<h4 className="text-md mb-1 leading-6">Current plan</h4>
<p className="mb-3 text-sm text-brand-secondary">
You are currently using the free plan
</p>
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
<SecondaryButton outline>View Plans and Upgrade</SecondaryButton>
</a>
</div>
<div>
<h4 className="text-md mb-1 leading-6">Billing history</h4>
<p className="mb-3 text-sm text-brand-secondary">There are no invoices to display</p>
</div>
</div>
<div>
<h4 className="text-md mb-1 leading-6">Current plan</h4>
<p className="mb-3 text-sm text-brand-secondary">
You are currently using the free plan
</p>
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
<SecondaryButton outline>View Plans and Upgrade</SecondaryButton>
</a>
</div>
<div>
<h4 className="text-md mb-1 leading-6">Billing history</h4>
<p className="mb-3 text-sm text-brand-secondary">There are no invoices to display</p>
</div>
</div>
</section>
</section>
</div>
</WorkspaceAuthorizationLayout>
);
};

View File

@ -2,6 +2,7 @@ import { useRouter } from "next/router";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components
import IntegrationGuide from "components/integration/guide";
// ui
@ -18,11 +19,14 @@ const ImportExport: NextPage = () => {
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title={`${workspaceSlug ?? "Workspace"}`} link={`/${workspaceSlug}`} />
<BreadcrumbItem title="Members Settings" />
<BreadcrumbItem title="Import/ Export Settings" />
</Breadcrumbs>
}
>
<IntegrationGuide />
<div className="px-24 py-8">
<SettingsHeader />
<IntegrationGuide />
</div>
</WorkspaceAuthorizationLayout>
);
};

View File

@ -14,9 +14,10 @@ import fileService from "services/file.service";
import useToast from "hooks/use-toast";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components
import { ImageUploadModal } from "components/core";
import { DeleteWorkspaceModal } from "components/workspace";
import { DeleteWorkspaceModal, SettingsHeader } from "components/workspace";
// ui
import { Spinner, Input, CustomSelect, SecondaryButton, DangerButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -172,163 +173,167 @@ const WorkspaceSettings: NextPage = () => {
}}
data={activeWorkspace ?? null}
/>
{activeWorkspace ? (
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Logo</h4>
<p className="text-sm text-brand-secondary">
Max file size is 5MB. Supported file types are .jpg and .png.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="flex items-center gap-4">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-12 w-12">
<Image
src={watch("logo")!}
alt="Workspace Logo"
objectFit="cover"
layout="fill"
className="rounded-md"
priority
/>
</div>
) : (
<div className="relative flex h-12 w-12 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
<div className="flex gap-4">
<SecondaryButton
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
{isImageUploading ? "Uploading..." : "Upload"}
</SecondaryButton>
{activeWorkspace.logo && activeWorkspace.logo !== "" && (
<DangerButton onClick={() => handleDelete(activeWorkspace.logo)}>
{isImageRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
<div className="px-24 py-8">
<SettingsHeader />
{activeWorkspace ? (
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Logo</h4>
<p className="text-sm text-brand-secondary">
Max file size is 5MB. Supported file types are .jpg and .png.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="flex items-center gap-4">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-12 w-12">
<Image
src={watch("logo")!}
alt="Workspace Logo"
objectFit="cover"
layout="fill"
className="rounded-md"
priority
/>
</div>
) : (
<div className="relative flex h-12 w-12 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
<div className="flex gap-4">
<SecondaryButton
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
{isImageUploading ? "Uploading..." : "Upload"}
</SecondaryButton>
{activeWorkspace.logo && activeWorkspace.logo !== "" && (
<DangerButton onClick={() => handleDelete(activeWorkspace.logo)}>
{isImageRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">URL</h4>
<p className="text-sm text-brand-secondary">Your workspace URL.</p>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">URL</h4>
<p className="text-sm text-brand-secondary">Your workspace URL.</p>
</div>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6">
<Input
id="url"
name="url"
autoComplete="off"
register={register}
error={errors.name}
className="w-full"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}
disabled
/>
<SecondaryButton
className="h-min"
onClick={() =>
copyTextToClipboard(
`${typeof window !== "undefined" && window.location.origin}/${
activeWorkspace.slug
}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Workspace link copied to clipboard.",
});
})
}
outline
>
<LinkIcon className="h-[18px] w-[18px]" />
</SecondaryButton>
</div>
</div>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6">
<Input
id="url"
name="url"
autoComplete="off"
register={register}
error={errors.name}
className="w-full"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}
disabled
/>
<SecondaryButton
className="h-min"
onClick={() =>
copyTextToClipboard(
`${typeof window !== "undefined" && window.location.origin}/${
activeWorkspace.slug
}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Workspace link copied to clipboard.",
});
})
}
outline
>
<LinkIcon className="h-[18px] w-[18px]" />
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Name</h4>
<p className="text-sm text-brand-secondary">Give a name to your workspace.</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="name"
name="name"
placeholder="Name"
autoComplete="off"
register={register}
error={errors.name}
validations={{
required: "Name is required",
}}
/>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Company Size</h4>
<p className="text-sm text-brand-secondary">How big is your company?</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="company_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select company size"}
input
>
{COMPANY_SIZE?.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="sm:text-right">
<SecondaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Workspace"}
</SecondaryButton>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Name</h4>
<p className="text-sm text-brand-secondary">Give a name to your workspace.</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="name"
name="name"
placeholder="Name"
autoComplete="off"
register={register}
error={errors.name}
validations={{
required: "Name is required",
}}
/>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Danger Zone</h4>
<p className="text-sm text-brand-secondary">
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.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<DangerButton onClick={() => setIsOpen(true)} outline>
Delete the workspace
</DangerButton>
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Company Size</h4>
<p className="text-sm text-brand-secondary">How big is your company?</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="company_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select company size"}
input
>
{COMPANY_SIZE?.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<Spinner />
</div>
<div className="sm:text-right">
<SecondaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update Workspace"}
</SecondaryButton>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Danger Zone</h4>
<p className="text-sm text-brand-secondary">
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.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<DangerButton onClick={() => setIsOpen(true)} outline>
Delete the workspace
</DangerButton>
</div>
</div>
</div>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<Spinner />
</div>
)}
)}
</div>
</WorkspaceAuthorizationLayout>
);
};

View File

@ -9,11 +9,14 @@ import workspaceService from "services/workspace.service";
import IntegrationService from "services/integration";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components
import { SingleIntegrationCard } from "components/integration";
// ui
import { Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { ExclamationIcon } from "components/icons";
// types
import type { NextPage } from "next";
// fetch-keys
@ -44,21 +47,34 @@ const WorkspaceIntegrations: NextPage = () => {
</Breadcrumbs>
}
>
<section className="space-y-8">
<h3 className="text-2xl font-semibold">Integrations</h3>
<div className="space-y-5">
{appIntegrations ? (
appIntegrations.map((integration) => (
<SingleIntegrationCard key={integration.id} integration={integration} />
))
) : (
<Loader className="space-y-5">
<Loader.Item height="60px" />
<Loader.Item height="60px" />
</Loader>
)}
</div>
</section>
<div className="px-24 py-8">
<SettingsHeader />
<section className="space-y-8">
<div className="flex flex-col items-start gap-3">
<h3 className="text-2xl font-semibold">Integrations</h3>
<div className="flex items-center gap-3 rounded-[10px] border border-brand-accent/75 bg-brand-accent/5 p-4 text-sm text-brand-base">
<ExclamationIcon height={24} width={24} className="fill-current text-brand-base" />
<p className="leading-5">
Integrations and importers are only available on the cloud version. We plan to
open-source our SDKs in the near future so that the community can request or
contribute integrations as needed.
</p>
</div>
</div>
<div className="space-y-5">
{appIntegrations ? (
appIntegrations.map((integration) => (
<SingleIntegrationCard key={integration.id} integration={integration} />
))
) : (
<Loader className="space-y-5">
<Loader.Item height="60px" />
<Loader.Item height="60px" />
</Loader>
)}
</div>
</section>
</div>
</WorkspaceAuthorizationLayout>
);
};

View File

@ -11,6 +11,7 @@ import useToast from "hooks/use-toast";
import workspaceService from "services/workspace.service";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components
import ConfirmWorkspaceMemberRemove from "components/workspace/confirm-workspace-member-remove";
import SendWorkspaceInvitationModal from "components/workspace/send-workspace-invitation-modal";
@ -137,117 +138,120 @@ const MembersSettings: NextPage = () => {
</Breadcrumbs>
}
>
<section className="space-y-8">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
{!workspaceMembers || !workspaceInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-8 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<Image
src={member.avatar}
alt={member.first_name}
layout="fill"
objectFit="cover"
className="rounded-lg"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p>
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: any) => {
workspaceService
.updateWorkspaceMember(activeWorkspace?.slug as string, member.id, {
role: value,
})
.then(() => {
mutateMembers(
(prevData) =>
prevData?.map((m) =>
m.id === member.id ? { ...m, role: value } : m
),
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Member role updated successfully.",
});
})
.catch(() => {
setToastAlert({
title: "Error",
type: "error",
message: "An error occurred while updating member role.",
});
});
}}
position="right"
>
{Object.keys(ROLE).map((key) => (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
))}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
Remove member
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
<div className="px-24 py-8">
<SettingsHeader />
<section className="space-y-8">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
)}
</section>
{!workspaceMembers || !workspaceInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-8 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<Image
src={member.avatar}
alt={member.first_name}
layout="fill"
objectFit="cover"
className="rounded-lg"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p>
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: any) => {
workspaceService
.updateWorkspaceMember(activeWorkspace?.slug as string, member.id, {
role: value,
})
.then(() => {
mutateMembers(
(prevData) =>
prevData?.map((m) =>
m.id === member.id ? { ...m, role: value } : m
),
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Member role updated successfully.",
});
})
.catch(() => {
setToastAlert({
title: "Error",
type: "error",
message: "An error occurred while updating member role.",
});
});
}}
position="right"
>
{Object.keys(ROLE).map((key) => (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
))}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
Remove member
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
)}
</section>
</div>
</WorkspaceAuthorizationLayout>
</>
);

View File

@ -8,6 +8,7 @@ import "styles/globals.css";
import "styles/editor.css";
import "styles/command-pallette.css";
import "styles/nprogress.css";
import "styles/react-datepicker.css";
// router
import Router from "next/router";

View File

@ -1,8 +1,6 @@
import React, { useCallback, useState } from "react";
import { useRouter } from "next/router";
import Image from "next/image";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";

View File

@ -1,6 +1,21 @@
import axios from "axios";
import Cookies from "js-cookie";
const unAuthorizedStatus = [401];
axios.interceptors.response.use(
(response) => response,
(error) => {
const { status }: any = error.response;
if (unAuthorizedStatus.includes(status)) {
Cookies.remove("refreshToken", { path: "/" });
Cookies.remove("accessToken", { path: "/" });
console.log("window.location.href", window.location.pathname);
if (window.location.pathname != "/signin") window.location.href = "/signin";
}
return Promise.reject(error);
}
);
abstract class APIService {
protected baseURL: string;
protected headers: any = {};

View File

@ -1,10 +1,14 @@
// services
import APIService from "services/api.service";
// types
import type { IEstimate, IEstimateFormData, IEstimatePoint } from "types";
import type { IEstimate, IEstimateFormData } from "types";
import trackEventServices from "services/track-event.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class ProjectEstimateServices extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
@ -16,7 +20,11 @@ class ProjectEstimateServices extends APIService {
data: IEstimateFormData
): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`, data)
.then((response) => response?.data)
.then((response) => {
if (trackEvent)
trackEventServices.trackIssueEstimateEvent(response?.data, "ESTIMATE_CREATE");
return response?.data;
})
.catch((error) => {
throw error?.response;
});
@ -32,7 +40,11 @@ class ProjectEstimateServices extends APIService {
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`,
data
)
.then((response) => response?.data)
.then((response) => {
if (trackEvent)
trackEventServices.trackIssueEstimateEvent(response?.data, "ESTIMATE_UPDATE");
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
@ -64,7 +76,11 @@ class ProjectEstimateServices extends APIService {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`
)
.then((response) => response?.data)
.then((response) => {
if (trackEvent)
trackEventServices.trackIssueEstimateEvent(response?.data, "ESTIMATE_DELETE");
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});

View File

@ -1,10 +1,14 @@
import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
import { IGithubRepoInfo, IGithubServiceImportFormData } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const integrationServiceType: string = "github";
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
const integrationServiceType: string = "github";
class GithubIntegrationService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
@ -41,7 +45,11 @@ class GithubIntegrationService extends APIService {
`/api/workspaces/${workspaceSlug}/projects/importers/${integrationServiceType}/`,
data
)
.then((response) => response?.data)
.then((response) => {
if (trackEvent)
trackEventServices.trackImporterEvent(response?.data, "GITHUB_IMPORTER_CREATE");
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});

View File

@ -1,9 +1,14 @@
import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
// types
import { IAppIntegration, IImporterService, IWorkspaceIntegration } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class IntegrationService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
@ -49,7 +54,12 @@ class IntegrationService extends APIService {
importerId: string
): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/importers/${service}/${importerId}/`)
.then((res) => res?.data)
.then((response) => {
const eventName = service === "github" ? "GITHUB_IMPORTER_DELETE" : "JIRA_IMPORTER_DELETE";
if (trackEvent) trackEventServices.trackImporterEvent(response?.data, eventName);
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});

View File

@ -1,10 +1,14 @@
import APIService from "services/api.service";
import trackEventServices from "services/track-event.service";
// types
import { IJiraMetadata, IJiraResponse, IJiraImporterForm } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
const trackEvent =
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
class JiraImportedService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
@ -22,7 +26,11 @@ class JiraImportedService extends APIService {
async createJiraImporter(workspaceSlug: string, data: IJiraImporterForm): Promise<IJiraResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/importers/jira/`, data)
.then((response) => response?.data)
.then((response) => {
if (trackEvent)
trackEventServices.trackImporterEvent(response?.data, "JIRA_IMPORTER_CREATE");
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});

View File

@ -7,6 +7,7 @@ const trackEvent =
// types
import type {
ICycle,
IEstimate,
IGptResponse,
IIssue,
IIssueComment,
@ -45,7 +46,10 @@ type PagesEventType = "PAGE_CREATE" | "PAGE_UPDATE" | "PAGE_DELETE";
type ViewEventType = "VIEW_CREATE" | "VIEW_UPDATE" | "VIEW_DELETE";
type IssueCommentType = "ISSUE_COMMENT_CREATE" | "ISSUE_COMMENT_UPDATE" | "ISSUE_COMMENT_DELETE";
type IssueCommentEventType =
| "ISSUE_COMMENT_CREATE"
| "ISSUE_COMMENT_UPDATE"
| "ISSUE_COMMENT_DELETE";
export type MiscellaneousEventType =
| "TOGGLE_CYCLE_ON"
@ -73,6 +77,13 @@ type IssueLabelEventType = "ISSUE_LABEL_CREATE" | "ISSUE_LABEL_UPDATE" | "ISSUE_
type GptEventType = "ASK_GPT" | "USE_GPT_RESPONSE_IN_ISSUE" | "USE_GPT_RESPONSE_IN_PAGE_BLOCK";
type IssueEstimateEventType = "ESTIMATE_CREATE" | "ESTIMATE_UPDATE" | "ESTIMATE_DELETE";
type ImporterEventType =
| "GITHUB_IMPORTER_CREATE"
| "GITHUB_IMPORTER_DELETE"
| "JIRA_IMPORTER_CREATE"
| "JIRA_IMPORTER_DELETE";
class TrackEventServices extends APIService {
constructor() {
super("/");
@ -209,7 +220,7 @@ class TrackEventServices extends APIService {
async trackIssueCommentEvent(
data: Partial<IIssueComment> | any,
eventName: IssueCommentType
eventName: IssueCommentEventType
): Promise<any> {
let payload: any;
if (eventName !== "ISSUE_COMMENT_DELETE")
@ -549,6 +560,61 @@ class TrackEventServices extends APIService {
},
});
}
async trackIssueEstimateEvent(
data: { estimate: IEstimate },
eventName: IssueEstimateEventType
): Promise<any> {
let payload: any;
if (eventName === "ESTIMATE_DELETE") payload = data;
else
payload = {
workspaceId: data?.estimate?.workspace_detail?.id,
workspaceName: data?.estimate?.workspace_detail?.name,
workspaceSlug: data?.estimate?.workspace_detail?.slug,
projectId: data?.estimate?.project_detail?.id,
projectName: data?.estimate?.project_detail?.name,
projectIdentifier: data?.estimate?.project_detail?.identifier,
estimateId: data.estimate?.id,
};
return this.request({
url: "/api/track-event",
method: "POST",
data: {
eventName,
extra: {
...payload,
},
},
});
}
async trackImporterEvent(data: any, eventName: ImporterEventType): Promise<any> {
let payload: any;
if (eventName === "GITHUB_IMPORTER_DELETE" || eventName === "JIRA_IMPORTER_DELETE")
payload = data;
else
payload = {
workspaceId: data?.workspace_detail?.id,
workspaceName: data?.workspace_detail?.name,
workspaceSlug: data?.workspace_detail?.slug,
projectId: data?.project_detail?.id,
projectName: data?.project_detail?.name,
projectIdentifier: data?.project_detail?.identifier,
};
return this.request({
url: "/api/track-event",
method: "POST",
data: {
eventName,
extra: {
...payload,
},
},
});
}
}
const trackEventServices = new TrackEventServices();

View File

@ -112,6 +112,7 @@ body {
.horizontal-scroll-enable::-webkit-scrollbar {
display: block;
height: 7px;
width: 0;
}
.horizontal-scroll-enable::-webkit-scrollbar-track {

View File

@ -0,0 +1,118 @@
.react-datepicker-wrapper input::placeholder {
color: rgba(var(--color-text-secondary));
opacity: 1;
}
.react-datepicker-wrapper input:-ms-input-placeholder {
color: rgba(var(--color-text-secondary));
}
.react-datepicker-wrapper .react-datepicker__close-icon::after {
background: transparent;
color: rgba(var(--color-text-secondary));
}
.react-datepicker-popper {
z-index: 30 !important;
}
.react-datepicker-wrapper {
position: relative;
background-color: rgba(var(--color-bg-base)) !important;
}
.react-datepicker {
font-family: "Inter" !important;
border: none !important;
background-color: rgba(var(--color-bg-base)) !important;
}
.react-datepicker__month-container {
width: 300px;
background-color: rgba(var(--color-bg-base)) !important;
color: rgba(var(--color-text-base)) !important;
border-radius: 10px !important;
/* border: 1px solid rgba(var(--color-border)) !important; */
}
.react-datepicker__header {
border-radius: 10px !important;
background-color: rgba(var(--color-bg-base)) !important;
border: none !important;
}
.react-datepicker__navigation {
line-height: 0.78;
}
.react-datepicker__triangle {
border-color: rgba(var(--color-bg-base)) transparent transparent transparent !important;
}
.react-datepicker__triangle:before {
border-bottom-color: rgba(var(--color-border)) !important;
}
.react-datepicker__triangle:after {
border-bottom-color: rgba(var(--color-bg-base)) !important;
}
.react-datepicker__current-month {
font-weight: 500 !important;
color: rgba(var(--color-text-base)) !important;
}
.react-datepicker__month {
border-collapse: collapse;
color: rgba(var(--color-text-base)) !important;
}
.react-datepicker__day-names {
margin-top: 10px;
margin-left: 14px;
width: 280px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
}
.react-datepicker__day-name {
color: rgba(var(--color-text-base)) !important;
}
.react-datepicker__week {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-left: 8px;
}
.react-datepicker__day {
color: rgba(var(--color-text-base)) !important;
}
.react-datepicker__day {
border-radius: 50% !important;
transition: all 0.15s ease-in-out;
}
.react-datepicker__day:hover {
background-color: rgba(var(--color-bg-surface-2)) !important;
color: rgba(var(--color-text-base)) !important;
}
.react-datepicker__day--selected {
background-color: #216ba5 !important;
color: white !important;
}
.react-datepicker__day--today {
font-weight: 800;
}
.react-datepicker__day--highlighted {
background-color: rgba(var(--color-bg-surface-2)) !important;
}
.react-datepicker__day--keyboard-selected {
background-color: #216ba5 !important;
color: white !important;
}

View File

@ -12,10 +12,6 @@ module.exports = {
theme: {
extend: {
colors: {
theme: "#3f76ff",
"hover-gray": "#f5f5f5",
primary: "#f9fafb", // gray-50
secondary: "white",
brand: {
accent: withOpacity("--color-accent"),
base: withOpacity("--color-bg-base"),

View File

@ -8,7 +8,9 @@ export interface IEstimate {
updated_by: string;
points: IEstimatePoint[];
project: string;
project_detail: IProject;
workspace: string;
workspace_detail: IWorkspace;
}
export interface IEstimatePoint {

View File

@ -35,24 +35,46 @@ services:
- redisdata:/data
plane-web:
container_name: planefrontend
image: makeplane/plane-frontend:0.5-dev
image: makeplane/plane-frontend:0.6
restart: always
command: node apps/app/server.js
env_file:
- ./apps/app/.env
command: [ "/usr/local/bin/start.sh" ]
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: 0
NEXT_PUBLIC_GITHUB_APP_NAME: 0
NEXT_PUBLIC_GITHUB_ID: 0
NEXT_PUBLIC_SENTRY_DSN: 0
NEXT_PUBLIC_ENABLE_OAUTH: 0
NEXT_PUBLIC_ENABLE_SENTRY: 0
ports:
- 3000:3000
plane-api:
container_name: planebackend
image: makeplane/plane-backend:0.5-dev
image: makeplane/plane-backend:0.6
build:
context: ./apiserver
dockerfile: Dockerfile.api
restart: always
ports:
- 8000:8000
env_file:
- ./apiserver/.env
environment:
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://plane:xyzzyspoon@db:5432/plane
REDIS_URL: redis://redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
WEB_URL: localhost/
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
depends_on:
- db
- redis
@ -62,7 +84,7 @@ services:
- redis:redis
plane-worker:
container_name: planerqworker
image: makeplane/plane-worker:0.5-dev
image: makeplane/plane-worker:0.6
depends_on:
- redis
- db
@ -71,8 +93,24 @@ services:
links:
- redis:redis
- db:db
env_file:
- ./apiserver/.env
environment:
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://plane:xyzzyspoon@db:5432/plane
REDIS_URL: redis://redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
WEB_URL: localhost/
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
volumes:
pgdata:
redisdata:

View File

@ -38,12 +38,21 @@ services:
build:
context: .
dockerfile: ./apps/app/Dockerfile.web
restart: always
command: node apps/app/server.js
env_file:
- ./apps/app/.env
args:
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
command: [ "/usr/local/bin/start.sh" ]
ports:
- 3000:3000
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
NEXT_PUBLIC_SENTRY_DSN: "0"
NEXT_PUBLIC_ENABLE_OAUTH: "0"
NEXT_PUBLIC_ENABLE_SENTRY: "0"
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
NEXT_PUBLIC_TRACK_EVENTS: "0"
plane-api:
container_name: planebackend
build:
@ -52,8 +61,24 @@ services:
restart: always
ports:
- 8000:8000
env_file:
- ./apiserver/.env
environment:
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://plane:xyzzyspoon@db:5432/plane
REDIS_URL: redis://redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
WEB_URL: localhost/
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
depends_on:
- db
- redis
@ -74,8 +99,24 @@ services:
links:
- redis:redis
- db:db
env_file:
- ./apiserver/.env
environment:
DJANGO_SETTINGS_MODULE: plane.settings.production
DATABASE_URL: postgres://plane:xyzzyspoon@db:5432/plane
REDIS_URL: redis://redis:6379/
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
AWS_REGION: ${AWS_REGION}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
WEB_URL: localhost/
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
DISABLE_COLLECTSTATIC: 1
DOCKERIZED: 1
OPENAI_API_KEY: ${OPENAI_API_KEY}
GPT_ENGINE: ${GPT_ENGINE}
SECRET_KEY: ${SECRET_KEY}
volumes:
pgdata:
redisdata:

View File

@ -2,7 +2,7 @@
nodaemon=true
[program:node]
command=node /app/apps/app/server.js
command=sh /usr/local/bin/start.sh
autostart=true
autorestart=true
stderr_logfile=/var/log/node.err.log
@ -21,4 +21,12 @@ command=nginx -g "daemon off;"
autostart=true
autorestart=true
stderr_logfile=/var/log/nginx.err.log
stdout_logfile=/var/log/nginx.out.log
stdout_logfile=/var/log/nginx.out.log
[program:worker]
directory=/code
command=sh bin/worker
autostart=true
autorestart=true
stderr_logfile=/var/log/worker.err.log
stdout_logfile=/var/log/worker.out.log

17
replace-env-vars.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
FROM=$1
TO=$2
if [ "${FROM}" = "${TO}" ]; then
echo "Nothing to replace, the value is already set to ${TO}."
exit 0
fi
# Only peform action if $FROM and $TO are different.
echo "Replacing all statically built instances of $FROM with this string $TO ."
find apps/app/.next -type f |
while read file; do
sed -i "s|$FROM|$TO|g" "$file"
done

View File

@ -1,9 +1,7 @@
#!/bin/bash
cp ./apiserver/.env.example ./apiserver/.env
# Generating App environmental variables
cp ./apps/app/.env.example ./apps/app/.env
cp ./.env.example ./.env
echo -e "\nNEXT_PUBLIC_API_BASE_URL=http://$1" >> ./apps/app/.env
echo -e "\nNEXT_PUBLIC_API_BASE_URL=http://$1" >> ./.env
export LC_ALL=C
export LC_CTYPE=C
echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9!@#$%^&*(-_=+)' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9!@#$%^&*(-_=+)' < /dev/urandom | head -c50)\"" >> ./.env

9
start.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/sh
set -x
# Replace the statically built BUILT_NEXT_PUBLIC_API_BASE_URL with run-time NEXT_PUBLIC_API_BASE_URL
# NOTE: if these values are the same, this will be skipped.
/usr/local/bin/replace-env-vars.sh "$BUILT_NEXT_PUBLIC_API_BASE_URL" "$NEXT_PUBLIC_API_BASE_URL"
echo "Starting Plane Frontend.."
node apps/app/server.js

View File

@ -4,6 +4,7 @@
"NEXT_PUBLIC_GITHUB_ID",
"NEXT_PUBLIC_GOOGLE_CLIENTID",
"NEXT_PUBLIC_API_BASE_URL",
"API_BASE_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_AUTH_TOKEN",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
@ -17,6 +18,7 @@
"NEXT_PUBLIC_CRISP_ID",
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
"NEXT_PUBLIC_SLACK_CLIENT_ID",
"NEXT_PUBLIC_SLACK_CLIENT_SECRET"
],

View File

@ -4229,6 +4229,11 @@ dot-case@^3.0.4:
no-case "^3.0.4"
tslib "^2.0.3"
dotenv@^16.0.3:
version "16.0.3"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07"
integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==
ejs@^3.1.6:
version "3.1.8"
resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz"